// Author: fruit.com.cn import { useEffect, useRef, useState } from 'react'; import Hls from 'hls.js'; import { Maximize, Minimize, Pause, Play, Volume2, VolumeX } from 'lucide-react'; const CONTROLS_HIDE_DELAY = 2600; const SOUND_KEY = 'ymb.player.sound'; function readSoundPreference() { try { return window.localStorage.getItem(SOUND_KEY) === '1'; } catch { return false; } } // 跨分集/切视频保持用户的音量偏好:一旦开过声音,后续自动播放也带声音 let soundEnabled = readSoundPreference(); function persistSoundPreference(value) { soundEnabled = value; try { window.localStorage.setItem(SOUND_KEY, value ? '1' : '0'); } catch { // 隐私模式下仅保持本次会话生效 } } // 路由切换/组件卸载时彻底停掉解码与音频,避免离开页面后仍有声音 function stopVideo(video) { if (!video) return; try { video.pause(); video.muted = true; video.removeAttribute('src'); video.load(); } catch { // 媒体已销毁时忽略 } } function formatTime(seconds) { if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const total = Math.floor(seconds); return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`; } export function VideoPlayer({ src, poster, autoPlay = true, muted = true, soundHint = 'Tap for sound', onProgress, onEnded }) { const wrapRef = useRef(null); const videoRef = useRef(null); const hideTimerRef = useRef(null); const draggingRef = useRef(false); const playedRef = useRef(false); const onProgressRef = useRef(onProgress); const [ready, setReady] = useState(false); const [error, setError] = useState(''); const [reloadKey, setReloadKey] = useState(0); const [playing, setPlaying] = useState(false); const [isMuted, setIsMuted] = useState(() => (soundEnabled ? false : muted)); const [soundBlocked, setSoundBlocked] = useState(false); const [volume, setVolume] = useState(1); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [buffered, setBuffered] = useState(0); const [controlsVisible, setControlsVisible] = useState(true); const [volumeOpen, setVolumeOpen] = useState(false); const [hoverRatio, setHoverRatio] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { onProgressRef.current = onProgress; }, [onProgress]); // 暂停/离开页面时立即上报一次进度 useEffect(() => { const video = videoRef.current; if (!video) return undefined; const flush = () => { if (playedRef.current) onProgressRef.current?.(video.currentTime, video.duration, true); }; // 关闭标签/切到后台时立即停止播放,避免被浏览器接管为画中画或后台音频 const onPageHide = () => { flush(); video.pause(); }; const onVisibilityChange = () => { if (document.hidden) video.pause(); }; video.addEventListener('pause', flush); window.addEventListener('pagehide', onPageHide); document.addEventListener('visibilitychange', onVisibilityChange); return () => { flush(); video.removeEventListener('pause', flush); window.removeEventListener('pagehide', onPageHide); document.removeEventListener('visibilitychange', onVisibilityChange); }; }, []); useEffect(() => { const video = videoRef.current; if (!video || !src) return undefined; playedRef.current = false; setReady(false); setError(''); setPlaying(false); setSoundBlocked(false); setCurrentTime(0); setBuffered(0); const hls = new Hls({ enableWorker: true }); const start = () => setReady(true); const onHlsError = (_event, data) => { if (!data?.fatal) return; setError(`${data.details || 'Video stream error'} · ${data.response?.code || ''}`); console.error('YMB HLS ERROR', data); }; const onVideoError = () => { const message = `VIDEO_ERROR: ${video.error?.code || 'unknown'} ${video.error?.message || ''}`; console.error(message, { src, mediaError: video.error }); setError(message); }; hls.on(Hls.Events.MANIFEST_PARSED, start); hls.on(Hls.Events.ERROR, onHlsError); video.addEventListener('error', onVideoError); if (Hls.isSupported()) { hls.attachMedia(video); hls.loadSource(src); } else if (video.canPlayType('application/vnd.apple.mpegurl')) { video.src = src; video.addEventListener('loadedmetadata', start, { once: true }); } else { setError('This browser does not support HLS'); } return () => { video.removeEventListener('error', onVideoError); hls.destroy(); stopVideo(video); }; }, [src, reloadKey]); useEffect(() => { const video = videoRef.current; if (!video) return; const nextMuted = muted && !soundEnabled; video.muted = nextMuted; setIsMuted(nextMuted); }, [muted]); useEffect(() => { const video = videoRef.current; if (!video || !ready) return; if (!autoPlay) { video.pause(); return; } const wantSound = soundEnabled; video.muted = !wantSound; setIsMuted(!wantSound); video.play().catch((playError) => { if (playError.name === 'AbortError') return; // 浏览器拦截带声音的自动播放时,降级为静音播放并提示用户手动开启 if (playError.name === 'NotAllowedError' && wantSound) { video.muted = true; setIsMuted(true); setSoundBlocked(true); video.play().catch(() => {}); return; } setError(`PLAY_FAILED: ${playError.message}`); }); }, [autoPlay, ready]); useEffect(() => { const onChange = () => setIsFullscreen(Boolean(document.fullscreenElement)); document.addEventListener('fullscreenchange', onChange); return () => document.removeEventListener('fullscreenchange', onChange); }, []); useEffect(() => { const video = videoRef.current; return () => { if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); stopVideo(video); }; }, []); const revealControls = () => { setControlsVisible(true); if (hideTimerRef.current) window.clearTimeout(hideTimerRef.current); hideTimerRef.current = window.setTimeout(() => { if (videoRef.current && !videoRef.current.paused) setControlsVisible(false); }, CONTROLS_HIDE_DELAY); }; const togglePlay = () => { const video = videoRef.current; if (!video) return; if (video.paused) { video.muted = false; setIsMuted(false); persistSoundPreference(true); video.play().catch((playError) => { if (playError.name !== 'AbortError') setError(`PLAY_FAILED: ${playError.message}`); }); } else { video.pause(); } revealControls(); }; const seekToRatio = (ratio) => { const video = videoRef.current; if (!video || !Number.isFinite(video.duration)) return; video.currentTime = Math.min(Math.max(ratio, 0), 1) * video.duration; }; const seekFromPointer = (event) => { const rect = event.currentTarget.getBoundingClientRect(); if (!rect.width) return; seekToRatio((event.clientX - rect.left) / rect.width); }; const enableSound = () => { const video = videoRef.current; if (!video) return; video.muted = false; setIsMuted(false); setSoundBlocked(false); persistSoundPreference(true); video.play().catch(() => {}); }; const toggleMute = () => { const video = videoRef.current; if (!video) return; video.muted = !video.muted; setIsMuted(video.muted); persistSoundPreference(!video.muted); }; const changeVolume = (event) => { const next = Number(event.target.value); const video = videoRef.current; setVolume(next); if (!video) return; video.volume = next; video.muted = next === 0; setIsMuted(next === 0); persistSoundPreference(next > 0); }; const toggleFullscreen = async () => { const node = wrapRef.current; if (!node) return; try { if (document.fullscreenElement) await document.exitFullscreen(); else await node.requestFullscreen(); } catch { // 全屏被浏览器拒绝时保持当前状态 } }; const onKeyDown = (event) => { const video = videoRef.current; if (!video) return; if (event.key === ' ' || event.key.toLowerCase() === 'k') { event.preventDefault(); togglePlay(); } else if (event.key === 'ArrowLeft') { video.currentTime = Math.max(0, video.currentTime - 5); } else if (event.key === 'ArrowRight') { video.currentTime = Math.min(video.duration || 0, video.currentTime + 5); } else if (event.key.toLowerCase() === 'm') { toggleMute(); } else if (event.key.toLowerCase() === 'f') { toggleFullscreen(); } revealControls(); }; const percent = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; const bufferedPercent = duration > 0 ? Math.min(100, (buffered / duration) * 100) : 0; return (
playing && setControlsVisible(false)} onKeyDown={onKeyDown} tabIndex={0} role="group" aria-label="Video player" >
); }