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 布局与三档响应式断点
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user