/* eslint-disable @next/next/no-img-element */ import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { Settings } from 'lucide-react'; import DanmakuPanel from '@/components/DanmakuPanel'; import EpisodeFilterSettings from '@/components/EpisodeFilterSettings'; import type { DanmakuSelection, DanmakuComment } from '@/lib/danmaku/types'; import { SearchResult, EpisodeFilterConfig } from '@/lib/types'; import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; // 定义视频信息类型 interface VideoInfo { quality: string; loadSpeed: string; pingTime: number; hasError?: boolean; // 添加错误状态标识 } interface EpisodeSelectorProps { /** 总集数 */ totalEpisodes: number; /** 剧集标题 */ episodes_titles: string[]; /** 每页显示多少集,默认 50 */ episodesPerPage?: number; /** 当前选中的集数(1 开始) */ value?: number; /** 用户点击选集后的回调 */ onChange?: (episodeNumber: number) => void; /** 换源相关 */ onSourceChange?: (source: string, id: string, title: string) => void; currentSource?: string; currentId?: string; videoTitle?: string; videoYear?: string; availableSources?: SearchResult[]; sourceSearchLoading?: boolean; sourceSearchError?: string | null; /** 后台源加载状态 */ backgroundSourcesLoading?: boolean; /** 预计算的测速结果,避免重复测速 */ precomputedVideoInfo?: Map; /** 弹幕相关 */ onDanmakuSelect?: (selection: DanmakuSelection) => void; currentDanmakuSelection?: DanmakuSelection | null; onUploadDanmaku?: (comments: DanmakuComment[]) => void; /** 观影室房员状态 - 禁用选集和换源,但保留弹幕 */ isRoomMember?: boolean; /** 集数过滤配置 */ episodeFilterConfig?: EpisodeFilterConfig | null; onFilterConfigUpdate?: (config: EpisodeFilterConfig) => void; onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; } /** * 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。 */ const EpisodeSelector: React.FC = ({ totalEpisodes, episodes_titles, episodesPerPage = 50, value = 1, onChange, onSourceChange, currentSource, currentId, videoTitle, availableSources = [], sourceSearchLoading = false, sourceSearchError = null, backgroundSourcesLoading = false, precomputedVideoInfo, onDanmakuSelect, currentDanmakuSelection, onUploadDanmaku, isRoomMember = false, episodeFilterConfig = null, onFilterConfigUpdate, onShowToast, }) => { const router = useRouter(); const pageCount = Math.ceil(totalEpisodes / episodesPerPage); // 存储每个源的视频信息 const [videoInfoMap, setVideoInfoMap] = useState>( new Map() ); const [attemptedSources, setAttemptedSources] = useState>( new Set() ); // 存储正在重新测试的源 const [retestingSources, setRetestingSources] = useState>( new Set() ); // 标记初始测速是否已完成 const [initialTestingCompleted, setInitialTestingCompleted] = useState(false); // 使用 ref 来避免闭包问题 const attemptedSourcesRef = useRef>(new Set()); const videoInfoMapRef = useRef>(new Map()); // 同步状态到 ref useEffect(() => { attemptedSourcesRef.current = attemptedSources; }, [attemptedSources]); useEffect(() => { videoInfoMapRef.current = videoInfoMap; }, [videoInfoMap]); // 主要的 tab 状态:'danmaku' | 'episodes' | 'sources' // 默认显示选集选项卡,但如果是房员则显示弹幕 const [activeTab, setActiveTab] = useState<'danmaku' | 'episodes' | 'sources'>( isRoomMember ? 'danmaku' : 'episodes' ); // 当房员状态变化时,自动切换到弹幕选项卡 useEffect(() => { if (isRoomMember && (activeTab === 'episodes' || activeTab === 'sources')) { setActiveTab('danmaku'); } }, [isRoomMember, activeTab]); // 当前分页索引(0 开始) const initialPage = Math.floor((value - 1) / episodesPerPage); const [currentPage, setCurrentPage] = useState(initialPage); // 是否倒序显示 const [descending, setDescending] = useState(false); // 集数过滤设置弹窗状态 const [showFilterSettings, setShowFilterSettings] = useState(false); // 集数过滤逻辑 const isEpisodeFiltered = useCallback( (episodeNumber: number): boolean => { if (!episodeFilterConfig || episodeFilterConfig.rules.length === 0) { return false; } // 获取集数标题 const title = episodes_titles?.[episodeNumber - 1]; if (!title) return false; // 检查每个启用的规则 for (const rule of episodeFilterConfig.rules) { if (!rule.enabled) continue; try { if (rule.type === 'normal') { // 普通模式:字符串包含匹配 if (title.includes(rule.keyword)) { return true; } } else if (rule.type === 'regex') { // 正则模式:正则表达式匹配 if (new RegExp(rule.keyword).test(title)) { return true; } } } catch (e) { console.error('集数过滤规则错误:', e); } } return false; }, [episodeFilterConfig, episodes_titles] ); // 根据 descending 状态计算实际显示的分页索引 const displayPage = useMemo(() => { if (descending) { return pageCount - 1 - currentPage; } return currentPage; }, [currentPage, descending, pageCount]); // 获取视频信息的函数 - 移除 attemptedSources 依赖避免不必要的重新创建 const getVideoInfo = useCallback(async (source: SearchResult) => { const sourceKey = `${source.source}-${source.id}`; // 使用 ref 获取最新的状态,避免闭包问题 if (attemptedSourcesRef.current.has(sourceKey)) { return; } // 获取第一集的URL if (!source.episodes || source.episodes.length === 0) { return; } const episodeUrl = source.episodes.length > 1 ? source.episodes[1] : source.episodes[0]; // 标记为已尝试 setAttemptedSources((prev) => new Set(prev).add(sourceKey)); try { const info = await getVideoResolutionFromM3u8(episodeUrl); setVideoInfoMap((prev) => new Map(prev).set(sourceKey, info)); } catch (error) { // 失败时保存错误状态 setVideoInfoMap((prev) => new Map(prev).set(sourceKey, { quality: '错误', loadSpeed: '未知', pingTime: 0, hasError: true, }) ); } }, []); // 当有预计算结果时,先合并到videoInfoMap中 useEffect(() => { if (precomputedVideoInfo && precomputedVideoInfo.size > 0) { // 原子性地更新两个状态,避免时序问题 setVideoInfoMap((prev) => { const newMap = new Map(prev); precomputedVideoInfo.forEach((value, key) => { newMap.set(key, value); }); return newMap; }); setAttemptedSources((prev) => { const newSet = new Set(prev); precomputedVideoInfo.forEach((info, key) => { if (!info.hasError) { newSet.add(key); } }); return newSet; }); // 同步更新 ref,确保 getVideoInfo 能立即看到更新 precomputedVideoInfo.forEach((info, key) => { if (!info.hasError) { attemptedSourcesRef.current.add(key); } }); } }, [precomputedVideoInfo]); // 读取本地"优选和测速"开关,默认开启 const [optimizationEnabled] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('enableOptimization'); if (saved !== null) { try { return JSON.parse(saved); } catch { /* ignore */ } } } return true; }); // 当切换到换源tab并且有源数据时,异步获取视频信息 - 移除 attemptedSources 依赖避免循环触发 useEffect(() => { const fetchVideoInfosInBatches = async () => { if ( !optimizationEnabled || // 若关闭测速则直接退出 activeTab !== 'sources' || availableSources.length === 0 || currentSource === 'openlist' || // 私人影库不进行测速 currentSource === 'emby' // Emby 不进行测速 ) return; // 筛选出尚未测速的播放源 const pendingSources = availableSources.filter((source) => { const sourceKey = `${source.source}-${source.id}`; return !attemptedSourcesRef.current.has(sourceKey); }); if (pendingSources.length === 0) return; const batchSize = Math.ceil(pendingSources.length / 2); for (let start = 0; start < pendingSources.length; start += batchSize) { const batch = pendingSources.slice(start, start + batchSize); await Promise.all(batch.map(getVideoInfo)); } // 初始测速完成后,标记为已完成 if (!initialTestingCompleted) { setInitialTestingCompleted(true); } }; fetchVideoInfosInBatches(); // 依赖项保持与之前一致 }, [activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]); // 监听后台加载完成,触发自动测速 const prevBackgroundLoadingRef = useRef(false); useEffect(() => { // 当后台加载从 true 变为 false 时(即加载完成) if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) { // 如果当前选项卡在换源位置,触发测速 if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') { // 筛选出尚未测速的播放源 const pendingSources = availableSources.filter((source) => { const sourceKey = `${source.source}-${source.id}`; return !attemptedSourcesRef.current.has(sourceKey); }); if (pendingSources.length > 0) { const batchSize = Math.ceil(pendingSources.length / 2); const fetchInBatches = async () => { for (let start = 0; start < pendingSources.length; start += batchSize) { const batch = pendingSources.slice(start, start + batchSize); await Promise.all(batch.map(getVideoInfo)); } if (!initialTestingCompleted) { setInitialTestingCompleted(true); } }; fetchInBatches(); } } } // 更新前一次的加载状态 prevBackgroundLoadingRef.current = backgroundSourcesLoading; }, [backgroundSourcesLoading, activeTab, availableSources, getVideoInfo, optimizationEnabled, initialTestingCompleted, currentSource]); // 升序分页标签 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) { // 倒序时,label 也倒序显示 return [...categoriesAsc] .reverse() .map(({ start, end }) => `${end}-${start}`); } return categoriesAsc.map(({ start, end }) => `${start}-${end}`); }, [categoriesAsc, descending]); const categoryContainerRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); // 添加鼠标悬停状态管理 const [isCategoryHovered, setIsCategoryHovered] = useState(false); // 阻止页面竖向滚动 const preventPageScroll = useCallback((e: WheelEvent) => { if (isCategoryHovered) { e.preventDefault(); } }, [isCategoryHovered]); // 处理滚轮事件,实现横向滚动 const handleWheel = useCallback((e: WheelEvent) => { if (isCategoryHovered && categoryContainerRef.current) { e.preventDefault(); // 阻止默认的竖向滚动 const container = categoryContainerRef.current; const scrollAmount = e.deltaY * 2; // 调整滚动速度 // 根据滚轮方向进行横向滚动 container.scrollBy({ left: scrollAmount, behavior: 'smooth' }); } }, [isCategoryHovered]); // 添加全局wheel事件监听器 useEffect(() => { if (isCategoryHovered) { // 鼠标悬停时阻止页面滚动 document.addEventListener('wheel', preventPageScroll, { passive: false }); document.addEventListener('wheel', handleWheel, { passive: false }); } else { // 鼠标离开时恢复页面滚动 document.removeEventListener('wheel', preventPageScroll); document.removeEventListener('wheel', handleWheel); } return () => { document.removeEventListener('wheel', preventPageScroll); document.removeEventListener('wheel', handleWheel); }; }, [isCategoryHovered, preventPageScroll, handleWheel]); // 当分页切换时,将激活的分页标签滚动到视口中间 useEffect(() => { const btn = buttonRefs.current[displayPage]; const container = categoryContainerRef.current; if (btn && container) { // 手动计算滚动位置,只滚动分页标签容器 const containerRect = container.getBoundingClientRect(); const btnRect = btn.getBoundingClientRect(); const scrollLeft = container.scrollLeft; // 计算按钮相对于容器的位置 const btnLeft = btnRect.left - containerRect.left + scrollLeft; const btnWidth = btnRect.width; const containerWidth = containerRect.width; // 计算目标滚动位置,使按钮居中 const targetScrollLeft = btnLeft - (containerWidth - btnWidth) / 2; // 平滑滚动到目标位置 container.scrollTo({ left: targetScrollLeft, behavior: 'smooth', }); } }, [displayPage, pageCount]); // 处理换源tab点击,只在点击时才搜索 const handleSourceTabClick = () => { setActiveTab('sources'); }; const handleCategoryClick = useCallback( (index: number) => { if (descending) { // 在倒序时,需要将显示索引转换为实际索引 setCurrentPage(pageCount - 1 - index); } else { setCurrentPage(index); } }, [descending, pageCount] ); const handleEpisodeClick = useCallback( (episodeNumber: number) => { onChange?.(episodeNumber); }, [onChange] ); const handleSourceClick = useCallback( (source: SearchResult) => { onSourceChange?.(source.source, source.id, source.title); }, [onSourceChange] ); // 解析网速字符串,转换为 KB/s 数值用于排序 const parseSpeedToKBps = useCallback((speedStr: string): number => { if (!speedStr || speedStr === '未知' || speedStr === '测量中...') { return -1; // 无效速度返回 -1,排在最后 } const match = speedStr.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/); if (!match) { return -1; } const value = parseFloat(match[1]); const unit = match[2]; // 统一转换为 KB/s return unit === 'MB/s' ? value * 1024 : value; }, []); // 重新测试单个源 const handleRetestSource = useCallback( async (source: SearchResult, e: React.MouseEvent) => { e.stopPropagation(); // 阻止事件冒泡,避免触发换源 const sourceKey = `${source.source}-${source.id}`; // 标记为正在测试 setRetestingSources((prev) => new Set(prev).add(sourceKey)); // 从已尝试列表中移除,允许重新测试 setAttemptedSources((prev) => { const newSet = new Set(prev); newSet.delete(sourceKey); return newSet; }); // 同步更新 ref attemptedSourcesRef.current.delete(sourceKey); // 执行测试 try { await getVideoInfo(source); } finally { // 无论成功或失败,都移除测试标记 setRetestingSources((prev) => { const newSet = new Set(prev); newSet.delete(sourceKey); return newSet; }); } }, [getVideoInfo] ); const currentStart = currentPage * episodesPerPage + 1; const currentEnd = Math.min( currentStart + episodesPerPage - 1, totalEpisodes ); return (
{/* 主要的 Tab 切换 - 无缝融入设计 */}
{/* 选集选项卡 - 仅在多集时显示 */} {totalEpisodes > 1 && (
!isRoomMember && setActiveTab('episodes')} className={`flex-1 py-3 px-6 text-center transition-all duration-200 font-medium relative ${isRoomMember ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${activeTab === 'episodes' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' } `.trim()} > 选集 {isRoomMember && 🔒}
)} {/* 换源选项卡 */}
!isRoomMember && handleSourceTabClick()} className={`flex-1 py-3 px-6 text-center transition-all duration-200 font-medium relative ${isRoomMember ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${activeTab === 'sources' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' } `.trim()} > 换源 {isRoomMember && 🔒}
{/* 弹幕选项卡 */}
setActiveTab('danmaku')} className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'danmaku' ? 'text-green-600 dark:text-green-400' : 'text-gray-700 hover:text-green-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-green-400 hover:bg-black/3 dark:hover:bg-white/3' } `.trim()} > 弹幕
{/* 弹幕 Tab 内容 */} {activeTab === 'danmaku' && onDanmakuSelect && (
)} {/* 选集 Tab 内容 */} {activeTab === 'episodes' && ( <> {/* 分类标签 */}
setIsCategoryHovered(true)} onMouseLeave={() => setIsCategoryHovered(false)} >
{categories.map((label, idx) => { const isActive = idx === displayPage; return ( ); })}
{/* 向上/向下按钮 */} {/* 集数屏蔽配置按钮 */}
{/* 集数网格 */}
{(() => { const len = currentEnd - currentStart + 1; const episodes = Array.from({ length: len }, (_, i) => descending ? currentEnd - i : currentStart + i ); // 过滤掉被屏蔽的集数,但保持原有索引 return episodes .filter(episodeNumber => !isEpisodeFiltered(episodeNumber)) .map((episodeNumber) => { const isActive = episodeNumber === value; return ( ); }); })()}
)} {/* 换源 Tab 内容 */} {activeTab === 'sources' && (
{sourceSearchLoading && (
搜索中...
)} {sourceSearchError && (
⚠️

{sourceSearchError}

)} {!sourceSearchLoading && !sourceSearchError && availableSources.length === 0 && (
📺

暂无可用的换源

)} {!sourceSearchLoading && !sourceSearchError && availableSources.length > 0 && (
{availableSources .sort((a, b) => { const aIsCurrent = a.source?.toString() === currentSource?.toString() && a.id?.toString() === currentId?.toString(); const bIsCurrent = b.source?.toString() === currentSource?.toString() && b.id?.toString() === currentId?.toString(); // 当前源始终置顶 if (aIsCurrent && !bIsCurrent) return -1; if (!aIsCurrent && bIsCurrent) return 1; // 如果初始测速已完成,按网速排序(快的在前) if (initialTestingCompleted) { const aKey = `${a.source}-${a.id}`; const bKey = `${b.source}-${b.id}`; const aInfo = videoInfoMap.get(aKey); const bInfo = videoInfoMap.get(bKey); const aSpeed = aInfo ? parseSpeedToKBps(aInfo.loadSpeed) : -1; const bSpeed = bInfo ? parseSpeedToKBps(bInfo.loadSpeed) : -1; // 速度快的排在前面(降序) return bSpeed - aSpeed; } return 0; }) .map((source, index) => { const isCurrentSource = source.source?.toString() === currentSource?.toString() && source.id?.toString() === currentId?.toString(); return (
!isCurrentSource && handleSourceClick(source) } className={`flex items-start gap-3 px-2 py-3 rounded-lg transition-all select-none duration-200 relative ${isCurrentSource ? 'bg-green-500/10 dark:bg-green-500/20 border-green-500/30 border' : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer' }`.trim()} > {/* 封面 */}
{source.poster && ( {source.title} { const target = e.target as HTMLImageElement; target.style.display = 'none'; }} /> )}
{/* 信息区域 */}
{/* 标题和分辨率 - 顶部 */}

