'use client'; /** * IPTVPlayer - Player for IPTV streams with controls, volume, progress, and sidebar. * Supports HLS (via HLS.js), native HLS (Safari), and direct video playback. * Features multi-level sidebar (source -> group -> channels), multi-route collapse, * and optimized search performance. */ import { useRef, useEffect, useState, useCallback, useMemo, useTransition } from 'react'; import Hls from 'hls.js'; import { Icons } from '@/components/ui/Icon'; import type { M3UChannel } from '@/lib/utils/m3u-parser'; import type { IPTVSource } from '@/lib/store/iptv-store'; import { settingsStore, DEFAULT_SEEK_STEP_SECONDS } from '@/lib/store/settings-store'; import { shouldHidePlayerCursor } from '@/lib/player/cursor-visibility'; const HLS_LIVE_CONFIG: Partial = { enableWorker: true, lowLatencyMode: true, liveDurationInfinity: true, manifestLoadingTimeOut: 10000, manifestLoadingMaxRetry: 3, levelLoadingTimeOut: 10000, fragLoadingTimeOut: 20000, // Prefer H.264 (avc) over HEVC (hev/hvc) for maximum browser compatibility preferManagedMediaSource: false, }; const LOADING_TIMEOUT_MS = 30000; const MAX_VISIBLE_ROUTES = 3; interface IPTVPlayerProps { channel: M3UChannel; onClose: () => void; channels: M3UChannel[]; onChannelChange: (channel: M3UChannel) => void; channelsBySource?: Record; sources?: IPTVSource[]; } function getProxiedUrl(url: string, ua?: string, referer?: string): string { let proxyUrl = `/api/iptv/stream?`; if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`; if (referer) proxyUrl += `referer=${encodeURIComponent(referer)}&`; proxyUrl += `url=${encodeURIComponent(url)}`; return proxyUrl; } function formatTime(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return '0:00'; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`; } function getSeekRange(video: HTMLVideoElement): { start: number; end: number; duration: number } | null { if (video.seekable.length > 0) { const start = video.seekable.start(0); const end = video.seekable.end(video.seekable.length - 1); if (isFinite(start) && isFinite(end) && end > start) { return { start, end, duration: end - start }; } } if (isFinite(video.duration) && video.duration > 0) { return { start: 0, end: video.duration, duration: video.duration }; } return null; } export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channelsBySource, sources }: IPTVPlayerProps) { const videoRef = useRef(null); const hlsRef = useRef(null); const containerRef = useRef(null); const activeChannelRef = useRef(null); const controlsTimeoutRef = useRef>(undefined); const loadingTimeoutRef = useRef>(undefined); const [error, setError] = useState(null); const [showSidebar, setShowSidebar] = useState(false); const [sidebarSearch, setSidebarSearch] = useState(''); const [filteredResults, setFilteredResults] = useState([]); const [isSearching, startSearchTransition] = useTransition(); const [sidebarVisibleCount, setSidebarVisibleCount] = useState(50); const [isLoading, setIsLoading] = useState(true); const [isPlaying, setIsPlaying] = useState(false); const [showControls, setShowControls] = useState(true); const [isLive, setIsLive] = useState(true); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [seekWindow, setSeekWindow] = useState<{ start: number; end: number; duration: number } | null>(null); const [volume, setVolume] = useState(1); const [isMuted, setIsMuted] = useState(false); const [showVolumeSlider, setShowVolumeSlider] = useState(false); const [currentRouteIndex, setCurrentRouteIndex] = useState(0); const [isFullscreen, setIsFullscreen] = useState(false); const [showAllRoutes, setShowAllRoutes] = useState(false); const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS); // Multi-level sidebar state const [expandedSources, setExpandedSources] = useState>(new Set()); const [expandedGroups, setExpandedGroups] = useState>(new Set()); // Whether we have multi-source data const hasMultiSource = channelsBySource && sources && sources.length > 0; const activeSourceId = channel.sourceId || null; const activeGroupKey = activeSourceId && channel.group ? `${activeSourceId}::${channel.group}` : null; const activeSource = useMemo( () => (activeSourceId && sources ? sources.find((source) => source.id === activeSourceId) || null : null), [activeSourceId, sources] ); // Get current route URL const routes = channel.routes || [channel.url]; const currentUrl = routes[currentRouteIndex] || channel.url; // Route display - collapse if > MAX_VISIBLE_ROUTES const visibleRoutes = showAllRoutes ? routes : routes.slice(0, MAX_VISIBLE_ROUTES); const hasMoreRoutes = routes.length > MAX_VISIBLE_ROUTES; // Auto-scroll to active channel in sidebar useEffect(() => { if (showSidebar && activeChannelRef.current) { activeChannelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [showSidebar, channel.url]); useEffect(() => { const syncSeekStep = () => { setSeekStepSeconds(settingsStore.getSettings().seekStepSeconds ?? DEFAULT_SEEK_STEP_SECONDS); }; syncSeekStep(); const unsubscribe = settingsStore.subscribe(syncSeekStep); return () => unsubscribe(); }, []); // Auto-expand the source/group containing the active channel useEffect(() => { if (channel.sourceId) { setExpandedSources(prev => new Set(prev).add(channel.sourceId!)); if (channel.group) { setExpandedGroups(prev => new Set(prev).add(`${channel.sourceId}::${channel.group}`)); } } }, [channel.sourceId, channel.group]); // Track fullscreen changes useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); // Controls auto-hide const resetControlsTimeout = useCallback(() => { setShowControls(true); if (controlsTimeoutRef.current) clearTimeout(controlsTimeoutRef.current); controlsTimeoutRef.current = setTimeout(() => { setShowControls(false); }, 3000); }, []); // Video event handlers useEffect(() => { const video = videoRef.current; if (!video) return; const onPlay = () => setIsPlaying(true); const onPause = () => setIsPlaying(false); const onTimeUpdate = () => { const range = getSeekRange(video); setCurrentTime(video.currentTime); setSeekWindow(range); if (range) { setDuration(range.duration); setIsLive(false); } else { const dur = video.duration; if (isFinite(dur) && dur > 0) { setDuration(dur); } setIsLive(true); } }; const onDurationChange = () => { const range = getSeekRange(video); setSeekWindow(range); if (range) { setDuration(range.duration); setIsLive(false); } else { const dur = video.duration; if (isFinite(dur) && dur > 0) { setDuration(dur); } } }; const onVolumeChange = () => { setVolume(video.volume); setIsMuted(video.muted); }; video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('durationchange', onDurationChange); video.addEventListener('volumechange', onVolumeChange); return () => { video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('durationchange', onDurationChange); video.removeEventListener('volumechange', onVolumeChange); }; }, []); const loadChannel = useCallback((url: string) => { const video = videoRef.current; if (!video) return; setError(null); setIsLoading(true); setIsLive(true); setCurrentTime(0); setDuration(0); setSeekWindow(null); // Clean up previous if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); loadingTimeoutRef.current = undefined; } video.removeAttribute('src'); video.load(); const proxiedUrl = getProxiedUrl(url, channel.httpUserAgent, channel.httpReferrer); const hasCustomHeaders = !!(channel.httpUserAgent || channel.httpReferrer); // When custom headers are needed, skip direct attempt (browsers cannot set // User-Agent on XHR/fetch). Always go through our proxy which can forward // the headers server-side. This fixes audio-only issues on CCTV and similar. const initialUrl = hasCustomHeaders ? proxiedUrl : url; // Global loading timeout let loadingResolved = false; const markLoaded = () => { if (loadingResolved) return; loadingResolved = true; if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); loadingTimeoutRef.current = undefined; } setIsLoading(false); }; const markError = (msg: string) => { if (loadingResolved) return; loadingResolved = true; if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); loadingTimeoutRef.current = undefined; } setIsLoading(false); setError(msg); }; loadingTimeoutRef.current = setTimeout(() => { markError('加载超时,请尝试其他线路或频道'); }, LOADING_TIMEOUT_MS); if (Hls.isSupported()) { const hls = new Hls(HLS_LIVE_CONFIG); hlsRef.current = hls; let triedProxy = false; let triedDirect = false; const tryDirectVideo = (directUrl: string) => { if (triedDirect) { markError('播放错误,请尝试其他线路或频道'); return; } triedDirect = true; const vid = videoRef.current; if (!vid) return; vid.src = directUrl; vid.addEventListener('canplay', () => { markLoaded(); vid.play().catch(() => {}); }, { once: true }); vid.addEventListener('error', () => { if (directUrl === url) { // Try proxied direct video const vid2 = videoRef.current; if (!vid2) return; vid2.src = proxiedUrl; vid2.addEventListener('canplay', () => { markLoaded(); vid2.play().catch(() => {}); }, { once: true }); vid2.addEventListener('error', () => { markError('播放错误,请尝试其他线路或频道'); }, { once: true }); } else { markError('播放错误,请尝试其他线路或频道'); } }, { once: true }); }; const tryWithProxy = () => { if (triedProxy) { tryDirectVideo(url); return; } triedProxy = true; hls.destroy(); const hlsProxy = new Hls(HLS_LIVE_CONFIG); hlsRef.current = hlsProxy; hlsProxy.loadSource(proxiedUrl); hlsProxy.attachMedia(video); // Filter HEVC levels for proxy attempt too hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => { filterHEVCLevels(hlsProxy); markLoaded(); video.play().catch(() => {}); }); hlsProxy.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { hlsProxy.recoverMediaError(); } else { hlsProxy.destroy(); hlsRef.current = null; tryDirectVideo(url); } } }); }; // Helper: Filter out HEVC levels that browser may not support (fixes audio-only issue) const filterHEVCLevels = (hlsInstance: Hls) => { if (!hlsInstance.levels || hlsInstance.levels.length <= 1) return; const h264Levels = hlsInstance.levels .map((level, index) => ({ level, index })) .filter(({ level }) => { const codec = level.videoCodec?.toLowerCase() || ''; // Keep levels without HEVC codec (H.264 or unknown) return !codec.includes('hev') && !codec.includes('h265') && !codec.includes('hvc'); }); // If we have H.264 levels, restrict to those if (h264Levels.length > 0 && h264Levels.length < hlsInstance.levels.length) { console.info('[IPTV] Filtering HEVC levels, using H.264 only for compatibility'); // Set level to first H.264 level hlsInstance.currentLevel = h264Levels[0].index; } }; // First try initial URL (direct or proxied based on custom headers) hls.loadSource(initialUrl); hls.attachMedia(video); hls.on(Hls.Events.MANIFEST_PARSED, () => { // Filter HEVC levels to prevent audio-only playback filterHEVCLevels(hls); markLoaded(); video.play().catch(() => {}); }); hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { tryWithProxy(); } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); } else { tryWithProxy(); } } }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { // Native HLS (Safari/iOS) video.src = initialUrl; video.addEventListener('canplay', () => { markLoaded(); video.play().catch(() => {}); }, { once: true }); video.addEventListener('error', () => { // If direct failed, try proxy; if already proxied, fail if (initialUrl === proxiedUrl) { markError('播放错误'); return; } video.src = proxiedUrl; video.addEventListener('canplay', () => { markLoaded(); video.play().catch(() => {}); }, { once: true }); video.addEventListener('error', () => { markError('播放错误'); }, { once: true }); }, { once: true }); } else { // Direct video fallback video.src = initialUrl; video.addEventListener('canplay', () => { markLoaded(); video.play().catch(() => {}); }, { once: true }); video.addEventListener('error', () => { if (initialUrl === proxiedUrl) { markError('播放错误,请尝试其他频道'); return; } video.src = proxiedUrl; video.addEventListener('canplay', () => { markLoaded(); video.play().catch(() => {}); }, { once: true }); video.addEventListener('error', () => { markError('播放错误,请尝试其他频道'); }, { once: true }); }, { once: true }); } }, [channel.httpUserAgent, channel.httpReferrer]); // Load on channel/route change useEffect(() => { loadChannel(currentUrl); return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } if (loadingTimeoutRef.current) { clearTimeout(loadingTimeoutRef.current); loadingTimeoutRef.current = undefined; } }; }, [currentUrl, loadChannel]); // Reset route index when channel changes useEffect(() => { setCurrentRouteIndex(0); setShowAllRoutes(false); }, [channel.name, channel.url]); // Playback controls const togglePlay = () => { const video = videoRef.current; if (!video) return; if (video.paused) video.play().catch(() => {}); else video.pause(); }; const toggleMute = () => { const video = videoRef.current; if (!video) return; video.muted = !video.muted; }; const handleVolumeChange = (value: number) => { const video = videoRef.current; if (!video) return; video.volume = value; if (value > 0 && video.muted) video.muted = false; }; const progressRef = useRef(null); const handleSeek = (e: React.MouseEvent) => { if (isLive) return; const video = videoRef.current; const bar = progressRef.current; if (!video || !bar) return; const seekRange = getSeekRange(video); if (!seekRange) return; const rect = bar.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); video.currentTime = seekRange.start + ratio * seekRange.duration; }; const progressPercent = useMemo(() => { if (seekWindow) { return Math.max(0, Math.min(100, ((currentTime - seekWindow.start) / seekWindow.duration) * 100)); } if (!duration) return 0; return Math.max(0, Math.min(100, (currentTime / duration) * 100)); }, [currentTime, duration, seekWindow]); const shouldHideCursor = shouldHidePlayerCursor({ isFullscreen, isPlaying, showControls, hasInteractiveOverlay: showSidebar || showVolumeSlider || Boolean(error), }); const toggleFullscreen = async () => { if (!containerRef.current) return; if (document.fullscreenElement) { await document.exitFullscreen(); } else { await containerRef.current.requestFullscreen(); } }; // Keyboard shortcuts (matching main video player) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; resetControlsTimeout(); const video = videoRef.current; if (!video) return; switch (e.key.toLowerCase()) { case ' ': case 'k': e.preventDefault(); togglePlay(); break; case 'f': e.preventDefault(); toggleFullscreen(); break; case 'm': e.preventDefault(); toggleMute(); break; case 'escape': e.preventDefault(); onClose(); break; case 'arrowright': case 'l': e.preventDefault(); if (!isLive && isFinite(video.duration)) { video.currentTime = Math.min(video.duration, video.currentTime + seekStepSeconds); } break; case 'arrowleft': case 'j': e.preventDefault(); if (!isLive && isFinite(video.duration)) { video.currentTime = Math.max(0, video.currentTime - seekStepSeconds); } break; case 'arrowup': e.preventDefault(); video.volume = Math.min(1, video.volume + 0.1); if (video.muted) video.muted = false; break; case 'arrowdown': e.preventDefault(); video.volume = Math.max(0, video.volume - 0.1); break; } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }); const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2; // Debounce search with useTransition for non-blocking rendering useEffect(() => { const timer = setTimeout(() => { const q = sidebarSearch.toLowerCase().trim(); if (!q) { setFilteredResults([]); setSidebarVisibleCount(50); return; } startSearchTransition(() => { const results = channels.filter(ch => ch.name.toLowerCase().includes(q)); setFilteredResults(results); setSidebarVisibleCount(50); }); }, 200); return () => clearTimeout(timer); }, [sidebarSearch, channels]); const isSearchMode = sidebarSearch.trim().length > 0; // Toggle source expansion const toggleSource = useCallback((sourceId: string) => { setExpandedSources(prev => { const next = new Set(prev); if (next.has(sourceId)) next.delete(sourceId); else next.add(sourceId); return next; }); }, []); // Toggle group expansion const toggleGroup = useCallback((key: string) => { setExpandedGroups(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }, []); const toggleActiveSource = useCallback(() => { if (!activeSourceId) return; toggleSource(activeSourceId); }, [activeSourceId, toggleSource]); const toggleActiveGroup = useCallback(() => { if (!activeSourceId || !channel.group) return; setExpandedSources(prev => new Set(prev).add(activeSourceId)); toggleGroup(`${activeSourceId}::${channel.group}`); }, [activeSourceId, channel.group, toggleGroup]); // Render a channel button const renderChannelButton = (ch: M3UChannel, i: number) => { const isActive = ch.name === channel.name && ch.url === channel.url; return ( ); }; // Render multi-level sidebar content const renderMultiLevelSidebar = () => { if (!channelsBySource || !sources) return null; const orderedSources = activeSourceId ? [ ...sources.filter((source) => source.id === activeSourceId), ...sources.filter((source) => source.id !== activeSourceId), ] : sources; return (
{orderedSources.map(source => { const sourceData = channelsBySource[source.id]; if (!sourceData || sourceData.channels.length === 0) return null; const isExpanded = expandedSources.has(source.id); const isActiveSource = source.id === activeSourceId; const orderedGroups = isActiveSource && channel.group ? [channel.group, ...sourceData.groups.filter((group) => group !== channel.group)] : sourceData.groups; return (
{/* Source Header */} {/* Source Content */} {isExpanded && (
{orderedGroups.length > 0 ? ( // Has groups — show group-level orderedGroups.map(group => { const groupKey = `${source.id}::${group}`; const groupExpanded = expandedGroups.has(groupKey); const groupChannels = sourceData.channels.filter(ch => ch.group === group); const isActiveGroup = groupKey === activeGroupKey; return (
{groupExpanded && (
{groupChannels.map((ch, i) => renderChannelButton(ch, i))}
)}
); }) ) : ( // No groups — show channels directly sourceData.channels.map((ch, i) => renderChannelButton(ch, i)) )} {/* Ungrouped channels */} {sourceData.groups.length > 0 && (() => { const ungrouped = sourceData.channels.filter(ch => !ch.group); if (ungrouped.length === 0) return null; return (
未分组
{ungrouped.map((ch, i) => renderChannelButton(ch, i))}
); })()}
)}
); })}
); }; // Render flat channel list (search results or single-source fallback) const renderFlatChannelList = (channelList: M3UChannel[]) => { const visible = channelList.slice(0, sidebarVisibleCount); return (
{visible.map((ch, i) => renderChannelButton(ch, i))} {channelList.length > sidebarVisibleCount && ( )}
); }; return (
{ if ((e.target as HTMLElement).closest('[data-controls]') || (e.target as HTMLElement).closest('[data-sidebar]')) return; togglePlay(); resetControlsTimeout(); }} > {/* Player Area */}