'use client'; 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, useRef, useState } from 'react'; 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 }; type TVPlayerSourceType = 'm3u8' | 'flv' | 'native'; function getLogoUrl(logo?: string, source?: string) { if (!logo) return ''; if (!source) return logo; return `/api/proxy/logo?url=${encodeURIComponent(logo)}&source=${encodeURIComponent(source)}`; } function getUrlSourceType(rawUrl: string): TVPlayerSourceType | 'unknown' { const lower = rawUrl.toLowerCase(); const path = lower.split('?')[0]; if (path.endsWith('.m3u8') || path.endsWith('.m3u') || lower.includes('.m3u8') || lower.includes('.m3u')) return 'm3u8'; if (path.endsWith('.flv') || lower.includes('.flv?')) return 'flv'; if (/\.(mp4|webm|ogv|ogg|mov)(\?.*)?$/.test(path)) return 'native'; return 'unknown'; } async function resolveLiveUrl(rawUrl: string, source?: LiveSource | null): Promise<{ url: string; type: TVPlayerSourceType }> { const proxyMode = source?.proxyMode || 'full'; const sourceType = getUrlSourceType(rawUrl); if (sourceType === 'm3u8') { return { type: 'm3u8', url: proxyMode === 'direct' ? rawUrl : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source?.key || '')}`, }; } if (sourceType === 'flv') return { type: 'flv', url: rawUrl }; if (sourceType === 'native') return { type: 'native', url: rawUrl }; if (!source?.key) throw new Error('未知直播流格式'); const precheckRes = await fetch( `/api/live/precheck?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source.key)}`, { cache: 'no-store' } ); if (!precheckRes.ok) throw new Error('不支持的直播流格式'); const precheck = await precheckRes.json(); if (precheck?.type === 'flv') return { type: 'flv', url: rawUrl }; if (precheck?.type === 'mp4') return { type: 'native', url: rawUrl }; if (precheck?.type === 'm3u8') { return { type: 'm3u8', url: proxyMode === 'direct' ? rawUrl : `/api/proxy/m3u8?url=${encodeURIComponent(rawUrl)}&moontv-source=${encodeURIComponent(source.key)}`, }; } throw new Error('不支持的直播流格式'); } function TVLivePlayClient() { const router = useRouter(); const searchParams = useSearchParams(); const needSource = searchParams.get('source'); const needChannel = searchParams.get('id'); const [sources, setSources] = useState([]); const [source, setSource] = useState(null); const [channels, setChannels] = useState([]); const [channel, setChannel] = useState(null); const [videoUrl, setVideoUrl] = useState(''); const [videoType, setVideoType] = useState(); const [unsupportedError, setUnsupportedError] = useState(''); const [loading, setLoading] = useState(true); 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) => { 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((err) => { setError(err instanceof Error ? err.message : '获取直播源失败'); setLoading(false); }); return () => { alive = false; }; }, [needSource]); useEffect(() => { if (!source) return; let alive = true; setLoading(true); setError(''); fetch(`/api/live/channels?source=${encodeURIComponent(source.key)}`) .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) => ({ id: item.id, tvgId: item.tvgId || item.name, name: item.name, logo: item.logo, group: item.group || '其他', url: item.url, })); setChannels(list); const selected = list.find((c: LiveChannel) => c.id === needChannel) || list[0] || null; setChannel(selected); setSelectedGroup(selected?.group || list[0]?.group || ''); }) .catch((err) => setError(err instanceof Error ? err.message : '获取频道列表失败')) .finally(() => alive && setLoading(false)); return () => { alive = false; }; }, [source, needChannel]); useEffect(() => { let alive = true; if (!channel) return; setVideoUrl(''); setVideoType(undefined); setUnsupportedError(''); setPlaybackError(false); setRetryCount(0); resolveLiveUrl(channel.url, source) .then(({ url, type }) => { if (!alive) return; setVideoType(type); setVideoUrl(url); }) .catch((err) => { if (!alive) return; setUnsupportedError(err instanceof Error ? err.message : '不支持的直播流格式'); }); if (source) { savePlayRecord(`live_${source.key}`, `live_${channel.id}`, { title: channel.name, source_name: source.name, year: '', cover: getLogoUrl(channel.logo, source.key), index: 1, total_episodes: 1, play_time: 0, total_time: 0, save_time: Date.now(), search_title: channel.name, origin: 'live', }).catch(() => undefined); } 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(''); setVideoType(undefined); setUnsupportedError(''); resolveLiveUrl(channel.url, source) .then(({ url, type }) => { setVideoType(type); setVideoUrl(url); }) .catch((err) => setUnsupportedError(err instanceof Error ? err.message : '不支持的直播流格式')); }, 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(() => { 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 (/^[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(); } if (event.key === 'PageUp' || event.key === 'PageDown') { const currentIndex = channels.findIndex((item) => item.id === channel?.id); if (currentIndex >= 0) { const nextIndex = event.key === 'PageUp' ? currentIndex - 1 : currentIndex + 1; const next = channels[Math.max(0, Math.min(channels.length - 1, nextIndex))]; if (next) switchChannel(next); } } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [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 (
setShowPanel(true)}> {unsupportedError ? (

当前直播流格式不支持

{unsupportedError}

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

当前频道播放失败

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

)}
{channel.logo ? : }
{channel.name}
{source?.name} · {channel.group}
setVideoVolume(Number(e.target.value))} className='tv-focusable w-28 accent-rose-600' />
{showPanel && ( )} {digitBuffer &&
频道 {digitBuffer}
}
); } export default function TVLivePlayPage() { return ; }