1318 lines
43 KiB
JavaScript
1318 lines
43 KiB
JavaScript
import {
|
||
assertBrowserFeature,
|
||
createFeedbackController,
|
||
escapeHtml,
|
||
fetchJson,
|
||
formatBytes,
|
||
linkToSource,
|
||
optionalQueryParam
|
||
} from './modules/common.js';
|
||
import { bindLogoutButton, loadAuthState } from './modules/auth-client.js';
|
||
import {
|
||
loadClientSecrets,
|
||
loadGlobalClientSecrets,
|
||
removeClientSecrets,
|
||
saveClientSecrets,
|
||
saveGlobalClientSecrets
|
||
} from './modules/clientSecrets.js';
|
||
import {
|
||
buildVolumeMap,
|
||
clearAllCachedVolumes,
|
||
clearCachedVolumesForSource,
|
||
getCachedVolumeStats,
|
||
getGlobalCacheStats,
|
||
isVideoMime,
|
||
restoreSegmentBytes,
|
||
supportsFileDownload,
|
||
supportsOpfs,
|
||
supportsVideoPreview
|
||
} from './modules/media-core.js';
|
||
import {
|
||
analyzeFrameLuma,
|
||
buildThumbnailCandidateTimes
|
||
} from './modules/thumbnail-utils.js';
|
||
|
||
const STATUS_CHANNEL_NAME = 'duplicati-media-status';
|
||
const FILE_VIEWS = new Set(['overview', 'preview', 'download', 'cache', 'thumbnail']);
|
||
|
||
const state = {
|
||
sourceId: optionalQueryParam('sourceId', ''),
|
||
fileId: optionalQueryParam('id', ''),
|
||
source: null,
|
||
fileInfo: null,
|
||
fileInfoError: null,
|
||
cacheStats: {
|
||
currentSource: null,
|
||
global: null
|
||
},
|
||
resolvedBrowserSecrets: null,
|
||
resolvedBrowserSecretsError: null,
|
||
currentSessionId: null,
|
||
workerStatus: null,
|
||
workerChannel: null,
|
||
browserGlobalSecrets: null,
|
||
browserSourceSecrets: null,
|
||
activePreviewSecrets: null,
|
||
currentView: 'overview',
|
||
pageLocked: false,
|
||
thumbnailCapture: {
|
||
inFlight: false,
|
||
attempted: false,
|
||
suppressed: false,
|
||
sessionId: null,
|
||
probeSessionId: null,
|
||
lastMessage: '尚未生成预览图。'
|
||
}
|
||
};
|
||
|
||
const feedback = createFeedbackController(document.querySelector('#feedback'));
|
||
const sourceCrumb = document.querySelector('#sourceCrumb');
|
||
const fileTitleElement = document.querySelector('#fileTitle');
|
||
const fileSummaryElement = document.querySelector('#fileSummary');
|
||
const sessionStatusElement = document.querySelector('#sessionStatus');
|
||
const cacheStatusElement = document.querySelector('#cacheStatus');
|
||
const downloadStatusElement = document.querySelector('#downloadStatus');
|
||
const previewPlayer = document.querySelector('#previewPlayer');
|
||
const videoHintElement = document.querySelector('#videoHint');
|
||
const startPreviewButton = document.querySelector('#startPreviewButton');
|
||
const deleteThumbnailFromPreviewButton = document.querySelector('#deleteThumbnailFromPreviewButton');
|
||
const downloadButton = document.querySelector('#downloadButton');
|
||
const clearSourceCacheButton = document.querySelector('#clearSourceCacheButton');
|
||
const clearAllCacheButton = document.querySelector('#clearAllCacheButton');
|
||
const saveClientSecretsButton = document.querySelector('#saveClientSecretsButton');
|
||
const saveClientGlobalsButton = document.querySelector('#saveClientGlobalsButton');
|
||
const deleteThumbnailButton = document.querySelector('#deleteThumbnailButton');
|
||
const clientSecretForm = document.querySelector('#clientSecretForm');
|
||
const clientSecretHintElement = document.querySelector('#clientSecretHint');
|
||
const previewThumbnailHintElement = document.querySelector('#previewThumbnailHint');
|
||
const thumbnailStatusElement = document.querySelector('#thumbnailStatus');
|
||
const thumbnailPreviewElement = document.querySelector('#thumbnailPreview');
|
||
const thumbnailCaptureCanvas = document.querySelector('#thumbnailCaptureCanvas');
|
||
const authStatusElement = document.querySelector('#authStatus');
|
||
const logoutButton = document.querySelector('#logoutButton');
|
||
const viewPanels = [...document.querySelectorAll('[data-view-panel]')];
|
||
const viewLinks = [...document.querySelectorAll('[data-view-link]')];
|
||
|
||
sourceCrumb.href = state.sourceId ? `${linkToSource(state.sourceId)}#browse` : '/';
|
||
|
||
function normalizeView(value) {
|
||
return FILE_VIEWS.has(value) ? value : 'overview';
|
||
}
|
||
|
||
function setCurrentView(nextView, { syncHash = true, replace = false } = {}) {
|
||
state.currentView = normalizeView(nextView);
|
||
|
||
for (const panel of viewPanels) {
|
||
panel.hidden = panel.dataset.viewPanel !== state.currentView;
|
||
}
|
||
|
||
for (const link of viewLinks) {
|
||
const active = link.dataset.viewLink === state.currentView;
|
||
link.classList.toggle('is-active', active);
|
||
link.setAttribute('aria-current', active ? 'page' : 'false');
|
||
}
|
||
|
||
if (!syncHash) {
|
||
return;
|
||
}
|
||
|
||
const url = new URL(window.location.href);
|
||
url.hash = `#${state.currentView}`;
|
||
if (replace) {
|
||
window.history.replaceState(null, '', url);
|
||
} else {
|
||
window.history.pushState(null, '', url);
|
||
}
|
||
}
|
||
|
||
function setSummaryCard(element, message) {
|
||
element.innerHTML = `<div class="summary-stack"><div class="summary-row"><strong>${escapeHtml(message)}</strong></div></div>`;
|
||
}
|
||
|
||
function isPreviewAvailable() {
|
||
return Boolean(state.fileInfo && isVideoMime(state.fileInfo.file.mime));
|
||
}
|
||
|
||
function isFileRestorable() {
|
||
return Boolean(state.fileInfo);
|
||
}
|
||
|
||
function updateActionStates() {
|
||
const locked = state.pageLocked;
|
||
startPreviewButton.disabled = locked || !isPreviewAvailable();
|
||
downloadButton.disabled = locked || !isFileRestorable();
|
||
clearSourceCacheButton.disabled = locked || !supportsOpfs() || !state.sourceId;
|
||
clearAllCacheButton.disabled = locked || !supportsOpfs();
|
||
saveClientSecretsButton.disabled = locked;
|
||
saveClientGlobalsButton.disabled = locked;
|
||
deleteThumbnailButton.disabled = locked || !Boolean(state.fileInfo?.file?.thumbnail?.available);
|
||
deleteThumbnailFromPreviewButton.disabled = locked || !Boolean(state.fileInfo?.file?.thumbnail?.available);
|
||
|
||
for (const element of clientSecretForm.querySelectorAll('input, select')) {
|
||
element.disabled = locked;
|
||
}
|
||
}
|
||
|
||
function setControlsDisabled(disabled) {
|
||
state.pageLocked = disabled;
|
||
updateActionStates();
|
||
}
|
||
|
||
function renderMissingRouteContext() {
|
||
const missing = [];
|
||
if (!state.sourceId) {
|
||
missing.push('sourceId');
|
||
}
|
||
if (!state.fileId) {
|
||
missing.push('id');
|
||
}
|
||
|
||
setControlsDisabled(true);
|
||
fileTitleElement.textContent = '文件页未就绪';
|
||
fileSummaryElement.innerHTML = `
|
||
<div class="empty-state-card">
|
||
<p>这个页面缺少必要的地址参数。</p>
|
||
<p>缺少:${escapeHtml(missing.join(', '))}</p>
|
||
<p>请从数据源工作台里的文件列表进入,或检查当前地址是否包含 <code>sourceId</code> 和 <code>id</code>。</p>
|
||
</div>
|
||
`;
|
||
setSummaryCard(sessionStatusElement, '尚未注册媒体会话。');
|
||
setSummaryCard(cacheStatusElement, '本地缓存不可用。');
|
||
setSummaryCard(downloadStatusElement, '下载不可用。');
|
||
setSummaryCard(thumbnailStatusElement, '预览图不可用。');
|
||
setSummaryCard(previewThumbnailHintElement, '当前无法管理预览图。');
|
||
thumbnailPreviewElement.innerHTML = '<div class="thumbnail-placeholder">缺少地址参数</div>';
|
||
setCurrentView('overview', { replace: true });
|
||
}
|
||
|
||
function readClientSecretsForm() {
|
||
return {
|
||
webdavBaseUrl: document.querySelector('#clientWebdavBaseUrl')?.value?.trim() ?? '',
|
||
authMode: document.querySelector('#clientAuthMode')?.value ?? 'basic',
|
||
username: document.querySelector('#clientUsername')?.value?.trim() ?? '',
|
||
password: document.querySelector('#clientPassword')?.value ?? '',
|
||
passphrase: document.querySelector('#clientPassphrase')?.value ?? '',
|
||
rememberOnDevice: Boolean(document.querySelector('#rememberOnDevice')?.checked)
|
||
};
|
||
}
|
||
|
||
function writeClientSecretsForm(secrets) {
|
||
document.querySelector('#clientWebdavBaseUrl').value = secrets?.webdavBaseUrl ?? '';
|
||
document.querySelector('#clientAuthMode').value = secrets?.authMode ?? 'basic';
|
||
document.querySelector('#clientUsername').value = secrets?.username ?? '';
|
||
document.querySelector('#clientPassword').value = secrets?.password ?? '';
|
||
document.querySelector('#clientPassphrase').value = secrets?.passphrase ?? '';
|
||
document.querySelector('#rememberOnDevice').checked = Boolean(secrets?.rememberOnDevice);
|
||
}
|
||
|
||
function mergeClientSecrets({ sourceSecrets, globalSecrets, resolvedSecrets, sourceWebdav }) {
|
||
return {
|
||
webdavBaseUrl:
|
||
sourceSecrets?.webdavBaseUrl ??
|
||
resolvedSecrets?.webdavBaseUrl ??
|
||
sourceWebdav?.effectiveWebdavBaseUrl ??
|
||
sourceWebdav?.derivedWebdavBaseUrl ??
|
||
globalSecrets?.webdavBaseUrl ??
|
||
'',
|
||
authMode: sourceSecrets?.authMode ?? globalSecrets?.authMode ?? resolvedSecrets?.authMode ?? 'basic',
|
||
username: sourceSecrets?.username ?? globalSecrets?.username ?? resolvedSecrets?.username ?? '',
|
||
password: sourceSecrets?.password ?? globalSecrets?.password ?? resolvedSecrets?.password ?? '',
|
||
passphrase: sourceSecrets?.passphrase ?? globalSecrets?.passphrase ?? resolvedSecrets?.passphrase ?? '',
|
||
rememberOnDevice: Boolean(sourceSecrets)
|
||
};
|
||
}
|
||
|
||
function requireClientSecrets() {
|
||
const secrets = readClientSecretsForm();
|
||
if (!secrets.webdavBaseUrl) {
|
||
throw new Error('CLIENT_SECRETS_MISSING: 请输入浏览器直连使用的 WebDAV Base URL。');
|
||
}
|
||
|
||
if (!secrets.passphrase) {
|
||
throw new Error('CLIENT_SECRETS_MISSING: 请输入浏览器直连使用的备份口令。');
|
||
}
|
||
|
||
return secrets;
|
||
}
|
||
|
||
async function persistClientSecretsPreference(secrets) {
|
||
if (secrets.rememberOnDevice) {
|
||
await saveClientSecrets(state.sourceId, secrets);
|
||
} else {
|
||
await removeClientSecrets(state.sourceId);
|
||
}
|
||
}
|
||
|
||
function renderClientSecretHint() {
|
||
const sourceWebdav = state.source?.webdav;
|
||
clientSecretHintElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>任务默认 URL</span>
|
||
<strong>${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrl ?? sourceWebdav?.derivedWebdavBaseUrl ?? '未推导到')}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>默认 URL 来源</span>
|
||
<strong>${escapeHtml(sourceWebdav?.effectiveWebdavBaseUrlSource ?? 'none')}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>浏览器全局默认值</span>
|
||
<strong>${state.browserGlobalSecrets ? 'available' : 'missing'}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>浏览器单源覆盖</span>
|
||
<strong>${state.browserSourceSecrets ? 'saved' : 'none'}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>后端解析后的直连凭据</span>
|
||
<strong>${state.resolvedBrowserSecrets ? 'available' : state.resolvedBrowserSecretsError ? 'missing' : 'pending'}</strong>
|
||
</div>
|
||
${
|
||
state.resolvedBrowserSecretsError
|
||
? `
|
||
<div class="summary-row">
|
||
<span>后端凭据状态</span>
|
||
<strong>${escapeHtml(state.resolvedBrowserSecretsError.message)}</strong>
|
||
</div>
|
||
`
|
||
: ''
|
||
}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderWorkerStatus() {
|
||
if (!state.workerStatus) {
|
||
setSummaryCard(sessionStatusElement, '尚未注册媒体会话。');
|
||
return;
|
||
}
|
||
|
||
sessionStatusElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>当前阶段</span>
|
||
<strong>${escapeHtml(state.workerStatus.phase ?? 'idle')}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>当前卷</span>
|
||
<strong>${escapeHtml(state.workerStatus.currentVolume ?? '无')}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>已缓存 dblock</span>
|
||
<strong>${escapeHtml(String((state.workerStatus.cachedVolumes ?? []).length))}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>缓存列表</span>
|
||
<strong>${escapeHtml((state.workerStatus.cachedVolumes ?? []).join(', ') || '暂无缓存')}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>最近错误</span>
|
||
<strong>${escapeHtml(state.workerStatus.lastError ?? '无')}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPreviewThumbnailHint() {
|
||
if (!state.fileInfo) {
|
||
setSummaryCard(previewThumbnailHintElement, state.fileInfoError?.message ?? '当前文件还不能管理预览图。');
|
||
return;
|
||
}
|
||
|
||
const thumbnail = state.fileInfo.file.thumbnail;
|
||
const captureMessage = state.thumbnailCapture.lastMessage ?? '尚未生成预览图。';
|
||
previewThumbnailHintElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>当前缩略图</span>
|
||
<strong>${thumbnail?.available ? '已缓存到后端' : '暂无'}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>自动截帧状态</span>
|
||
<strong>${escapeHtml(captureMessage)}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>删除说明</span>
|
||
<strong>只删除后端缩略图缓存,不会删除浏览器本地视频缓存。</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderCacheStatus() {
|
||
if (!supportsOpfs()) {
|
||
setSummaryCard(cacheStatusElement, '当前浏览器不支持 OPFS,本地缓存功能不可用。');
|
||
updateActionStates();
|
||
return;
|
||
}
|
||
|
||
const currentSource = state.cacheStats.currentSource ?? {
|
||
volumeCount: 0,
|
||
totalBytes: 0
|
||
};
|
||
const global = state.cacheStats.global ?? {
|
||
sourceCount: 0,
|
||
volumeCount: 0,
|
||
totalBytes: 0
|
||
};
|
||
|
||
cacheStatusElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>当前 source 缓存</span>
|
||
<strong>${escapeHtml(String(currentSource.volumeCount))} 个 dblock / ${formatBytes(currentSource.totalBytes)}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>全部浏览器缓存</span>
|
||
<strong>${escapeHtml(String(global.sourceCount))} 个 source,${escapeHtml(String(global.volumeCount))} 个 dblock / ${formatBytes(global.totalBytes)}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderFileSummary() {
|
||
if (state.fileInfo) {
|
||
const file = state.fileInfo.file;
|
||
const volumeCount = state.fileInfo.volumes.length;
|
||
const isVideo = isVideoMime(file.mime);
|
||
fileTitleElement.textContent = file.name;
|
||
fileSummaryElement.innerHTML = `
|
||
<article class="source-card">
|
||
<div class="source-main">
|
||
<div>
|
||
<p class="source-name">${escapeHtml(file.name)}</p>
|
||
<p class="source-meta">${escapeHtml(file.path)}</p>
|
||
<p class="source-meta">sourceId: ${escapeHtml(state.sourceId)}</p>
|
||
</div>
|
||
<span class="status-pill ${state.source?.enhancement?.status === 'ready' ? 'status-ready' : 'status-idle'}">
|
||
${escapeHtml(state.source?.enhancement?.status ?? 'unknown')}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="stats-grid">
|
||
<div class="stat">
|
||
<span>MIME</span>
|
||
<strong>${escapeHtml(file.mime ?? 'unknown')}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>文件大小</span>
|
||
<strong>${formatBytes(file.size)}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>恢复片段</span>
|
||
<strong>${escapeHtml(String(state.fileInfo.restorePlan.segmentCount))}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>dblock 数量</span>
|
||
<strong>${escapeHtml(String(volumeCount))}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>块大小</span>
|
||
<strong>${formatBytes(state.fileInfo.restorePlan.blockSize)}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>范围播放</span>
|
||
<strong>${state.fileInfo.hints.rangeReady ? '可用' : '不可用'}</strong>
|
||
</div>
|
||
<div class="stat">
|
||
<span>后端预览图</span>
|
||
<strong>${state.fileInfo.file.thumbnail?.available ? '已缓存' : '暂无'}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="summary-chip">${isVideo ? '这是一个视频文件,可以启用前端 Range 播放。' : '这不是视频文件,播放器不会启用,但仍然可以直接下载。'}</div>
|
||
</article>
|
||
`;
|
||
videoHintElement.textContent = isVideo
|
||
? '点击“启动视频预览”后,浏览器会注册本地媒体会话,并通过 Service Worker 伪造 Range 请求。'
|
||
: '这个文件不是视频,视频预览区不会启用,但仍然可以直接下载。';
|
||
updateActionStates();
|
||
return;
|
||
}
|
||
|
||
const errorMessage = state.fileInfoError?.message ?? '尚未获得文件恢复信息。';
|
||
fileTitleElement.textContent = '文件操作未就绪';
|
||
fileSummaryElement.innerHTML = `
|
||
<div class="empty-state-card">
|
||
<p>这个文件当前还不能进入视频预览或下载流程。</p>
|
||
<p>${escapeHtml(errorMessage)}</p>
|
||
</div>
|
||
`;
|
||
videoHintElement.textContent = '请先回到数据源工作台完成增强,再回到文件页。';
|
||
updateActionStates();
|
||
}
|
||
|
||
function renderThumbnailPanel() {
|
||
if (!state.fileInfo) {
|
||
setSummaryCard(thumbnailStatusElement, state.fileInfoError?.message ?? '当前文件还不能读取预览图状态。');
|
||
thumbnailPreviewElement.innerHTML = '<div class="thumbnail-placeholder">暂无预览图</div>';
|
||
renderPreviewThumbnailHint();
|
||
updateActionStates();
|
||
return;
|
||
}
|
||
|
||
const thumbnail = state.fileInfo.file.thumbnail;
|
||
const capture = state.thumbnailCapture;
|
||
const statusText = thumbnail?.available
|
||
? '已缓存到后端'
|
||
: capture.inFlight
|
||
? '正在从当前视频首帧生成'
|
||
: '暂无预览图';
|
||
const captureMessage = capture.lastMessage ?? '尚未生成预览图。';
|
||
|
||
thumbnailStatusElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>当前状态</span>
|
||
<strong>${escapeHtml(statusText)}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>自动生成状态</span>
|
||
<strong>${escapeHtml(captureMessage)}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>缩略图 ID</span>
|
||
<strong>${escapeHtml(thumbnail?.thumbnailId ?? '无')}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (thumbnail?.available) {
|
||
thumbnailPreviewElement.innerHTML = `
|
||
<img
|
||
class="thumbnail-preview-image"
|
||
src="${escapeHtml(thumbnail.thumbnailUrl)}"
|
||
alt="${escapeHtml(state.fileInfo.file.name)} 的预览图"
|
||
>
|
||
`;
|
||
} else {
|
||
thumbnailPreviewElement.innerHTML = `
|
||
<div class="thumbnail-placeholder">${capture.inFlight ? '正在生成预览图...' : '暂无预览图'}</div>
|
||
`;
|
||
}
|
||
|
||
renderPreviewThumbnailHint();
|
||
updateActionStates();
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const previousCachedCount = state.workerStatus?.cachedVolumes?.length ?? 0;
|
||
state.workerStatus = event.data.status;
|
||
renderWorkerStatus();
|
||
|
||
const nextCachedCount = state.workerStatus?.cachedVolumes?.length ?? 0;
|
||
if (nextCachedCount !== previousCachedCount) {
|
||
void refreshCacheStats();
|
||
}
|
||
});
|
||
}
|
||
|
||
async function registerPreviewSession(secrets) {
|
||
await ensureServiceWorkerReady();
|
||
ensureWorkerStatusChannel();
|
||
|
||
if (state.currentSessionId) {
|
||
try {
|
||
await sendWorkerMessage('unregister-session', {
|
||
sessionId: state.currentSessionId
|
||
});
|
||
} catch (error) {
|
||
// 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.currentSessionId = sessionId;
|
||
state.workerStatus = response.status;
|
||
renderWorkerStatus();
|
||
return sessionId;
|
||
}
|
||
|
||
function resetThumbnailCaptureState(sessionId = null) {
|
||
state.thumbnailCapture = {
|
||
inFlight: false,
|
||
attempted: Boolean(state.fileInfo?.file?.thumbnail?.available),
|
||
suppressed: false,
|
||
sessionId,
|
||
probeSessionId: null,
|
||
lastMessage: state.fileInfo?.file?.thumbnail?.available
|
||
? '当前文件已存在后端预览图。'
|
||
: '等待视频首帧解码后自动生成。'
|
||
};
|
||
renderThumbnailPanel();
|
||
}
|
||
|
||
async function unregisterWorkerSession(sessionId) {
|
||
if (!sessionId) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await ensureServiceWorkerReady();
|
||
await sendWorkerMessage('unregister-session', {
|
||
sessionId
|
||
});
|
||
} catch (error) {
|
||
// ignore stale or already-closed sessions during local cleanup
|
||
}
|
||
}
|
||
|
||
async function stopCurrentPreviewSession() {
|
||
previewPlayer.pause();
|
||
previewPlayer.removeAttribute('src');
|
||
previewPlayer.load();
|
||
await unregisterWorkerSession(state.thumbnailCapture.probeSessionId);
|
||
state.thumbnailCapture.probeSessionId = null;
|
||
|
||
if (!state.currentSessionId) {
|
||
state.workerStatus = null;
|
||
renderWorkerStatus();
|
||
return;
|
||
}
|
||
|
||
await unregisterWorkerSession(state.currentSessionId);
|
||
state.currentSessionId = null;
|
||
state.activePreviewSecrets = null;
|
||
state.workerStatus = null;
|
||
renderWorkerStatus();
|
||
}
|
||
|
||
async function saveClientSecretsLocally(event) {
|
||
event.preventDefault();
|
||
feedback.set('info', '正在更新浏览器本地 source 覆盖...');
|
||
|
||
try {
|
||
const secrets = requireClientSecrets();
|
||
await persistClientSecretsPreference(secrets);
|
||
state.browserSourceSecrets = secrets.rememberOnDevice ? secrets : null;
|
||
renderClientSecretHint();
|
||
feedback.set(
|
||
secrets.rememberOnDevice ? 'success' : 'info',
|
||
secrets.rememberOnDevice
|
||
? '当前 source 的浏览器本地覆盖已保存。'
|
||
: '没有勾选记住,本次只保留在当前页面内存中。'
|
||
);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
async function saveClientSecretsAsGlobal() {
|
||
feedback.set('info', '正在保存浏览器全局默认值...');
|
||
|
||
try {
|
||
const secrets = requireClientSecrets();
|
||
await saveGlobalClientSecrets(secrets);
|
||
state.browserGlobalSecrets = secrets;
|
||
renderClientSecretHint();
|
||
feedback.set('success', '浏览器全局默认值已保存。后续文件页会默认继承这些字段。');
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
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: Hidden thumbnail 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 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));
|
||
|
||
thumbnailCaptureCanvas.width = targetWidth;
|
||
thumbnailCaptureCanvas.height = targetHeight;
|
||
const context = thumbnailCaptureCanvas.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) => {
|
||
thumbnailCaptureCanvas.toBlob(
|
||
(blob) => {
|
||
if (!blob) {
|
||
reject(new Error('THUMBNAIL_CAPTURE_FAILED: 浏览器没有生成有效的缩略图 Blob。'));
|
||
return;
|
||
}
|
||
|
||
resolve({
|
||
blob,
|
||
frameAnalysis
|
||
});
|
||
},
|
||
'image/webp',
|
||
0.82
|
||
);
|
||
});
|
||
}
|
||
|
||
async function createThumbnailProbeVideo(sessionId) {
|
||
const probeVideo = document.createElement('video');
|
||
probeVideo.muted = true;
|
||
probeVideo.playsInline = true;
|
||
probeVideo.preload = 'auto';
|
||
probeVideo.controls = false;
|
||
probeVideo.style.position = 'fixed';
|
||
probeVideo.style.opacity = '0';
|
||
probeVideo.style.pointerEvents = 'none';
|
||
probeVideo.style.width = '1px';
|
||
probeVideo.style.height = '1px';
|
||
probeVideo.style.left = '-9999px';
|
||
probeVideo.style.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 maybeCapturePreviewThumbnail() {
|
||
if (!state.fileInfo || !state.currentSessionId || !isVideoMime(state.fileInfo.file.mime)) {
|
||
return;
|
||
}
|
||
|
||
if (state.fileInfo.file.thumbnail?.available) {
|
||
return;
|
||
}
|
||
|
||
if (state.thumbnailCapture.inFlight || state.thumbnailCapture.attempted || state.thumbnailCapture.suppressed) {
|
||
return;
|
||
}
|
||
|
||
if (previewPlayer.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || previewPlayer.videoWidth === 0) {
|
||
return;
|
||
}
|
||
|
||
state.thumbnailCapture.inFlight = true;
|
||
state.thumbnailCapture.attempted = true;
|
||
state.thumbnailCapture.lastMessage = '正在寻找有效画面(准备探测候选时间点)...';
|
||
renderThumbnailPanel();
|
||
|
||
const activeSessionId = state.currentSessionId;
|
||
const previewSecrets = state.activePreviewSecrets ?? readClientSecretsForm();
|
||
const probeSessionId = crypto.randomUUID();
|
||
state.thumbnailCapture.probeSessionId = probeSessionId;
|
||
|
||
let probeVideo = null;
|
||
try {
|
||
await sendWorkerMessage('register-session', {
|
||
sessionId: probeSessionId,
|
||
sourceId: state.sourceId,
|
||
fileInfo: state.fileInfo,
|
||
secrets: previewSecrets
|
||
});
|
||
|
||
probeVideo = await createThumbnailProbeVideo(probeSessionId);
|
||
const candidates = buildThumbnailCandidateTimes(probeVideo.duration);
|
||
let selectedBlob = null;
|
||
let selectedMessage = '';
|
||
let fallbackBlob = null;
|
||
|
||
for (let index = 0; index < candidates.length; index += 1) {
|
||
if (state.currentSessionId !== activeSessionId || state.thumbnailCapture.sessionId !== activeSessionId) {
|
||
state.thumbnailCapture.lastMessage = '当前会话已切换,跳过旧会话的预览图上传。';
|
||
return;
|
||
}
|
||
|
||
const candidate = candidates[index];
|
||
state.thumbnailCapture.lastMessage = `正在寻找有效画面(第 ${index + 1}/${candidates.length} 个候选时间点,${candidate.toFixed(2)}s)`;
|
||
renderThumbnailPanel();
|
||
|
||
await seekHiddenVideoTo(probeVideo, candidate);
|
||
const { blob, frameAnalysis } = await createThumbnailBlobFromVideo(probeVideo);
|
||
fallbackBlob = blob;
|
||
|
||
if (!frameAnalysis.isBlackFrame) {
|
||
selectedBlob = blob;
|
||
selectedMessage = `已选中第 ${index + 1}/${candidates.length} 个候选时间点(${candidate.toFixed(2)}s)。`;
|
||
break;
|
||
}
|
||
|
||
state.thumbnailCapture.lastMessage = `已跳过黑帧(第 ${index + 1}/${candidates.length} 个候选时间点,${candidate.toFixed(2)}s),继续向后寻找。`;
|
||
renderThumbnailPanel();
|
||
}
|
||
|
||
const blobToUpload = selectedBlob ?? fallbackBlob;
|
||
if (!blobToUpload) {
|
||
throw new Error('THUMBNAIL_CAPTURE_FAILED: 没有拿到可上传的预览图。');
|
||
}
|
||
|
||
if (!selectedMessage) {
|
||
selectedMessage = '前面的候选时间点都偏黑,已回退到最后一个可用画面。';
|
||
}
|
||
|
||
if (state.currentSessionId !== activeSessionId || state.thumbnailCapture.sessionId !== activeSessionId) {
|
||
state.thumbnailCapture.lastMessage = '当前会话已切换,跳过旧会话的预览图上传。';
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.set('fileId', state.fileId);
|
||
formData.set('image', blobToUpload, 'preview.webp');
|
||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails`, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
state.fileInfo.file.thumbnail = payload.thumbnail;
|
||
state.thumbnailCapture.lastMessage = `${selectedMessage} 预览图已缓存到后端。`;
|
||
renderFileSummary();
|
||
renderThumbnailPanel();
|
||
} catch (error) {
|
||
state.thumbnailCapture.lastMessage = error.message;
|
||
renderThumbnailPanel();
|
||
} finally {
|
||
cleanupThumbnailProbeVideo(probeVideo);
|
||
await unregisterWorkerSession(probeSessionId);
|
||
if (state.thumbnailCapture.probeSessionId === probeSessionId) {
|
||
state.thumbnailCapture.probeSessionId = null;
|
||
}
|
||
state.thumbnailCapture.inFlight = false;
|
||
renderThumbnailPanel();
|
||
}
|
||
}
|
||
|
||
async function startVideoPreview() {
|
||
feedback.set('info', '正在注册浏览器端媒体会话...');
|
||
|
||
try {
|
||
assertBrowserFeature(
|
||
supportsVideoPreview(),
|
||
'BROWSER_PREVIEW_UNSUPPORTED: 当前浏览器不支持 OPFS、Service Worker 或 Range 预览所需能力。'
|
||
);
|
||
if (!state.fileInfo) {
|
||
throw state.fileInfoError ?? new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。');
|
||
}
|
||
if (!isVideoMime(state.fileInfo.file.mime)) {
|
||
throw new Error('VIDEO_MIME_UNSUPPORTED: 当前文件不是视频,无法启用播放器。');
|
||
}
|
||
|
||
const secrets = requireClientSecrets();
|
||
await persistClientSecretsPreference(secrets);
|
||
state.activePreviewSecrets = { ...secrets };
|
||
const sessionId = await registerPreviewSession(secrets);
|
||
resetThumbnailCaptureState(sessionId);
|
||
previewPlayer.src = `/__media__/session/${encodeURIComponent(sessionId)}`;
|
||
previewPlayer.load();
|
||
setCurrentView('preview');
|
||
feedback.set('success', '视频预览会话已注册,现在可以开始播放并拖动进度。');
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
async function downloadFileDirectly() {
|
||
feedback.set('info', '正在准备浏览器直连下载...');
|
||
|
||
try {
|
||
assertBrowserFeature(
|
||
supportsFileDownload(),
|
||
'BROWSER_DOWNLOAD_UNSUPPORTED: 当前浏览器不支持 OPFS 或 showSaveFilePicker。'
|
||
);
|
||
if (!state.fileInfo) {
|
||
throw state.fileInfoError ?? new Error('FILE_INFO_UNAVAILABLE: 当前文件还不能恢复。');
|
||
}
|
||
|
||
const secrets = requireClientSecrets();
|
||
await persistClientSecretsPreference(secrets);
|
||
|
||
const extension = state.fileInfo.file.name.includes('.')
|
||
? `.${state.fileInfo.file.name.split('.').pop()}`
|
||
: '.bin';
|
||
const fileHandle = await showSaveFilePicker({
|
||
suggestedName: state.fileInfo.file.name,
|
||
types: [
|
||
{
|
||
description: 'Restored file',
|
||
accept: {
|
||
[state.fileInfo.file.mime || 'application/octet-stream']: [extension]
|
||
}
|
||
}
|
||
]
|
||
});
|
||
const writable = await fileHandle.createWritable();
|
||
const volumeByRef = buildVolumeMap(state.fileInfo.volumes);
|
||
let writtenBytes = 0;
|
||
|
||
for (const segment of state.fileInfo.segments) {
|
||
const volume = volumeByRef.get(segment.volumeRef);
|
||
downloadStatusElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>当前卷</span>
|
||
<strong>${escapeHtml(volume?.name ?? segment.volumeRef)}</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>已写入</span>
|
||
<strong>${formatBytes(writtenBytes)} / ${formatBytes(state.fileInfo.file.size)}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const bytes = await restoreSegmentBytes({
|
||
sourceId: state.sourceId,
|
||
volume,
|
||
zip: segment.zip,
|
||
secrets,
|
||
onStatus: ({ phase, currentVolume }) => {
|
||
state.workerStatus = {
|
||
phase,
|
||
currentVolume,
|
||
cachedVolumes: state.workerStatus?.cachedVolumes ?? [],
|
||
lastError: null
|
||
};
|
||
renderWorkerStatus();
|
||
}
|
||
});
|
||
|
||
await writable.write(bytes);
|
||
writtenBytes += bytes.length;
|
||
}
|
||
|
||
await writable.close();
|
||
await refreshCacheStats();
|
||
downloadStatusElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>下载状态</span>
|
||
<strong>已完成</strong>
|
||
</div>
|
||
<div class="summary-row">
|
||
<span>写入总量</span>
|
||
<strong>${formatBytes(writtenBytes)}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
feedback.set('success', '文件已经直接恢复并写入本地。');
|
||
} catch (error) {
|
||
downloadStatusElement.innerHTML = `
|
||
<div class="summary-stack">
|
||
<div class="summary-row">
|
||
<span>下载状态</span>
|
||
<strong>${escapeHtml(error.message)}</strong>
|
||
</div>
|
||
</div>
|
||
`;
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
async function refreshCacheStats() {
|
||
if (!supportsOpfs()) {
|
||
renderCacheStatus();
|
||
return;
|
||
}
|
||
|
||
const [currentSource, global] = await Promise.all([
|
||
state.sourceId ? getCachedVolumeStats(state.sourceId) : Promise.resolve({ volumeCount: 0, totalBytes: 0 }),
|
||
getGlobalCacheStats()
|
||
]);
|
||
|
||
state.cacheStats = {
|
||
currentSource,
|
||
global
|
||
};
|
||
renderCacheStatus();
|
||
}
|
||
|
||
async function clearCurrentSourceCache() {
|
||
feedback.set('info', '正在清理当前 source 的浏览器本地缓存...');
|
||
|
||
try {
|
||
assertBrowserFeature(supportsOpfs(), 'CACHE_UNSUPPORTED: 当前浏览器不支持 OPFS,本地缓存不可清理。');
|
||
await stopCurrentPreviewSession();
|
||
const result = await clearCachedVolumesForSource(state.sourceId);
|
||
await refreshCacheStats();
|
||
feedback.set(
|
||
'success',
|
||
`当前 source 缓存已清理,删除了 ${result.removedVolumes} 个 dblock,共 ${formatBytes(result.removedBytes)}。`
|
||
);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
async function clearAllBrowserCache() {
|
||
const confirmed = window.confirm(
|
||
'确定要清理当前浏览器保存的全部视频和下载缓存吗?\n\n这不会删除后端数据库、增强索引或 Duplicati-server.sqlite,只会删除浏览器 OPFS 里的本地缓存。'
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
feedback.set('info', '正在清理全部浏览器本地缓存...');
|
||
|
||
try {
|
||
assertBrowserFeature(supportsOpfs(), 'CACHE_UNSUPPORTED: 当前浏览器不支持 OPFS,本地缓存不可清理。');
|
||
await stopCurrentPreviewSession();
|
||
const result = await clearAllCachedVolumes();
|
||
await refreshCacheStats();
|
||
feedback.set(
|
||
'success',
|
||
`全部浏览器缓存已清理,删除了 ${result.removedSources} 个 source、${result.removedVolumes} 个 dblock,共 ${formatBytes(result.removedBytes)}。`
|
||
);
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentThumbnail() {
|
||
const thumbnailId = state.fileInfo?.file?.thumbnail?.thumbnailId;
|
||
if (!thumbnailId) {
|
||
return;
|
||
}
|
||
|
||
const confirmed = window.confirm(
|
||
'确定要删除当前文件的预览图吗?\n\n这不会影响视频播放、下载或 Duplicati-server.sqlite,只会删除后端保存的这张缩略图。'
|
||
);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
|
||
feedback.set('info', '正在删除当前文件的预览图...');
|
||
|
||
try {
|
||
await fetchJson(
|
||
`/api/sources/${encodeURIComponent(state.sourceId)}/thumbnails/${encodeURIComponent(thumbnailId)}`,
|
||
{
|
||
method: 'DELETE'
|
||
}
|
||
);
|
||
state.fileInfo.file.thumbnail = {
|
||
available: false,
|
||
thumbnailId: null,
|
||
thumbnailUrl: null
|
||
};
|
||
state.thumbnailCapture.suppressed = true;
|
||
state.thumbnailCapture.attempted = true;
|
||
state.thumbnailCapture.lastMessage = '当前预览图已删除。下次启动新预览会话时会重新生成。';
|
||
renderFileSummary();
|
||
renderThumbnailPanel();
|
||
feedback.set('success', '当前文件的预览图已删除。');
|
||
} catch (error) {
|
||
feedback.set('error', error.message);
|
||
}
|
||
}
|
||
|
||
async function loadSource() {
|
||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}`);
|
||
state.source = payload.source;
|
||
renderClientSecretHint();
|
||
}
|
||
|
||
async function loadFileInfo() {
|
||
try {
|
||
const payload = await fetchJson(
|
||
`/api/file-info?sourceId=${encodeURIComponent(state.sourceId)}&id=${encodeURIComponent(state.fileId)}`
|
||
);
|
||
state.fileInfo = payload;
|
||
state.fileInfoError = null;
|
||
if (!state.fileInfo.file.thumbnail) {
|
||
state.fileInfo.file.thumbnail = {
|
||
available: false,
|
||
thumbnailId: null,
|
||
thumbnailUrl: null
|
||
};
|
||
}
|
||
} catch (error) {
|
||
state.fileInfo = null;
|
||
state.fileInfoError = error;
|
||
}
|
||
}
|
||
|
||
async function loadResolvedBrowserSecrets() {
|
||
try {
|
||
const payload = await fetchJson(`/api/sources/${encodeURIComponent(state.sourceId)}/browser-secrets`);
|
||
state.resolvedBrowserSecrets = payload.secrets ?? null;
|
||
state.resolvedBrowserSecretsError = null;
|
||
} catch (error) {
|
||
state.resolvedBrowserSecrets = null;
|
||
state.resolvedBrowserSecretsError = error;
|
||
}
|
||
}
|
||
|
||
async function preloadClientSecrets() {
|
||
const [globalSecrets, sourceSecrets] = await Promise.all([
|
||
loadGlobalClientSecrets(),
|
||
loadClientSecrets(state.sourceId)
|
||
]);
|
||
state.browserGlobalSecrets = globalSecrets;
|
||
state.browserSourceSecrets = sourceSecrets;
|
||
|
||
const merged = mergeClientSecrets({
|
||
sourceSecrets,
|
||
globalSecrets,
|
||
resolvedSecrets: state.resolvedBrowserSecrets,
|
||
sourceWebdav: state.source?.webdav
|
||
});
|
||
writeClientSecretsForm(merged);
|
||
renderClientSecretHint();
|
||
}
|
||
|
||
clientSecretForm.addEventListener('submit', (event) => {
|
||
void saveClientSecretsLocally(event);
|
||
});
|
||
|
||
saveClientGlobalsButton.addEventListener('click', () => {
|
||
void saveClientSecretsAsGlobal();
|
||
});
|
||
|
||
clearSourceCacheButton.addEventListener('click', () => {
|
||
void clearCurrentSourceCache();
|
||
});
|
||
|
||
clearAllCacheButton.addEventListener('click', () => {
|
||
void clearAllBrowserCache();
|
||
});
|
||
|
||
startPreviewButton.addEventListener('click', () => {
|
||
void startVideoPreview();
|
||
});
|
||
|
||
deleteThumbnailFromPreviewButton.addEventListener('click', () => {
|
||
void deleteCurrentThumbnail();
|
||
});
|
||
|
||
downloadButton.addEventListener('click', () => {
|
||
void downloadFileDirectly();
|
||
});
|
||
|
||
deleteThumbnailButton.addEventListener('click', () => {
|
||
void deleteCurrentThumbnail();
|
||
});
|
||
|
||
previewPlayer.addEventListener('loadeddata', () => {
|
||
void maybeCapturePreviewThumbnail();
|
||
});
|
||
|
||
for (const link of viewLinks) {
|
||
link.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
setCurrentView(normalizeView(link.dataset.viewLink ?? 'overview'));
|
||
});
|
||
}
|
||
|
||
window.addEventListener('hashchange', () => {
|
||
setCurrentView(normalizeView(window.location.hash.slice(1)), { syncHash: false });
|
||
});
|
||
|
||
window.addEventListener('beforeunload', () => {
|
||
if (!navigator.serviceWorker?.controller) {
|
||
return;
|
||
}
|
||
|
||
if (state.currentSessionId) {
|
||
navigator.serviceWorker.controller.postMessage({
|
||
type: 'unregister-session',
|
||
sessionId: state.currentSessionId
|
||
});
|
||
}
|
||
|
||
if (state.thumbnailCapture.probeSessionId) {
|
||
navigator.serviceWorker.controller.postMessage({
|
||
type: 'unregister-session',
|
||
sessionId: state.thumbnailCapture.probeSessionId
|
||
});
|
||
}
|
||
});
|
||
|
||
async function bootstrap() {
|
||
const initialView = normalizeView(window.location.hash.slice(1) || 'overview');
|
||
setCurrentView(initialView, { replace: true });
|
||
const authentication = await loadAuthState();
|
||
authStatusElement.textContent = authentication.authenticated
|
||
? `已登录:${authentication.username}`
|
||
: '未登录';
|
||
bindLogoutButton(logoutButton, authStatusElement);
|
||
|
||
if (!state.sourceId || !state.fileId) {
|
||
renderMissingRouteContext();
|
||
return;
|
||
}
|
||
|
||
setControlsDisabled(false);
|
||
setSummaryCard(sessionStatusElement, '尚未注册媒体会话。');
|
||
setSummaryCard(cacheStatusElement, '正在统计浏览器本地缓存...');
|
||
setSummaryCard(downloadStatusElement, '尚未开始下载。');
|
||
setSummaryCard(thumbnailStatusElement, '正在读取预览图状态...');
|
||
|
||
await Promise.all([loadSource(), loadFileInfo(), loadResolvedBrowserSecrets()]);
|
||
await preloadClientSecrets();
|
||
await refreshCacheStats();
|
||
resetThumbnailCaptureState(null);
|
||
renderFileSummary();
|
||
renderWorkerStatus();
|
||
renderThumbnailPanel();
|
||
updateActionStates();
|
||
}
|
||
|
||
void bootstrap().catch((error) => {
|
||
feedback.set('error', error.message);
|
||
fileSummaryElement.innerHTML = `
|
||
<div class="empty-state-card">
|
||
<p>无法进入文件页。</p>
|
||
</div>
|
||
`;
|
||
});
|