diff --git a/src/components/DownloadManagementPanel.tsx b/src/components/DownloadManagementPanel.tsx index 3188fd8..019e71e 100644 --- a/src/components/DownloadManagementPanel.tsx +++ b/src/components/DownloadManagementPanel.tsx @@ -1,11 +1,16 @@ 'use client'; -import { Check, ChevronDown, Trash2, X } from 'lucide-react'; +import { Check, ChevronDown, Download, Trash2, X } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { downloadDB, CompletedTask } from '@/lib/download-db'; -import { deleteIndexedDBVideoCacheByEpisode } from '@/lib/indexeddb-video-cache'; +import { + buildIndexedDBVideoCacheKey, + getIndexedDBVideoManifestByEpisode, + getIndexedDBVideoSegments, + deleteIndexedDBVideoCacheByEpisode, +} from '@/lib/indexeddb-video-cache'; import { ConfirmDialog } from './ConfirmDialog'; @@ -25,11 +30,17 @@ interface VideoDownloadGroup { downloadModes: CompletedTask['downloadMode'][]; } -export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) { +export function DownloadManagementPanel({ + isOpen, + onClose, +}: DownloadManagementPanelProps) { const [completedTasks, setCompletedTasks] = useState([]); const [selectedIds, setSelectedIds] = useState>(new Set()); - const [expandedGroupKeys, setExpandedGroupKeys] = useState>(new Set()); + const [expandedGroupKeys, setExpandedGroupKeys] = useState>( + new Set() + ); const [isDeleting, setIsDeleting] = useState(false); + const [isExporting, setIsExporting] = useState(false); const [mounted, setMounted] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); @@ -56,7 +67,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP if (selectedIds.size === completedTasks.length) { setSelectedIds(new Set()); } else { - setSelectedIds(new Set(completedTasks.map(t => t.id))); + setSelectedIds(new Set(completedTasks.map((t) => t.id))); } }; @@ -104,7 +115,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP setIsDeleting(true); try { // 获取要删除的任务 - const tasksToDelete = completedTasks.filter(t => selectedIds.has(t.id)); + const tasksToDelete = completedTasks.filter((t) => selectedIds.has(t.id)); //禁止SzeMeng76抄袭狗抄袭 // 删除文件系统中的文件 @@ -115,7 +126,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP const dbName = 'MoonTVPlus'; const storeName = 'dirHandles'; - const dirHandle = await new Promise((resolve) => { + const dirHandle = await new Promise< + FileSystemDirectoryHandle | undefined + >((resolve) => { const request = indexedDB.open(dbName, 2); // 使用版本 2 request.onsuccess = (event) => { @@ -132,7 +145,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP const getRequest = store.get('downloadDir'); getRequest.onsuccess = () => { - const handle = getRequest.result as FileSystemDirectoryHandle | undefined; + const handle = getRequest.result as + | FileSystemDirectoryHandle + | undefined; db.close(); resolve(handle); }; @@ -150,7 +165,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP if (dirHandle) { // 请求写权限 - const permission = await (dirHandle as any).requestPermission({ mode: 'readwrite' }); + const permission = await (dirHandle as any).requestPermission({ + mode: 'readwrite', + }); if (permission !== 'granted') { console.error('未获得写权限,无法删除文件'); continue; @@ -158,10 +175,24 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP // 删除目录 try { - const sourceDirHandle = await dirHandle.getDirectoryHandle(task.source, { create: false }); - const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false }); - await videoIdDirHandle.removeEntry(`ep${task.episodeIndex + 1}`, { recursive: true }); - console.log('已删除文件:', task.source, task.videoId, `ep${task.episodeIndex + 1}`); + const sourceDirHandle = await dirHandle.getDirectoryHandle( + task.source, + { create: false } + ); + const videoIdDirHandle = + await sourceDirHandle.getDirectoryHandle(task.videoId, { + create: false, + }); + await videoIdDirHandle.removeEntry( + `ep${task.episodeIndex + 1}`, + { recursive: true } + ); + console.log( + '已删除文件:', + task.source, + task.videoId, + `ep${task.episodeIndex + 1}` + ); } catch (deleteError) { console.error('删除目录失败:', deleteError); // 如果目录不存在,也算成功 @@ -180,7 +211,12 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP task.videoId, task.episodeIndex ); - console.log('已删除 IndexedDB 视频缓存:', task.source, task.videoId, task.episodeIndex); + console.log( + '已删除 IndexedDB 视频缓存:', + task.source, + task.videoId, + task.episodeIndex + ); } catch (error) { console.error('删除 IndexedDB 视频缓存失败:', task.title, error); } @@ -199,6 +235,214 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP } }; + const sanitizeFilename = (name: string) => { + return ( + name + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 120) || 'download' + ); + }; + + const triggerBlobDownload = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + const getStoredDownloadDirHandle = async (): Promise< + FileSystemDirectoryHandle | undefined + > => { + const dbName = 'MoonTVPlus'; + const storeName = 'dirHandles'; + + return new Promise((resolve) => { + const request = indexedDB.open(dbName, 2); + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + + if (!db.objectStoreNames.contains(storeName)) { + db.close(); + resolve(undefined); + return; + } + + const transaction = db.transaction([storeName], 'readonly'); + const store = transaction.objectStore(storeName); + const getRequest = store.get('downloadDir'); + + getRequest.onsuccess = () => { + const handle = getRequest.result as + | FileSystemDirectoryHandle + | undefined; + db.close(); + resolve(handle); + }; + + getRequest.onerror = () => { + db.close(); + resolve(undefined); + }; + }; + + request.onerror = () => resolve(undefined); + }); + }; + + const readFilesystemTaskAsBlobParts = async ( + task: CompletedTask, + dirHandle: FileSystemDirectoryHandle + ): Promise => { + const sourceDirHandle = await dirHandle.getDirectoryHandle(task.source, { + create: false, + }); + const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle( + task.videoId, + { create: false } + ); + const epDirHandle = await videoIdDirHandle.getDirectoryHandle( + `ep${task.episodeIndex + 1}`, + { create: false } + ); + + const segmentFiles: Array<{ name: string; file: File }> = []; + for await (const entry of epDirHandle.values()) { + if (entry.kind === 'file' && /^segment_\d+\.ts$/i.test(entry.name)) { + const fileHandle = entry as FileSystemFileHandle; + segmentFiles.push({ + name: entry.name, + file: await fileHandle.getFile(), + }); + } + } + + segmentFiles.sort((a, b) => + a.name.localeCompare(b.name, undefined, { numeric: true }) + ); + + if (segmentFiles.length === 0) { + throw new Error('未找到可导出的分片文件'); + } + + return segmentFiles.map(({ file }) => file); + }; + + const readIndexedDBTaskAsBlobParts = async ( + task: CompletedTask + ): Promise => { + const manifest = await getIndexedDBVideoManifestByEpisode( + task.source, + task.videoId, + task.episodeIndex + ); + const cacheKey = + manifest?.cacheKey || + buildIndexedDBVideoCacheKey(task.source, task.videoId, task.episodeIndex); + const segments = await getIndexedDBVideoSegments(cacheKey); + + if (segments.length === 0) { + throw new Error('未找到可导出的 IndexedDB 分片'); + } + + return segments.map((segment) => segment.data); + }; + + const handleExport = async () => { + if (selectedIds.size === 0 || isExporting) return; + + setIsExporting(true); + try { + const tasksToExport = completedTasks + .filter((task) => selectedIds.has(task.id)) + .sort((a, b) => { + const groupCompare = `${a.source}::${a.videoId}`.localeCompare( + `${b.source}::${b.videoId}` + ); + if (groupCompare !== 0) return groupCompare; + if (a.episodeIndex !== b.episodeIndex) + return a.episodeIndex - b.episodeIndex; + return a.completedAt - b.completedAt; + }); + + const unsupportedTasks = tasksToExport.filter( + (task) => task.downloadMode === 'browser' + ); + if (unsupportedTasks.length > 0) { + alert( + '浏览器下载模式的文件未保存在本地缓存中,无法从下载管理导出。请仅选择 File System API 或 IndexedDB 缓存记录。' + ); + return; + } + + let dirHandle: FileSystemDirectoryHandle | undefined; + if (tasksToExport.some((task) => task.downloadMode === 'filesystem')) { + dirHandle = await getStoredDownloadDirHandle(); + if (!dirHandle) { + alert('无法读取下载目录授权,请先在下载设置中重新选择保存目录。'); + return; + } + + const permission = await (dirHandle as any).requestPermission({ + mode: 'read', + }); + if (permission !== 'granted') { + alert('未获得下载目录读取权限,无法导出。'); + return; + } + } + + const blobParts: BlobPart[] = []; + const failedTitles: string[] = []; + + for (const task of tasksToExport) { + try { + const parts = + task.downloadMode === 'filesystem' + ? await readFilesystemTaskAsBlobParts(task, dirHandle!) + : await readIndexedDBTaskAsBlobParts(task); + blobParts.push(...parts); + } catch (error) { + console.error('导出任务失败:', task.title, error); + failedTitles.push(`第 ${task.episodeIndex + 1} 集`); + } + } + + if (blobParts.length === 0) { + alert(`导出失败:${failedTitles.join('、') || '未找到可导出的内容'}`); + return; + } + + const selectedGroups = videoGroups.filter((group) => + group.tasks.some((task) => selectedIds.has(task.id)) + ); + const baseName = + selectedGroups.length === 1 + ? selectedGroups[0].title + : `MoonTVPlus导出_${tasksToExport.length}集`; + const blob = new Blob(blobParts, { type: 'video/MP2T' }); + triggerBlobDownload(blob, `${sanitizeFilename(baseName)}.ts`); + + if (failedTitles.length > 0) { + alert(`已导出可读取的内容,但以下条目失败:${failedTitles.join('、')}`); + } + } catch (error) { + console.error('导出失败:', error); + alert( + `导出失败:${error instanceof Error ? error.message : String(error)}` + ); + } finally { + setIsExporting(false); + } + }; + const formatDate = (timestamp: number) => { const date = new Date(timestamp); return date.toLocaleString('zh-CN', { @@ -214,7 +458,8 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP if (!bytes) return '未知'; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'; - if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; + if (bytes < 1024 * 1024 * 1024) + return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }; @@ -235,10 +480,12 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP if (taskWithVideoTitle?.videoTitle) return taskWithVideoTitle.videoTitle; const firstTitle = tasks[0]?.title || '未知视频'; - return firstTitle - .replace(/[_\s-]*第\s*\d+\s*集.*$/u, '') - .replace(/[_\s-]*EP?\s*\d+.*$/iu, '') - .trim() || firstTitle; + return ( + firstTitle + .replace(/[_\s-]*第\s*\d+\s*集.*$/u, '') + .replace(/[_\s-]*EP?\s*\d+.*$/iu, '') + .trim() || firstTitle + ); }; const videoGroups = useMemo(() => { @@ -259,7 +506,10 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP } return b.completedAt - a.completedAt; }); - const totalSize = sortedTasks.reduce((sum, task) => sum + (task.fileSize || 0), 0); + const totalSize = sortedTasks.reduce( + (sum, task) => sum + (task.fileSize || 0), + 0 + ); const modeSet = new Set( sortedTasks.map((task) => task.downloadMode) ); @@ -271,7 +521,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP title: getGroupTitle(sortedTasks), tasks: sortedTasks, totalSize: totalSize > 0 ? totalSize : undefined, - lastCompletedAt: Math.max(...sortedTasks.map((task) => task.completedAt)), + lastCompletedAt: Math.max( + ...sortedTasks.map((task) => task.completedAt) + ), downloadModes: Array.from(modeSet), }; }) @@ -283,205 +535,237 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP return ( <> {createPortal( -
-
-
- {/* Header */} -
-

- 下载文件管理 -

- -
- - {/* Toolbar */} -
-
- - - 已选择 {selectedIds.size} / {completedTasks.length} 集 - {videoGroups.length > 0 && `,共 ${videoGroups.length} 个视频`} - -
- -
- - {/* Content */} -
- {completedTasks.length === 0 ? ( -
- 暂无下载记录 +
+
+
+ {/* Header */} +
+

+ 下载文件管理 +

+
- ) : ( -
- {videoGroups.map((group) => { - const isExpanded = expandedGroupKeys.has(group.key); - const selectedCount = group.tasks.filter((task) => selectedIds.has(task.id)).length; - const isGroupSelected = selectedCount === group.tasks.length; - const isGroupPartiallySelected = selectedCount > 0 && !isGroupSelected; - return ( -
+
+ + + 已选择 {selectedIds.size} / {completedTasks.length} 集 + {videoGroups.length > 0 && + `,共 ${videoGroups.length} 个视频`} + +
+
+ - -
-
-
-

- {group.title} -

-
- 来源: {group.source} - - {group.tasks.length} 集 - - {group.downloadModes.map(getDownloadModeLabel).join(' / ')} -
-
-
-
-
- 最近完成:{formatDate(group.lastCompletedAt)} -
-
- 总大小:{formatFileSize(group.totalSize)} -
- {selectedCount > 0 && ( -
- 已选 {selectedCount} 集 -
- )} -
- -
-
-
-
- - {isExpanded && ( -
- {group.tasks.map((task) => ( -
handleToggleSelect(task.id)} - > -
-
- {selectedIds.has(task.id) && ( - - )} -
-
-
-
-
-

- 第 {task.episodeIndex + 1} 集 - {task.episodeTitle ? `:${task.episodeTitle}` : ''} -

-

- {task.title} -

-
-
-
- {formatDate(task.completedAt)} -
-
- {formatFileSize(task.fileSize)} -
-
-
-
- 第 {task.episodeIndex + 1} 集 - - {getDownloadModeLabel(task.downloadMode)} -
-
-
- ))} -
- )} -
- ); - })} + + + {isExporting ? '导出中...' : '导出选中'} + + + +
- )} -
-
-
, - document.body - )} + + {/* Content */} +
+ {completedTasks.length === 0 ? ( +
+ 暂无下载记录 +
+ ) : ( +
+ {videoGroups.map((group) => { + const isExpanded = expandedGroupKeys.has(group.key); + const selectedCount = group.tasks.filter((task) => + selectedIds.has(task.id) + ).length; + const isGroupSelected = + selectedCount === group.tasks.length; + const isGroupPartiallySelected = + selectedCount > 0 && !isGroupSelected; + + return ( +
+
handleToggleGroupExpand(group.key)} + > + + +
+
+
+

+ {group.title} +

+
+ 来源: {group.source} + + {group.tasks.length} 集 + + + {group.downloadModes + .map(getDownloadModeLabel) + .join(' / ')} + +
+
+
+
+
+ 最近完成: + {formatDate(group.lastCompletedAt)} +
+
+ 总大小:{formatFileSize(group.totalSize)} +
+ {selectedCount > 0 && ( +
+ 已选 {selectedCount} 集 +
+ )} +
+ +
+
+
+
+ + {isExpanded && ( +
+ {group.tasks.map((task) => ( +
handleToggleSelect(task.id)} + > +
+
+ {selectedIds.has(task.id) && ( + + )} +
+
+
+
+
+

+ 第 {task.episodeIndex + 1} 集 + {task.episodeTitle + ? `:${task.episodeTitle}` + : ''} +

+

+ {task.title} +

+
+
+
+ {formatDate(task.completedAt)} +
+
+ {formatFileSize(task.fileSize)} +
+
+
+
+ 第 {task.episodeIndex + 1} 集 + + + {getDownloadModeLabel(task.downloadMode)} + +
+
+
+ ))} +
+ )} +
+ ); + })} +
+ )} +
+
+
, + document.body + )} { const manifestStore = db.createObjectStore(MANIFESTS_STORE, { keyPath: 'cacheKey', }); - manifestStore.createIndex('sourceVideoEpisode', ['source', 'videoId', 'episodeIndex'], { - unique: true, - }); + manifestStore.createIndex( + 'sourceVideoEpisode', + ['source', 'videoId', 'episodeIndex'], + { + unique: true, + } + ); manifestStore.createIndex('completed', 'completed', { unique: false }); manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false }); } @@ -152,7 +156,9 @@ export async function getIndexedDBVideoManifestByCacheKey( const db = await openIndexedDBVideoCache(); const tx = db.transaction([MANIFESTS_STORE], 'readonly'); const store = tx.objectStore(MANIFESTS_STORE); - return requestToPromise(store.get(cacheKey)); + return requestToPromise( + store.get(cacheKey) + ); } export async function getIndexedDBVideoManifestByEpisode( @@ -174,7 +180,11 @@ export async function isIndexedDBVideoDownloaded( videoId: string, episodeIndex: number ): Promise { - const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex); + const manifest = await getIndexedDBVideoManifestByEpisode( + source, + videoId, + episodeIndex + ); return Boolean(manifest?.completed && manifest.segmentCount > 0); } @@ -185,9 +195,10 @@ export async function saveIndexedDBVideoSegment(input: { mimeType?: string; }): Promise { const db = await openIndexedDBVideoCache(); - const blob = input.data instanceof Blob - ? input.data - : new Blob([input.data], { type: input.mimeType || 'video/MP2T' }); + const blob = + input.data instanceof Blob + ? input.data + : new Blob([input.data], { type: input.mimeType || 'video/MP2T' }); const record: IndexedDBVideoSegmentRecord = { id: makeSegmentId(input.cacheKey, input.index), @@ -217,9 +228,12 @@ export async function saveIndexedDBVideoAsset(input: { mimeType?: string; }): Promise { const db = await openIndexedDBVideoCache(); - const blob = input.data instanceof Blob - ? input.data - : new Blob([input.data], { type: input.mimeType || 'application/octet-stream' }); + const blob = + input.data instanceof Blob + ? input.data + : new Blob([input.data], { + type: input.mimeType || 'application/octet-stream', + }); const record: IndexedDBVideoAssetRecord = { id: makeAssetId(input.cacheKey, input.name), @@ -267,30 +281,66 @@ async function getIndexedDBVideoAsset( ); } -export async function getIndexedDBVideoCacheSize(cacheKey: string): Promise { +export async function getIndexedDBVideoSegments( + cacheKey: string +): Promise> { + const db = await openIndexedDBVideoCache(); + const tx = db.transaction([SEGMENTS_STORE], 'readonly'); + const store = tx.objectStore(SEGMENTS_STORE); + const index = store.index('cacheKey'); + + return new Promise((resolve, reject) => { + const segments: Array<{ index: number; data: Blob; size: number }> = []; + const request = index.openCursor(IDBKeyRange.only(cacheKey)); + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + segments.sort((a, b) => a.index - b.index); + resolve(segments); + return; + } + + const record = cursor.value as IndexedDBVideoSegmentRecord; + segments.push({ + index: record.index, + data: record.data, + size: record.size, + }); + cursor.continue(); + }; + + request.onerror = () => reject(request.error); + }); +} + +export async function getIndexedDBVideoCacheSize( + cacheKey: string +): Promise { const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey); if (manifest?.totalSize) return manifest.totalSize; const db = await openIndexedDBVideoCache(); const tx = db.transaction([SEGMENTS_STORE, ASSETS_STORE], 'readonly'); - const sumByIndex = (storeName: string) => new Promise((resolve, reject) => { - const store = tx.objectStore(storeName); - const index = store.index('cacheKey'); - const request = index.openCursor(IDBKeyRange.only(cacheKey)); - let total = 0; + const sumByIndex = (storeName: string) => + new Promise((resolve, reject) => { + const store = tx.objectStore(storeName); + const index = store.index('cacheKey'); + const request = index.openCursor(IDBKeyRange.only(cacheKey)); + let total = 0; - request.onsuccess = () => { - const cursor = request.result; - if (!cursor) { - resolve(total); - return; - } - total += Number((cursor.value as { size?: number }).size || 0); - cursor.continue(); - }; - request.onerror = () => reject(request.error); - }); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(total); + return; + } + total += Number((cursor.value as { size?: number }).size || 0); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); const [segmentSize, assetSize] = await Promise.all([ sumByIndex(SEGMENTS_STORE), @@ -316,9 +366,10 @@ export async function saveIndexedDBVideoManifest(input: { const db = await openIndexedDBVideoCache(); const existing = await getIndexedDBVideoManifestByCacheKey(input.cacheKey); const now = Date.now(); - const totalSize = typeof input.totalSize === 'number' - ? input.totalSize - : await getIndexedDBVideoCacheSize(input.cacheKey); + const totalSize = + typeof input.totalSize === 'number' + ? input.totalSize + : await getIndexedDBVideoCacheSize(input.cacheKey); const manifest: IndexedDBVideoCacheManifest = { cacheKey: input.cacheKey, @@ -376,7 +427,9 @@ async function deleteRecordsByCacheKey( }); } -export async function deleteIndexedDBVideoCache(cacheKey: string): Promise { +export async function deleteIndexedDBVideoCache( + cacheKey: string +): Promise { const db = await openIndexedDBVideoCache(); await deleteRecordsByCacheKey(db, SEGMENTS_STORE, cacheKey); await deleteRecordsByCacheKey(db, ASSETS_STORE, cacheKey); @@ -396,7 +449,11 @@ export async function deleteIndexedDBVideoCacheByEpisode( videoId: string, episodeIndex: number ): Promise { - const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex); + const manifest = await getIndexedDBVideoManifestByEpisode( + source, + videoId, + episodeIndex + ); if (manifest) { await deleteIndexedDBVideoCache(manifest.cacheKey); return; @@ -415,7 +472,9 @@ export async function getIndexedDBVideoStorageUsage(): Promise<{ const tx = db.transaction([MANIFESTS_STORE], 'readonly'); const store = tx.objectStore(MANIFESTS_STORE); const request = store.getAll(); - const manifests = await requestToPromise(request); + const manifests = await requestToPromise( + request + ); return manifests.reduce( (acc, manifest) => ({ @@ -453,7 +512,8 @@ export async function getBrowserStorageEstimate(): Promise { - const worker = registration.installing || registration.waiting || registration.active; + const worker = + registration.installing || registration.waiting || registration.active; if (!worker || worker.state === 'activated') { return Promise.resolve(registration); @@ -475,7 +535,9 @@ function waitForServiceWorkerActivation( }); } -async function waitForServiceWorkerController(timeoutMs = 2500): Promise { +async function waitForServiceWorkerController( + timeoutMs = 2500 +): Promise { if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { return false; } @@ -484,22 +546,32 @@ async function waitForServiceWorkerController(timeoutMs = 2500): Promise { const timer = window.setTimeout(() => { - navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + navigator.serviceWorker.removeEventListener( + 'controllerchange', + onControllerChange + ); resolve(Boolean(navigator.serviceWorker.controller)); }, timeoutMs); const onControllerChange = () => { window.clearTimeout(timer); - navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + navigator.serviceWorker.removeEventListener( + 'controllerchange', + onControllerChange + ); resolve(true); }; - navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + navigator.serviceWorker.addEventListener( + 'controllerchange', + onControllerChange + ); }); } export async function ensureIndexedDBVideoServiceWorker(): Promise { - if (typeof window === 'undefined' || typeof navigator === 'undefined') return false; + if (typeof window === 'undefined' || typeof navigator === 'undefined') + return false; if (!('serviceWorker' in navigator)) return false; try { @@ -511,7 +583,10 @@ export async function ensureIndexedDBVideoServiceWorker(): Promise { await waitForServiceWorkerActivation(registration); return waitForServiceWorkerController(); } catch (error) { - console.warn('[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:', error); + console.warn( + '[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:', + error + ); return false; } } @@ -561,7 +636,10 @@ export async function createIndexedDBVideoBlobPlaybackUrl( const segment = await getIndexedDBVideoSegment(cacheKey, segmentIndex); if (!segment?.data) { objectUrls.forEach((url) => URL.revokeObjectURL(url)); - return { hasLocal: false, reason: `缺少 IndexedDB 分片 ${segmentIndex + 1}` }; + return { + hasLocal: false, + reason: `缺少 IndexedDB 分片 ${segmentIndex + 1}`, + }; } const segmentUrl = URL.createObjectURL(segment.data); @@ -619,7 +697,11 @@ export async function getIndexedDBVideoPlaybackUrl( options: { preferServiceWorker?: boolean } = {} ): Promise { try { - const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex); + const manifest = await getIndexedDBVideoManifestByEpisode( + source, + videoId, + episodeIndex + ); if (!manifest?.completed) { return { hasLocal: false, reason: '未找到 IndexedDB 本地缓存' }; }