'use client'; import { useRef, useCallback, useState, useMemo, useEffect } from 'react'; import Image from 'next/image'; import { Card } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; import { Icons } from '@/components/ui/Icon'; import { LatencyBadge } from '@/components/ui/LatencyBadge'; import { Button } from '@/components/ui/Button'; import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation'; import { settingsStore } from '@/lib/store/settings-store'; import type { VideoResolutionInfo } from './hooks/useVideoResolution'; import type { ResolutionInfo } from '@/lib/hooks/useResolutionProbe'; import { getCachedResolution } from '@/lib/player/resolution-cache'; import { getSourceResolutionBadge, shouldExpandForCurrentSource } from '@/lib/player/source-list-utils'; interface Episode { name?: string; url: string; } export interface SourceInfo { id: string | number; source: string; sourceName?: string; latency?: number; pic?: string; typeName?: string; remarks?: string; } interface EpisodeListProps { episodes: Episode[] | null; currentEpisode: number; isReversed?: boolean; onEpisodeClick: (episode: Episode, index: number) => void; onToggleReverse?: (reversed: boolean) => void; // Optional source integration props sources?: SourceInfo[]; currentSource?: string; onSourceChange?: (source: SourceInfo) => void; // Actual detected resolution for the current source currentResolution?: VideoResolutionInfo | null; // Probed resolutions for all sources (key: "source:id") sourceResolutions?: Record; sourceSectionCollapsed?: boolean; onSourceSectionCollapseChange?: (collapsed: boolean) => void; episodeSectionCollapsed?: boolean; onEpisodeSectionCollapseChange?: (collapsed: boolean) => void; } export function EpisodeList({ episodes, currentEpisode, isReversed = false, onEpisodeClick, onToggleReverse, sources, currentSource, onSourceChange, currentResolution, sourceResolutions, sourceSectionCollapsed = false, onSourceSectionCollapseChange, episodeSectionCollapsed = false, onEpisodeSectionCollapseChange, }: EpisodeListProps) { const listRef = useRef(null); const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]); const sourceItemRefs = useRef>({}); const [sourceExpanded, setSourceExpanded] = useState(false); const [showAllSources, setShowAllSources] = useState(false); // list = classic vertical list; grid = multi-column with section pages const [episodeLayout, setEpisodeLayout] = useState<'list' | 'grid'>('grid'); const [episodePage, setEpisodePage] = useState(0); const EPISODES_PER_PAGE = 50; // Source latency state const [latencies, setLatencies] = useState>({}); const [isLoadingLatency, setIsLoadingLatency] = useState(false); const showSourceSelector = sources && sources.length > 1 && onSourceChange; // Helper: get best resolution badge for a source const getResBadge = useCallback((source: SourceInfo, isCurrent: boolean) => { const probeKey = `${source.source}:${source.id}`; return getSourceResolutionBadge({ isCurrent, currentResolution: currentResolution || undefined, probedResolution: sourceResolutions?.[probeKey] || undefined, cachedResolution: getCachedResolution(source.source, source.id) || undefined, remarks: source.remarks, }); }, [currentResolution, sourceResolutions]); // Current source info const currentSourceInfo = useMemo(() => { if (!sources || !currentSource) return null; return sources.find(s => s.source === currentSource) || null; }, [sources, currentSource]); // Sort sources by latency const initialLatencies = useMemo(() => { if (!sources) return {}; return sources.reduce>((accumulator, source) => { if (source.latency !== undefined) { accumulator[source.source] = source.latency; } return accumulator; }, {}); }, [sources]); const mergedLatencies = useMemo(() => ({ ...initialLatencies, ...latencies, }), [initialLatencies, latencies]); const sortedSources = useMemo(() => { if (!sources) return []; return [...sources].sort((a, b) => { const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity; const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity; return latA - latB; }); }, [mergedLatencies, sources]); const isSourceListOpen = !sourceSectionCollapsed && sourceExpanded; const forceExpandedForCurrentSource = !!currentSource && shouldExpandForCurrentSource(sortedSources, currentSource); const showAllVisibleSources = showAllSources || forceExpandedForCurrentSource; useEffect(() => { if (!isSourceListOpen || !currentSource) return; const frame = requestAnimationFrame(() => { sourceItemRefs.current[currentSource]?.scrollIntoView({ behavior: 'smooth', block: 'center', }); }); return () => cancelAnimationFrame(frame); }, [currentSource, isSourceListOpen, showAllVisibleSources, sortedSources]); // Resolve source ID to its actual baseUrl for pinging const getSourcePingUrl = useCallback((sourceId: string): string | null => { const settings = settingsStore.getSettings(); const allConfigs = [ ...settings.sources, ...settings.premiumSources, ]; const config = allConfigs.find(s => s.id === sourceId); return config?.baseUrl || null; }, []); // Initialize latencies from sources useEffect(() => { if (!sources) return; const hasMissing = sources.some((source) => source.latency === undefined); // Auto-refresh latencies for sources that don't have them if (hasMissing && sources.length > 1) { const autoRefresh = async () => { const missing = sources.filter(s => s.latency === undefined); const results = await Promise.all( missing.map(async (source) => { try { const pingUrl = getSourcePingUrl(source.source); if (!pingUrl) return { source: source.source, latency: undefined }; const response = await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: pingUrl }), }); if (response.ok) { const data = await response.json(); return { source: source.source, latency: data.latency as number | undefined }; } } catch { /* ignore */ } return { source: source.source, latency: undefined }; }) ); setLatencies(prev => { const updated = { ...prev }; results.forEach(({ source, latency }) => { if (latency !== undefined) updated[source] = latency; }); return updated; }); }; autoRefresh(); } }, [sources, getSourcePingUrl]); // Refresh latencies const refreshLatencies = useCallback(async () => { if (!sources) return; setIsLoadingLatency(true); const results = await Promise.all( sources.map(async (source) => { try { const pingUrl = getSourcePingUrl(source.source); if (!pingUrl) return { source: source.source, latency: undefined }; const response = await fetch('/api/ping', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: pingUrl }), }); if (response.ok) { const data = await response.json(); return { source: source.source, latency: data.latency }; } } catch { // Ignore errors } return { source: source.source, latency: undefined }; }) ); const newLatencies: Record = {}; results.forEach(({ source, latency }) => { if (latency !== undefined) { newLatencies[source] = latency; } }); setLatencies(newLatencies); setIsLoadingLatency(false); }, [sources, getSourcePingUrl]); // Memoized display episodes - reversed if toggle is on const displayEpisodes = useMemo(() => { if (!episodes) return null; return isReversed ? [...episodes].reverse() : episodes; }, [episodes, isReversed]); const totalEpisodePages = useMemo(() => { if (!displayEpisodes || displayEpisodes.length === 0) return 1; return Math.max(1, Math.ceil(displayEpisodes.length / EPISODES_PER_PAGE)); }, [displayEpisodes]); // Keep the current episode's page visible when order/layout changes useEffect(() => { if (!episodes || episodes.length === 0) { setEpisodePage(0); return; } const displayIndex = isReversed ? episodes.length - 1 - currentEpisode : currentEpisode; const page = Math.floor(displayIndex / EPISODES_PER_PAGE); setEpisodePage(Math.min(Math.max(0, page), Math.max(0, Math.ceil(episodes.length / EPISODES_PER_PAGE) - 1))); }, [currentEpisode, episodes, isReversed, episodeLayout]); const pagedEpisodes = useMemo(() => { if (!displayEpisodes) return null; if (episodeLayout === 'list' || displayEpisodes.length <= EPISODES_PER_PAGE) { return displayEpisodes.map((episode, displayIndex) => ({ episode, displayIndex })); } const start = episodePage * EPISODES_PER_PAGE; return displayEpisodes .slice(start, start + EPISODES_PER_PAGE) .map((episode, offset) => ({ episode, displayIndex: start + offset })); }, [displayEpisodes, episodeLayout, episodePage]); const pageRangeLabels = useMemo(() => { if (!displayEpisodes) return [] as string[]; const labels: string[] = []; for (let page = 0; page < totalEpisodePages; page++) { const start = page * EPISODES_PER_PAGE + 1; const end = Math.min((page + 1) * EPISODES_PER_PAGE, displayEpisodes.length); labels.push(`${start}-${end}`); } return labels; }, [displayEpisodes, totalEpisodePages]); // Map display index to original index const getOriginalIndex = useCallback((displayIndex: number) => { if (!episodes || !isReversed) return displayIndex; return episodes.length - 1 - displayIndex; }, [episodes, isReversed]); // Map original index to display index (for highlighting current episode) const getDisplayIndex = useCallback((originalIndex: number) => { if (!episodes || !isReversed) return originalIndex; return episodes.length - 1 - originalIndex; }, [episodes, isReversed]); // Keyboard navigation useKeyboardNavigation({ enabled: !episodeSectionCollapsed, containerRef: listRef, currentIndex: getDisplayIndex(currentEpisode), itemCount: episodes?.length || 0, orientation: 'vertical', onNavigate: useCallback((index: number) => { buttonRefs.current[index]?.focus(); buttonRefs.current[index]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []), onSelect: useCallback((displayIndex: number) => { if (episodes) { const originalIndex = getOriginalIndex(displayIndex); if (episodes[originalIndex]) { onEpisodeClick(episodes[originalIndex], originalIndex); } } }, [episodes, onEpisodeClick, getOriginalIndex]), }); const showReverseToggle = episodes && episodes.length > 1; const currentEpisodeLabel = episodes?.[currentEpisode]?.name || `第${currentEpisode + 1}集`; return ( {showSourceSelector && (
源列表 {sources!.length}
{!sourceSectionCollapsed && ( )}
当前线路:{currentSourceInfo?.sourceName || currentSourceInfo?.source || '未知来源'} 共 {sources!.length} 条
{/* Expanded source list */} {isSourceListOpen && (
{(() => { const MAX_VISIBLE = 5; const visibleSources = showAllVisibleSources ? sortedSources : sortedSources.slice(0, MAX_VISIBLE); const hasMoreSources = sortedSources.length > MAX_VISIBLE; // Group sources by typeName const groupedByType = new Map(); for (const source of visibleSources) { const typeName = source.typeName || ''; if (!groupedByType.has(typeName)) groupedByType.set(typeName, []); groupedByType.get(typeName)!.push(source); } const hasTypeGroups = groupedByType.size > 1 || (groupedByType.size === 1 && !groupedByType.has('')); return ( <>
{hasTypeGroups ? ( Array.from(groupedByType.entries()).map(([typeName, typeSources]) => (
{typeName && (
{typeName}
)} {typeSources.map((source, index) => { const isCurrent = source.source === currentSource; const latency = mergedLatencies[source.source] ?? source.latency; const globalIndex = sortedSources.indexOf(source); const badge = getResBadge(source, isCurrent); return ( ); })}
)) ) : ( visibleSources.map((source, index) => { const isCurrent = source.source === currentSource; const latency = mergedLatencies[source.source] ?? source.latency; const badge = getResBadge(source, isCurrent); return ( ); }) )}
{hasMoreSources && ( )} ); })()}
)}
)}
选集 {episodes && ( {episodes.length} )}
{/* Layout toggle */} {showReverseToggle && !episodeSectionCollapsed && ( )} {/* Reverse order toggle button - only show when more than 1 episode */} {showReverseToggle && !episodeSectionCollapsed && ( )}
{episodeSectionCollapsed ? (
当前选集 {currentEpisodeLabel}
) : (
{/* Section page chips for long episode lists */} {episodeLayout === 'grid' && totalEpisodePages > 1 && (
{pageRangeLabels.map((label, page) => ( ))}
)}
{pagedEpisodes && pagedEpisodes.length > 0 ? ( pagedEpisodes.map(({ episode, displayIndex }) => { const originalIndex = getOriginalIndex(displayIndex); const isCurrentEpisode = currentEpisode === originalIndex; const isGrid = episodeLayout === 'grid'; return ( ); }) ) : (

暂无剧集信息

)}
)}
); }