Files
MoonTVPlus/src/components/OfflineDownloadPanel.tsx
2025-12-14 00:22:38 +08:00

467 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
interface OfflineDownloadTask {
id: string;
source: string;
videoId: string;
episodeIndex: number;
title: string;
m3u8Url: string;
status: 'pending' | 'downloading' | 'completed' | 'error' | 'paused';
progress: number;
totalSegments: number;
downloadedSegments: number;
errorMessage?: string;
createdAt: string;
updatedAt: string;
downloadDir: string;
metadata?: {
videoTitle?: string;
cover?: string;
description?: string;
year?: string;
rating?: number;
totalEpisodes?: number;
};
}
interface OfflineDownloadPanelProps {
isOpen: boolean;
onClose: () => void;
}
export function OfflineDownloadPanel({ isOpen, onClose }: OfflineDownloadPanelProps) {
const [tasks, setTasks] = useState<OfflineDownloadTask[]>([]);
const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false);
const [viewMode, setViewMode] = useState<'tasks' | 'library'>('tasks'); // 视图模式:任务列表或视频库
// 确保只在客户端渲染
useEffect(() => {
setMounted(true);
}, []);
// 获取任务列表
const fetchTasks = async () => {
try {
const response = await fetch('/api/offline-download');
if (response.ok) {
const data = await response.json();
setTasks(data.tasks || []);
}
} catch (error) {
console.error('获取离线下载任务列表失败:', error);
}
};
// 删除任务
const handleDeleteTask = async (taskId: string) => {
try {
const response = await fetch(`/api/offline-download?taskId=${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
// 从列表中移除
setTasks((prev) => prev.filter((t) => t.id !== taskId));
} else {
const data = await response.json();
alert(`删除失败: ${data.error}`);
}
} catch (error) {
console.error('删除任务失败:', error);
alert('删除任务失败');
}
};
// 重试任务
const handleRetryTask = async (taskId: string) => {
try {
const response = await fetch(`/api/offline-download?taskId=${taskId}&action=retry`, {
method: 'PUT',
});
if (response.ok) {
const data = await response.json();
// 更新任务状态(保留进度,只重试失败的片段)
setTasks((prev) =>
prev.map((t) =>
t.id === taskId
? {
...t,
status: 'pending',
errorMessage: undefined,
updatedAt: new Date().toISOString(),
}
: t
)
);
// 立即刷新以获取最新状态
fetchTasks();
} else {
const data = await response.json();
alert(`重试失败: ${data.error}`);
}
} catch (error) {
console.error('重试任务失败:', error);
alert('重试任务失败');
}
};
// 定期刷新任务列表
useEffect(() => {
if (isOpen) {
fetchTasks();
const interval = setInterval(fetchTasks, 3000); // 每3秒刷新一次
return () => clearInterval(interval);
}
}, [isOpen]);
if (!isOpen || !mounted) {
return null;
}
const getStatusText = (status: OfflineDownloadTask['status']) => {
switch (status) {
case 'pending':
return '等待中';
case 'downloading':
return '下载中';
case 'paused':
return '已暂停';
case 'completed':
return '已完成';
case 'error':
return '错误';
default:
return '未知';
}
};
const getStatusColor = (status: OfflineDownloadTask['status']) => {
switch (status) {
case 'pending':
return 'text-gray-500 dark:text-gray-400';
case 'downloading':
return 'text-blue-500 dark:text-blue-400';
case 'paused':
return 'text-yellow-500 dark:text-yellow-400';
case 'completed':
return 'text-green-500 dark:text-green-400';
case 'error':
return 'text-red-500 dark:text-red-400';
default:
return 'text-gray-500 dark:text-gray-400';
}
};
// 获取视频库中的视频按videoId分组包含已完成和有进度的任务
const getLibraryVideos = () => {
// 筛选已完成或有下载进度的任务
const libraryTasks = tasks.filter((t) => t.status === 'completed' || t.progress > 0);
const videoMap = new Map<string, { video: OfflineDownloadTask; episodes: OfflineDownloadTask[] }>();
libraryTasks.forEach((task) => {
const key = `${task.source}_${task.videoId}`;
if (!videoMap.has(key)) {
videoMap.set(key, { video: task, episodes: [] });
}
videoMap.get(key)!.episodes.push(task);
});
// 按集数排序
videoMap.forEach((value) => {
value.episodes.sort((a, b) => a.episodeIndex - b.episodeIndex);
});
return Array.from(videoMap.values());
};
const libraryVideos = getLibraryVideos();
const panelContent = (
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-2xl w-full max-w-4xl max-h-[80vh] flex flex-col'>
{/* 标题栏 */}
<div className='flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700'>
<h2 className='text-xl font-bold text-gray-900 dark:text-white'>
{viewMode === 'tasks' ? '离线下载任务列表' : '视频库'}
</h2>
<div className='flex items-center gap-3'>
{/* 视图切换按钮 */}
<div className='flex gap-2'>
<button
onClick={() => setViewMode('tasks')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
viewMode === 'tasks'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
</button>
<button
onClick={() => setViewMode('library')}
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
viewMode === 'library'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
}`}
>
({libraryVideos.length})
</button>
</div>
{/* 关闭按钮 */}
<button
onClick={onClose}
className='text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth='2' d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
</div>
{/* 内容区域 */}
<div className='flex-1 overflow-y-auto p-4 space-y-3'>
{loading ? (
<div className='flex items-center justify-center h-full'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500'></div>
</div>
) : viewMode === 'library' ? (
// 视频库视图
libraryVideos.length === 0 ? (
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z'
/>
</svg>
<p className='text-lg'></p>
</div>
) : (
libraryVideos.map(({ video, episodes }) => (
<div
key={`${video.source}_${video.videoId}`}
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
>
<div className='flex gap-4'>
{/* 封面图 */}
{video.metadata?.cover && (
<div className='flex-shrink-0'>
<img
src={video.metadata.cover}
alt={video.metadata.videoTitle || video.title}
className='w-32 h-48 object-cover rounded'
/>
</div>
)}
{/* 视频信息 */}
<div className='flex-1 min-w-0'>
<h3 className='text-lg font-bold text-gray-900 dark:text-white mb-2'>
{video.metadata?.videoTitle || video.title}
</h3>
{video.metadata?.year && (
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>: {video.metadata.year}</p>
)}
{video.metadata?.rating && (
<p className='text-sm text-gray-600 dark:text-gray-400 mb-1'>
: {video.metadata.rating.toFixed(1)}
</p>
)}
{video.metadata?.description && (
<p className='text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2'>
{video.metadata.description}
</p>
)}
<div className='flex items-center gap-2 mb-3'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
{episodes.length}
</span>
{video.metadata?.totalEpisodes && (
<span className='text-sm text-gray-600 dark:text-gray-400'>
/ {video.metadata.totalEpisodes}
</span>
)}
</div>
{/* 集数列表 */}
<div className='flex flex-wrap gap-2 mb-3'>
{episodes.map((ep) => (
<div
key={ep.id}
className='flex items-center gap-1 px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 text-xs rounded group'
>
<span>{ep.episodeIndex + 1}</span>
<button
onClick={() => {
if (confirm(`确定要删除第${ep.episodeIndex + 1}集吗?`)) {
handleDeleteTask(ep.id);
}
}}
className='ml-1 opacity-0 group-hover:opacity-100 transition-opacity text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300'
title='删除此集'
>
<svg className='w-3 h-3' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
))}
</div>
{/* 删除全部按钮 */}
<button
onClick={() => {
if (confirm(`确定要删除《${video.metadata?.videoTitle || video.title}》的所有已下载集数吗?`)) {
episodes.forEach((ep) => handleDeleteTask(ep.id));
}
}}
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
/>
</svg>
</button>
</div>
</div>
</div>
))
)
) : tasks.length === 0 ? (
// 任务列表为空
<div className='flex flex-col items-center justify-center h-full text-gray-500 dark:text-gray-400'>
<svg className='w-16 h-16 mb-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4'
/>
</svg>
<p className='text-lg'>线</p>
</div>
) : (
// 任务列表视图
tasks.map((task) => (
<div
key={task.id}
className='bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4 border border-gray-200 dark:border-gray-600'
>
{/* 任务信息 */}
<div className='flex items-start justify-between mb-3'>
<div className='flex-1 min-w-0'>
<h3 className='text-sm font-medium text-gray-900 dark:text-white truncate mb-1'>
{task.title}
</h3>
<p className='text-xs text-gray-500 dark:text-gray-400'>
: {task.source} | ID: {task.videoId} | {task.episodeIndex + 1}
</p>
</div>
<div className='flex items-center gap-2 ml-4'>
<span className={`text-xs font-medium ${getStatusColor(task.status)}`}>
{getStatusText(task.status)}
</span>
</div>
</div>
{/* 进度条 */}
{task.totalSegments > 0 && (
<div className='mb-3'>
<div className='flex items-center justify-between text-xs text-gray-600 dark:text-gray-300 mb-1'>
<span>
{task.downloadedSegments} / {task.totalSegments}
</span>
<span>{task.progress.toFixed(1)}%</span>
</div>
<div className='w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden'>
<div
className={`h-full rounded-full transition-all duration-300 ${
task.status === 'downloading'
? 'bg-gradient-to-r from-blue-500 to-purple-600 animate-pulse'
: task.status === 'completed'
? 'bg-green-500'
: task.status === 'error'
? 'bg-red-500'
: 'bg-gray-400'
}`}
style={{ width: `${task.progress}%` }}
></div>
</div>
</div>
)}
{/* 错误信息 */}
{task.errorMessage && (
<div className='mb-3'>
<div className='text-xs text-red-500 dark:text-red-400'>{task.errorMessage}</div>
</div>
)}
{/* 时间信息 */}
<div className='flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-3'>
<span>: {new Date(task.createdAt).toLocaleString('zh-CN')}</span>
<span>: {new Date(task.updatedAt).toLocaleString('zh-CN')}</span>
</div>
{/* 操作按钮 */}
<div className='flex items-center gap-2'>
{/* 重试按钮 - 只在错误或暂停状态显示 */}
{(task.status === 'error' || task.status === 'paused') && (
<button
onClick={() => handleRetryTask(task.id)}
className='flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 text-white text-xs font-medium rounded transition-colors'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15'
/>
</svg>
</button>
)}
<button
onClick={() => handleDeleteTask(task.id)}
className='flex items-center gap-1 px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white text-xs font-medium rounded transition-colors'
>
<svg className='w-4 h-4' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
d='M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16'
/>
</svg>
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
);
return createPortal(panelContent, document.body);
}