Files
duplicati_preview/public/modules/explorer/mediaPlayer.js
T
nanxun fcf56bcbd3 feat: rebuild frontend as file-manager style explorer SPA
将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器:
左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板,
管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用,
仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。

- 新增 explorer.html/js 与 modules/explorer/ 六个组件模块
- 删除旧的 index/source/file 页面及其控制器
- styles.css 新增 explorer 布局与三档响应式断点
2026-07-04 14:53:07 +08:00

408 lines
13 KiB
JavaScript

import { assertBrowserFeature } from '../common.js';
import { isVideoMime, supportsVideoPreview } from '../media-core.js';
import { analyzeFrameLuma, buildThumbnailCandidateTimes } from '../thumbnail-utils.js';
// 参数驱动的媒体播放器组件。从 file.js 抽取媒体会话逻辑,去掉页面全局与硬编码 ID。
// 一个实例管理:Service Worker 注册、session 注册/注销、<video> 挂载、预览图自动抓取。
//
// 用法:
// const player = createMediaPlayer({ canvas, onStatus, onThumbnail });
// await player.play({ videoElement, sourceId, fileId, fileInfo, secrets });
// await player.stop();
const STATUS_CHANNEL_NAME = 'duplicati-media-status';
export function createMediaPlayer({ canvas, onStatus = () => {}, onThumbnail = () => {} } = {}) {
const state = {
videoElement: null,
sourceId: null,
fileId: null,
fileInfo: null,
secrets: null,
currentSessionId: null,
probeSessionId: null,
workerChannel: null,
loadedDataHandler: null,
thumbnail: {
inFlight: false,
attempted: false
}
};
async function sendWorkerMessage(type, payload) {
const registration = await navigator.serviceWorker.ready;
const worker = navigator.serviceWorker.controller ?? registration.active ?? registration.waiting;
if (!worker) {
throw new Error('SERVICE_WORKER_UNAVAILABLE: Service Worker is not ready.');
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
const timeoutId = setTimeout(() => {
reject(new Error('SERVICE_WORKER_TIMEOUT: Service Worker did not reply in time.'));
}, 10_000);
channel.port1.onmessage = (event) => {
clearTimeout(timeoutId);
if (event.data?.ok) {
resolve(event.data);
} else {
reject(new Error(event.data?.error ?? 'Service Worker request failed.'));
}
};
worker.postMessage({ type, ...payload }, [channel.port2]);
});
}
async function ensureServiceWorkerReady() {
assertBrowserFeature(navigator.serviceWorker, 'SERVICE_WORKER_UNSUPPORTED: 当前浏览器不支持 Service Worker。');
await navigator.serviceWorker.register('/media-sw.js', { scope: '/', type: 'module' });
await navigator.serviceWorker.ready;
if (!navigator.serviceWorker.controller) {
await new Promise((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), { once: true });
});
}
}
function ensureWorkerStatusChannel() {
if (state.workerChannel || typeof BroadcastChannel !== 'function') {
return;
}
state.workerChannel = new BroadcastChannel(STATUS_CHANNEL_NAME);
state.workerChannel.addEventListener('message', (event) => {
if (event.data?.type !== 'status' || event.data.sessionId !== state.currentSessionId) {
return;
}
onStatus(event.data.status);
});
}
async function unregisterWorkerSession(sessionId) {
if (!sessionId) {
return;
}
try {
await ensureServiceWorkerReady();
await sendWorkerMessage('unregister-session', { sessionId });
} catch {
// ignore stale or already-closed sessions
}
}
async function registerPreviewSession() {
await ensureServiceWorkerReady();
ensureWorkerStatusChannel();
if (state.currentSessionId) {
try {
await sendWorkerMessage('unregister-session', { sessionId: state.currentSessionId });
} catch {
// ignore stale sessions during re-registration
}
}
const sessionId = crypto.randomUUID();
const response = await sendWorkerMessage('register-session', {
sessionId,
sourceId: state.sourceId,
fileInfo: state.fileInfo,
secrets: state.secrets
});
state.currentSessionId = sessionId;
onStatus(response.status);
return sessionId;
}
// ── 预览图抓取(隐藏探测视频) ──
function waitForEventOnce(target, eventName, timeoutMs, readyCheck = null) {
if (typeof readyCheck === 'function' && readyCheck()) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`THUMBNAIL_EVENT_TIMEOUT: Timed out waiting for ${eventName}.`));
}, timeoutMs);
function cleanup() {
clearTimeout(timeoutId);
target.removeEventListener(eventName, handleSuccess);
target.removeEventListener('error', handleError);
}
function handleSuccess() {
cleanup();
resolve();
}
function handleError() {
cleanup();
reject(new Error(`THUMBNAIL_VIDEO_ERROR: probe video failed during ${eventName}.`));
}
target.addEventListener(eventName, handleSuccess, { once: true });
target.addEventListener('error', handleError, { once: true });
});
}
async function waitForStableVideoFrame(videoElement) {
if (typeof videoElement.requestVideoFrameCallback === 'function') {
await new Promise((resolve) => {
videoElement.requestVideoFrameCallback(() => resolve());
});
return;
}
await waitForEventOnce(
videoElement,
'loadeddata',
5_000,
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
await new Promise((resolve) => setTimeout(resolve, 80));
}
async function seekHiddenVideoTo(videoElement, seconds) {
const targetTime = Number(seconds);
if (!Number.isFinite(targetTime) || targetTime < 0) {
return;
}
if (Math.abs(videoElement.currentTime - targetTime) > 0.05) {
const seekPromise = waitForEventOnce(videoElement, 'seeked', 12_000);
videoElement.currentTime = targetTime;
await seekPromise;
} else if (videoElement.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
await waitForEventOnce(
videoElement,
'loadeddata',
12_000,
() => videoElement.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
);
}
await waitForStableVideoFrame(videoElement);
}
async function createThumbnailProbeVideo(sessionId) {
const probeVideo = document.createElement('video');
probeVideo.muted = true;
probeVideo.playsInline = true;
probeVideo.preload = 'auto';
probeVideo.controls = false;
Object.assign(probeVideo.style, {
position: 'fixed',
opacity: '0',
pointerEvents: 'none',
width: '1px',
height: '1px',
left: '-9999px',
top: '-9999px'
});
probeVideo.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
document.body.append(probeVideo);
probeVideo.load();
await waitForEventOnce(
probeVideo,
'loadedmetadata',
12_000,
() => probeVideo.readyState >= HTMLMediaElement.HAVE_METADATA
);
return probeVideo;
}
function cleanupThumbnailProbeVideo(videoElement) {
if (!videoElement) {
return;
}
videoElement.pause();
videoElement.removeAttribute('src');
videoElement.load();
videoElement.remove();
}
async function createThumbnailBlobFromVideo(videoElement) {
const maxWidth = 480;
const sourceWidth = Math.max(videoElement.videoWidth || 0, 1);
const sourceHeight = Math.max(videoElement.videoHeight || 0, 1);
const scale = Math.min(1, maxWidth / sourceWidth);
const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
throw new Error('THUMBNAIL_CANVAS_UNAVAILABLE: 无法创建缩略图绘制上下文。');
}
context.drawImage(videoElement, 0, 0, targetWidth, targetHeight);
const frameAnalysis = analyzeFrameLuma(context.getImageData(0, 0, targetWidth, targetHeight).data);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。'));
return;
}
resolve({ blob, frameAnalysis });
},
'image/webp',
0.82
);
});
}
async function maybeCaptureThumbnail() {
if (!canvas || !state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) {
return;
}
if (state.fileInfo.file.thumbnail?.available) {
return;
}
if (state.thumbnail.inFlight || state.thumbnail.attempted) {
return;
}
const player = state.videoElement;
if (!player || player.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || player.videoWidth === 0) {
return;
}
state.thumbnail.inFlight = true;
state.thumbnail.attempted = true;
const activeSessionId = state.currentSessionId;
const probeSessionId = crypto.randomUUID();
state.probeSessionId = probeSessionId;
let probeVideo = null;
try {
await sendWorkerMessage('register-session', {
sessionId: probeSessionId,
sourceId: state.sourceId,
fileInfo: state.fileInfo,
secrets: state.secrets
});
probeVideo = await createThumbnailProbeVideo(probeSessionId);
const candidates = buildThumbnailCandidateTimes(probeVideo.duration);
let selectedBlob = null;
let fallbackBlob = null;
for (let index = 0; index < candidates.length; index += 1) {
if (state.currentSessionId !== activeSessionId) {
return;
}
await seekHiddenVideoTo(probeVideo, candidates[index]);
const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo);
fallbackBlob = blob;
if (!frameAnalysis.isBlackFrame) {
selectedBlob = blob;
break;
}
}
const blobToUpload = selectedBlob ?? fallbackBlob;
if (!blobToUpload) {
throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。');
}
if (state.currentSessionId !== activeSessionId) {
return;
}
const formData = new FormData();
formData.set('fileId', state.fileId);
formData.set('image', blobToUpload, 'preview.webp');
const response = await fetch(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
method: 'POST',
body: formData
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${payload.error?.code ?? 'THUMBNAIL_UPLOAD_FAILED'}: ${payload.error?.message ?? '预览图上传失败。'}`);
}
if (state.fileInfo?.file) {
state.fileInfo.file.thumbnail = payload.thumbnail;
}
onThumbnail({ fileId: state.fileId, thumbnail: payload.thumbnail });
} catch {
// 预览图抓取失败不影响播放,静默处理
} finally {
cleanupThumbnailProbeVideo(probeVideo);
await unregisterWorkerSession(probeSessionId);
if (state.probeSessionId === probeSessionId) {
state.probeSessionId = null;
}
state.thumbnail.inFlight = false;
}
}
function detachVideo() {
const player = state.videoElement;
if (!player) {
return;
}
if (state.loadedDataHandler) {
player.removeEventListener('loadeddata', state.loadedDataHandler);
state.loadedDataHandler = null;
}
player.pause();
player.removeAttribute('src');
player.load();
}
return {
canPreview(fileInfo) {
return Boolean(supportsVideoPreview() && fileInfo && isVideoMime(fileInfo.file.mime));
},
async play({ videoElement, sourceId, fileId, fileInfo, secrets }) {
assertBrowserFeature(
supportsVideoPreview(),
'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。'
);
if (!fileInfo) {
throw new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。');
}
if (!isVideoMime(fileInfo.file.mime)) {
throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。');
}
// 切换文件:先拆掉旧的 video 绑定与 session
detachVideo();
state.videoElement = videoElement;
state.sourceId = sourceId;
state.fileId = fileId;
state.fileInfo = fileInfo;
state.secrets = secrets;
state.thumbnail = { inFlight: false, attempted: Boolean(fileInfo.file.thumbnail?.available) };
const sessionId = await registerPreviewSession();
state.loadedDataHandler = () => {
void maybeCaptureThumbnail();
};
videoElement.addEventListener('loadeddata', state.loadedDataHandler);
videoElement.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
videoElement.load();
return sessionId;
},
async stop() {
detachVideo();
await unregisterWorkerSession(state.probeSessionId);
state.probeSessionId = null;
if (state.currentSessionId) {
await unregisterWorkerSession(state.currentSessionId);
state.currentSessionId = null;
}
state.videoElement = null;
state.fileInfo = null;
state.secrets = null;
onStatus(null);
}
};
}