支持选集下载
This commit is contained in:
@@ -40,6 +40,7 @@ import { SearchResult, DanmakuFilterConfig } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
|
||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import DoubanComments from '@/components/DoubanComments';
|
||||
import DanmakuFilterSettings from '@/components/DanmakuFilterSettings';
|
||||
@@ -500,6 +501,9 @@ function PlayPageClient() {
|
||||
const [isEpisodeSelectorCollapsed, setIsEpisodeSelectorCollapsed] =
|
||||
useState(false);
|
||||
|
||||
// 下载选集面板显示状态
|
||||
const [showDownloadSelector, setShowDownloadSelector] = useState(false);
|
||||
|
||||
// 换源加载状态
|
||||
const [isVideoLoading, setIsVideoLoading] = useState(true);
|
||||
const [videoLoadingStage, setVideoLoadingStage] = useState<
|
||||
@@ -754,6 +758,76 @@ function PlayPageClient() {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理下载指定集数(支持批量下载)
|
||||
const handleDownloadEpisode = async (episodeIndexes: number[]) => {
|
||||
if (!detail || !detail.episodes || episodeIndexes.length === 0) {
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '无法获取视频地址';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const origin = `${window.location.protocol}//${window.location.host}`;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 批量处理下载
|
||||
for (const episodeIndex of episodeIndexes) {
|
||||
if (episodeIndex >= detail.episodes.length) {
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const episodeUrl = detail.episodes[episodeIndex];
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(episodeUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: episodeUrl;
|
||||
const isM3u8 = episodeUrl.toLowerCase().includes('.m3u8') || episodeUrl.toLowerCase().includes('/m3u8/');
|
||||
|
||||
if (isM3u8) {
|
||||
// M3U8格式 - 使用新的下载器,TS 格式
|
||||
try {
|
||||
const downloadTitle = `${videoTitle}_第${episodeIndex + 1}集`;
|
||||
await addDownloadTask(proxyUrl, downloadTitle, 'TS');
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`添加下载任务失败 (第${episodeIndex + 1}集):`, error);
|
||||
failCount++;
|
||||
}
|
||||
} else {
|
||||
// 普通视频格式 - 直接下载
|
||||
try {
|
||||
const a = document.createElement('a');
|
||||
a.href = proxyUrl;
|
||||
a.download = `${videoTitle}_第${episodeIndex + 1}集.mp4`;
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
successCount++;
|
||||
// 添加延迟避免浏览器阻止多个下载
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
} catch (error) {
|
||||
console.error(`下载失败 (第${episodeIndex + 1}集):`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示结果通知
|
||||
if (artPlayerRef.current) {
|
||||
if (failCount === 0) {
|
||||
artPlayerRef.current.notice.show = `已添加 ${successCount} 个下载任务!`;
|
||||
} else if (successCount === 0) {
|
||||
artPlayerRef.current.notice.show = '下载失败,请重试';
|
||||
} else {
|
||||
artPlayerRef.current.notice.show = `成功 ${successCount} 个,失败 ${failCount} 个`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureVideoSource = (video: HTMLVideoElement | null, url: string) => {
|
||||
if (!video || !url) return;
|
||||
const sources = Array.from(video.getElementsByTagName('source'));
|
||||
@@ -3646,47 +3720,9 @@ function PlayPageClient() {
|
||||
<div className='flex gap-1.5 lg:flex-wrap'>
|
||||
{/* 下载按钮 */}
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// 获取正确的代理 URL - 使用浏览器实际访问的地址
|
||||
const tokenParam = proxyToken ? `&token=${encodeURIComponent(proxyToken)}` : '';
|
||||
const origin = `${window.location.protocol}//${window.location.host}`;
|
||||
console.log('下载按钮 - origin:', origin);
|
||||
console.log('下载按钮 - window.location:', window.location.href);
|
||||
const proxyUrl = externalPlayerAdBlock
|
||||
? `${origin}/api/proxy-m3u8?url=${encodeURIComponent(videoUrl)}&source=${encodeURIComponent(currentSource)}${tokenParam}`
|
||||
: videoUrl;
|
||||
console.log('下载按钮 - proxyUrl:', proxyUrl);
|
||||
const isM3u8 = videoUrl.toLowerCase().includes('.m3u8') || videoUrl.toLowerCase().includes('/m3u8/');
|
||||
|
||||
if (isM3u8) {
|
||||
// M3U8格式 - 使用新的下载器,TS 格式
|
||||
try {
|
||||
const downloadTitle = `${videoTitle}_第${currentEpisodeIndex + 1}集`;
|
||||
await addDownloadTask(proxyUrl, downloadTitle, 'TS');
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '已添加到下载队列!';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加下载任务失败:', error);
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '添加下载失败,请重试';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 普通视频格式 - 直接下载
|
||||
const a = document.createElement('a');
|
||||
a.href = proxyUrl;
|
||||
a.download = `${videoTitle}_第${currentEpisodeIndex + 1}集.mp4`;
|
||||
a.target = '_blank';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
if (artPlayerRef.current) {
|
||||
artPlayerRef.current.notice.show = '开始下载...';
|
||||
}
|
||||
}
|
||||
setShowDownloadSelector(true);
|
||||
}}
|
||||
className='group relative flex items-center justify-center gap-1 w-8 h-8 lg:w-auto lg:h-auto lg:px-2 lg:py-1.5 bg-gradient-to-r from-green-500 to-emerald-600 hover:from-green-600 hover:to-emerald-700 text-xs font-medium rounded-md transition-all duration-200 shadow-sm hover:shadow-md cursor-pointer overflow-hidden border border-green-400 flex-shrink-0'
|
||||
title='下载视频'
|
||||
@@ -4126,6 +4162,17 @@ function PlayPageClient() {
|
||||
{/* Toast通知 */}
|
||||
{toast && <Toast {...toast} />}
|
||||
|
||||
{/* 下载选集面板 */}
|
||||
<DownloadEpisodeSelector
|
||||
isOpen={showDownloadSelector}
|
||||
onClose={() => setShowDownloadSelector(false)}
|
||||
totalEpisodes={totalEpisodes}
|
||||
episodesTitles={detail?.episodes_titles || []}
|
||||
videoTitle={videoTitle}
|
||||
currentEpisodeIndex={currentEpisodeIndex}
|
||||
onDownload={handleDownloadEpisode}
|
||||
/>
|
||||
|
||||
{/* 弹幕过滤设置对话框 */}
|
||||
<DanmakuFilterSettings
|
||||
isOpen={showDanmakuFilterSettings}
|
||||
|
||||
297
src/components/DownloadEpisodeSelector.tsx
Normal file
297
src/components/DownloadEpisodeSelector.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
interface DownloadEpisodeSelectorProps {
|
||||
/** 是否显示弹窗 */
|
||||
isOpen: boolean;
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
/** 总集数 */
|
||||
totalEpisodes: number;
|
||||
/** 剧集标题 */
|
||||
episodesTitles?: string[];
|
||||
/** 视频标题 */
|
||||
videoTitle: string;
|
||||
/** 当前集数索引(0开始) */
|
||||
currentEpisodeIndex: number;
|
||||
/** 下载回调 - 支持批量下载 */
|
||||
onDownload: (episodeIndexes: number[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载选集面板组件
|
||||
*/
|
||||
const DownloadEpisodeSelector: React.FC<DownloadEpisodeSelectorProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
totalEpisodes,
|
||||
episodesTitles = [],
|
||||
videoTitle,
|
||||
currentEpisodeIndex,
|
||||
onDownload,
|
||||
}) => {
|
||||
// 多选状态 - 使用 Set 存储选中的集数索引
|
||||
const [selectedEpisodes, setSelectedEpisodes] = useState<Set<number>>(
|
||||
new Set([currentEpisodeIndex])
|
||||
);
|
||||
|
||||
// 每页显示的集数
|
||||
const episodesPerPage = 50;
|
||||
const pageCount = Math.ceil(totalEpisodes / episodesPerPage);
|
||||
|
||||
// 当前分页索引(0 开始)
|
||||
const initialPage = Math.floor(currentEpisodeIndex / episodesPerPage);
|
||||
const [currentPage, setCurrentPage] = useState<number>(initialPage);
|
||||
|
||||
// 是否倒序显示
|
||||
const [descending, setDescending] = useState<boolean>(false);
|
||||
|
||||
// 根据 descending 状态计算实际显示的分页索引
|
||||
const displayPage = useMemo(() => {
|
||||
if (descending) {
|
||||
return pageCount - 1 - currentPage;
|
||||
}
|
||||
return currentPage;
|
||||
}, [currentPage, descending, pageCount]);
|
||||
|
||||
// 升序分页标签
|
||||
const categoriesAsc = useMemo(() => {
|
||||
return Array.from({ length: pageCount }, (_, i) => {
|
||||
const start = i * episodesPerPage + 1;
|
||||
const end = Math.min(start + episodesPerPage - 1, totalEpisodes);
|
||||
return { start, end };
|
||||
});
|
||||
}, [pageCount, episodesPerPage, totalEpisodes]);
|
||||
|
||||
// 根据 descending 状态决定分页标签的排序和内容
|
||||
const categories = useMemo(() => {
|
||||
if (descending) {
|
||||
return [...categoriesAsc]
|
||||
.reverse()
|
||||
.map(({ start, end }) => `${end}-${start}`);
|
||||
}
|
||||
return categoriesAsc.map(({ start, end }) => `${start}-${end}`);
|
||||
}, [categoriesAsc, descending]);
|
||||
|
||||
const handleCategoryClick = (index: number) => {
|
||||
if (descending) {
|
||||
setCurrentPage(pageCount - 1 - index);
|
||||
} else {
|
||||
setCurrentPage(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEpisodeClick = (episodeIndex: number) => {
|
||||
setSelectedEpisodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(episodeIndex)) {
|
||||
newSet.delete(episodeIndex);
|
||||
} else {
|
||||
newSet.add(episodeIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
const allEpisodes = Array.from({ length: totalEpisodes }, (_, i) => i);
|
||||
setSelectedEpisodes(new Set(allEpisodes));
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
setSelectedEpisodes(new Set());
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const episodeIndexes = Array.from(selectedEpisodes).sort((a, b) => a - b);
|
||||
onDownload(episodeIndexes);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const currentStart = currentPage * episodesPerPage;
|
||||
const currentEnd = Math.min(currentStart + episodesPerPage - 1, totalEpisodes - 1);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm'>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col overflow-hidden border border-gray-200 dark:border-gray-700'>
|
||||
{/* 标题栏 */}
|
||||
<div className='flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex-1'>
|
||||
<h2 className='text-xl font-bold text-gray-900 dark:text-gray-100'>
|
||||
选择要下载的集数
|
||||
</h2>
|
||||
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||
{videoTitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
className='px-3 py-1.5 text-xs font-medium text-green-600 hover:text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:text-green-300 dark:hover:bg-green-900/20 rounded-md transition-colors'
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className='px-3 py-1.5 text-xs font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-300 dark:hover:bg-gray-700 rounded-md transition-colors'
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-gray-700 transition-colors'
|
||||
>
|
||||
<svg
|
||||
className='w-5 h-5'
|
||||
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>
|
||||
|
||||
{/* 分页标签 */}
|
||||
{pageCount > 1 && (
|
||||
<div className='flex items-center gap-4 px-6 py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||
<div className='flex-1 overflow-x-auto'>
|
||||
<div className='flex gap-2 min-w-max'>
|
||||
{categories.map((label, idx) => {
|
||||
const isActive = idx === displayPage;
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => handleCategoryClick(idx)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-all whitespace-nowrap
|
||||
${
|
||||
isActive
|
||||
? 'bg-green-500 text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* 向上/向下按钮 */}
|
||||
<button
|
||||
className='flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center text-gray-700 hover:text-green-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-green-400 dark:hover:bg-gray-700 transition-colors'
|
||||
onClick={() => setDescending((prev) => !prev)}
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth='2'
|
||||
d='M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 集数网格 */}
|
||||
<div className='flex-1 overflow-y-auto px-6 py-4'>
|
||||
<div className='flex flex-wrap gap-3'>
|
||||
{(() => {
|
||||
const len = currentEnd - currentStart + 1;
|
||||
const episodes = Array.from({ length: len }, (_, i) =>
|
||||
descending ? currentEnd - i : currentStart + i
|
||||
);
|
||||
return episodes;
|
||||
})().map((episodeIndex) => {
|
||||
const isSelected = selectedEpisodes.has(episodeIndex);
|
||||
const isCurrent = episodeIndex === currentEpisodeIndex;
|
||||
const episodeNumber = episodeIndex + 1;
|
||||
return (
|
||||
<button
|
||||
key={episodeIndex}
|
||||
onClick={() => handleEpisodeClick(episodeIndex)}
|
||||
className={`h-10 min-w-10 px-3 py-2 flex items-center justify-center text-sm font-medium rounded-md transition-all duration-200 whitespace-nowrap font-mono relative
|
||||
${
|
||||
isSelected
|
||||
? 'bg-green-500 text-white shadow-lg shadow-green-500/25 dark:bg-green-600'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 hover:scale-105 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
}
|
||||
`.trim()}
|
||||
>
|
||||
{(() => {
|
||||
const title = episodesTitles?.[episodeIndex];
|
||||
if (!title) {
|
||||
return episodeNumber;
|
||||
}
|
||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字
|
||||
const match = title.match(/(?:第)?(\d+)(?:集|话)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return title;
|
||||
})()}
|
||||
{isCurrent && (
|
||||
<span className='absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full'></span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
<div className='flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900'>
|
||||
<div className='text-sm text-gray-600 dark:text-gray-400'>
|
||||
已选择:
|
||||
{selectedEpisodes.size === 0 ? (
|
||||
<span className='text-red-500 dark:text-red-400'>未选择任何集数</span>
|
||||
) : selectedEpisodes.size === 1 ? (
|
||||
<>
|
||||
第 {Array.from(selectedEpisodes)[0] + 1} 集
|
||||
{Array.from(selectedEpisodes)[0] === currentEpisodeIndex && (
|
||||
<span className='ml-2 text-blue-500 dark:text-blue-400'>(当前播放)</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className='font-medium text-green-600 dark:text-green-400'>
|
||||
{selectedEpisodes.size} 集
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className='px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 rounded-md transition-colors'
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={selectedEpisodes.size === 0}
|
||||
className='px-4 py-2 text-sm font-medium text-white bg-green-500 hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700 rounded-md transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-green-500'
|
||||
>
|
||||
下载 {selectedEpisodes.size > 0 && `(${selectedEpisodes.size})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadEpisodeSelector;
|
||||
Reference in New Issue
Block a user