{source.title}

{/* 标题级别的 tooltip - 第一个元素不显示 */} {index !== 0 && (
{source.title}
)}
{(() => { const sourceKey = `${source.source}-${source.id}`; const videoInfo = videoInfoMap.get(sourceKey); if (videoInfo && videoInfo.quality !== '未知') { if (videoInfo.hasError) { return (
检测失败
); } else { // 根据分辨率设置不同颜色:2K、4K为紫色,1080p、720p为绿色,其他为黄色 const isUltraHigh = ['4K', '2K'].includes( videoInfo.quality ); const isHigh = ['1080p', '720p'].includes( videoInfo.quality ); const textColorClasses = isUltraHigh ? 'text-purple-600 dark:text-purple-400' : isHigh ? 'text-green-600 dark:text-green-400' : 'text-yellow-600 dark:text-yellow-400'; return (
{videoInfo.quality}
); } } return null; })()}
{/* 源名称和集数信息 - 垂直居中 */}
{source.source_name} {source.episodes.length > 1 && ( {source.episodes.length} 集 )}
{/* 网络信息 - 底部 */}
{(() => { const sourceKey = `${source.source}-${source.id}`; const videoInfo = videoInfoMap.get(sourceKey); if (videoInfo) { if (!videoInfo.hasError) { return (
{videoInfo.loadSpeed}
{videoInfo.pingTime}ms
); } else { return (
无测速数据
); } } return null; })()}
{/* 重新测试按钮 */} {(() => { // 私人影库和 Emby 不显示重新测试按钮 if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) { return null; } const sourceKey = `${source.source}-${source.id}`; const isTesting = retestingSources.has(sourceKey); const videoInfo = videoInfoMap.get(sourceKey); // 只有第一次测试完成后(有测速数据)才显示重新测试按钮 if (videoInfo) { return ( ); } return null; })()}
); })} {/* 后台加载提示 */} {backgroundSourcesLoading && (
正在加载更多播放源...
)}
)}
)} {/* 集数过滤设置弹窗 */} setShowFilterSettings(false)} onConfigUpdate={(config) => { onFilterConfigUpdate?.(config); }} onShowToast={onShowToast} />
); }; export default EpisodeSelector;