From a880b65f3563ae9dcd9b09a0c9d0361ccb5deacb Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 29 May 2026 23:33:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/tv/play/page.tsx | 86 ++++++++++++++++++++-- src/components/tv/player/TVNativeVideo.tsx | 75 ++++++++++++++++++- 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/src/app/tv/play/page.tsx b/src/app/tv/play/page.tsx index e7d409c..fef6a39 100644 --- a/src/app/tv/play/page.tsx +++ b/src/app/tv/play/page.tsx @@ -12,6 +12,13 @@ import TVNativeVideo from '@/components/tv/player/TVNativeVideo'; import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils'; import TVVirtualRemote from '@/components/tv/TVVirtualRemote'; +const TV_DANMAKU_LANES = 8; +const TV_DANMAKU_SPAWN_GRACE = 0.6; + +function getTVDanmakuDuration(text: string) { + return Math.max(6, 12 - Math.min(6, text.length / 6)); +} + function TVPlayClient() { const router = useRouter(); const searchParams = useSearchParams(); @@ -32,6 +39,7 @@ function TVPlayClient() { const [episodePage, setEpisodePage] = useState(0); const [playbackError, setPlaybackError] = useState(false); const [isPlaying, setIsPlaying] = useState(false); + const [isBuffering, setIsBuffering] = useState(false); const [favorited, setFavorited] = useState(false); const [muted, setMuted] = useState(false); const [volume, setVolume] = useState(1); @@ -48,6 +56,14 @@ function TVPlayClient() { return saved === null ? true : saved === 'true'; }); const [danmakuItems, setDanmakuItems] = useState>([]); + const [activeDanmakuItems, setActiveDanmakuItems] = useState>([]); const [playbackRate, setPlaybackRate] = useState(() => { if (typeof window === 'undefined') return 1; return Number(localStorage.getItem('tv_playback_rate') || '1') || 1; @@ -61,6 +77,8 @@ function TVPlayClient() { const idleTimerRef = useRef(null); const volumeHintTimerRef = useRef(null); const seekHintTimerRef = useRef(null); + const spawnedDanmakuRef = useRef>(new Set()); + const lastDanmakuTimeRef = useRef(0); const skippedIntroRef = useRef(''); const skippedOutroRef = useRef(''); const lastSavedRef = useRef<{ @@ -153,6 +171,9 @@ function TVPlayClient() { let alive = true; async function loadDanmaku() { setDanmakuItems([]); + setActiveDanmakuItems([]); + spawnedDanmakuRef.current.clear(); + lastDanmakuTimeRef.current = timeRef.current.current; if (!danmakuEnabled || !detail?.title) return; try { const search = await searchAnime(title || detail.title); @@ -177,6 +198,51 @@ function TVPlayClient() { return () => { alive = false; }; }, [danmakuEnabled, detail?.title, episodeIndex, title]); + useEffect(() => { + if (!danmakuEnabled || danmakuItems.length === 0) { + setActiveDanmakuItems([]); + spawnedDanmakuRef.current.clear(); + lastDanmakuTimeRef.current = time.current; + return; + } + + const current = time.current; + const previous = lastDanmakuTimeRef.current; + const jumped = current < previous - 1 || current - previous > 2; + const spawnWindow = jumped ? TV_DANMAKU_SPAWN_GRACE : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2); + + const spawned = spawnedDanmakuRef.current; + const nextItems = danmakuItems + .map((item, index) => { + const id = `${index}-${item.time}-${item.text}`; + return { + id, + text: item.text, + time: item.time, + color: item.color, + duration: getTVDanmakuDuration(item.text), + lane: index % TV_DANMAKU_LANES, + }; + }) + .filter((item) => { + const lateBy = current - item.time; + return lateBy >= 0 && lateBy <= spawnWindow && !spawned.has(item.id); + }) + .slice(0, TV_DANMAKU_LANES); + + if (nextItems.length > 0) { + nextItems.forEach((item) => spawned.add(item.id)); + } + + setActiveDanmakuItems((prev) => { + if (jumped) return nextItems; + if (nextItems.length === 0) return prev; + return [...prev, ...nextItems]; + }); + + lastDanmakuTimeRef.current = current; + }, [danmakuEnabled, danmakuItems, time.current]); + useEffect(() => { if (!detail?.source || !detail?.id) return; isFavorited(detail.source, detail.id).then(setFavorited).catch(() => undefined); @@ -376,6 +442,8 @@ function TVPlayClient() { revealPanel(); setShowEpisodes(false); setLoading(true); + setIsBuffering(false); + const currentPlayTime = Math.floor(timeRef.current.current || 0); try { let next = item; if (!item.episodes?.length) { @@ -385,7 +453,7 @@ function TVPlayClient() { const targetIndex = Math.max(0, Math.min(episodeIndex, Math.max(0, (next.episodes?.length || 1) - 1))); setDetail(next); setEpisodeIndex(targetIndex); - setStartTime(0); + setStartTime(currentPlayTime > 1 ? currentPlayTime : 0); setEpisodePage(Math.floor(targetIndex / 30)); } catch (err) { setError(err instanceof Error ? err.message : '切换播放源失败'); @@ -524,19 +592,23 @@ function TVPlayClient() { return (
- {videoUrl ? setPlaybackError(true)} onPlayingChange={setIsPlaying} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : ( + {videoUrl ? setPlaybackError(true)} onPlayingChange={setIsPlaying} onBufferingChange={setIsBuffering} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
{resolving ? '正在解析播放地址...' : '准备播放...'}
)} - {danmakuEnabled && danmakuItems.length > 0 && ( + {activeDanmakuItems.length > 0 && (
- {danmakuItems.filter((item) => Math.abs(item.time - time.current) < 0.35).slice(0, 8).map((item, idx) => ( + {activeDanmakuItems.map((item) => (
{ + setActiveDanmakuItems((prev) => prev.filter((active) => active.id !== item.id)); + }} style={{ - top: `${(idx % 8) * 12}%`, + top: `${item.lane * 12}%`, color: item.color || '#fff', - animation: `tv-danmaku ${Math.max(6, 12 - Math.min(6, item.text.length / 6))}s linear forwards`, + animation: `tv-danmaku ${item.duration}s linear forwards`, + animationPlayState: isPlaying && !isBuffering ? 'running' : 'paused', }} > {item.text} diff --git a/src/components/tv/player/TVNativeVideo.tsx b/src/components/tv/player/TVNativeVideo.tsx index 7a7844c..ff8cacf 100644 --- a/src/components/tv/player/TVNativeVideo.tsx +++ b/src/components/tv/player/TVNativeVideo.tsx @@ -56,6 +56,7 @@ export default function TVNativeVideo({ onTime, onError: onPlaybackError, onPlayingChange, + onBufferingChange, adFilterEnabled = false, playbackRate = 1, startTime = 0, @@ -69,6 +70,7 @@ export default function TVNativeVideo({ onTime?: (current: number, duration: number) => void; onError?: () => void; onPlayingChange?: (playing: boolean) => void; + onBufferingChange?: (buffering: boolean) => void; adFilterEnabled?: boolean; playbackRate?: number; startTime?: number; @@ -79,7 +81,11 @@ export default function TVNativeVideo({ const onTimeRef = useRef(onTime); const onPlaybackErrorRef = useRef(onPlaybackError); const onPlayingChangeRef = useRef(onPlayingChange); + const onBufferingChangeRef = useRef(onBufferingChange); + const bufferingTimerRef = useRef(null); + const bufferingStateRef = useRef(false); const [loading, setLoading] = useState(false); + const [buffering, setBuffering] = useState(false); const [playing, setPlaying] = useState(false); const [error, setError] = useState(''); @@ -95,6 +101,10 @@ export default function TVNativeVideo({ onPlayingChangeRef.current = onPlayingChange; }, [onPlayingChange]); + useEffect(() => { + onBufferingChangeRef.current = onBufferingChange; + }, [onBufferingChange]); + useEffect(() => { const video = videoRef.current; if (!video || !url) return; @@ -102,6 +112,9 @@ export default function TVNativeVideo({ let disposed = false; setLoading(true); + bufferingStateRef.current = false; + setBuffering(false); + onBufferingChangeRef.current?.(false); setError(''); setPlaying(false); @@ -124,6 +137,31 @@ export default function TVNativeVideo({ }); }; + const setBufferingState = (next: boolean) => { + if (bufferingStateRef.current === next) return; + bufferingStateRef.current = next; + setBuffering(next); + onBufferingChangeRef.current?.(next); + }; + + const clearBuffering = () => { + if (bufferingTimerRef.current) { + window.clearTimeout(bufferingTimerRef.current); + bufferingTimerRef.current = null; + } + setBufferingState(false); + }; + + const scheduleBuffering = () => { + if (videoEl.paused || videoEl.ended || bufferingTimerRef.current) return; + bufferingTimerRef.current = window.setTimeout(() => { + bufferingTimerRef.current = null; + if (!videoEl.paused && !videoEl.ended && videoEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + setBufferingState(true); + } + }, 700); + }; + async function attach() { cleanup(); const type = getSourceType(url); @@ -212,27 +250,46 @@ export default function TVNativeVideo({ const onLoaded = () => { seekToInitialTime(); setLoading(false); + clearBuffering(); }; const onPlay = () => { setPlaying(true); + clearBuffering(); + onPlayingChangeRef.current?.(true); + }; + const onPlaying = () => { + setPlaying(true); + clearBuffering(); onPlayingChangeRef.current?.(true); }; const onPause = () => { setPlaying(false); + clearBuffering(); onPlayingChangeRef.current?.(false); }; + const onBuffering = () => scheduleBuffering(); const onError = () => { setLoading(false); + clearBuffering(); setError('视频加载失败,请尝试切换线路或频道'); onPlaybackErrorRef.current?.(); }; - const onTimeUpdate = () => onTimeRef.current?.(videoEl.currentTime || 0, videoEl.duration || 0); + const onTimeUpdate = () => { + clearBuffering(); + onTimeRef.current?.(videoEl.currentTime || 0, videoEl.duration || 0); + }; videoEl.addEventListener('loadedmetadata', seekToInitialTime); videoEl.addEventListener('loadeddata', onLoaded); videoEl.addEventListener('canplay', onLoaded); + videoEl.addEventListener('canplaythrough', onLoaded); videoEl.addEventListener('play', onPlay); + videoEl.addEventListener('playing', onPlaying); videoEl.addEventListener('pause', onPause); + videoEl.addEventListener('waiting', onBuffering); + videoEl.addEventListener('stalled', onBuffering); + videoEl.addEventListener('seeking', onBuffering); + videoEl.addEventListener('seeked', onLoaded); videoEl.addEventListener('error', onError); videoEl.addEventListener('timeupdate', onTimeUpdate); @@ -241,10 +298,17 @@ export default function TVNativeVideo({ videoEl.removeEventListener('loadedmetadata', seekToInitialTime); videoEl.removeEventListener('loadeddata', onLoaded); videoEl.removeEventListener('canplay', onLoaded); + videoEl.removeEventListener('canplaythrough', onLoaded); videoEl.removeEventListener('play', onPlay); + videoEl.removeEventListener('playing', onPlaying); videoEl.removeEventListener('pause', onPause); + videoEl.removeEventListener('waiting', onBuffering); + videoEl.removeEventListener('stalled', onBuffering); + videoEl.removeEventListener('seeking', onBuffering); + videoEl.removeEventListener('seeked', onLoaded); videoEl.removeEventListener('error', onError); videoEl.removeEventListener('timeupdate', onTimeUpdate); + clearBuffering(); cleanup(); }; }, [url, live, startTime, adFilterEnabled, playbackRate]); @@ -278,9 +342,12 @@ export default function TVNativeVideo({ onClick={toggle} aria-label={title || 'TV 视频播放器'} /> - {loading && ( -
- 正在缓冲... + {(loading || buffering) && ( +
+
+ + {buffering ? '缓冲中' : '正在载入'} +
)} {error && (