/* eslint-disable @next/next/no-img-element */ import { useRouter } from 'next/navigation'; import React, { useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { SearchResult } 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; /** 预计算的测速结果,避免重复测速 */ precomputedVideoInfo?: Map; } /** * 选集组件,支持分页、自动滚动聚焦当前分页标签,以及换源功能。 */ const EpisodeSelector: React.FC = ({ totalEpisodes, episodes_titles, episodesPerPage = 50, value = 1, onChange, onSourceChange, currentSource, currentId, videoTitle, availableSources = [], sourceSearchLoading = false, sourceSearchError = null, precomputedVideoInfo, }) => { const router = useRouter(); const pageCount = Math.ceil(totalEpisodes / episodesPerPage); // 存储每个源的视频信息 const [videoInfoMap, setVideoInfoMap] = useState>( new Map() ); const [attemptedSources, setAttemptedSources] = useState>( new Set() ); // 使用 ref 来避免闭包问题 const attemptedSourcesRef = useRef>(new Set()); const videoInfoMapRef = useRef>(new Map()); // 同步状态到 ref useEffect(() => { attemptedSourcesRef.current = attemptedSources; }, [attemptedSources]); useEffect(() => { videoInfoMapRef.current = videoInfoMap; }, [videoInfoMap]); // 主要的 tab 状态:'episodes' 或 'sources' // 当只有一集时默认展示 "换源",并隐藏 "选集" 标签 const [activeTab, setActiveTab] = useState<'episodes' | 'sources'>( totalEpisodes > 1 ? 'episodes' : 'sources' ); // 当前分页索引(0 开始) const initialPage = Math.floor((value - 1) / episodesPerPage); const [currentPage, setCurrentPage] = useState(initialPage); // 是否倒序显示 const [descending, setDescending] = useState(false); // 根据 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 ) 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)); } }; fetchVideoInfosInBatches(); // 依赖项保持与之前一致 }, [activeTab, availableSources, getVideoInfo, optimizationEnabled]); // 升序分页标签 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] ); const currentStart = currentPage * episodesPerPage + 1; const currentEnd = Math.min( currentStart + episodesPerPage - 1, totalEpisodes ); return (
{/* 主要的 Tab 切换 - 无缝融入设计 */}
{totalEpisodes > 1 && (
setActiveTab('episodes')} className={`flex-1 py-3 px-6 text-center cursor-pointer transition-all duration-200 font-medium ${activeTab === 'episodes' ? 'text-blue-600 dark:text-blue-400' : 'text-gray-700 hover:text-blue-600 bg-black/5 dark:bg-white/5 dark:text-gray-300 dark:hover:text-blue-400 hover:bg-black/3 dark:hover:bg-white/3' } `.trim()} > 选集
)}
换源
{/* 选集 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; })().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; 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-blue-500/10 dark:bg-blue-500/20 border-blue-500/30 border' : 'hover:bg-gray-200/50 dark:hover:bg-white/10 hover:scale-[1.02] cursor-pointer' }`.trim()} > {/* 封面 */}
{source.episodes && source.episodes.length > 0 && ( {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-blue-600 dark:text-blue-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 (
无测速数据
); // 占位div } } })()}
); })}
)}
)}
); }; export default EpisodeSelector;