From b121b328c3483f8d2268c64cf95e993b3ca28b59 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 29 May 2026 22:22:12 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E5=AE=8C=E5=96=84tv=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + src/app/tv/live/page.tsx | 134 ++++- src/app/tv/live/play/page.tsx | 259 +++++++++- src/app/tv/play/page.tsx | 542 +++++++++++++++++++-- src/components/tv/TVVirtualRemote.tsx | 43 +- src/components/tv/player/TVNativeVideo.tsx | 123 ++++- src/components/tv/player/utils.ts | 19 +- 7 files changed, 1040 insertions(+), 83 deletions(-) diff --git a/.gitignore b/.gitignore index 2a5ecbe..db18cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ public/workbox-*.js.map # local scripts scripts/tvbox/ scripts/test + +.agents/ +skills-lock.json diff --git a/src/app/tv/live/page.tsx b/src/app/tv/live/page.tsx index 2af727a..e41629e 100644 --- a/src/app/tv/live/page.tsx +++ b/src/app/tv/live/page.tsx @@ -1,9 +1,11 @@ 'use client'; -import { Loader2, Radio } from 'lucide-react'; +import { AlertTriangle, Loader2, Radio, Search } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; +import { Favorite, getAllFavorites, getAllPlayRecords, PlayRecord } from '@/lib/db.client'; + import TVLayout from '@/components/tv/TVLayout'; type LiveSource = { key: string; name: string }; @@ -15,28 +17,94 @@ export default function TVLivePage() { const [source, setSource] = useState(''); const [channels, setChannels] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [selectedGroup, setSelectedGroup] = useState('全部'); + const [query, setQuery] = useState(''); + const [visibleCount, setVisibleCount] = useState(120); + const [quickChannels, setQuickChannels] = useState>([]); useEffect(() => { fetch('/api/live/sources') - .then((r) => r.json()) + .then((r) => { + if (!r.ok) throw new Error('获取直播源失败'); + return r.json(); + }) .then((data) => { const list = data.data || []; setSources(list); if (list[0]?.key) setSource(list[0].key); }) + .catch((err) => setError(err instanceof Error ? err.message : '获取直播源失败')) .finally(() => setLoading(false)); }, []); + useEffect(() => { + Promise.all([ + getAllPlayRecords().catch(() => ({} as Record)), + getAllFavorites().catch(() => ({} as Record)), + ]).then(([records, favorites]) => { + const recents = Object.entries(records) + .filter(([, record]) => record.origin === 'live') + .sort((a, b) => (b[1].save_time || 0) - (a[1].save_time || 0)) + .slice(0, 8) + .map(([key, record]) => { + const plus = key.indexOf('+'); + return { + source: key.slice(0, plus).replace(/^live_/, ''), + id: key.slice(plus + 1).replace(/^live_/, ''), + title: record.title, + cover: record.cover, + type: '最近' as const, + }; + }); + const favs = Object.entries(favorites) + .filter(([, favorite]) => favorite.origin === 'live') + .sort((a, b) => (b[1].save_time || 0) - (a[1].save_time || 0)) + .slice(0, 8) + .map(([key, favorite]) => { + const plus = key.indexOf('+'); + return { + source: key.slice(0, plus).replace(/^live_/, ''), + id: key.slice(plus + 1).replace(/^live_/, ''), + title: favorite.title, + cover: favorite.cover, + type: '收藏' as const, + }; + }); + setQuickChannels([...favs, ...recents].slice(0, 12)); + }); + }, []); + useEffect(() => { if (!source) return; setLoading(true); + setError(''); + setSelectedGroup('全部'); + setVisibleCount(120); fetch(`/api/live/channels?source=${encodeURIComponent(source)}`) - .then((r) => r.json()) + .then((r) => { + if (r.status === 401 || r.status === 403) throw new Error('无权限访问电视直播,请先登录或检查权限'); + if (!r.ok) throw new Error('获取频道列表失败'); + return r.json(); + }) .then((data) => setChannels(data.data || [])) + .catch((err) => { + setChannels([]); + setError(err instanceof Error ? err.message : '获取频道列表失败'); + }) .finally(() => setLoading(false)); }, [source]); - const groups = useMemo(() => Array.from(new Set(channels.map((c) => c.group || '其他'))).slice(0, 12), [channels]); + const groups = useMemo(() => ['全部', ...Array.from(new Set(channels.map((c) => c.group || '其他')))], [channels]); + const filteredChannels = useMemo(() => { + const keyword = query.trim().toLowerCase(); + return channels.filter((channel) => { + const groupMatched = selectedGroup === '全部' || (channel.group || '其他') === selectedGroup; + const queryMatched = !keyword || channel.name.toLowerCase().includes(keyword) || (channel.group || '').toLowerCase().includes(keyword); + return groupMatched && queryMatched; + }); + }, [channels, query, selectedGroup]); + const visibleChannels = useMemo(() => filteredChannels.slice(0, visibleCount), [filteredChannels, visibleCount]); return ( @@ -53,20 +121,58 @@ export default function TVLivePage() { ))} + - {loading ?
正在加载频道...
: ( -
- -
- {channels.slice(0, 80).map((channel) => ( - ))} +
+ + )} + + {error ? ( +
+
{error}
+ +
+ ) : loading ?
正在加载频道...
: ( +
+ +
+
{selectedGroup} · {filteredChannels.length} 个频道
+
+ {visibleChannels.map((channel, index) => ( + + ))} +
+ {visibleCount < filteredChannels.length && ( + + )}
)} diff --git a/src/app/tv/live/play/page.tsx b/src/app/tv/live/play/page.tsx index 138a4dc..a854969 100644 --- a/src/app/tv/live/play/page.tsx +++ b/src/app/tv/live/play/page.tsx @@ -1,16 +1,17 @@ 'use client'; -import { ArrowLeft, Loader2, Radio, Star } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, Clock, Heart, Loader2, Maximize, Radio, RotateCcw, Search, Star, Volume2, VolumeX } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Suspense, useEffect, useMemo, useState } from 'react'; +import { Suspense, useEffect, useMemo, useRef, useState } from 'react'; -import { savePlayRecord } from '@/lib/db.client'; +import { deleteFavorite, isFavorited, saveFavorite, savePlayRecord } from '@/lib/db.client'; import TVNativeVideo from '@/components/tv/player/TVNativeVideo'; import TVVirtualRemote from '@/components/tv/TVVirtualRemote'; type LiveSource = { key: string; name: string; proxyMode?: 'full' | 'm3u8-only' | 'direct' }; type LiveChannel = { id: string; tvgId?: string; name: string; logo?: string; group?: string; url: string }; +type EpgProgram = { start: string; end: string; title: string }; function getLogoUrl(logo?: string, source?: string) { if (!logo) return ''; @@ -41,19 +42,38 @@ function TVLivePlayClient() { const [error, setError] = useState(''); const [showPanel, setShowPanel] = useState(true); const [selectedGroup, setSelectedGroup] = useState(''); + const [query, setQuery] = useState(''); + const [digitBuffer, setDigitBuffer] = useState(''); + const [playbackError, setPlaybackError] = useState(false); + const [retryCount, setRetryCount] = useState(0); + const [favorited, setFavorited] = useState(false); + const [epgPrograms, setEpgPrograms] = useState([]); + const [epgLoading, setEpgLoading] = useState(false); + const [muted, setMuted] = useState(false); + const [volume, setVolume] = useState(1); + const channelButtonRefs = useRef>({}); + const digitTimerRef = useRef(null); useEffect(() => { let alive = true; fetch('/api/live/sources') - .then((r) => r.json()) + .then((r) => { + if (r.status === 401 || r.status === 403) throw new Error('无权限访问电视直播,请先登录或检查权限'); + if (!r.ok) throw new Error('获取直播源失败'); + return r.json(); + }) .then((data) => { if (!alive) return; const list = data.data || []; setSources(list); const selected = list.find((s: LiveSource) => s.key === needSource) || list[0] || null; setSource(selected); + if (!selected) setLoading(false); }) - .catch(() => setError('获取直播源失败')); + .catch((err) => { + setError(err instanceof Error ? err.message : '获取直播源失败'); + setLoading(false); + }); return () => { alive = false; }; }, [needSource]); @@ -61,8 +81,13 @@ function TVLivePlayClient() { if (!source) return; let alive = true; setLoading(true); + setError(''); fetch(`/api/live/channels?source=${encodeURIComponent(source.key)}`) - .then((r) => r.json()) + .then((r) => { + if (r.status === 401 || r.status === 403) throw new Error('无权限访问电视直播,请先登录或检查权限'); + if (!r.ok) throw new Error('获取频道列表失败'); + return r.json(); + }) .then((data) => { if (!alive) return; const list = (data.data || []).map((item: any) => ({ @@ -78,7 +103,7 @@ function TVLivePlayClient() { setChannel(selected); setSelectedGroup(selected?.group || list[0]?.group || ''); }) - .catch(() => setError('获取频道列表失败')) + .catch((err) => setError(err instanceof Error ? err.message : '获取频道列表失败')) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [source, needChannel]); @@ -86,6 +111,9 @@ function TVLivePlayClient() { useEffect(() => { let alive = true; if (!channel) return; + setVideoUrl(''); + setPlaybackError(false); + setRetryCount(0); resolveLiveUrl(channel.url, source).then((url) => alive && setVideoUrl(url)); if (source) { savePlayRecord(`live_${source.key}`, `live_${channel.id}`, { @@ -105,20 +133,156 @@ function TVLivePlayClient() { return () => { alive = false; }; }, [channel, source]); + useEffect(() => { + if (!source || !channel) return; + isFavorited(`live_${source.key}`, `live_${channel.id}`).then(setFavorited).catch(() => undefined); + }, [channel, source]); + + useEffect(() => { + if (!source || !channel?.tvgId) { + setEpgPrograms([]); + return; + } + let alive = true; + setEpgLoading(true); + fetch(`/api/live/epg?source=${encodeURIComponent(source.key)}&tvgId=${encodeURIComponent(channel.tvgId)}`) + .then((r) => r.ok ? r.json() : null) + .then((data) => { + if (!alive) return; + setEpgPrograms((data?.data?.programs || []).slice(0, 12)); + }) + .catch(() => alive && setEpgPrograms([])) + .finally(() => alive && setEpgLoading(false)); + return () => { alive = false; }; + }, [channel?.tvgId, source]); + + useEffect(() => { + if (!playbackError || !channel || retryCount >= 3) return; + const timer = window.setTimeout(() => { + setRetryCount((value) => value + 1); + setPlaybackError(false); + setVideoUrl(''); + resolveLiveUrl(channel.url, source).then(setVideoUrl).catch(() => setPlaybackError(true)); + }, 2200); + return () => window.clearTimeout(timer); + }, [channel, playbackError, retryCount, source]); + const groups = useMemo(() => Array.from(new Set(channels.map((item) => item.group || '其他'))), [channels]); - const filteredChannels = useMemo(() => channels.filter((item) => (item.group || '其他') === selectedGroup), [channels, selectedGroup]); + const filteredChannels = useMemo(() => { + const keyword = query.trim().toLowerCase(); + return channels.filter((item) => { + const groupMatched = (item.group || '其他') === selectedGroup; + const queryMatched = !keyword || item.name.toLowerCase().includes(keyword) || (item.group || '').toLowerCase().includes(keyword); + return groupMatched && queryMatched; + }); + }, [channels, query, selectedGroup]); + + const precheckChannel = async (next: LiveChannel) => { + if (!source) return; + try { + await fetch(`/api/live/precheck?url=${encodeURIComponent(next.url)}&moontv-source=${encodeURIComponent(source.key)}`, { cache: 'no-store' }); + } catch { + // 预检查失败不阻止切台,播放器错误层会给出重试/换台。 + } + }; const switchChannel = (next: LiveChannel) => { + precheckChannel(next); setChannel(next); setSelectedGroup(next.group || '其他'); setShowPanel(true); if (source) router.replace(`/tv/live/play?source=${encodeURIComponent(source.key)}&id=${encodeURIComponent(next.id)}`); }; + const switchSource = (next: LiveSource) => { + setSource(next); + setChannel(null); + setChannels([]); + setSelectedGroup(''); + setQuery(''); + setShowPanel(true); + router.replace(`/tv/live/play?source=${encodeURIComponent(next.key)}`); + }; + + const toggleFavorite = async () => { + if (!source || !channel) return; + if (favorited) { + await deleteFavorite(`live_${source.key}`, `live_${channel.id}`); + setFavorited(false); + } else { + await saveFavorite(`live_${source.key}`, `live_${channel.id}`, { + title: channel.name, + source_name: source.name, + year: '', + cover: getLogoUrl(channel.logo, source.key), + total_episodes: 1, + save_time: Date.now(), + search_title: channel.name, + origin: 'live', + }); + setFavorited(true); + } + }; + + const setVideoVolume = (next: number) => { + const safe = Math.max(0, Math.min(1, next)); + const video = document.querySelector('[data-tv-player-root] video'); + if (video) { + video.volume = safe; + video.muted = safe <= 0; + } + setVolume(safe); + setMuted(safe <= 0); + }; + + const toggleMute = () => { + const video = document.querySelector('[data-tv-player-root] video'); + const next = !muted; + if (video) video.muted = next; + setMuted(next); + }; + + const toggleFullscreen = () => { + const root = document.querySelector('[data-tv-player-root]'); + if (!root) return; + if (document.fullscreenElement) document.exitFullscreen().catch(() => undefined); + else root.requestFullscreen?.().catch(() => undefined); + }; + + useEffect(() => { + if (!videoUrl) return; + window.requestAnimationFrame(() => { + const video = document.querySelector('[data-tv-player-root] video'); + if (!video) return; + video.volume = volume; + video.muted = muted; + }); + }, [muted, videoUrl, volume]); + useEffect(() => { const onKey = (event: KeyboardEvent) => { - if (event.key === 'Enter') setShowPanel((v) => !v); + if (/^[0-9]$/.test(event.key) && channels.length) { + event.preventDefault(); + const nextBuffer = `${digitBuffer}${event.key}`.slice(-4); + setDigitBuffer(nextBuffer); + if (digitTimerRef.current) window.clearTimeout(digitTimerRef.current); + digitTimerRef.current = window.setTimeout(() => { + const target = Number(nextBuffer); + const next = channels[target - 1]; + if (next) switchChannel(next); + setDigitBuffer(''); + }, 850); + } + if (event.key === 'Enter') { + const active = document.activeElement; + const isControlFocused = active instanceof HTMLElement && Boolean(active.closest('[data-tv-live-control]')); + if (!isControlFocused) { + event.preventDefault(); + setShowPanel((v) => !v); + } + } if (event.key === 'Escape') { + event.preventDefault(); if (showPanel) setShowPanel(false); else router.back(); } @@ -133,19 +297,52 @@ function TVLivePlayClient() { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [channel?.id, channels, router, showPanel, source]); + }, [channel?.id, channels, digitBuffer, router, showPanel, source]); + + useEffect(() => { + if (!showPanel || !channel?.id) return; + window.requestAnimationFrame(() => { + channelButtonRefs.current[channel.id]?.focus({ preventScroll: true }); + channelButtonRefs.current[channel.id]?.scrollIntoView({ block: 'center', inline: 'nearest' }); + }); + }, [channel?.id, selectedGroup, showPanel]); if (loading) { return
正在进入电视直播...
; } if (error || !channel) { - return
{error || '没有可播放频道'}
; + return ( +
+
+ +

{error || '没有可播放频道'}

+
+ + +
+
+
+ ); } return (
setShowPanel(true)}> - {videoUrl ? :
正在解析直播地址...
} + {videoUrl ? setPlaybackError(true)} /> :
正在解析直播地址...
} + + {playbackError && ( +
+
+ +

当前频道播放失败

+

{retryCount < 3 ? `正在自动重连(${retryCount + 1}/3)...` : '可以重试当前频道,或打开频道面板切换频道/直播源。'}

+
+ + +
+
+
+ )}
@@ -153,29 +350,59 @@ function TVLivePlayClient() {
- +
{channel.logo ? : }
{channel.name}
{source?.name} · {channel.group}
+
+ + setVideoVolume(Number(e.target.value))} className='tv-focusable w-28 accent-rose-600' /> + + +
{showPanel && ( -
); diff --git a/src/app/tv/play/page.tsx b/src/app/tv/play/page.tsx index 2a413cc..e7d409c 100644 --- a/src/app/tv/play/page.tsx +++ b/src/app/tv/play/page.tsx @@ -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>([]); + 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>({}); + const detailCloseButtonRef = useRef(null); + const digitTimerRef = useRef(null); + const idleTimerRef = useRef(null); + const volumeHintTimerRef = useRef(null); + const seekHintTimerRef = useRef(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('[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('[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('[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('[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('[data-tv-player-root] video'); + const next = !muted; + if (video) video.muted = next; + setMuted(next); + }; + + const toggleFullscreen = () => { + const root = document.querySelector('[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('[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
正在进入电视播放...
; } if (error || !detail) { - return
{error || '播放信息不存在'}
; + return ( +
+
+ +

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

+
+ + +
+
+
+ ); } return ( -
setShowPanel(true)}> - {videoUrl ? : ( +
+ {videoUrl ? setPlaybackError(true)} onPlayingChange={setIsPlaying} adFilterEnabled={adFilterEnabled} playbackRate={playbackRate} /> : (
{resolving ? '正在解析播放地址...' : '准备播放...'}
)} + {danmakuEnabled && danmakuItems.length > 0 && ( +
+ {danmakuItems.filter((item) => Math.abs(item.time - time.current) < 0.35).slice(0, 8).map((item, idx) => ( +
+ {item.text} +
+ ))} + +
+ )} + + {playbackError && ( +
+
+ +

当前视频加载失败

+

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

+
+ + +
+
+
+ )}
@@ -187,29 +579,103 @@ function TVPlayClient() {
-
-
-
- + + + + + + + +
+
+ + + {formatTVTime(time.current)} / {formatTVTime(time.duration)} +
+
+
+ seekTo(Number(e.target.value))} className='tv-focusable 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%'} + {formatTVTime(time.duration)}
-
{formatTVTime(time.current)} / {formatTVTime(time.duration)}
{showEpisodes && ( )} + {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.poster && } +

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

+
+
+ )} + {digitBuffer &&
第 {digitBuffer} 集
} + {showVolumeHint && !showPanel && !showEpisodes && ( +
+ {muted || volume <= 0 ? : } +
+
+
+
{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)} +
+
+
+
+
+ )}
); diff --git a/src/components/tv/TVVirtualRemote.tsx b/src/components/tv/TVVirtualRemote.tsx index fcb890a..4d35317 100644 --- a/src/components/tv/TVVirtualRemote.tsx +++ b/src/components/tv/TVVirtualRemote.tsx @@ -128,11 +128,12 @@ const keys = { home: { key: 'Home', code: 'Home', keyCode: 36 }, }; -function fireRemoteKey(name: keyof typeof keys) { +function fireRemoteKey(name: keyof typeof keys, repeat = false) { const cfg = keys[name]; const eventInit: KeyboardEventInit = { key: cfg.key, code: cfg.code, + repeat, bubbles: true, cancelable: true, }; @@ -157,20 +158,50 @@ function fireRemoteKey(name: keyof typeof keys) { function RemoteButton({ label, onClick, + onRepeat, + repeatable = false, className = '', children, }: { label: string; onClick: () => void; + onRepeat?: () => void; + repeatable?: boolean; className?: string; children: React.ReactNode; }) { + const delayRef = useRef(null); + const intervalRef = useRef(null); + + const clearRepeat = () => { + if (delayRef.current) window.clearTimeout(delayRef.current); + if (intervalRef.current) window.clearInterval(intervalRef.current); + delayRef.current = null; + intervalRef.current = null; + }; + + useEffect(() => clearRepeat, []); + return (