将原来的三页跳转结构(控制台/工作台/文件页)重构为单页资源管理器: 左侧数据源盘符树、主区列表/网格双视图、右侧内嵌播放器与详情面板, 管理功能收进侧栏对话框。媒体引擎(media-core + media-sw)原样复用, 仅把 file.js 的播放器逻辑抽成参数驱动组件。默认路由指向 explorer。 - 新增 explorer.html/js 与 modules/explorer/ 六个组件模块 - 删除旧的 index/source/file 页面及其控制器 - styles.css 新增 explorer 布局与三档响应式断点
408 lines
13 KiB
JavaScript
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);
|
|
}
|
|
};
|
|
}
|