From aa8f8e9ff9244a3474f2a8bc5287979b31a3fd49 Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Tue, 17 Feb 2026 22:24:49 +0800 Subject: [PATCH] feat: enhance IPTV player with comprehensive controls, improved stream loading robustness, and UI refinements including volume, progress, and fullscreen. --- app/player/page.tsx | 6 + components/iptv/IPTVChannelGrid.tsx | 23 +- components/iptv/IPTVPlayer.tsx | 547 +++++++++++++++++++++------- components/player/VideoPlayer.tsx | 12 + lib/store/iptv-store.ts | 11 +- lib/utils/m3u-parser.ts | 35 ++ package-lock.json | 4 +- package.json | 2 +- 8 files changed, 501 insertions(+), 139 deletions(-) diff --git a/app/player/page.tsx b/app/player/page.tsx index 450a017..c5998f8 100644 --- a/app/player/page.tsx +++ b/app/player/page.tsx @@ -179,6 +179,7 @@ function PlayerContent() { // Track current source for switching const [currentSourceId, setCurrentSourceId] = useState(source); + const playerTimeRef = useRef(0); // Add initial history entry when video data is loaded useEffect(() => { @@ -277,6 +278,7 @@ function PlayerContent() { isPremium={isPremium} videoTitle={videoData?.vod_name || title || ''} episodeName={videoData?.episodes?.[currentEpisode]?.name || ''} + externalTimeRef={playerTimeRef} />
1) { + params.set('t', Math.floor(playerTimeRef.current).toString()); + } // Pass all known sources so switching persists const allSources = groupedSources.length > 0 ? groupedSources : []; if (allSources.length > 1) { diff --git a/components/iptv/IPTVChannelGrid.tsx b/components/iptv/IPTVChannelGrid.tsx index 9da91df..8aed202 100644 --- a/components/iptv/IPTVChannelGrid.tsx +++ b/components/iptv/IPTVChannelGrid.tsx @@ -149,13 +149,22 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel }: I }`}> {channel.name}

