继续完善tv播放页

This commit is contained in:
mtvpls
2026-05-29 22:22:12 +08:00
parent 134628f773
commit b121b328c3
7 changed files with 1040 additions and 83 deletions
+504 -38
View File
@@ -1,11 +1,12 @@
'use client';
import { ArrowLeft, Layers, ListVideo, Loader2, Pause, SkipBack, SkipForward } from 'lucide-react';
import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, X, Volume2, VolumeX } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { savePlayRecord } from '@/lib/db.client';
import { deleteFavorite, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { convertDanmakuFormat, getDanmakuById, getEpisodes, initDanmakuModule, searchAnime } from '@/lib/danmaku/api';
import TVNativeVideo from '@/components/tv/player/TVNativeVideo';
import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils';
@@ -23,9 +24,52 @@ function TVPlayClient() {
const [error, setError] = useState('');
const [showPanel, setShowPanel] = useState(true);
const [showEpisodes, setShowEpisodes] = useState(false);
const [showDetail, setShowDetail] = useState(false);
const [toggleCommand, setToggleCommand] = useState(0);
const [retryNonce, setRetryNonce] = useState(0);
const [startTime, setStartTime] = useState(0);
const [digitBuffer, setDigitBuffer] = useState('');
const [episodePage, setEpisodePage] = useState(0);
const [playbackError, setPlaybackError] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [favorited, setFavorited] = useState(false);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [showVolumeHint, setShowVolumeHint] = useState(false);
const [seekHint, setSeekHint] = useState<{ current: number; duration: number; delta: number } | null>(null);
const [adFilterEnabled, setAdFilterEnabled] = useState(() => {
if (typeof window === 'undefined') return true;
const saved = localStorage.getItem('enable_blockad');
return saved === null ? true : saved === 'true';
});
const [danmakuEnabled, setDanmakuEnabled] = useState(() => {
if (typeof window === 'undefined') return true;
const saved = localStorage.getItem('tv_danmaku_enabled');
return saved === null ? true : saved === 'true';
});
const [danmakuItems, setDanmakuItems] = useState<Array<{ text: string; time: number; color: string; mode: number }>>([]);
const [playbackRate, setPlaybackRate] = useState(() => {
if (typeof window === 'undefined') return 1;
return Number(localStorage.getItem('tv_playback_rate') || '1') || 1;
});
const [skipConfig, setSkipConfig] = useState<{ enable?: boolean; intro_time?: number; outro_time?: number } | null>(null);
const [time, setTime] = useState({ current: 0, duration: 0 });
const timeRef = useRef({ current: 0, duration: 0 });
const episodeButtonRefs = useRef<Record<number, HTMLButtonElement | null>>({});
const detailCloseButtonRef = useRef<HTMLButtonElement | null>(null);
const digitTimerRef = useRef<number | null>(null);
const idleTimerRef = useRef<number | null>(null);
const volumeHintTimerRef = useRef<number | null>(null);
const seekHintTimerRef = useRef<number | null>(null);
const skippedIntroRef = useRef('');
const skippedOutroRef = useRef('');
const lastSavedRef = useRef<{
source: string;
id: string;
index: number;
playTime: number;
totalTime: number;
} | null>(null);
const source = searchParams.get('source');
const id = searchParams.get('id');
@@ -42,7 +86,24 @@ function TVPlayClient() {
if (!alive) return;
setDetail(data.detail);
setSources(data.sources);
const safeIndex = Math.max(0, Math.min(initialIndex || data.detail.initialEpisodeIndex || 0, Math.max(0, (data.detail.episodes?.length || 1) - 1)));
const maxIndex = Math.max(0, (data.detail.episodes?.length || 1) - 1);
const explicitIndex = searchParams.has('index');
let safeIndex = Math.max(0, Math.min(initialIndex || data.detail.initialEpisodeIndex || 0, maxIndex));
if (!explicitIndex && data.detail.source && data.detail.id) {
getAllPlayRecords()
.then((records) => {
if (!alive) return;
const record = records[generateStorageKey(data.detail.source, data.detail.id)];
if (record?.index) {
const rememberedIndex = Math.max(0, Math.min(maxIndex, record.index - 1));
setEpisodeIndex(rememberedIndex);
setStartTime(record.play_time > 1 ? record.play_time : 0);
}
})
.catch(() => undefined);
} else {
setStartTime(0);
}
setEpisodeIndex(safeIndex);
})
.catch((err) => alive && setError(err instanceof Error ? err.message : '加载播放信息失败'))
@@ -56,6 +117,7 @@ function TVPlayClient() {
if (!detail?.episodes?.[episodeIndex]) return;
setResolving(true);
setVideoUrl('');
setPlaybackError(false);
try {
const url = await resolveTVEpisodeUrl(detail.episodes[episodeIndex], detail.source, detail.proxyMode);
if (alive) setVideoUrl(url);
@@ -67,19 +129,122 @@ function TVPlayClient() {
}
resolve();
return () => { alive = false; };
}, [detail, episodeIndex]);
}, [detail, episodeIndex, retryNonce]);
const episodeTitle = useMemo(() => detail?.episodes_titles?.[episodeIndex] || `${episodeIndex + 1}`, [detail, episodeIndex]);
useEffect(() => {
initDanmakuModule();
}, []);
useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem('enable_blockad', String(adFilterEnabled));
}, [adFilterEnabled]);
useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem('tv_danmaku_enabled', String(danmakuEnabled));
}, [danmakuEnabled]);
useEffect(() => {
if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate));
}, [playbackRate]);
useEffect(() => {
let alive = true;
async function loadDanmaku() {
setDanmakuItems([]);
if (!danmakuEnabled || !detail?.title) return;
try {
const search = await searchAnime(title || detail.title);
const anime = search.animes?.[0];
if (!alive || !anime?.animeId) return;
const eps = await getEpisodes(anime.animeId);
const ep = eps.bangumi?.episodes?.[Math.min(episodeIndex, Math.max(0, (eps.bangumi?.episodes?.length || 1) - 1))];
if (!alive || !ep?.episodeId) return;
const comments = await getDanmakuById(ep.episodeId, detail.title, episodeIndex, undefined, {
animeId: anime.animeId,
animeTitle: anime.animeTitle,
episodeTitle: ep.episodeTitle,
searchKeyword: title || detail.title,
});
if (!alive) return;
setDanmakuItems(convertDanmakuFormat(comments).slice(0, 250));
} catch {
if (alive) setDanmakuItems([]);
}
}
loadDanmaku();
return () => { alive = false; };
}, [danmakuEnabled, detail?.title, episodeIndex, title]);
useEffect(() => {
if (!detail?.source || !detail?.id) return;
isFavorited(detail.source, detail.id).then(setFavorited).catch(() => undefined);
getSkipConfig(detail.source, detail.id).then(setSkipConfig).catch(() => setSkipConfig(null));
}, [detail?.source, detail?.id]);
const switchEpisode = (next: number) => {
if (!detail) return;
const max = detail.episodes.length - 1;
const target = Math.max(0, Math.min(max, next));
setStartTime(0);
setEpisodeIndex(target);
setEpisodePage(Math.floor(target / 30));
setShowPanel(true);
};
const onTime = useCallback((current: number, duration: number) => {
const next = { current, duration };
timeRef.current = next;
setTime(next);
}, []);
if (!skipConfig?.enable || !duration) return;
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
if (!video) return;
const episodeKey = `${detail?.source || ''}-${detail?.id || ''}-${episodeIndex}`;
const intro = Math.max(0, skipConfig.intro_time || 0);
if (intro > 1 && current > 0.5 && current < intro && skippedIntroRef.current !== episodeKey) {
skippedIntroRef.current = episodeKey;
video.currentTime = intro;
return;
}
const outroRaw = skipConfig.outro_time || 0;
const outroStart = outroRaw < 0 ? duration - Math.abs(outroRaw) : duration - outroRaw;
if (outroRaw !== 0 && outroStart > 0 && current >= outroStart && skippedOutroRef.current !== episodeKey) {
skippedOutroRef.current = episodeKey;
switchEpisode(episodeIndex + 1);
}
}, [detail, episodeIndex, skipConfig]);
useEffect(() => {
if (!detail) return;
const timer = window.setInterval(() => {
const saveProgress = () => {
const playTime = Math.floor(timeRef.current.current || 0);
const totalTime = Math.floor(timeRef.current.duration || 0);
// 参考 /play:无有效进度时不保存;同一秒/同一集重复触发不保存,避免网络里刷 /api/playrecords。
if (playTime <= 0 && totalTime <= 0) return;
const last = lastSavedRef.current;
if (
last &&
last.source === detail.source &&
last.id === detail.id &&
last.index === episodeIndex + 1 &&
last.playTime === playTime &&
last.totalTime === totalTime
) {
return;
}
lastSavedRef.current = {
source: detail.source,
id: detail.id,
index: episodeIndex + 1,
playTime,
totalTime,
};
savePlayRecord(detail.source, detail.id, {
title: detail.title,
source_name: detail.source_name,
@@ -87,24 +252,128 @@ function TVPlayClient() {
cover: detail.poster || '',
index: episodeIndex + 1,
total_episodes: detail.episodes?.length || 1,
play_time: Math.floor(timeRef.current.current || 0),
total_time: Math.floor(timeRef.current.duration || 0),
play_time: playTime,
total_time: totalTime,
save_time: Date.now(),
search_title: title || detail.title,
}).catch(() => undefined);
}, 10000);
return () => window.clearInterval(timer);
};
const timer = window.setInterval(() => {
saveProgress();
}, 20000);
return () => {
window.clearInterval(timer);
saveProgress();
};
}, [detail, episodeIndex, title]);
const switchEpisode = (next: number) => {
if (!detail) return;
const max = detail.episodes.length - 1;
setEpisodeIndex(Math.max(0, Math.min(max, next)));
setShowPanel(true);
const showSeekOverlay = (current: number, duration: number, delta: number) => {
setSeekHint({ current, duration, delta });
if (seekHintTimerRef.current) window.clearTimeout(seekHintTimerRef.current);
seekHintTimerRef.current = window.setTimeout(() => setSeekHint(null), 1200);
};
const switchSource = async (item: SearchResult) => {
const seekBy = (delta: number, showOverlay = false) => {
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
if (!video || !Number.isFinite(video.duration)) return;
const duration = video.duration || 0;
const next = Math.max(0, Math.min(duration, (video.currentTime || 0) + delta));
video.currentTime = next;
if (showOverlay) showSeekOverlay(next, duration, delta);
};
const seekTo = (value: number) => {
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
if (!video || !Number.isFinite(video.duration)) return;
const duration = video.duration || 0;
const next = Math.max(0, Math.min(duration, value));
video.currentTime = next;
showSeekOverlay(next, duration, 0);
};
const setVideoVolume = (next: number) => {
const safe = Math.max(0, Math.min(1, next));
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
if (video) {
video.volume = safe;
video.muted = safe <= 0;
}
setVolume(safe);
setMuted(safe <= 0);
setShowVolumeHint(true);
if (volumeHintTimerRef.current) window.clearTimeout(volumeHintTimerRef.current);
volumeHintTimerRef.current = window.setTimeout(() => setShowVolumeHint(false), 1200);
};
const toggleMute = () => {
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
const next = !muted;
if (video) video.muted = next;
setMuted(next);
};
const toggleFullscreen = () => {
const root = document.querySelector<HTMLElement>('[data-tv-player-root]');
if (!root) return;
if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined);
else root.requestFullscreen?.().catch(() => undefined);
};
const revealPanel = useCallback(() => {
setShowPanel(true);
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current);
idleTimerRef.current = window.setTimeout(() => {
setShowPanel(false);
setShowEpisodes(false);
}, 10000);
}, []);
const toggleFavorite = async () => {
if (!detail) return;
if (favorited) {
await deleteFavorite(detail.source, detail.id);
setFavorited(false);
} else {
await saveFavorite(detail.source, detail.id, {
title: detail.title,
source_name: detail.source_name || detail.source,
year: detail.year || '',
cover: detail.poster || '',
total_episodes: detail.episodes?.length || 1,
save_time: Date.now(),
search_title: title || detail.title,
vod_remarks: detail.vod_remarks,
});
setFavorited(true);
}
};
const cyclePlaybackRate = () => {
const rates = [0.75, 1, 1.25, 1.5, 2];
const currentIndex = rates.findIndex((rate) => rate === playbackRate);
setPlaybackRate(rates[(currentIndex + 1 + rates.length) % rates.length]);
};
useEffect(() => {
if (!videoUrl) return;
window.requestAnimationFrame(() => {
const video = document.querySelector<HTMLVideoElement>('[data-tv-player-root] video');
if (!video) return;
video.volume = volume;
video.muted = muted;
});
}, [muted, videoUrl, volume]);
useEffect(() => {
if (showPanel || showEpisodes) revealPanel();
return () => {
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current);
};
}, [revealPanel, showEpisodes, showPanel]);
const switchSource = async (item: SearchResult) => {
revealPanel();
setShowEpisodes(false);
setLoading(true);
try {
@@ -113,8 +382,11 @@ function TVPlayClient() {
const data = await fetchTVDetail({ source: item.source, id: item.id, title: item.title });
next = data.detail;
}
const targetIndex = Math.max(0, Math.min(episodeIndex, Math.max(0, (next.episodes?.length || 1) - 1)));
setDetail(next);
setEpisodeIndex(0);
setEpisodeIndex(targetIndex);
setStartTime(0);
setEpisodePage(Math.floor(targetIndex / 30));
} catch (err) {
setError(err instanceof Error ? err.message : '切换播放源失败');
} finally {
@@ -122,8 +394,44 @@ function TVPlayClient() {
}
};
useEffect(() => {
if (showDetail) {
window.requestAnimationFrame(() => detailCloseButtonRef.current?.focus({ preventScroll: true }));
}
}, [showDetail]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (showDetail && event.key === 'Escape') {
event.preventDefault();
setShowDetail(false);
revealPanel();
return;
}
const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93;
if (isMenuKey) {
event.preventDefault();
if (showPanel || showEpisodes) {
setShowPanel(false);
setShowEpisodes(false);
} else {
revealPanel();
}
return;
}
if (/^[0-9]$/.test(event.key) && detail?.episodes?.length) {
event.preventDefault();
const nextBuffer = `${digitBuffer}${event.key}`.slice(-3);
setDigitBuffer(nextBuffer);
if (digitTimerRef.current) window.clearTimeout(digitTimerRef.current);
digitTimerRef.current = window.setTimeout(() => {
const target = Number(nextBuffer);
if (target > 0) switchEpisode(target - 1);
setDigitBuffer('');
}, 850);
}
if (event.key === 'Enter') {
const active = document.activeElement;
const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]'));
@@ -134,21 +442,30 @@ function TVPlayClient() {
}
if (!isControlFocused) {
event.preventDefault();
if (showEpisodes) {
setShowEpisodes(false);
} else {
setShowPanel(false);
}
if (showPanel || showEpisodes) revealPanel();
}
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
if (!showPanel && !showEpisodes) {
setShowPanel(true);
}
if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
event.preventDefault();
const base = event.repeat ? 30 : 10;
seekBy(event.key === 'ArrowLeft' ? -base : base, true);
return;
}
if (!showPanel && !showEpisodes && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
event.preventDefault();
setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05));
return;
}
if (showPanel || showEpisodes) {
revealPanel();
}
if (event.key === 'Escape') {
event.preventDefault();
if (showEpisodes) setShowEpisodes(false);
if (showDetail) setShowDetail(false);
else if (showEpisodes) setShowEpisodes(false);
else if (showPanel) setShowPanel(false);
else router.back();
}
@@ -157,21 +474,96 @@ function TVPlayClient() {
};
window.addEventListener('keydown', onKey, true);
return () => window.removeEventListener('keydown', onKey, true);
}, [episodeIndex, router, showEpisodes, showPanel]);
}, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDetail, showEpisodes, showPanel, volume]);
useEffect(() => {
if (!showEpisodes) return;
const targetPage = Math.floor(episodeIndex / 30);
setEpisodePage(targetPage);
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
window.requestAnimationFrame(() => {
episodeButtonRefs.current[episodeIndex]?.focus({ preventScroll: true });
});
}, [episodeIndex, showEpisodes]);
useEffect(() => {
if (!showEpisodes) {
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
}
}, [showEpisodes]);
const episodePages = useMemo(() => {
const total = detail?.episodes?.length || 0;
return Math.max(1, Math.ceil(total / 30));
}, [detail?.episodes?.length]);
const visibleEpisodeIndexes = useMemo(() => {
const total = detail?.episodes?.length || 0;
const start = Math.max(0, Math.min(episodePage, episodePages - 1)) * 30;
return Array.from({ length: Math.max(0, Math.min(30, total - start)) }, (_, idx) => start + idx);
}, [detail?.episodes?.length, episodePage, episodePages]);
if (loading) {
return <main className='fixed inset-0 flex items-center justify-center bg-black text-3xl font-bold text-white'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />...</main>;
}
if (error || !detail) {
return <main className='fixed inset-0 flex items-center justify-center bg-black p-10 text-center text-3xl font-black text-red-100'>{error || '播放信息不存在'}</main>;
return (
<main className='fixed inset-0 flex items-center justify-center bg-black p-10 text-center text-white'>
<section role='alert' className='max-w-3xl rounded-[36px] border border-red-500/40 bg-red-950/50 p-10 shadow-2xl shadow-red-950/40'>
<AlertTriangle className='mx-auto mb-5 h-16 w-16 text-red-300' />
<h1 className='text-4xl font-black text-red-100'>{error || '播放信息不存在'}</h1>
<div className='mt-8 flex justify-center gap-4'>
<button onClick={() => window.location.reload()} className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><RotateCcw className='h-7 w-7' /></button>
<button onClick={() => router.back()} className='tv-focusable rounded-2xl bg-white/10 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-white/40'></button>
</div>
</section>
</main>
);
}
return (
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white' onMouseMove={() => setShowPanel(true)}>
{videoUrl ? <TVNativeVideo url={videoUrl} poster={detail.poster} title={detail.title} onTime={onTime} command={toggleCommand} /> : (
<main data-tv-player-root className='fixed inset-0 overflow-hidden bg-black text-white'>
{videoUrl ? <TVNativeVideo key={`${videoUrl}-${retryNonce}`} url={videoUrl} poster={detail.poster} title={detail.title} onTime={onTime} command={toggleCommand} startTime={startTime} onError={() => setPlaybackError(true)} onPlayingChange={setIsPlaying} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
<div className='flex h-full w-full items-center justify-center text-3xl font-bold text-white'><Loader2 className='mr-4 h-10 w-10 animate-spin text-rose-500' />{resolving ? '正在解析播放地址...' : '准备播放...'}</div>
)}
{danmakuEnabled && danmakuItems.length > 0 && (
<div className='pointer-events-none absolute inset-x-0 top-12 z-10 h-[42vh] overflow-hidden'>
{danmakuItems.filter((item) => Math.abs(item.time - time.current) < 0.35).slice(0, 8).map((item, idx) => (
<div
key={`${item.time}-${idx}-${item.text}`}
className='absolute whitespace-nowrap text-3xl font-black drop-shadow-[0_2px_4px_rgba(0,0,0,0.9)]'
style={{
top: `${(idx % 8) * 12}%`,
color: item.color || '#fff',
animation: `tv-danmaku ${Math.max(6, 12 - Math.min(6, item.text.length / 6))}s linear forwards`,
}}
>
{item.text}
</div>
))}
<style jsx>{`
@keyframes tv-danmaku {
from { transform: translateX(100vw); }
to { transform: translateX(-120%); }
}
`}</style>
</div>
)}
{playbackError && (
<div role='alert' className='absolute inset-0 z-30 flex items-center justify-center bg-black/72 p-8 text-white backdrop-blur-sm'>
<section className='max-w-3xl rounded-[36px] border border-white/10 bg-slate-950/92 p-9 text-center shadow-2xl shadow-black/70'>
<AlertTriangle className='mx-auto mb-5 h-14 w-14 text-amber-300' />
<h2 className='text-4xl font-black'></h2>
<p className='mt-3 text-2xl text-slate-300'>线</p>
<div className='mt-8 flex justify-center gap-4'>
<button onClick={() => { setPlaybackError(false); setRetryNonce((v) => v + 1); }} className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-rose-600 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><RotateCcw className='h-7 w-7' /></button>
<button onClick={() => { setPlaybackError(false); setShowPanel(true); setShowEpisodes(true); }} className='tv-focusable flex cursor-pointer items-center gap-3 rounded-2xl bg-white/10 px-7 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-white/40'><ListVideo className='h-7 w-7' />/</button>
</div>
</section>
</div>
)}
<div className={`absolute inset-0 pointer-events-none transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'opacity-0'}`}>
<div className='absolute inset-x-0 top-0 h-44 bg-gradient-to-b from-black/90 to-transparent' />
@@ -187,29 +579,103 @@ function TVPlayClient() {
</div>
<div data-tv-player-control className={`absolute bottom-8 left-8 right-8 transition-opacity duration-300 ${showPanel ? 'opacity-100' : 'pointer-events-none opacity-0'}`}>
<div className='mb-5 h-2 overflow-hidden rounded-full bg-white/20'>
<div className='h-full rounded-full bg-rose-600' style={{ width: time.duration ? `${Math.min(100, (time.current / time.duration) * 100)}%` : '0%' }} />
</div>
<div className='flex items-center justify-between gap-5 rounded-[28px] bg-black/75 p-4 backdrop-blur'>
<div className='flex items-center gap-3'>
<button onClick={() => switchEpisode(episodeIndex - 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipBack className='h-6 w-6' /></button>
<button onClick={() => setShowPanel(false)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none'><Pause className='h-6 w-6' /></button>
<button onClick={() => setToggleCommand((value) => value + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-rose-600 px-6 py-4 text-xl font-black outline-none'>
{isPlaying ? <Pause className='h-6 w-6' /> : <Play className='h-6 w-6 fill-current' />}
{isPlaying ? '暂停' : '播放'}
</button>
<button onClick={() => switchEpisode(episodeIndex + 1)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><SkipForward className='h-6 w-6' /></button>
<button onClick={() => setShowEpisodes((v) => !v)} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><ListVideo className='h-6 w-6' /></button>
<button onClick={() => { setShowEpisodes(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><Layers className='h-6 w-6' /></button>
<button onClick={toggleFavorite} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${favorited ? 'bg-rose-600' : 'bg-white/10'}`}><Heart className={`h-6 w-6 ${favorited ? 'fill-current' : ''}`} /></button>
<button onClick={() => { setShowDetail(true); revealPanel(); }} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'><Info className='h-6 w-6' /></button>
<button onClick={() => setDanmakuEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${danmakuEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><MessageCircle className='h-6 w-6' /></button>
<button onClick={() => setAdFilterEnabled((v) => !v)} data-tv-player-control className={`tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl px-5 py-4 text-xl font-bold outline-none ${adFilterEnabled ? 'bg-rose-600' : 'bg-white/10'}`}><ShieldOff className='h-6 w-6' />广</button>
<button onClick={cyclePlaybackRate} data-tv-player-control className='tv-focusable flex cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-xl font-bold outline-none'>{playbackRate}x</button>
</div>
<div className='flex items-center gap-3 text-xl font-bold text-slate-200'>
<button onClick={toggleMute} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none' title='静音'>{muted ? <VolumeX className='h-6 w-6' /> : <Volume2 className='h-6 w-6' />}</button>
<button onClick={toggleFullscreen} data-tv-player-control className='tv-focusable rounded-2xl bg-white/10 p-4 outline-none'><Maximize className='h-6 w-6' /></button>
<span>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</span>
</div>
</div>
<div className='mt-4 rounded-3xl bg-black/70 p-4 backdrop-blur'>
<input aria-label='播放进度' data-tv-player-control type='range' min='0' max={Math.max(1, time.duration || 1)} step='1' value={Math.min(time.current, time.duration || time.current)} onChange={(e) => seekTo(Number(e.target.value))} className='tv-focusable h-3 w-full cursor-pointer accent-rose-600' />
<div className='mt-2 flex items-center justify-between text-lg font-bold text-slate-200'>
<span>{formatTVTime(time.current)}</span>
<span>{time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'}</span>
<span>{formatTVTime(time.duration)}</span>
</div>
<div className='text-xl font-bold text-slate-200'>{formatTVTime(time.current)} / {formatTVTime(time.duration)}</div>
</div>
</div>
{showEpisodes && (
<aside className='absolute bottom-40 right-8 max-h-[55vh] w-[560px] overflow-y-auto rounded-[34px] border border-white/10 bg-slate-950/92 p-6 shadow-2xl shadow-black/70 backdrop-blur-2xl'>
<h2 className='mb-5 flex items-center gap-3 text-3xl font-black'><Layers className='h-8 w-8 text-rose-500' />线</h2>
{sources.length > 1 && <div className='mb-6 flex gap-3 overflow-x-auto px-2 py-3 [scrollbar-width:none]'>{sources.map((item) => <button key={`${item.source}-${item.id}`} onClick={() => switchSource(item)} data-tv-player-control className={`tv-focusable cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none ${detail.source === item.source && detail.id === item.id ? 'bg-rose-600' : 'bg-white/10'}`}>{item.source_name || item.source}</button>)}</div>}
<div className='mb-6'>
<div className='mb-2 text-xl font-black text-slate-300'></div>
<div className='flex gap-3 overflow-x-auto px-2 py-3 [scrollbar-width:none]'>
{sources.length > 0 ? sources.map((item) => <button key={`${item.source}-${item.id}`} onClick={() => switchSource(item)} data-tv-player-control className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-bold outline-none focus:ring-4 focus:ring-rose-300 ${detail.source === item.source && detail.id === item.id ? 'bg-rose-600' : 'bg-white/10'}`}>{item.source_name || item.source}</button>) : <span className='text-xl text-slate-400'></span>}
</div>
</div>
{episodePages > 1 && (
<div className='mb-5 flex gap-3 overflow-x-auto px-2 py-2 [scrollbar-width:none]'>
{Array.from({ length: episodePages }, (_, page) => (
<button key={page} onClick={() => setEpisodePage(page)} data-tv-player-control className={`tv-focusable shrink-0 cursor-pointer rounded-2xl px-5 py-3 text-xl font-black outline-none focus:ring-4 focus:ring-rose-300 ${page === episodePage ? 'bg-rose-600' : 'bg-white/10'}`}>
{page * 30 + 1}-{Math.min((page + 1) * 30, detail.episodes.length)}
</button>
))}
</div>
)}
<div className='grid grid-cols-4 gap-3'>
{detail.episodes.map((_, index) => <button key={index} onClick={() => switchEpisode(index)} data-tv-player-control className={`tv-focusable min-h-16 cursor-pointer rounded-2xl px-3 py-3 text-lg font-black outline-none ${index === episodeIndex ? 'bg-rose-600' : 'bg-white/10'}`}>{detail.episodes_titles?.[index] || `${index + 1}`}</button>)}
{visibleEpisodeIndexes.map((index) => <button key={index} ref={(el) => { episodeButtonRefs.current[index] = el; }} onClick={() => switchEpisode(index)} data-tv-player-control className={`tv-focusable min-h-16 cursor-pointer rounded-2xl px-3 py-3 text-lg font-black outline-none focus:ring-4 focus:ring-rose-300 ${index === episodeIndex ? 'bg-rose-600' : 'bg-white/10'}`}>{detail.episodes_titles?.[index] || `${index + 1}`}</button>)}
</div>
</aside>
)}
{showDetail && (
<div className='absolute inset-0 z-40 flex items-center justify-center bg-black/72 p-10 backdrop-blur-sm'>
<section data-tv-player-control className='max-h-[82vh] w-[980px] max-w-[92vw] overflow-y-auto rounded-[42px] border border-white/10 bg-slate-950/95 p-8 text-white shadow-2xl shadow-black/80'>
<div className='mb-6 flex items-start justify-between gap-6'>
<div>
<h2 className='text-5xl font-black'>{detail.title}</h2>
<div className='mt-4 flex flex-wrap gap-3 text-xl font-bold text-slate-200'>
<span className='rounded-full bg-rose-600 px-4 py-2'>{detail.source_name || detail.source}</span>
{detail.year && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.year}</span>}
{detail.type_name && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.type_name}</span>}
{detail.vod_remarks && <span className='rounded-full bg-white/10 px-4 py-2'>{detail.vod_remarks}</span>}
<span className='rounded-full bg-white/10 px-4 py-2'>{episodeTitle}</span>
</div>
</div>
<button ref={detailCloseButtonRef} onClick={() => setShowDetail(false)} data-tv-player-control className='tv-focusable flex shrink-0 cursor-pointer items-center gap-2 rounded-2xl bg-white/10 px-5 py-4 text-2xl font-black outline-none focus:ring-4 focus:ring-rose-300'><X className='h-7 w-7' /></button>
</div>
{detail.poster && <img src={detail.poster} alt='' className='float-left mr-7 mb-4 h-72 w-48 rounded-3xl object-cover shadow-xl shadow-black/50' />}
<p className='whitespace-pre-line text-2xl leading-relaxed text-slate-200'>{detail.desc || '暂无详情简介'}</p>
</section>
</div>
)}
{digitBuffer && <div className='absolute right-10 top-32 rounded-3xl bg-black/75 px-7 py-5 text-5xl font-black text-white shadow-2xl'> {digitBuffer} </div>}
{showVolumeHint && !showPanel && !showEpisodes && (
<div className='absolute right-10 top-1/2 flex -translate-y-1/2 flex-col items-center gap-4 rounded-3xl bg-black/80 px-6 py-7 text-3xl font-black text-white shadow-2xl backdrop-blur'>
{muted || volume <= 0 ? <VolumeX className='h-10 w-10' /> : <Volume2 className='h-10 w-10' />}
<div className='relative h-56 w-4 overflow-hidden rounded-full bg-white/20'>
<div className='absolute bottom-0 left-0 right-0 rounded-full bg-rose-600' style={{ height: `${Math.round((muted ? 0 : volume) * 100)}%` }} />
</div>
<div className='min-w-16 text-center'>{Math.round((muted ? 0 : volume) * 100)}</div>
</div>
)}
{seekHint && !showPanel && !showEpisodes && (
<div className='absolute bottom-16 left-1/2 w-[720px] max-w-[86vw] -translate-x-1/2 rounded-[34px] bg-black/82 px-8 py-6 text-white shadow-2xl backdrop-blur'>
<div className='mb-4 flex items-center justify-between text-3xl font-black'>
<span>{seekHint.delta > 0 ? `快进 ${seekHint.delta}s` : seekHint.delta < 0 ? `快退 ${Math.abs(seekHint.delta)}s` : '定位进度'}</span>
<span>{formatTVTime(seekHint.current)} / {formatTVTime(seekHint.duration)}</span>
</div>
<div className='h-3 overflow-hidden rounded-full bg-white/20'>
<div className='h-full rounded-full bg-rose-600' style={{ width: seekHint.duration ? `${Math.min(100, (seekHint.current / seekHint.duration) * 100)}%` : '0%' }} />
</div>
</div>
)}
<TVVirtualRemote />
</main>
);