mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-12 23:33:43 +08:00
Address issues #217 #218 and integrate the useful parts of PR #219 without regressions: stop player rebuild on episode reverse, grid/paged episodes, poster placeholders, chrome69 client transpile for Amlogic WebView, and duration-signature ad block detection while keeping interstitial/proxy filters. Bump to 4.9.15.
757 lines
34 KiB
TypeScript
757 lines
34 KiB
TypeScript
'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<string, ResolutionInfo | null>;
|
|
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<HTMLDivElement>(null);
|
|
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
const sourceItemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
|
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<Record<string, number>>({});
|
|
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<Record<string, number>>((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<string, number> = {};
|
|
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 (
|
|
<Card hover={false}>
|
|
{showSourceSelector && (
|
|
<div className="mb-4">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Icons.Layers size={18} className="text-[var(--text-color)]" />
|
|
<span className="text-base sm:text-lg font-semibold text-[var(--text-color)]">
|
|
源列表
|
|
</span>
|
|
<Badge variant="primary">{sources!.length}</Badge>
|
|
</div>
|
|
<button
|
|
onClick={() => onSourceSectionCollapseChange?.(!sourceSectionCollapsed)}
|
|
className="ml-auto p-1.5 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)] transition-all duration-200 cursor-pointer"
|
|
aria-label={sourceSectionCollapsed ? '展开源列表' : '折叠源列表'}
|
|
title={sourceSectionCollapsed ? '展开源列表' : '折叠源列表'}
|
|
>
|
|
<Icons.ChevronDown
|
|
size={16}
|
|
className={`transition-transform duration-200 ${sourceSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)]">
|
|
<div className="flex items-start gap-3">
|
|
<button
|
|
onClick={() => {
|
|
if (!sourceSectionCollapsed) {
|
|
setSourceExpanded((current) => !current);
|
|
}
|
|
}}
|
|
className={`flex-1 min-w-0 flex items-center justify-between gap-3 text-left ${sourceSectionCollapsed ? 'cursor-default' : 'cursor-pointer'}`}
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-sm font-medium text-[var(--text-color)] truncate">
|
|
{currentSourceInfo?.sourceName || currentSourceInfo?.source || '当前来源'}
|
|
</span>
|
|
{currentResolution && (
|
|
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${currentResolution.color} flex-shrink-0`}>
|
|
{currentResolution.label}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!sourceSectionCollapsed && (
|
|
<Icons.ChevronDown
|
|
size={16}
|
|
className={`flex-shrink-0 text-[var(--text-color-secondary)] transition-transform duration-200 ${isSourceListOpen ? 'rotate-180' : 'rotate-0'}`}
|
|
/>
|
|
)}
|
|
</button>
|
|
|
|
{!sourceSectionCollapsed && (
|
|
<Button
|
|
variant="secondary"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
refreshLatencies();
|
|
}}
|
|
disabled={isLoadingLatency}
|
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1 min-h-[36px] md:px-3 md:py-1.5 md:text-sm"
|
|
>
|
|
<Icons.RefreshCw size={12} className={isLoadingLatency ? 'animate-spin' : ''} />
|
|
刷新延迟
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-[var(--text-color-secondary)]">
|
|
<span className="truncate">
|
|
当前线路:{currentSourceInfo?.sourceName || currentSourceInfo?.source || '未知来源'}
|
|
</span>
|
|
<span className="shrink-0">共 {sources!.length} 条</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded source list */}
|
|
{isSourceListOpen && (
|
|
<div className="mt-2 space-y-2">
|
|
{(() => {
|
|
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<string, typeof visibleSources>();
|
|
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 (
|
|
<>
|
|
<div className="space-y-1.5 max-h-[300px] overflow-y-auto">
|
|
{hasTypeGroups ? (
|
|
Array.from(groupedByType.entries()).map(([typeName, typeSources]) => (
|
|
<div key={typeName || '__default'}>
|
|
{typeName && (
|
|
<div className="text-[10px] font-medium text-[var(--text-color-secondary)] uppercase tracking-wider px-2 pt-2 pb-1">
|
|
{typeName}
|
|
</div>
|
|
)}
|
|
{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 (
|
|
<button
|
|
key={`${source.source}-${index}`}
|
|
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
|
onClick={() => {
|
|
if (!isCurrent) {
|
|
onSourceChange!(source);
|
|
setSourceExpanded(false);
|
|
}
|
|
}}
|
|
className={`
|
|
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
|
flex items-center gap-2.5
|
|
${isCurrent
|
|
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
|
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
|
}
|
|
`}
|
|
aria-current={isCurrent ? 'true' : undefined}
|
|
>
|
|
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
|
<Image
|
|
src={source.pic || '/placeholder-poster.svg'}
|
|
alt=""
|
|
width={40}
|
|
height={56}
|
|
className="w-full h-full object-cover"
|
|
unoptimized
|
|
referrerPolicy="no-referrer"
|
|
onError={(e) => {
|
|
const target = e.currentTarget as HTMLImageElement;
|
|
if (target.dataset.fallback === '1') return;
|
|
target.dataset.fallback = '1';
|
|
target.src = '/placeholder-poster.svg';
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
|
{source.sourceName || source.source}
|
|
{badge ? (
|
|
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
|
{badge.label}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{source.remarks && !badge && (
|
|
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
|
)}
|
|
{latency !== undefined && (
|
|
<div className="mt-0.5">
|
|
<LatencyBadge latency={latency} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isCurrent && (
|
|
<Icons.Play size={14} className="flex-shrink-0" />
|
|
)}
|
|
{!isCurrent && globalIndex < 3 && (
|
|
<Badge
|
|
variant="secondary"
|
|
className={`flex-shrink-0 ${globalIndex === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
|
globalIndex === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
|
'bg-orange-400/20 text-orange-600 border-orange-400'
|
|
}`}
|
|
>
|
|
#{globalIndex + 1}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
) : (
|
|
visibleSources.map((source, index) => {
|
|
const isCurrent = source.source === currentSource;
|
|
const latency = mergedLatencies[source.source] ?? source.latency;
|
|
const badge = getResBadge(source, isCurrent);
|
|
|
|
return (
|
|
<button
|
|
key={`${source.source}-${index}`}
|
|
ref={(element) => { sourceItemRefs.current[source.source] = element; }}
|
|
onClick={() => {
|
|
if (!isCurrent) {
|
|
onSourceChange!(source);
|
|
setSourceExpanded(false);
|
|
}
|
|
}}
|
|
className={`
|
|
w-full p-2.5 rounded-[var(--radius-2xl)] text-left transition-all duration-200
|
|
flex items-center gap-2.5
|
|
${isCurrent
|
|
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)]'
|
|
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)] cursor-pointer'
|
|
}
|
|
`}
|
|
aria-current={isCurrent ? 'true' : undefined}
|
|
>
|
|
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
|
<Image
|
|
src={source.pic || '/placeholder-poster.svg'}
|
|
alt=""
|
|
width={40}
|
|
height={56}
|
|
className="w-full h-full object-cover"
|
|
unoptimized
|
|
referrerPolicy="no-referrer"
|
|
onError={(e) => {
|
|
const target = e.currentTarget as HTMLImageElement;
|
|
if (target.dataset.fallback === '1') return;
|
|
target.dataset.fallback = '1';
|
|
target.src = '/placeholder-poster.svg';
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium text-sm truncate flex items-center gap-1.5">
|
|
{source.sourceName || source.source}
|
|
{badge ? (
|
|
<span className={`inline-flex items-center px-1 py-0 rounded text-[9px] font-bold text-white ${badge.color}`}>
|
|
{badge.label}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{source.remarks && !badge && (
|
|
<div className="text-[10px] text-[var(--text-color-secondary)] truncate mt-0.5">{source.remarks}</div>
|
|
)}
|
|
{latency !== undefined && (
|
|
<div className="mt-0.5">
|
|
<LatencyBadge latency={latency} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isCurrent && (
|
|
<Icons.Play size={14} className="flex-shrink-0" />
|
|
)}
|
|
{!isCurrent && index < 3 && (
|
|
<Badge
|
|
variant="secondary"
|
|
className={`flex-shrink-0 ${index === 0 ? 'bg-yellow-500/20 text-yellow-600 border-yellow-500' :
|
|
index === 1 ? 'bg-gray-400/20 text-gray-600 border-gray-400' :
|
|
'bg-orange-400/20 text-orange-600 border-orange-400'
|
|
}`}
|
|
>
|
|
#{index + 1}
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{hasMoreSources && (
|
|
<button
|
|
onClick={() => setShowAllSources((current) => !current)}
|
|
className="w-full mt-1.5 py-1.5 text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] flex items-center justify-center gap-1 transition-colors cursor-pointer"
|
|
>
|
|
{showAllVisibleSources ? (
|
|
<>收起 <Icons.ChevronDown size={12} className="rotate-180" /></>
|
|
) : (
|
|
<>展开更多 ({sortedSources.length - MAX_VISIBLE}) <Icons.ChevronDown size={12} /></>
|
|
)}
|
|
</button>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2 flex-wrap">
|
|
<Icons.List size={20} className="sm:w-6 sm:h-6" />
|
|
<span>选集</span>
|
|
{episodes && (
|
|
<Badge variant="primary">{episodes.length}</Badge>
|
|
)}
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
{/* Layout toggle */}
|
|
{showReverseToggle && !episodeSectionCollapsed && (
|
|
<button
|
|
onClick={() => setEpisodeLayout((current) => (current === 'grid' ? 'list' : 'grid'))}
|
|
className={`
|
|
p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 cursor-pointer
|
|
${episodeLayout === 'grid'
|
|
? 'bg-[var(--accent-color)] text-white'
|
|
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
|
}
|
|
`}
|
|
aria-label={episodeLayout === 'grid' ? '切换为列表' : '切换为网格'}
|
|
title={episodeLayout === 'grid' ? '切换为列表' : '切换为网格'}
|
|
>
|
|
<Icons.Layers size={16} />
|
|
</button>
|
|
)}
|
|
{/* Reverse order toggle button - only show when more than 1 episode */}
|
|
{showReverseToggle && !episodeSectionCollapsed && (
|
|
<button
|
|
onClick={() => onToggleReverse?.(!isReversed)}
|
|
className={`
|
|
p-1.5 rounded-[var(--radius-2xl)] transition-all duration-200 cursor-pointer
|
|
${isReversed
|
|
? 'bg-[var(--accent-color)] text-white'
|
|
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
|
}
|
|
`}
|
|
aria-label={isReversed ? '恢复正序' : '倒序排列'}
|
|
title={isReversed ? '恢复正序' : '倒序排列'}
|
|
>
|
|
<Icons.ArrowUpDown size={16} />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => onEpisodeSectionCollapseChange?.(!episodeSectionCollapsed)}
|
|
className="p-1.5 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)] transition-all duration-200 cursor-pointer"
|
|
aria-label={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
|
|
title={episodeSectionCollapsed ? '展开选集列表' : '折叠选集列表'}
|
|
>
|
|
<Icons.ChevronDown
|
|
size={16}
|
|
className={`transition-transform duration-200 ${episodeSectionCollapsed ? '-rotate-90' : 'rotate-0'}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{episodeSectionCollapsed ? (
|
|
<div className="rounded-[var(--radius-2xl)] border border-[var(--glass-border)] bg-[var(--glass-bg)] p-3">
|
|
<div className="flex items-center justify-between gap-3 text-sm">
|
|
<span className="text-[var(--text-color-secondary)]">当前选集</span>
|
|
<span className="font-medium text-[var(--text-color)] truncate">
|
|
{currentEpisodeLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* Section page chips for long episode lists */}
|
|
{episodeLayout === 'grid' && totalEpisodePages > 1 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{pageRangeLabels.map((label, page) => (
|
|
<button
|
|
key={label}
|
|
onClick={() => setEpisodePage(page)}
|
|
className={`
|
|
px-2.5 py-1 rounded-[var(--radius-2xl)] text-xs font-medium transition-all duration-200 cursor-pointer
|
|
${episodePage === page
|
|
? 'bg-[var(--accent-color)] text-white'
|
|
: 'bg-[var(--glass-bg)] text-[var(--text-color-secondary)] hover:bg-[var(--glass-hover)] border border-[var(--glass-border)]'
|
|
}
|
|
`}
|
|
aria-current={episodePage === page ? 'true' : undefined}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={listRef}
|
|
className={`max-h-[400px] sm:max-h-[600px] overflow-y-auto pr-1 ${
|
|
episodeLayout === 'grid'
|
|
? 'grid grid-cols-3 sm:grid-cols-4 gap-2'
|
|
: 'space-y-2'
|
|
}`}
|
|
role="radiogroup"
|
|
aria-label="剧集选择"
|
|
>
|
|
{pagedEpisodes && pagedEpisodes.length > 0 ? (
|
|
pagedEpisodes.map(({ episode, displayIndex }) => {
|
|
const originalIndex = getOriginalIndex(displayIndex);
|
|
const isCurrentEpisode = currentEpisode === originalIndex;
|
|
const isGrid = episodeLayout === 'grid';
|
|
|
|
return (
|
|
<button
|
|
key={originalIndex}
|
|
ref={(el) => { buttonRefs.current[displayIndex] = el; }}
|
|
onClick={() => onEpisodeClick(episode, originalIndex)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onEpisodeClick(episode, originalIndex);
|
|
}
|
|
}}
|
|
tabIndex={0}
|
|
role="radio"
|
|
aria-checked={isCurrentEpisode}
|
|
aria-current={isCurrentEpisode ? 'true' : undefined}
|
|
aria-label={`${episode.name || `第 ${originalIndex + 1} 集`}${isCurrentEpisode ? ',当前播放' : ''}`}
|
|
className={`
|
|
rounded-[var(--radius-2xl)] transition-[var(--transition-fluid)] cursor-pointer
|
|
${isGrid
|
|
? 'px-2 py-2.5 text-center'
|
|
: 'w-full px-3 py-2 sm:px-4 sm:py-3 text-left'
|
|
}
|
|
${isCurrentEpisode
|
|
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
|
|
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
|
|
}
|
|
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
|
|
`}
|
|
>
|
|
<div className={`flex items-center ${isGrid ? 'justify-center gap-1' : 'justify-between'}`}>
|
|
<span className={`font-medium ${isGrid ? 'text-xs sm:text-sm truncate' : 'text-sm sm:text-base'}`}>
|
|
{episode.name || `第 ${originalIndex + 1} 集`}
|
|
</span>
|
|
{isCurrentEpisode && !isGrid && (
|
|
<Icons.Play size={16} />
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="text-center py-8 text-[var(--text-secondary)] col-span-full">
|
|
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
|
|
<p>暂无剧集信息</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|