From 83ed7099e7a7d75043f512bf2290b87b46a306ae Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 30 May 2026 10:31:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96tv=E8=AF=A6=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/tv/detail/page.tsx | 491 ++++++++++++++++-- src/app/tv/play/page.tsx | 991 ++++++++++++++++++++++++++++++------- 2 files changed, 1261 insertions(+), 221 deletions(-) diff --git a/src/app/tv/detail/page.tsx b/src/app/tv/detail/page.tsx index 8543247..66dc48c 100644 --- a/src/app/tv/detail/page.tsx +++ b/src/app/tv/detail/page.tsx @@ -2,13 +2,91 @@ import { ArrowLeft, Loader2, Play, Server } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; -import { processImageUrl } from '@/lib/utils'; +import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils'; import { SearchResult } from '@/lib/types'; import TVLayout from '@/components/tv/TVLayout'; -import { fetchTVDetail } from '@/components/tv/player/utils'; +import { + fetchTVDetail, + resolveTVEpisodeUrl, +} from '@/components/tv/player/utils'; + +type SourceTestInfo = { + quality: string; + loadSpeed: string; + pingTime: number; + bitrate: string; + score: number; + status: 'testing' | 'ok' | 'fail'; +}; + +function parseSpeedKBps(speed: string) { + const match = speed.match(/^([\d.]+)\s*(KB\/s|MB\/s)$/); + if (!match) return 0; + const value = Number.parseFloat(match[1]); + if (!Number.isFinite(value)) return 0; + return match[2] === 'MB/s' ? value * 1024 : value; +} + +function qualityScore(quality: string) { + switch (quality) { + case '4K': + return 100; + case '2K': + return 85; + case '1080p': + return 75; + case '720p': + return 60; + case '480p': + return 40; + case 'SD': + return 20; + default: + return 0; + } +} + +function sourceKey(item: Pick) { + return `${item.source}-${item.id}`; +} + +function sourceStatusRank(info?: SourceTestInfo) { + if (info?.status === 'ok') return 0; + if (info?.status === 'testing') return 1; + if (info?.status === 'fail') return 2; + return 1; +} + +function sortSourcesByTests( + sourceList: SearchResult[], + testMap: Record +) { + return [...sourceList].sort((a, b) => { + const infoA = testMap[sourceKey(a)]; + const infoB = testMap[sourceKey(b)]; + const rankA = sourceStatusRank(infoA); + const rankB = sourceStatusRank(infoB); + if (rankA !== rankB) return rankA - rankB; + if ( + infoA?.status === 'ok' && + infoB?.status === 'ok' && + infoA.score !== infoB.score + ) { + return infoB.score - infoA.score; + } + return (b.weight ?? 0) - (a.weight ?? 0); + }); +} function TVDetailClient() { const router = useRouter(); @@ -17,6 +95,14 @@ function TVDetailClient() { const [sources, setSources] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [testingSources, setTestingSources] = useState(false); + const testedSourcesSignatureRef = useRef(''); + const sourceTestsRef = useRef>({}); + const speedSamplesRef = useRef([]); + const pingSamplesRef = useRef([]); + const [sourceTests, setSourceTests] = useState< + Record + >({}); const source = searchParams.get('source'); const id = searchParams.get('id'); @@ -38,50 +124,339 @@ function TVDetailClient() { setError(err instanceof Error ? err.message : '加载详情失败'); }) .finally(() => alive && setLoading(false)); - return () => { alive = false; }; + return () => { + alive = false; + }; }, [source, id, title, fileName]); - const poster = useMemo(() => detail?.poster ? processImageUrl(detail.poster) : '', [detail?.poster]); + const poster = useMemo( + () => (detail?.poster ? processImageUrl(detail.poster) : ''), + [detail?.poster] + ); - const play = (episode = 0, target = detail) => { + useEffect(() => { + sourceTestsRef.current = sourceTests; + }, [sourceTests]); + + const rankedSources = useMemo( + () => sortSourcesByTests(sources, sourceTests), + [sourceTests, sources] + ); + + const bestSource = rankedSources[0] || detail; + + const play = (target = detail, episode?: number) => { if (!target) return; const qs = new URLSearchParams({ source: target.source, id: target.id, title: target.title, - index: String(episode), }); - if (fileName) qs.set('fileName', fileName); + if (typeof episode === 'number') qs.set('index', String(episode)); + if (fileName && target.source === source && target.id === id) + qs.set('fileName', fileName); router.push(`/tv/play?${qs.toString()}`); }; + const calculateSourceScore = useCallback( + ( + testResult: Pick, + maxSpeed: number, + minPing: number, + maxPing: number, + weight = 0 + ) => { + const speed = parseSpeedKBps(testResult.loadSpeed); + const speedScore = + speed > 0 ? Math.min(100, (speed / maxSpeed) * 100) : 30; + const pingScore = (() => { + const ping = testResult.pingTime; + if (ping <= 0) return 0; + if (maxPing === minPing) return 100; + return Math.min( + 100, + Math.max(0, ((maxPing - ping) / (maxPing - minPing)) * 100) + ); + })(); + + return ( + Math.round( + (qualityScore(testResult.quality) * 0.4 + + speedScore * 0.4 + + pingScore * 0.2 + + weight) * + 100 + ) / 100 + ); + }, + [] + ); + + const fetchPlayableSource = useCallback(async (item: SearchResult) => { + if (item.episodes?.length) return item; + const data = await fetchTVDetail({ + source: item.source, + id: item.id, + title: item.title, + }); + return data.detail; + }, []); + + const testSource = useCallback( + async ( + item: SearchResult + ): Promise<{ + item: SearchResult; + result: Omit | null; + }> => { + try { + const playable = await fetchPlayableSource(item); + const rawUrl = + playable.episodes?.[ + Math.min(1, Math.max(0, playable.episodes.length - 1)) + ]; + if (!rawUrl) throw new Error('无播放地址'); + const testUrl = await resolveTVEpisodeUrl( + rawUrl, + playable.source, + playable.proxyMode + ); + const result = await getVideoResolutionFromM3u8(testUrl, 5000); + return { item: playable, result }; + } catch { + return { item, result: null }; + } + }, + [fetchPlayableSource] + ); + + const updateSourceTest = useCallback( + (item: SearchResult, info: SourceTestInfo) => { + const key = sourceKey(item); + const nextTests = { ...sourceTestsRef.current, [key]: info }; + sourceTestsRef.current = nextTests; + setSourceTests(nextTests); + setSources((currentSources) => { + const nextSources = currentSources.map((sourceItem) => + sourceKey(sourceItem) === key ? item : sourceItem + ); + return sortSourcesByTests(nextSources, nextTests); + }); + setDetail((current) => { + if (!current || sourceKey(current) !== key) return current; + return item; + }); + }, + [] + ); + + const testAllSources = useCallback( + async (candidateSources = sources) => { + if (candidateSources.length === 0 || testingSources) return null; + setTestingSources(true); + speedSamplesRef.current = []; + pingSamplesRef.current = []; + + const initialTests: Record = {}; + candidateSources.forEach((item) => { + initialTests[sourceKey(item)] = { + quality: '测速中', + loadSpeed: '测量中...', + pingTime: 0, + bitrate: '未知', + score: 0, + status: 'testing', + }; + }); + sourceTestsRef.current = initialTests; + setSourceTests(initialTests); + setSources((currentSources) => + sortSourcesByTests(currentSources, initialTests) + ); + + let completedCount = 0; + let bestSource: SearchResult | null = null; + let nextIndex = 0; + const maxConcurrency = Math.max( + 1, + Math.min(Math.ceil(candidateSources.length / 2), 8) + ); + + const worker = async () => { + while (nextIndex < candidateSources.length) { + const currentIndex = nextIndex++; + const originalItem = candidateSources[currentIndex]; + const { item, result } = await testSource(originalItem); + + if (result) { + const speed = parseSpeedKBps(result.loadSpeed); + if (speed > 0) speedSamplesRef.current.push(speed); + if (result.pingTime > 0) + pingSamplesRef.current.push(result.pingTime); + const maxSpeed = + speedSamplesRef.current.length > 0 + ? Math.max(...speedSamplesRef.current) + : 1024; + const minPing = + pingSamplesRef.current.length > 0 + ? Math.min(...pingSamplesRef.current) + : 50; + const maxPing = + pingSamplesRef.current.length > 0 + ? Math.max(...pingSamplesRef.current) + : 1000; + const info: SourceTestInfo = { + ...result, + score: calculateSourceScore( + result, + maxSpeed, + minPing, + maxPing, + item.weight ?? 0 + ), + status: 'ok', + }; + updateSourceTest(item, info); + const currentBest = sortSourcesByTests( + [item, ...(bestSource ? [bestSource] : [])], + { ...sourceTestsRef.current, [sourceKey(item)]: info } + )[0]; + bestSource = currentBest || item; + } else { + updateSourceTest(item, { + quality: '失败', + loadSpeed: '不可用', + pingTime: 0, + bitrate: '未知', + score: -1, + status: 'fail', + }); + } + + completedCount += 1; + if (completedCount === candidateSources.length) { + setTestingSources(false); + } + } + }; + + try { + await Promise.all( + Array.from( + { length: Math.min(maxConcurrency, candidateSources.length) }, + () => worker() + ) + ); + return ( + sortSourcesByTests(candidateSources, sourceTestsRef.current)[0] || + bestSource + ); + } finally { + setTestingSources(false); + } + }, + [ + calculateSourceScore, + sources, + testSource, + testingSources, + updateSourceTest, + ] + ); + + useEffect(() => { + if (loading || sources.length <= 1) return; + const signature = Array.from(new Set(sources.map(sourceKey))) + .sort() + .join('|'); + if (!signature || testedSourcesSignatureRef.current === signature) return; + testedSourcesSignatureRef.current = signature; + testAllSources(sources).catch(() => setTestingSources(false)); + }, [loading, sources, testAllSources]); + if (loading) { - return
正在加载详情...
; + return ( + +
+ + 正在加载详情... +
+
+ ); } if (error || !detail) { - return
{error || '详情不存在'}
; + return ( + +
+ {error || '详情不存在'} +
+
+ ); } return (
- {poster && } + {poster && ( + + )}
- {poster ? {detail.title} :
} + {poster ? ( + {detail.title} + ) : ( +
+ )}
- -

{detail.title}

+ +

+ {detail.title} +

- {detail.source_name || detail.source} - {detail.year && {detail.year}} - {detail.type_name && {detail.type_name}} - {detail.vod_remarks && {detail.vod_remarks}} + + {detail.source_name || detail.source} + + {detail.year && ( + + {detail.year} + + )} + {detail.type_name && ( + + {detail.type_name} + + )} + {detail.vod_remarks && ( + + {detail.vod_remarks} + + )}
- {detail.desc &&

{detail.desc}

} -
@@ -90,13 +465,60 @@ function TVDetailClient() { {sources.length > 1 && (
-

播放源

+
+

播放源

+
+ {testingSources && ( + + )} + + {testingSources + ? '正在自动优选测速并排序...' + : Object.keys(sourceTests).length > 0 + ? '已按优选测速结果自动排序' + : '将自动优选测速排序'} + +
+
- {sources.map((item) => ( - - ))} + {rankedSources.map((item, index) => { + const info = sourceTests[sourceKey(item)]; + const active = + detail.source === item.source && detail.id === item.id; + return ( + + ); + })}
)} @@ -104,8 +526,15 @@ function TVDetailClient() {

选集

- {(detail.episodes_titles?.length ? detail.episodes_titles : detail.episodes).map((ep, index) => ( - ))} @@ -116,5 +545,9 @@ function TVDetailClient() { } export default function TVDetailPage() { - return ; + return ( + + + + ); } diff --git a/src/app/tv/play/page.tsx b/src/app/tv/play/page.tsx index 1fbb5e8..87fd0cf 100644 --- a/src/app/tv/play/page.tsx +++ b/src/app/tv/play/page.tsx @@ -1,15 +1,65 @@ 'use client'; -import { AlertTriangle, ArrowLeft, Heart, Info, Layers, ListVideo, Loader2, Maximize, MessageCircle, Pause, Play, RotateCcw, ShieldOff, SkipBack, SkipForward, SlidersHorizontal, X, Volume2, VolumeX } from 'lucide-react'; +import { + AlertTriangle, + ArrowLeft, + Heart, + Info, + Layers, + ListVideo, + Loader2, + Maximize, + MessageCircle, + Pause, + Play, + RotateCcw, + ShieldOff, + SkipBack, + SkipForward, + SlidersHorizontal, + X, + Volume2, + VolumeX, +} from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, type Dispatch, type FocusEvent, type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Suspense, + type Dispatch, + type FocusEvent, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; -import { deleteFavorite, deletePlayRecord, generateStorageKey, getAllPlayRecords, getSkipConfig, isFavorited, saveFavorite, 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, loadDanmakuDisplayState, saveDanmakuDisplayState, searchAnime } from '@/lib/danmaku/api'; +import { + convertDanmakuFormat, + getDanmakuById, + getEpisodes, + initDanmakuModule, + loadDanmakuDisplayState, + saveDanmakuDisplayState, + searchAnime, +} from '@/lib/danmaku/api'; import TVNativeVideo from '@/components/tv/player/TVNativeVideo'; -import { fetchTVDetail, formatTVTime, resolveTVEpisodeUrl } from '@/components/tv/player/utils'; +import { + fetchTVDetail, + formatTVTime, + resolveTVEpisodeUrl, +} from '@/components/tv/player/utils'; import TVVirtualRemote from '@/components/tv/TVVirtualRemote'; const TV_DANMAKU_LANES = 8; @@ -38,9 +88,18 @@ function loadTVDanmakuSettings(): TVDanmakuSettings { if (!saved) return DEFAULT_TV_DANMAKU_SETTINGS; const parsed = JSON.parse(saved) as Partial; return { - fontSize: typeof parsed.fontSize === 'number' ? parsed.fontSize : DEFAULT_TV_DANMAKU_SETTINGS.fontSize, - displayArea: typeof parsed.displayArea === 'number' ? parsed.displayArea : DEFAULT_TV_DANMAKU_SETTINGS.displayArea, - opacity: typeof parsed.opacity === 'number' ? parsed.opacity : DEFAULT_TV_DANMAKU_SETTINGS.opacity, + fontSize: + typeof parsed.fontSize === 'number' + ? parsed.fontSize + : DEFAULT_TV_DANMAKU_SETTINGS.fontSize, + displayArea: + typeof parsed.displayArea === 'number' + ? parsed.displayArea + : DEFAULT_TV_DANMAKU_SETTINGS.displayArea, + opacity: + typeof parsed.opacity === 'number' + ? parsed.opacity + : DEFAULT_TV_DANMAKU_SETTINGS.opacity, }; } catch { return DEFAULT_TV_DANMAKU_SETTINGS; @@ -53,7 +112,10 @@ function getTVDanmakuDuration(text: string) { function blurTVPlayerControl() { const active = document.activeElement; - if (active instanceof HTMLElement && active.closest('[data-tv-player-control]')) { + if ( + active instanceof HTMLElement && + active.closest('[data-tv-player-control]') + ) { active.blur(); } } @@ -61,7 +123,11 @@ function blurTVPlayerControl() { function scrollFocusedControlIntoView(event: FocusEvent) { const target = event.target; if (target instanceof HTMLElement) { - target.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' }); + target.scrollIntoView({ + block: 'nearest', + inline: 'center', + behavior: 'smooth', + }); } } @@ -86,22 +152,28 @@ function updateTVDanmakuSetting( }); } -function getDanmakuSettingField(target: HTMLElement | null): keyof TVDanmakuSettings | null { - if (!(target instanceof HTMLInputElement) || target.type !== 'range') return null; +function getDanmakuSettingField( + target: HTMLElement | null +): keyof TVDanmakuSettings | null { + if (!(target instanceof HTMLInputElement) || target.type !== 'range') + return null; const field = target.dataset.tvDanmakuField; - if (field === 'fontSize' || field === 'displayArea' || field === 'opacity') return field; + if (field === 'fontSize' || field === 'displayArea' || field === 'opacity') + return field; return null; } function getFocusableElementsInScope(scope: HTMLElement) { return Array.from( - scope.querySelectorAll([ - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - 'textarea:not([disabled])', - '[tabindex]:not([tabindex="-1"])', - ].join(',')) + scope.querySelectorAll( + [ + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + ].join(',') + ) ).filter((element) => !element.closest('[data-tv-no-focus="true"]')); } @@ -116,9 +188,10 @@ function moveFocusWithinScope(scope: HTMLElement, direction: 'up' | 'down') { return; } - const nextIndex = direction === 'down' - ? Math.min(elements.length - 1, index + 1) - : Math.max(0, index - 1); + const nextIndex = + direction === 'down' + ? Math.min(elements.length - 1, index + 1) + : Math.max(0, index - 1); elements[nextIndex]?.focus({ preventScroll: true }); } @@ -148,7 +221,11 @@ function TVPlayClient() { 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 [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'); @@ -161,24 +238,36 @@ function TVPlayClient() { const legacySaved = localStorage.getItem('tv_danmaku_enabled'); return legacySaved === null ? true : legacySaved === 'true'; }); - const [danmakuItems, setDanmakuItems] = useState>([]); - const [danmakuSettings, setDanmakuSettings] = useState(() => loadTVDanmakuSettings()); - const [activeDanmakuItems, setActiveDanmakuItems] = useState>([]); + const [danmakuItems, setDanmakuItems] = useState< + Array<{ text: string; time: number; color: string; mode: number }> + >([]); + const [danmakuSettings, setDanmakuSettings] = useState( + () => loadTVDanmakuSettings() + ); + const [activeDanmakuItems, setActiveDanmakuItems] = useState< + Array<{ + id: string; + text: string; + time: number; + color: string; + duration: number; + lane: 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 [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>({}); + const episodeButtonRefs = useRef>( + {} + ); const detailCloseButtonRef = useRef(null); const danmakuFontSizeInputRef = useRef(null); const digitTimerRef = useRef(null); @@ -187,7 +276,6 @@ function TVPlayClient() { const seekHintTimerRef = useRef(null); const spawnedDanmakuRef = useRef>(new Set()); const lastDanmakuTimeRef = useRef(0); - const suppressPlayRecordSaveKeyRef = useRef(null); const skippedIntroRef = useRef(''); const skippedOutroRef = useRef(''); const lastSavedRef = useRef<{ @@ -215,14 +303,24 @@ function TVPlayClient() { setSources(data.sources); 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)); + 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)]; + const record = + records[generateStorageKey(data.detail.source, data.detail.id)]; if (record?.index) { - const rememberedIndex = Math.max(0, Math.min(maxIndex, record.index - 1)); + const rememberedIndex = Math.max( + 0, + Math.min(maxIndex, record.index - 1) + ); setEpisodeIndex(rememberedIndex); setStartTime(record.play_time > 1 ? record.play_time : 0); } @@ -233,9 +331,15 @@ function TVPlayClient() { } setEpisodeIndex(safeIndex); }) - .catch((err) => alive && setError(err instanceof Error ? err.message : '加载播放信息失败')) + .catch( + (err) => + alive && + setError(err instanceof Error ? err.message : '加载播放信息失败') + ) .finally(() => alive && setLoading(false)); - return () => { alive = false; }; + return () => { + alive = false; + }; }, [source, id, title, fileName, initialIndex]); useEffect(() => { @@ -246,26 +350,38 @@ function TVPlayClient() { setVideoUrl(''); setPlaybackError(false); try { - const url = await resolveTVEpisodeUrl(detail.episodes[episodeIndex], detail.source, detail.proxyMode); + const url = await resolveTVEpisodeUrl( + detail.episodes[episodeIndex], + detail.source, + detail.proxyMode + ); if (alive) setVideoUrl(url); } catch (err) { - if (alive) setError(err instanceof Error ? err.message : '获取播放地址失败'); + if (alive) + setError(err instanceof Error ? err.message : '获取播放地址失败'); } finally { if (alive) setResolving(false); } } resolve(); - return () => { alive = false; }; + return () => { + alive = false; + }; }, [detail, episodeIndex, retryNonce]); - const episodeTitle = useMemo(() => detail?.episodes_titles?.[episodeIndex] || `第 ${episodeIndex + 1} 集`, [detail, episodeIndex]); + const episodeTitle = useMemo( + () => + detail?.episodes_titles?.[episodeIndex] || `第 ${episodeIndex + 1} 集`, + [detail, episodeIndex] + ); useEffect(() => { initDanmakuModule(); }, []); useEffect(() => { - if (typeof window !== 'undefined') localStorage.setItem('enable_blockad', String(adFilterEnabled)); + if (typeof window !== 'undefined') + localStorage.setItem('enable_blockad', String(adFilterEnabled)); }, [adFilterEnabled]); useEffect(() => { @@ -276,11 +392,15 @@ function TVPlayClient() { useEffect(() => { if (typeof window === 'undefined') return; - localStorage.setItem(TV_DANMAKU_SETTINGS_KEY, JSON.stringify(danmakuSettings)); + localStorage.setItem( + TV_DANMAKU_SETTINGS_KEY, + JSON.stringify(danmakuSettings) + ); }, [danmakuSettings]); useEffect(() => { - if (typeof window !== 'undefined') localStorage.setItem('tv_playback_rate', String(playbackRate)); + if (typeof window !== 'undefined') + localStorage.setItem('tv_playback_rate', String(playbackRate)); }, [playbackRate]); useEffect(() => { @@ -296,23 +416,42 @@ function TVPlayClient() { 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))]; + 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, - }); + 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; - lastDanmakuTimeRef.current = Math.max(0, timeRef.current.current - TV_DANMAKU_SEEK_WINDOW - 1); - setDanmakuItems(convertDanmakuFormat(comments).slice(0, TV_DANMAKU_MAX_ITEMS)); + lastDanmakuTimeRef.current = Math.max( + 0, + timeRef.current.current - TV_DANMAKU_SEEK_WINDOW - 1 + ); + setDanmakuItems( + convertDanmakuFormat(comments).slice(0, TV_DANMAKU_MAX_ITEMS) + ); } catch { if (alive) setDanmakuItems([]); } } loadDanmaku(); - return () => { alive = false; }; + return () => { + alive = false; + }; }, [danmakuEnabled, detail?.title, episodeIndex, title]); useEffect(() => { @@ -326,7 +465,9 @@ function TVPlayClient() { const current = time.current; const previous = lastDanmakuTimeRef.current; const jumped = current < previous - 1 || current - previous > 2; - const spawnWindow = jumped ? TV_DANMAKU_SEEK_WINDOW : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2); + const spawnWindow = jumped + ? TV_DANMAKU_SEEK_WINDOW + : Math.max(TV_DANMAKU_SPAWN_GRACE, current - previous + 0.2); const spawned = spawnedDanmakuRef.current; if (jumped) spawned.clear(); @@ -343,7 +484,9 @@ function TVPlayClient() { }; }) .filter((item) => { - const delta = jumped ? Math.abs(item.time - current) : current - item.time; + const delta = jumped + ? Math.abs(item.time - current) + : current - item.time; return delta >= 0 && delta <= spawnWindow && !spawned.has(item.id); }) .slice(0, TV_DANMAKU_LANES); @@ -363,8 +506,12 @@ function TVPlayClient() { 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)); + 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) => { @@ -377,35 +524,50 @@ function TVPlayClient() { setShowPanel(true); }; - const onTime = useCallback((current: number, duration: number) => { - const next = { current, duration }; - timeRef.current = next; - setTime(next); + 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('[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]); + if (!skipConfig?.enable || !duration) return; + const video = document.querySelector( + '[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 saveProgress = () => { - const storageKey = generateStorageKey(detail.source, detail.id); - if (suppressPlayRecordSaveKeyRef.current === storageKey) return; - const playTime = Math.floor(timeRef.current.current || 0); const totalTime = Math.floor(timeRef.current.duration || 0); @@ -455,23 +617,34 @@ function TVPlayClient() { }; }, [detail, episodeIndex, title]); - const showSeekOverlay = (current: number, duration: number, delta: number) => { + 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 seekBy = (delta: number, showOverlay = false) => { - const video = document.querySelector('[data-tv-player-root] video'); + const video = document.querySelector( + '[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)); + 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('[data-tv-player-root] video'); + const video = document.querySelector( + '[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)); @@ -481,7 +654,9 @@ function TVPlayClient() { const setVideoVolume = (next: number) => { const safe = Math.max(0, Math.min(1, next)); - const video = document.querySelector('[data-tv-player-root] video'); + const video = document.querySelector( + '[data-tv-player-root] video' + ); if (video) { video.volume = safe; video.muted = safe <= 0; @@ -489,12 +664,18 @@ function TVPlayClient() { setVolume(safe); setMuted(safe <= 0); setShowVolumeHint(true); - if (volumeHintTimerRef.current) window.clearTimeout(volumeHintTimerRef.current); - volumeHintTimerRef.current = window.setTimeout(() => setShowVolumeHint(false), 1200); + if (volumeHintTimerRef.current) + window.clearTimeout(volumeHintTimerRef.current); + volumeHintTimerRef.current = window.setTimeout( + () => setShowVolumeHint(false), + 1200 + ); }; const toggleMute = () => { - const video = document.querySelector('[data-tv-player-root] video'); + const video = document.querySelector( + '[data-tv-player-root] video' + ); const next = !muted; if (video) video.muted = next; setMuted(next); @@ -503,7 +684,8 @@ function TVPlayClient() { const toggleFullscreen = () => { const root = document.querySelector('[data-tv-player-root]'); if (!root) return; - if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined); + if (document.fullscreenElement) + document.exitFullscreen().catch(() => undefined); else root.requestFullscreen?.().catch(() => undefined); }; @@ -547,7 +729,9 @@ function TVPlayClient() { useEffect(() => { if (!videoUrl) return; window.requestAnimationFrame(() => { - const video = document.querySelector('[data-tv-player-root] video'); + const video = document.querySelector( + '[data-tv-player-root] video' + ); if (!video) return; video.volume = volume; video.muted = muted; @@ -568,27 +752,34 @@ function TVPlayClient() { setShowEpisodes(false); setLoading(true); setIsBuffering(false); - const currentPlayTime = Math.floor(timeRef.current.current || 0); - const oldSource = detail.source; - const oldId = detail.id; - const oldStorageKey = generateStorageKey(oldSource, oldId); - suppressPlayRecordSaveKeyRef.current = oldStorageKey; try { let next = item; if (!item.episodes?.length) { - const data = await fetchTVDetail({ source: item.source, id: item.id, title: item.title }); + 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))); + const maxIndex = Math.max(0, (next.episodes?.length || 1) - 1); + let targetIndex = Math.max(0, Math.min(episodeIndex, maxIndex)); + let targetStartTime = 0; + try { + const records = await getAllPlayRecords(); + const record = records[generateStorageKey(next.source, next.id)]; + if (record?.index) { + targetIndex = Math.max(0, Math.min(maxIndex, record.index - 1)); + targetStartTime = record.play_time > 1 ? record.play_time : 0; + } + } catch { + // 读取播放记录失败时保留当前集数,避免切源失败。 + } setDetail(next); setEpisodeIndex(targetIndex); - setStartTime(currentPlayTime > 1 ? currentPlayTime : 0); + setStartTime(targetStartTime); setEpisodePage(Math.floor(targetIndex / 30)); - if (!(next.source === oldSource && next.id === oldId)) { - await deletePlayRecord(oldSource, oldId); - } } catch (err) { - suppressPlayRecordSaveKeyRef.current = null; setError(err instanceof Error ? err.message : '切换播放源失败'); } finally { setLoading(false); @@ -597,13 +788,17 @@ function TVPlayClient() { useEffect(() => { if (showDetail) { - window.requestAnimationFrame(() => detailCloseButtonRef.current?.focus({ preventScroll: true })); + window.requestAnimationFrame(() => + detailCloseButtonRef.current?.focus({ preventScroll: true }) + ); } }, [showDetail]); useEffect(() => { if (showDanmakuSettings) { - window.requestAnimationFrame(() => danmakuFontSizeInputRef.current?.focus({ preventScroll: true })); + window.requestAnimationFrame(() => + danmakuFontSizeInputRef.current?.focus({ preventScroll: true }) + ); } }, [showDanmakuSettings]); @@ -624,7 +819,10 @@ function TVPlayClient() { return; } - const isMenuKey = event.key === 'ContextMenu' || event.key === 'Menu' || event.keyCode === 93; + const isMenuKey = + event.key === 'ContextMenu' || + event.key === 'Menu' || + event.keyCode === 93; if (isMenuKey) { event.preventDefault(); if (showPanel || showEpisodes || showDanmakuSettings) { @@ -651,7 +849,9 @@ function TVPlayClient() { } if (event.key === 'Enter') { const active = document.activeElement; - const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-player-control]')); + const isControlFocused = + active instanceof HTMLElement && + Boolean(active.closest('[data-tv-player-control]')); if (!showPanel && !showEpisodes) { event.preventDefault(); event.stopImmediatePropagation(); @@ -668,14 +868,22 @@ function TVPlayClient() { return; } - if (!showPanel && !showEpisodes && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) { + 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')) { + if ( + !showPanel && + !showEpisodes && + (event.key === 'ArrowUp' || event.key === 'ArrowDown') + ) { event.preventDefault(); setVideoVolume(volume + (event.key === 'ArrowUp' ? 0.05 : -0.05)); return; @@ -696,15 +904,25 @@ function TVPlayClient() { } else if (showPanel) { setShowPanel(false); blurTVPlayerControl(); - } - else router.back(); + } else router.back(); } if (event.key === 'PageUp') switchEpisode(episodeIndex - 1); if (event.key === 'PageDown') switchEpisode(episodeIndex + 1); }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, [detail?.episodes?.length, digitBuffer, episodeIndex, revealPanel, router, showDanmakuSettings, showDetail, showEpisodes, showPanel, volume]); + }, [ + detail?.episodes?.length, + digitBuffer, + episodeIndex, + revealPanel, + router, + showDanmakuSettings, + showDetail, + showEpisodes, + showPanel, + volume, + ]); useEffect(() => { if (!showEpisodes) return; @@ -730,22 +948,46 @@ function TVPlayClient() { 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); + return Array.from( + { length: Math.max(0, Math.min(30, total - start)) }, + (_, idx) => start + idx + ); }, [detail?.episodes?.length, episodePage, episodePages]); if (loading) { - return
正在进入电视播放...
; + return ( +
+ + 正在进入电视播放... +
+ ); } if (error || !detail) { return (
-
+
-

{error || '播放信息不存在'}

+

+ {error || '播放信息不存在'} +

- - + +
@@ -753,9 +995,35 @@ function TVPlayClient() { } return ( -
- {videoUrl ? setPlaybackError(true)} onPlayingChange={setIsPlaying} onBufferingChange={setIsBuffering} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : ( -
{resolving ? '正在解析播放地址...' : '准备播放...'}
+
+ {videoUrl ? ( + setPlaybackError(true)} + onPlayingChange={setIsPlaying} + onBufferingChange={setIsBuffering} + adFilterEnabled={adFilterEnabled} + playbackRate={playbackRate} + /> + ) : ( +
+ + {resolving ? '正在解析播放地址...' : '准备播放...'} +
)} {activeDanmakuItems.length > 0 && (
{ - setActiveDanmakuItems((prev) => prev.filter((active) => active.id !== item.id)); + setActiveDanmakuItems((prev) => + prev.filter((active) => active.id !== item.id) + ); }} style={{ top: `${item.lane * 12}%`, @@ -775,7 +1045,8 @@ function TVPlayClient() { fontSize: `${danmakuSettings.fontSize}px`, opacity: danmakuSettings.opacity, animation: `tv-danmaku ${item.duration}s linear forwards`, - animationPlayState: isPlaying && !isBuffering ? 'running' : 'paused', + animationPlayState: + isPlaying && !isBuffering ? 'running' : 'paused', }} > {item.text} @@ -783,66 +1054,241 @@ function TVPlayClient() { ))}
)} {playbackError && ( -
+

当前视频加载失败

-

可以重试当前地址,或打开选集与线路面板切换播放源。

+

+ 可以重试当前地址,或打开选集与线路面板切换播放源。 +

- - + +
)} -
+
-
- +
+
-
{detail.title}
-
{episodeTitle} · {detail.source_name}
+
+ {detail.title} +
+
+ {episodeTitle} · {detail.source_name} +
-
+
-
- - + - - - - - - - - - - - {formatTVTime(time.current)} / {formatTVTime(time.duration)} + + + + + + + + + + + + {formatTVTime(time.current)} / {formatTVTime(time.duration)} +
- seekTo(Number(e.target.value))} className='h-3 w-full cursor-pointer accent-rose-600' /> + seekTo(Number(e.target.value))} + className='h-3 w-full cursor-pointer accent-rose-600' + />
{formatTVTime(time.current)} - {time.duration ? `${Math.max(0, Math.round((time.current / time.duration) * 100))}%` : '0%'} + + {time.duration + ? `${Math.max( + 0, + Math.round((time.current / time.duration) * 100) + )}%` + : '0%'} + {formatTVTime(time.duration)}
@@ -850,24 +1296,66 @@ function TVPlayClient() { {showEpisodes && ( )} @@ -880,15 +1368,26 @@ function TVPlayClient() { className='w-[720px] max-w-[92vw] rounded-[34px] border border-white/10 bg-slate-950/95 p-7 text-white shadow-2xl shadow-black/80' onKeyDownCapture={(event) => { const target = event.target; - if (!(target instanceof HTMLElement) || !target.closest('[data-tv-danmaku-settings]')) return; + if ( + !(target instanceof HTMLElement) || + !target.closest('[data-tv-danmaku-settings]') + ) + return; if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { - if (target instanceof HTMLInputElement && target.type === 'range') { + if ( + target instanceof HTMLInputElement && + target.type === 'range' + ) { const field = getDanmakuSettingField(target); if (!field) return; event.preventDefault(); event.stopPropagation(); - updateTVDanmakuSetting(field, event.key === 'ArrowRight' ? 1 : -1, setDanmakuSettings); + updateTVDanmakuSetting( + field, + event.key === 'ArrowRight' ? 1 : -1, + setDanmakuSettings + ); revealPanel(); } return; @@ -897,21 +1396,39 @@ function TVPlayClient() { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); - moveFocusWithinScope(event.currentTarget as HTMLElement, event.key === 'ArrowDown' ? 'down' : 'up'); + moveFocusWithinScope( + event.currentTarget as HTMLElement, + event.key === 'ArrowDown' ? 'down' : 'up' + ); revealPanel(); } }} >
-

弹幕设置

- +

+ + 弹幕设置 +

+
@@ -931,7 +1453,9 @@ function TVPlayClient() { @@ -950,7 +1479,9 @@ function TVPlayClient() { @@ -971,43 +1507,110 @@ function TVPlayClient() { )} {showDetail && (
-
+

{detail.title}

- {detail.source_name || detail.source} - {detail.year && {detail.year}} - {detail.type_name && {detail.type_name}} - {detail.vod_remarks && {detail.vod_remarks}} - {episodeTitle} + + {detail.source_name || detail.source} + + {detail.year && ( + + {detail.year} + + )} + {detail.type_name && ( + + {detail.type_name} + + )} + {detail.vod_remarks && ( + + {detail.vod_remarks} + + )} + + {episodeTitle} +
- +
- {detail.poster && } -

{detail.desc || '暂无详情简介'}

+ {detail.poster && ( + + )} +

+ {detail.desc || '暂无详情简介'} +

)} - {digitBuffer &&
第 {digitBuffer} 集
} + {digitBuffer && ( +
+ 第 {digitBuffer} 集 +
+ )} {showVolumeHint && !showPanel && !showEpisodes && (
- {muted || volume <= 0 ? : } + {muted || volume <= 0 ? ( + + ) : ( + + )}
-
+
+
+
+ {Math.round((muted ? 0 : volume) * 100)}
-
{Math.round((muted ? 0 : volume) * 100)}
)} {seekHint && !showPanel && !showEpisodes && (
- {seekHint.delta > 0 ? `快进 ${seekHint.delta}s` : seekHint.delta < 0 ? `快退 ${Math.abs(seekHint.delta)}s` : '定位进度'} - {formatTVTime(seekHint.current)} / {formatTVTime(seekHint.duration)} + + {seekHint.delta > 0 + ? `快进 ${seekHint.delta}s` + : seekHint.delta < 0 + ? `快退 ${Math.abs(seekHint.delta)}s` + : '定位进度'} + + + {formatTVTime(seekHint.current)} /{' '} + {formatTVTime(seekHint.duration)} +
-
+
)} @@ -1017,5 +1620,9 @@ function TVPlayClient() { } export default function TVPlayPage() { - return ; + return ( + + + + ); }