import { escapeHtml, fetchJson, formatBytes, inferEnhancementClass, inferEnhancementLabel } from '../common.js'; import { buildVolumeMap, isVideoMime, restoreSegmentBytes, supportsFileDownload } from '../media-core.js'; // 右侧详情面板: // - 选中文件 → 属性 + 内嵌 播放器 + 预览/下载按钮 // - 选中数据源 → 源概况(移植 source.js renderSourceSummary) // - 空 → 提示 // // 依赖注入:mediaPlayer(createMediaPlayer 实例)、resolveSecrets(异步返回凭据或抛出)、 // feedback(createFeedbackController)、onThumbnailUpdated(回调刷新列表缩略图) function isVideoFileInfo(fileInfo) { return Boolean(fileInfo && isVideoMime(fileInfo.file.mime)); } export function createDetailPanel(rootElement, deps = {}) { const { mediaPlayer, resolveSecrets, feedback, onThumbnailUpdated } = deps; const state = { mode: 'empty', sourceId: null, entry: null, fileInfo: null, loadToken: 0 }; function renderEmpty(message = '选中一个文件或数据源查看详情。') { state.mode = 'empty'; rootElement.innerHTML = `${escapeHtml(message)}`; } function renderSourceOverview(source) { state.mode = 'source'; if (!source) { renderEmpty('未找到对应数据源。'); return; } const enhancementError = source.enhancement?.lastErrorMessage ? `最近失败:${escapeHtml(source.enhancement.lastErrorMessage)}` : ''; rootElement.innerHTML = ` 💽 ${escapeHtml(source.displayName || source.originalFilename)} ${inferEnhancementLabel(source)} 数据库文件${escapeHtml(source.originalFilename)} Source ID${escapeHtml(source.id)} 上传大小${formatBytes(source.fileSize)} 目录浏览${source.canBrowse ? '可用' : '不可用'} 预览图缓存${escapeHtml(String(source.thumbnailCache?.count ?? 0))} 张 最新快照${escapeHtml(source.latestSnapshot?.timestamp ?? '暂无')} ${enhancementError} ⚙ 属性 / 增强配置 `; } function renderFileLoading(entry) { state.mode = 'file'; rootElement.innerHTML = ` 📄 ${escapeHtml(entry.name)} 正在读取文件信息... `; } function renderFile(entry, fileInfo, { error = null } = {}) { state.mode = 'file'; const canPreview = isVideoFileInfo(fileInfo); const previewBlock = canPreview ? ` ` : ` ${ entry.thumbnail?.available ? `` : '📄' } `; const infoAvailable = Boolean(fileInfo); const errorBlock = error ? `${escapeHtml(error)}` : ''; rootElement.innerHTML = ` ${previewBlock} ${escapeHtml(entry.name)} 类型${escapeHtml(entry.mime || '未知')} 大小${formatBytes(entry.size)} 修改时间${escapeHtml(entry.mtime ?? '—')} 路径${escapeHtml(entry.path)} ${errorBlock} ${canPreview ? '▶ 预览' : ''} ↓ 下载 `; } async function loadAndRenderFile(sourceId, entry) { state.sourceId = sourceId; state.entry = entry; state.fileInfo = null; const token = ++state.loadToken; renderFileLoading(entry); try { const payload = await fetchJson( `/api/file-info?sourceId=${encodeURIComponent(sourceId)}&id=${encodeURIComponent(entry.id)}` ); if (token !== state.loadToken) { return; } state.fileInfo = payload; renderFile(entry, payload); } catch (error) { if (token !== state.loadToken) { return; } renderFile(entry, null, { error: error.message }); } } function setDetailStatus(message) { const el = rootElement.querySelector('#detailStatus'); if (!el) { return; } el.hidden = false; el.textContent = message; } async function startPreview() { const videoElement = rootElement.querySelector('#detailVideo'); if (!videoElement || !state.fileInfo) { return; } try { const secrets = await resolveSecrets(state.sourceId, state.fileInfo); feedback?.set('info', '正在注册浏览器端媒体会话...'); await mediaPlayer.play({ videoElement, sourceId: state.sourceId, fileId: state.entry.id, fileInfo: state.fileInfo, secrets }); feedback?.set('success', '视频预览已就绪,可播放并拖动进度。'); } catch (error) { if (error?.code === 'CREDENTIALS_CANCELLED') { return; } feedback?.set('error', error.message); } } async function downloadFile() { if (!state.fileInfo) { return; } try { if (!supportsFileDownload()) { throw new Error('BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。'); } const secrets = await resolveSecrets(state.sourceId, state.fileInfo); const fileInfo = state.fileInfo; const extension = fileInfo.file.name.includes('.') ? `.${fileInfo.file.name.split('.').pop()}` : '.bin'; const fileHandle = await showSaveFilePicker({ suggestedName: fileInfo.file.name, types: [ { description: 'Restored file', accept: { [fileInfo.file.mime || 'application/octet-stream']: [extension] } } ] }); feedback?.set('info', '正在浏览器直连下载...'); const writable = await fileHandle.createWritable(); const volumeByRef = buildVolumeMap(fileInfo.volumes); let writtenBytes = 0; for (const segment of fileInfo.segments) { const volume = volumeByRef.get(segment.volumeRef); setDetailStatus(`正在恢复卷 ${volume?.name ?? segment.volumeRef} · 已写入 ${formatBytes(writtenBytes)}`); const bytes = await restoreSegmentBytes({ sourceId: state.sourceId, volume, zip: segment.zip, secrets, onStatus: ({ phase, currentVolume }) => { setDetailStatus(`${phase ?? '处理中'} · ${currentVolume ?? ''} · 已写入 ${formatBytes(writtenBytes)}`); } }); await writable.write(bytes); writtenBytes += bytes.length; } await writable.close(); setDetailStatus(`下载完成 · 共 ${formatBytes(writtenBytes)}`); feedback?.set('success', '文件已直接恢复并写入本地。'); } catch (error) { if (error?.name === 'AbortError' || error?.code === 'CREDENTIALS_CANCELLED') { return; } setDetailStatus(error.message); feedback?.set('error', error.message); } } rootElement.addEventListener('click', (event) => { const button = event.target.closest('[data-detail-action]'); if (!(button instanceof HTMLElement)) { return; } const action = button.dataset.detailAction; if (action === 'preview') { void startPreview(); } else if (action === 'download') { void downloadFile(); } else if (action === 'props') { deps.onOpenProps?.(state.sourceId); } }); return { async showFile(sourceId, entry) { await mediaPlayer.stop(); await loadAndRenderFile(sourceId, entry); }, async showSource(source) { await mediaPlayer.stop(); state.sourceId = source?.id ?? null; state.entry = null; state.fileInfo = null; renderSourceOverview(source); }, async clear() { await mediaPlayer.stop(); state.sourceId = null; state.entry = null; state.fileInfo = null; renderEmpty(); }, updateThumbnail(fileId, thumbnail) { if (state.entry?.id === fileId) { state.entry.thumbnail = thumbnail; } onThumbnailUpdated?.(fileId, thumbnail); } }; }