- {channel.group && ( -

- {channel.group} -

- )} +
+ {channel.group && ( +

+ {channel.group} +

+ )} + {channel.routes && channel.routes.length > 1 && ( + + {channel.routes.length}线路 + + )} +
diff --git a/components/iptv/IPTVPlayer.tsx b/components/iptv/IPTVPlayer.tsx index 938fe53..5b17523 100644 --- a/components/iptv/IPTVPlayer.tsx +++ b/components/iptv/IPTVPlayer.tsx @@ -1,9 +1,9 @@ 'use client'; /** - * IPTVPlayer - Lightweight player for IPTV live streams - * Uses HLS.js for playback with a channel switching sidebar. - * Routes streams through /api/iptv/stream proxy to avoid CORS issues. + * IPTVPlayer - Player for IPTV streams with controls, volume, progress, and sidebar. + * Supports HLS (via HLS.js), native HLS (Safari), and direct video playback. + * Routes streams through proxy to avoid CORS when direct access fails. */ import { useRef, useEffect, useState, useCallback } from 'react'; @@ -22,88 +22,215 @@ function getProxiedUrl(url: string): string { return `/api/iptv/stream?url=${encodeURIComponent(url)}`; } +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')}`; +} + export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTVPlayerProps) { const videoRef = useRef(null); const hlsRef = useRef(null); + const containerRef = useRef(null); + const activeChannelRef = useRef(null); + const controlsTimeoutRef = useRef>(undefined); + const [error, setError] = useState(null); const [showSidebar, setShowSidebar] = useState(false); 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 [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 loadChannel = useCallback((ch: M3UChannel) => { + // Get current route URL + const routes = channel.routes || [channel.url]; + const currentUrl = routes[currentRouteIndex] || channel.url; + + // Auto-scroll to active channel in sidebar + useEffect(() => { + if (showSidebar && activeChannelRef.current) { + activeChannelRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [showSidebar, channel.url]); + + // 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 = () => { + setCurrentTime(video.currentTime); + const dur = video.duration; + if (isFinite(dur) && dur > 0) { + setDuration(dur); + setIsLive(false); + } else { + setIsLive(true); + } + }; + const onDurationChange = () => { + const dur = video.duration; + if (isFinite(dur) && dur > 0) { + setDuration(dur); + setIsLive(false); + } + }; + 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); - // Clean up previous HLS instance + // Clean up previous if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } + video.removeAttribute('src'); + video.load(); - const originalUrl = ch.url; - const proxiedUrl = getProxiedUrl(originalUrl); + const proxiedUrl = getProxiedUrl(url); - // Try HLS.js first for all URLs (many IPTV streams are HLS even without .m3u8 extension) if (Hls.isSupported()) { const hls = new Hls({ enableWorker: true, lowLatencyMode: true, - liveDurationInfinity: true, }); hlsRef.current = hls; let triedProxy = false; + let triedDirect = false; + + const tryDirectVideo = (directUrl: string) => { + if (triedDirect) { + setIsLoading(false); + setError('播放错误,请尝试其他线路或频道'); + return; + } + triedDirect = true; + const vid = videoRef.current; + if (!vid) return; + vid.src = directUrl; + vid.addEventListener('loadedmetadata', () => { + setIsLoading(false); + 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('loadedmetadata', () => { + setIsLoading(false); + vid2.play().catch(() => {}); + }, { once: true }); + vid2.addEventListener('error', () => { + setIsLoading(false); + setError('播放错误,请尝试其他线路或频道'); + }, { once: true }); + } else { + setIsLoading(false); + setError('播放错误,请尝试其他线路或频道'); + } + }, { once: true }); + }; const tryWithProxy = () => { - if (triedProxy) return; + if (triedProxy) { + tryDirectVideo(url); + return; + } triedProxy = true; - // Retry with proxied URL hls.destroy(); const hlsProxy = new Hls({ enableWorker: true, lowLatencyMode: true, - liveDurationInfinity: true, }); hlsRef.current = hlsProxy; - hlsProxy.loadSource(proxiedUrl); hlsProxy.attachMedia(video); - hlsProxy.on(Hls.Events.MANIFEST_PARSED, () => { setIsLoading(false); video.play().catch(() => {}); }); - hlsProxy.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { - setIsLoading(false); if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { hlsProxy.recoverMediaError(); } else { - // Last resort: try direct video element hlsProxy.destroy(); hlsRef.current = null; - tryDirectVideo(proxiedUrl); + tryDirectVideo(url); } } }); }; - // First try direct URL - hls.loadSource(originalUrl); + // First try direct URL with HLS.js + hls.loadSource(url); hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => { setIsLoading(false); video.play().catch(() => {}); }); - hls.on(Hls.Events.ERROR, (_, data) => { if (data.fatal) { if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - // Likely CORS - try proxy tryWithProxy(); } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { hls.recoverMediaError(); @@ -113,64 +240,114 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange }: IPTV } }); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - // Native HLS (Safari/iOS) - try direct first, fall back to proxy - tryNativeHls(video, originalUrl, proxiedUrl); - } else { - tryDirectVideo(originalUrl); - } - - function tryNativeHls(vid: HTMLVideoElement, url: string, fallbackUrl: string) { - vid.src = url; - const onLoad = () => { + // Native HLS (Safari/iOS) + video.src = url; + video.addEventListener('loadedmetadata', () => { setIsLoading(false); - vid.play().catch(() => {}); - }; - const onError = () => { - vid.removeEventListener('loadedmetadata', onLoad); - // Try proxied URL - vid.src = fallbackUrl; - vid.addEventListener('loadedmetadata', () => { + video.play().catch(() => {}); + }, { once: true }); + video.addEventListener('error', () => { + video.src = proxiedUrl; + video.addEventListener('loadedmetadata', () => { setIsLoading(false); - vid.play().catch(() => {}); + video.play().catch(() => {}); }, { once: true }); - vid.addEventListener('error', () => { + video.addEventListener('error', () => { setIsLoading(false); setError('播放错误'); }, { once: true }); - }; - vid.addEventListener('loadedmetadata', onLoad, { once: true }); - vid.addEventListener('error', onError, { once: true }); - } - - function tryDirectVideo(url: string) { - const vid = videoRef.current; - if (!vid) return; - vid.src = url; - vid.addEventListener('loadedmetadata', () => { - setIsLoading(false); - vid.play().catch(() => {}); }, { once: true }); - vid.addEventListener('error', () => { + } else { + // Direct video fallback + video.src = url; + video.addEventListener('loadedmetadata', () => { setIsLoading(false); - setError('播放错误,请尝试其他频道'); + video.play().catch(() => {}); + }, { once: true }); + video.addEventListener('error', () => { + video.src = proxiedUrl; + video.addEventListener('loadedmetadata', () => { + setIsLoading(false); + video.play().catch(() => {}); + }, { once: true }); + video.addEventListener('error', () => { + setIsLoading(false); + setError('播放错误,请尝试其他频道'); + }, { once: true }); }, { once: true }); } }, []); + // Load on channel/route change useEffect(() => { - loadChannel(channel); + loadChannel(currentUrl); return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } }; - }, [channel, loadChannel]); + }, [currentUrl, loadChannel]); + + // Reset route index when channel changes + useEffect(() => { + setCurrentRouteIndex(0); + }, [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 handleSeek = (e: React.MouseEvent) => { + if (isLive) return; + const video = videoRef.current; + if (!video || !duration) return; + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + video.currentTime = ratio * duration; + }; + + const toggleFullscreen = async () => { + if (!containerRef.current) return; + if (document.fullscreenElement) { + await document.exitFullscreen(); + } else { + await containerRef.current.requestFullscreen(); + } + }; + + const VolumeIcon = isMuted || volume === 0 ? Icons.VolumeX : volume < 0.5 ? Icons.Volume1 : Icons.Volume2; return ( -
+
{ + if ((e.target as HTMLElement).closest('[data-controls]') || (e.target as HTMLElement).closest('[data-sidebar]')) return; + togglePlay(); + resetControlsTimeout(); + }} + > {/* Player Area */} -
+