增加下载内容导出功能
This commit is contained in:
@@ -1,11 +1,16 @@
|
|||||||
'use client';
|
'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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { downloadDB, CompletedTask } from '@/lib/download-db';
|
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';
|
import { ConfirmDialog } from './ConfirmDialog';
|
||||||
|
|
||||||
@@ -25,11 +30,17 @@ interface VideoDownloadGroup {
|
|||||||
downloadModes: CompletedTask['downloadMode'][];
|
downloadModes: CompletedTask['downloadMode'][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementPanelProps) {
|
export function DownloadManagementPanel({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: DownloadManagementPanelProps) {
|
||||||
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
const [completedTasks, setCompletedTasks] = useState<CompletedTask[]>([]);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(new Set());
|
const [expandedGroupKeys, setExpandedGroupKeys] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
|
||||||
@@ -56,7 +67,7 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
if (selectedIds.size === completedTasks.length) {
|
if (selectedIds.size === completedTasks.length) {
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
} else {
|
} 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);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
// 获取要删除的任务
|
// 获取要删除的任务
|
||||||
const tasksToDelete = completedTasks.filter(t => selectedIds.has(t.id));
|
const tasksToDelete = completedTasks.filter((t) => selectedIds.has(t.id));
|
||||||
|
|
||||||
//禁止SzeMeng76抄袭狗抄袭
|
//禁止SzeMeng76抄袭狗抄袭
|
||||||
// 删除文件系统中的文件
|
// 删除文件系统中的文件
|
||||||
@@ -115,7 +126,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
const dbName = 'MoonTVPlus';
|
const dbName = 'MoonTVPlus';
|
||||||
const storeName = 'dirHandles';
|
const storeName = 'dirHandles';
|
||||||
|
|
||||||
const dirHandle = await new Promise<FileSystemDirectoryHandle | undefined>((resolve) => {
|
const dirHandle = await new Promise<
|
||||||
|
FileSystemDirectoryHandle | undefined
|
||||||
|
>((resolve) => {
|
||||||
const request = indexedDB.open(dbName, 2); // 使用版本 2
|
const request = indexedDB.open(dbName, 2); // 使用版本 2
|
||||||
|
|
||||||
request.onsuccess = (event) => {
|
request.onsuccess = (event) => {
|
||||||
@@ -132,7 +145,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
const getRequest = store.get('downloadDir');
|
const getRequest = store.get('downloadDir');
|
||||||
|
|
||||||
getRequest.onsuccess = () => {
|
getRequest.onsuccess = () => {
|
||||||
const handle = getRequest.result as FileSystemDirectoryHandle | undefined;
|
const handle = getRequest.result as
|
||||||
|
| FileSystemDirectoryHandle
|
||||||
|
| undefined;
|
||||||
db.close();
|
db.close();
|
||||||
resolve(handle);
|
resolve(handle);
|
||||||
};
|
};
|
||||||
@@ -150,7 +165,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
|
|
||||||
if (dirHandle) {
|
if (dirHandle) {
|
||||||
// 请求写权限
|
// 请求写权限
|
||||||
const permission = await (dirHandle as any).requestPermission({ mode: 'readwrite' });
|
const permission = await (dirHandle as any).requestPermission({
|
||||||
|
mode: 'readwrite',
|
||||||
|
});
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
console.error('未获得写权限,无法删除文件');
|
console.error('未获得写权限,无法删除文件');
|
||||||
continue;
|
continue;
|
||||||
@@ -158,10 +175,24 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
|
|
||||||
// 删除目录
|
// 删除目录
|
||||||
try {
|
try {
|
||||||
const sourceDirHandle = await dirHandle.getDirectoryHandle(task.source, { create: false });
|
const sourceDirHandle = await dirHandle.getDirectoryHandle(
|
||||||
const videoIdDirHandle = await sourceDirHandle.getDirectoryHandle(task.videoId, { create: false });
|
task.source,
|
||||||
await videoIdDirHandle.removeEntry(`ep${task.episodeIndex + 1}`, { recursive: true });
|
{ create: false }
|
||||||
console.log('已删除文件:', task.source, task.videoId, `ep${task.episodeIndex + 1}`);
|
);
|
||||||
|
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) {
|
} catch (deleteError) {
|
||||||
console.error('删除目录失败:', deleteError);
|
console.error('删除目录失败:', deleteError);
|
||||||
// 如果目录不存在,也算成功
|
// 如果目录不存在,也算成功
|
||||||
@@ -180,7 +211,12 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
task.videoId,
|
task.videoId,
|
||||||
task.episodeIndex
|
task.episodeIndex
|
||||||
);
|
);
|
||||||
console.log('已删除 IndexedDB 视频缓存:', task.source, task.videoId, task.episodeIndex);
|
console.log(
|
||||||
|
'已删除 IndexedDB 视频缓存:',
|
||||||
|
task.source,
|
||||||
|
task.videoId,
|
||||||
|
task.episodeIndex
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除 IndexedDB 视频缓存失败:', task.title, 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<FileSystemDirectoryHandle | undefined>((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<BlobPart[]> => {
|
||||||
|
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<BlobPart[]> => {
|
||||||
|
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 formatDate = (timestamp: number) => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleString('zh-CN', {
|
return date.toLocaleString('zh-CN', {
|
||||||
@@ -214,7 +458,8 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
if (!bytes) return '未知';
|
if (!bytes) return '未知';
|
||||||
if (bytes < 1024) return bytes + ' B';
|
if (bytes < 1024) return bytes + ' B';
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
|
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';
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -235,10 +480,12 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
if (taskWithVideoTitle?.videoTitle) return taskWithVideoTitle.videoTitle;
|
if (taskWithVideoTitle?.videoTitle) return taskWithVideoTitle.videoTitle;
|
||||||
|
|
||||||
const firstTitle = tasks[0]?.title || '未知视频';
|
const firstTitle = tasks[0]?.title || '未知视频';
|
||||||
return firstTitle
|
return (
|
||||||
.replace(/[_\s-]*第\s*\d+\s*集.*$/u, '')
|
firstTitle
|
||||||
.replace(/[_\s-]*EP?\s*\d+.*$/iu, '')
|
.replace(/[_\s-]*第\s*\d+\s*集.*$/u, '')
|
||||||
.trim() || firstTitle;
|
.replace(/[_\s-]*EP?\s*\d+.*$/iu, '')
|
||||||
|
.trim() || firstTitle
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const videoGroups = useMemo<VideoDownloadGroup[]>(() => {
|
const videoGroups = useMemo<VideoDownloadGroup[]>(() => {
|
||||||
@@ -259,7 +506,10 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
}
|
}
|
||||||
return b.completedAt - a.completedAt;
|
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<CompletedTask['downloadMode']>(
|
const modeSet = new Set<CompletedTask['downloadMode']>(
|
||||||
sortedTasks.map((task) => task.downloadMode)
|
sortedTasks.map((task) => task.downloadMode)
|
||||||
);
|
);
|
||||||
@@ -271,7 +521,9 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
title: getGroupTitle(sortedTasks),
|
title: getGroupTitle(sortedTasks),
|
||||||
tasks: sortedTasks,
|
tasks: sortedTasks,
|
||||||
totalSize: totalSize > 0 ? totalSize : undefined,
|
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),
|
downloadModes: Array.from(modeSet),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -283,205 +535,237 @@ export function DownloadManagementPanel({ isOpen, onClose }: DownloadManagementP
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<div className='fixed inset-0 z-[9999] flex items-end justify-center p-0 sm:items-center sm:p-4'>
|
<div className='fixed inset-0 z-[9999] flex items-end justify-center p-0 sm:items-center sm:p-4'>
|
||||||
<div
|
<div className='absolute inset-0 bg-black/50' onClick={onClose} />
|
||||||
className='absolute inset-0 bg-black/50'
|
<div className='relative flex h-[92dvh] max-h-[92dvh] w-full max-w-4xl flex-col rounded-t-2xl bg-white shadow-xl dark:bg-gray-900 sm:h-auto sm:max-h-[90vh] sm:rounded-lg'>
|
||||||
onClick={onClose}
|
{/* Header */}
|
||||||
/>
|
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
|
||||||
<div className='relative flex h-[92dvh] max-h-[92dvh] w-full max-w-4xl flex-col rounded-t-2xl bg-white shadow-xl dark:bg-gray-900 sm:h-auto sm:max-h-[90vh] sm:rounded-lg'>
|
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
|
||||||
{/* Header */}
|
下载文件管理
|
||||||
<div className='flex items-center justify-between border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
|
</h2>
|
||||||
<h2 className='text-lg font-semibold text-gray-800 dark:text-gray-200 sm:text-xl'>
|
<button
|
||||||
下载文件管理
|
onClick={onClose}
|
||||||
</h2>
|
className='rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||||
<button
|
aria-label='关闭下载文件管理'
|
||||||
onClick={onClose}
|
>
|
||||||
className='rounded p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800'
|
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
|
||||||
aria-label='关闭下载文件管理'
|
</button>
|
||||||
>
|
|
||||||
<X className='w-5 h-5 text-gray-600 dark:text-gray-400' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className='flex flex-col gap-3 border-b border-gray-200 p-3 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between sm:p-4'>
|
|
||||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4'>
|
|
||||||
<label className='flex items-center gap-2 cursor-pointer'>
|
|
||||||
<input
|
|
||||||
type='checkbox'
|
|
||||||
checked={selectedIds.size === completedTasks.length && completedTasks.length > 0}
|
|
||||||
onChange={handleSelectAll}
|
|
||||||
className='w-4 h-4'
|
|
||||||
/>
|
|
||||||
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
|
||||||
全选
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
|
||||||
已选择 {selectedIds.size} / {completedTasks.length} 集
|
|
||||||
{videoGroups.length > 0 && `,共 ${videoGroups.length} 个视频`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={selectedIds.size === 0 || isDeleting}
|
|
||||||
className='flex w-full items-center justify-center gap-2 rounded bg-red-500 px-4 py-2.5 text-sm text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:py-2'
|
|
||||||
>
|
|
||||||
<Trash2 className='w-4 h-4' />
|
|
||||||
{isDeleting ? '删除中...' : '删除选中'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
|
|
||||||
{completedTasks.length === 0 ? (
|
|
||||||
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
|
||||||
暂无下载记录
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className='space-y-3'>
|
|
||||||
{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 (
|
{/* Toolbar */}
|
||||||
<div
|
<div className='flex items-center justify-between gap-3 border-b border-gray-200 p-3 dark:border-gray-700 sm:p-4'>
|
||||||
key={group.key}
|
<div className='min-w-0 flex-1 flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-4'>
|
||||||
className={`border rounded-lg overflow-hidden transition-colors ${
|
<label className='flex items-center gap-2 cursor-pointer'>
|
||||||
isGroupSelected || isGroupPartiallySelected
|
<input
|
||||||
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
|
type='checkbox'
|
||||||
: 'border-gray-200 dark:border-gray-700'
|
checked={
|
||||||
}`}
|
selectedIds.size === completedTasks.length &&
|
||||||
|
completedTasks.length > 0
|
||||||
|
}
|
||||||
|
onChange={handleSelectAll}
|
||||||
|
className='w-4 h-4'
|
||||||
|
/>
|
||||||
|
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||||
|
全选
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<span className='text-sm text-gray-500 dark:text-gray-400'>
|
||||||
|
已选择 {selectedIds.size} / {completedTasks.length} 集
|
||||||
|
{videoGroups.length > 0 &&
|
||||||
|
`,共 ${videoGroups.length} 个视频`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='ml-auto flex flex-shrink-0 items-center justify-end gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
aria-label={isExporting ? '导出中' : '导出选中'}
|
||||||
|
disabled={selectedIds.size === 0 || isExporting || isDeleting}
|
||||||
|
className='flex h-10 w-10 items-center justify-center rounded bg-green-500 text-white transition-colors hover:bg-green-600 disabled:cursor-not-allowed disabled:opacity-50 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm'
|
||||||
>
|
>
|
||||||
<div
|
<Download className='h-4 w-4' />
|
||||||
className='flex cursor-pointer items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/80 sm:gap-3 sm:p-4'
|
<span className='hidden sm:inline'>
|
||||||
onClick={() => handleToggleGroupExpand(group.key)}
|
{isExporting ? '导出中...' : '导出选中'}
|
||||||
>
|
</span>
|
||||||
<button
|
</button>
|
||||||
type='button'
|
<button
|
||||||
onClick={(event) => {
|
onClick={handleDelete}
|
||||||
event.stopPropagation();
|
aria-label={isDeleting ? '删除中' : '删除选中'}
|
||||||
handleToggleGroupSelect(group);
|
disabled={selectedIds.size === 0 || isDeleting || isExporting}
|
||||||
}}
|
className='flex h-10 w-10 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50 sm:h-auto sm:w-auto sm:gap-2 sm:px-4 sm:py-2 sm:text-sm'
|
||||||
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 transition-colors sm:mt-1 sm:h-5 sm:w-5 ${
|
>
|
||||||
isGroupSelected
|
<Trash2 className='h-4 w-4' />
|
||||||
? 'border-green-500 bg-green-500'
|
<span className='hidden sm:inline'>
|
||||||
: isGroupPartiallySelected
|
{isDeleting ? '删除中...' : '删除选中'}
|
||||||
? 'border-green-500 bg-green-100 dark:bg-green-900/40'
|
</span>
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
</button>
|
||||||
}`}
|
</div>
|
||||||
aria-label={`选择 ${group.title}`}
|
|
||||||
>
|
|
||||||
{isGroupSelected ? (
|
|
||||||
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
|
|
||||||
) : isGroupPartiallySelected ? (
|
|
||||||
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3'>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<h3 className='line-clamp-2 text-sm font-semibold text-gray-800 dark:text-gray-200 sm:truncate'>
|
|
||||||
{group.title}
|
|
||||||
</h3>
|
|
||||||
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
<span>来源: {group.source}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{group.tasks.length} 集</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{group.downloadModes.map(getDownloadModeLabel).join(' / ')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex w-full flex-row-reverse items-center justify-between gap-3 text-left sm:w-auto sm:flex-row sm:items-start sm:text-right'>
|
|
||||||
<div className='min-w-0 sm:min-w-[150px]'>
|
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
最近完成:{formatDate(group.lastCompletedAt)}
|
|
||||||
</div>
|
|
||||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
总大小:{formatFileSize(group.totalSize)}
|
|
||||||
</div>
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<div className='mt-1 text-xs text-green-600 dark:text-green-400'>
|
|
||||||
已选 {selectedCount} 集
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
|
||||||
className={`h-5 w-5 flex-shrink-0 text-gray-400 transition-transform sm:mt-1 ${
|
|
||||||
isExpanded ? 'rotate-180' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className='border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
|
|
||||||
{group.tasks.map((task) => (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
className={`flex cursor-pointer items-start gap-2 px-3 py-3.5 transition-colors sm:gap-3 sm:px-4 sm:py-3 ${
|
|
||||||
selectedIds.has(task.id)
|
|
||||||
? 'bg-green-50 dark:bg-green-900/20'
|
|
||||||
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
|
|
||||||
}`}
|
|
||||||
onClick={() => handleToggleSelect(task.id)}
|
|
||||||
>
|
|
||||||
<div className='flex-shrink-0 sm:mt-1'>
|
|
||||||
<div className={`flex h-8 w-8 items-center justify-center rounded border-2 sm:h-5 sm:w-5 ${
|
|
||||||
selectedIds.has(task.id)
|
|
||||||
? 'border-green-500 bg-green-500'
|
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
|
||||||
}`}>
|
|
||||||
{selectedIds.has(task.id) && (
|
|
||||||
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<h4 className='line-clamp-2 text-sm font-medium text-gray-800 dark:text-gray-200 sm:truncate'>
|
|
||||||
第 {task.episodeIndex + 1} 集
|
|
||||||
{task.episodeTitle ? `:${task.episodeTitle}` : ''}
|
|
||||||
</h4>
|
|
||||||
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
{task.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex-shrink-0 text-left sm:text-right'>
|
|
||||||
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
{formatDate(task.completedAt)}
|
|
||||||
</div>
|
|
||||||
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
{formatFileSize(task.fileSize)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
|
|
||||||
<span>第 {task.episodeIndex + 1} 集</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>{getDownloadModeLabel(task.downloadMode)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Content */}
|
||||||
</div>
|
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
|
||||||
</div>,
|
{completedTasks.length === 0 ? (
|
||||||
document.body
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
)}
|
暂无下载记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={group.key}
|
||||||
|
className={`border rounded-lg overflow-hidden transition-colors ${
|
||||||
|
isGroupSelected || isGroupPartiallySelected
|
||||||
|
? 'border-green-500 bg-green-50/70 dark:bg-green-900/10'
|
||||||
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className='flex cursor-pointer items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/80 sm:gap-3 sm:p-4'
|
||||||
|
onClick={() => handleToggleGroupExpand(group.key)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleToggleGroupSelect(group);
|
||||||
|
}}
|
||||||
|
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 transition-colors sm:mt-1 sm:h-5 sm:w-5 ${
|
||||||
|
isGroupSelected
|
||||||
|
? 'border-green-500 bg-green-500'
|
||||||
|
: isGroupPartiallySelected
|
||||||
|
? 'border-green-500 bg-green-100 dark:bg-green-900/40'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
aria-label={`选择 ${group.title}`}
|
||||||
|
>
|
||||||
|
{isGroupSelected ? (
|
||||||
|
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
|
||||||
|
) : isGroupPartiallySelected ? (
|
||||||
|
<span className='h-0.5 w-3 rounded bg-green-500 sm:w-2.5' />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3'>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<h3 className='line-clamp-2 text-sm font-semibold text-gray-800 dark:text-gray-200 sm:truncate'>
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<div className='mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
<span>来源: {group.source}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{group.tasks.length} 集</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{group.downloadModes
|
||||||
|
.map(getDownloadModeLabel)
|
||||||
|
.join(' / ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex w-full flex-row-reverse items-center justify-between gap-3 text-left sm:w-auto sm:flex-row sm:items-start sm:text-right'>
|
||||||
|
<div className='min-w-0 sm:min-w-[150px]'>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
最近完成:
|
||||||
|
{formatDate(group.lastCompletedAt)}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
总大小:{formatFileSize(group.totalSize)}
|
||||||
|
</div>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<div className='mt-1 text-xs text-green-600 dark:text-green-400'>
|
||||||
|
已选 {selectedCount} 集
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-5 w-5 flex-shrink-0 text-gray-400 transition-transform sm:mt-1 ${
|
||||||
|
isExpanded ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className='border-t border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900'>
|
||||||
|
{group.tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex cursor-pointer items-start gap-2 px-3 py-3.5 transition-colors sm:gap-3 sm:px-4 sm:py-3 ${
|
||||||
|
selectedIds.has(task.id)
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToggleSelect(task.id)}
|
||||||
|
>
|
||||||
|
<div className='flex-shrink-0 sm:mt-1'>
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded border-2 sm:h-5 sm:w-5 ${
|
||||||
|
selectedIds.has(task.id)
|
||||||
|
? 'border-green-500 bg-green-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedIds.has(task.id) && (
|
||||||
|
<Check className='h-4 w-4 text-white sm:h-3 sm:w-3' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<h4 className='line-clamp-2 text-sm font-medium text-gray-800 dark:text-gray-200 sm:truncate'>
|
||||||
|
第 {task.episodeIndex + 1} 集
|
||||||
|
{task.episodeTitle
|
||||||
|
? `:${task.episodeTitle}`
|
||||||
|
: ''}
|
||||||
|
</h4>
|
||||||
|
<p className='mt-1 truncate text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='flex-shrink-0 text-left sm:text-right'>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{formatDate(task.completedAt)}
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
{formatFileSize(task.fileSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
<span>第 {task.episodeIndex + 1} 集</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>
|
||||||
|
{getDownloadModeLabel(task.downloadMode)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={showConfirmDialog}
|
isOpen={showConfirmDialog}
|
||||||
|
|||||||
@@ -120,9 +120,13 @@ export async function openIndexedDBVideoCache(): Promise<IDBDatabase> {
|
|||||||
const manifestStore = db.createObjectStore(MANIFESTS_STORE, {
|
const manifestStore = db.createObjectStore(MANIFESTS_STORE, {
|
||||||
keyPath: 'cacheKey',
|
keyPath: 'cacheKey',
|
||||||
});
|
});
|
||||||
manifestStore.createIndex('sourceVideoEpisode', ['source', 'videoId', 'episodeIndex'], {
|
manifestStore.createIndex(
|
||||||
unique: true,
|
'sourceVideoEpisode',
|
||||||
});
|
['source', 'videoId', 'episodeIndex'],
|
||||||
|
{
|
||||||
|
unique: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
manifestStore.createIndex('completed', 'completed', { unique: false });
|
manifestStore.createIndex('completed', 'completed', { unique: false });
|
||||||
manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false });
|
manifestStore.createIndex('updatedAt', 'updatedAt', { unique: false });
|
||||||
}
|
}
|
||||||
@@ -152,7 +156,9 @@ export async function getIndexedDBVideoManifestByCacheKey(
|
|||||||
const db = await openIndexedDBVideoCache();
|
const db = await openIndexedDBVideoCache();
|
||||||
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
|
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
|
||||||
const store = tx.objectStore(MANIFESTS_STORE);
|
const store = tx.objectStore(MANIFESTS_STORE);
|
||||||
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(store.get(cacheKey));
|
return requestToPromise<IndexedDBVideoCacheManifest | undefined>(
|
||||||
|
store.get(cacheKey)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getIndexedDBVideoManifestByEpisode(
|
export async function getIndexedDBVideoManifestByEpisode(
|
||||||
@@ -174,7 +180,11 @@ export async function isIndexedDBVideoDownloaded(
|
|||||||
videoId: string,
|
videoId: string,
|
||||||
episodeIndex: number
|
episodeIndex: number
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
|
const manifest = await getIndexedDBVideoManifestByEpisode(
|
||||||
|
source,
|
||||||
|
videoId,
|
||||||
|
episodeIndex
|
||||||
|
);
|
||||||
return Boolean(manifest?.completed && manifest.segmentCount > 0);
|
return Boolean(manifest?.completed && manifest.segmentCount > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,9 +195,10 @@ export async function saveIndexedDBVideoSegment(input: {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const db = await openIndexedDBVideoCache();
|
const db = await openIndexedDBVideoCache();
|
||||||
const blob = input.data instanceof Blob
|
const blob =
|
||||||
? input.data
|
input.data instanceof Blob
|
||||||
: new Blob([input.data], { type: input.mimeType || 'video/MP2T' });
|
? input.data
|
||||||
|
: new Blob([input.data], { type: input.mimeType || 'video/MP2T' });
|
||||||
|
|
||||||
const record: IndexedDBVideoSegmentRecord = {
|
const record: IndexedDBVideoSegmentRecord = {
|
||||||
id: makeSegmentId(input.cacheKey, input.index),
|
id: makeSegmentId(input.cacheKey, input.index),
|
||||||
@@ -217,9 +228,12 @@ export async function saveIndexedDBVideoAsset(input: {
|
|||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}): Promise<number> {
|
}): Promise<number> {
|
||||||
const db = await openIndexedDBVideoCache();
|
const db = await openIndexedDBVideoCache();
|
||||||
const blob = input.data instanceof Blob
|
const blob =
|
||||||
? input.data
|
input.data instanceof Blob
|
||||||
: new Blob([input.data], { type: input.mimeType || 'application/octet-stream' });
|
? input.data
|
||||||
|
: new Blob([input.data], {
|
||||||
|
type: input.mimeType || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
|
||||||
const record: IndexedDBVideoAssetRecord = {
|
const record: IndexedDBVideoAssetRecord = {
|
||||||
id: makeAssetId(input.cacheKey, input.name),
|
id: makeAssetId(input.cacheKey, input.name),
|
||||||
@@ -267,30 +281,66 @@ async function getIndexedDBVideoAsset(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getIndexedDBVideoCacheSize(cacheKey: string): Promise<number> {
|
export async function getIndexedDBVideoSegments(
|
||||||
|
cacheKey: string
|
||||||
|
): Promise<Array<{ index: number; data: Blob; size: number }>> {
|
||||||
|
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<number> {
|
||||||
const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey);
|
const manifest = await getIndexedDBVideoManifestByCacheKey(cacheKey);
|
||||||
if (manifest?.totalSize) return manifest.totalSize;
|
if (manifest?.totalSize) return manifest.totalSize;
|
||||||
|
|
||||||
const db = await openIndexedDBVideoCache();
|
const db = await openIndexedDBVideoCache();
|
||||||
const tx = db.transaction([SEGMENTS_STORE, ASSETS_STORE], 'readonly');
|
const tx = db.transaction([SEGMENTS_STORE, ASSETS_STORE], 'readonly');
|
||||||
|
|
||||||
const sumByIndex = (storeName: string) => new Promise<number>((resolve, reject) => {
|
const sumByIndex = (storeName: string) =>
|
||||||
const store = tx.objectStore(storeName);
|
new Promise<number>((resolve, reject) => {
|
||||||
const index = store.index('cacheKey');
|
const store = tx.objectStore(storeName);
|
||||||
const request = index.openCursor(IDBKeyRange.only(cacheKey));
|
const index = store.index('cacheKey');
|
||||||
let total = 0;
|
const request = index.openCursor(IDBKeyRange.only(cacheKey));
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
const cursor = request.result;
|
const cursor = request.result;
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
resolve(total);
|
resolve(total);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
total += Number((cursor.value as { size?: number }).size || 0);
|
total += Number((cursor.value as { size?: number }).size || 0);
|
||||||
cursor.continue();
|
cursor.continue();
|
||||||
};
|
};
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
const [segmentSize, assetSize] = await Promise.all([
|
const [segmentSize, assetSize] = await Promise.all([
|
||||||
sumByIndex(SEGMENTS_STORE),
|
sumByIndex(SEGMENTS_STORE),
|
||||||
@@ -316,9 +366,10 @@ export async function saveIndexedDBVideoManifest(input: {
|
|||||||
const db = await openIndexedDBVideoCache();
|
const db = await openIndexedDBVideoCache();
|
||||||
const existing = await getIndexedDBVideoManifestByCacheKey(input.cacheKey);
|
const existing = await getIndexedDBVideoManifestByCacheKey(input.cacheKey);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const totalSize = typeof input.totalSize === 'number'
|
const totalSize =
|
||||||
? input.totalSize
|
typeof input.totalSize === 'number'
|
||||||
: await getIndexedDBVideoCacheSize(input.cacheKey);
|
? input.totalSize
|
||||||
|
: await getIndexedDBVideoCacheSize(input.cacheKey);
|
||||||
|
|
||||||
const manifest: IndexedDBVideoCacheManifest = {
|
const manifest: IndexedDBVideoCacheManifest = {
|
||||||
cacheKey: input.cacheKey,
|
cacheKey: input.cacheKey,
|
||||||
@@ -376,7 +427,9 @@ async function deleteRecordsByCacheKey(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteIndexedDBVideoCache(cacheKey: string): Promise<void> {
|
export async function deleteIndexedDBVideoCache(
|
||||||
|
cacheKey: string
|
||||||
|
): Promise<void> {
|
||||||
const db = await openIndexedDBVideoCache();
|
const db = await openIndexedDBVideoCache();
|
||||||
await deleteRecordsByCacheKey(db, SEGMENTS_STORE, cacheKey);
|
await deleteRecordsByCacheKey(db, SEGMENTS_STORE, cacheKey);
|
||||||
await deleteRecordsByCacheKey(db, ASSETS_STORE, cacheKey);
|
await deleteRecordsByCacheKey(db, ASSETS_STORE, cacheKey);
|
||||||
@@ -396,7 +449,11 @@ export async function deleteIndexedDBVideoCacheByEpisode(
|
|||||||
videoId: string,
|
videoId: string,
|
||||||
episodeIndex: number
|
episodeIndex: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
|
const manifest = await getIndexedDBVideoManifestByEpisode(
|
||||||
|
source,
|
||||||
|
videoId,
|
||||||
|
episodeIndex
|
||||||
|
);
|
||||||
if (manifest) {
|
if (manifest) {
|
||||||
await deleteIndexedDBVideoCache(manifest.cacheKey);
|
await deleteIndexedDBVideoCache(manifest.cacheKey);
|
||||||
return;
|
return;
|
||||||
@@ -415,7 +472,9 @@ export async function getIndexedDBVideoStorageUsage(): Promise<{
|
|||||||
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
|
const tx = db.transaction([MANIFESTS_STORE], 'readonly');
|
||||||
const store = tx.objectStore(MANIFESTS_STORE);
|
const store = tx.objectStore(MANIFESTS_STORE);
|
||||||
const request = store.getAll();
|
const request = store.getAll();
|
||||||
const manifests = await requestToPromise<IndexedDBVideoCacheManifest[]>(request);
|
const manifests = await requestToPromise<IndexedDBVideoCacheManifest[]>(
|
||||||
|
request
|
||||||
|
);
|
||||||
|
|
||||||
return manifests.reduce(
|
return manifests.reduce(
|
||||||
(acc, manifest) => ({
|
(acc, manifest) => ({
|
||||||
@@ -453,7 +512,8 @@ export async function getBrowserStorageEstimate(): Promise<StorageEstimate | nul
|
|||||||
function waitForServiceWorkerActivation(
|
function waitForServiceWorkerActivation(
|
||||||
registration: ServiceWorkerRegistration
|
registration: ServiceWorkerRegistration
|
||||||
): Promise<ServiceWorkerRegistration> {
|
): Promise<ServiceWorkerRegistration> {
|
||||||
const worker = registration.installing || registration.waiting || registration.active;
|
const worker =
|
||||||
|
registration.installing || registration.waiting || registration.active;
|
||||||
|
|
||||||
if (!worker || worker.state === 'activated') {
|
if (!worker || worker.state === 'activated') {
|
||||||
return Promise.resolve(registration);
|
return Promise.resolve(registration);
|
||||||
@@ -475,7 +535,9 @@ function waitForServiceWorkerActivation(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForServiceWorkerController(timeoutMs = 2500): Promise<boolean> {
|
async function waitForServiceWorkerController(
|
||||||
|
timeoutMs = 2500
|
||||||
|
): Promise<boolean> {
|
||||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -484,22 +546,32 @@ async function waitForServiceWorkerController(timeoutMs = 2500): Promise<boolean
|
|||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
navigator.serviceWorker.removeEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
onControllerChange
|
||||||
|
);
|
||||||
resolve(Boolean(navigator.serviceWorker.controller));
|
resolve(Boolean(navigator.serviceWorker.controller));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
const onControllerChange = () => {
|
const onControllerChange = () => {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
navigator.serviceWorker.removeEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
onControllerChange
|
||||||
|
);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
onControllerChange
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureIndexedDBVideoServiceWorker(): Promise<boolean> {
|
export async function ensureIndexedDBVideoServiceWorker(): Promise<boolean> {
|
||||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') return false;
|
if (typeof window === 'undefined' || typeof navigator === 'undefined')
|
||||||
|
return false;
|
||||||
if (!('serviceWorker' in navigator)) return false;
|
if (!('serviceWorker' in navigator)) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -511,7 +583,10 @@ export async function ensureIndexedDBVideoServiceWorker(): Promise<boolean> {
|
|||||||
await waitForServiceWorkerActivation(registration);
|
await waitForServiceWorkerActivation(registration);
|
||||||
return waitForServiceWorkerController();
|
return waitForServiceWorkerController();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:', error);
|
console.warn(
|
||||||
|
'[IndexedDBVideo] Service Worker 不可用,降级为 Blob URL 播放:',
|
||||||
|
error
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -561,7 +636,10 @@ export async function createIndexedDBVideoBlobPlaybackUrl(
|
|||||||
const segment = await getIndexedDBVideoSegment(cacheKey, segmentIndex);
|
const segment = await getIndexedDBVideoSegment(cacheKey, segmentIndex);
|
||||||
if (!segment?.data) {
|
if (!segment?.data) {
|
||||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
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);
|
const segmentUrl = URL.createObjectURL(segment.data);
|
||||||
@@ -619,7 +697,11 @@ export async function getIndexedDBVideoPlaybackUrl(
|
|||||||
options: { preferServiceWorker?: boolean } = {}
|
options: { preferServiceWorker?: boolean } = {}
|
||||||
): Promise<IndexedDBVideoPlaybackResult> {
|
): Promise<IndexedDBVideoPlaybackResult> {
|
||||||
try {
|
try {
|
||||||
const manifest = await getIndexedDBVideoManifestByEpisode(source, videoId, episodeIndex);
|
const manifest = await getIndexedDBVideoManifestByEpisode(
|
||||||
|
source,
|
||||||
|
videoId,
|
||||||
|
episodeIndex
|
||||||
|
);
|
||||||
if (!manifest?.completed) {
|
if (!manifest?.completed) {
|
||||||
return { hasLocal: false, reason: '未找到 IndexedDB 本地缓存' };
|
return { hasLocal: false, reason: '未找到 IndexedDB 本地缓存' };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user