- 移动端移除底部导航,Shorts 视频改为圆角卡片并适配 iOS 裁剪 - 播放进度条全局贴到视频最底,移动端精简控件(仅播放/全屏) - 右侧互动栏下移,首页进入播放页增加模糊封面过渡 - 关于页下载入口改为标准 App Store / Google Play 徽章 - 新增自动连播下一集与自动播放被拦截时的开启声音提示
414 lines
14 KiB
React
414 lines
14 KiB
React
// 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 (
|
|
<div
|
|
ref={wrapRef}
|
|
className={`vp video-player-wrap ${controlsVisible ? 'show-controls' : ''} ${playing ? 'is-playing' : ''}`}
|
|
onMouseMove={revealControls}
|
|
onMouseLeave={() => playing && setControlsVisible(false)}
|
|
onKeyDown={onKeyDown}
|
|
tabIndex={0}
|
|
role="group"
|
|
aria-label="Video player"
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
className="video-player"
|
|
poster={poster}
|
|
playsInline
|
|
disablePictureInPicture
|
|
disableRemotePlayback
|
|
muted={muted}
|
|
onClick={togglePlay}
|
|
onDoubleClick={toggleFullscreen}
|
|
onPlay={() => { setPlaying(true); revealControls(); }}
|
|
onPause={() => { setPlaying(false); setControlsVisible(true); }}
|
|
onWaiting={() => setReady(false)}
|
|
onPlaying={() => setReady(true)}
|
|
onEnded={onEnded}
|
|
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
|
|
onTimeUpdate={(event) => {
|
|
const video = event.currentTarget;
|
|
if (video.currentTime > 0) playedRef.current = true;
|
|
setCurrentTime(video.currentTime);
|
|
setDuration(video.duration || 0);
|
|
if (video.buffered.length) setBuffered(video.buffered.end(video.buffered.length - 1));
|
|
onProgressRef.current?.(video.currentTime, video.duration, false);
|
|
}}
|
|
/>
|
|
|
|
{!ready && !error && <span className="vp-spinner" aria-hidden="true" />}
|
|
|
|
{soundBlocked && !error && (
|
|
<button className="vp-sound-hint" type="button" onClick={enableSound}>
|
|
<VolumeX size={15} />
|
|
<span>{soundHint}</span>
|
|
</button>
|
|
)}
|
|
|
|
{error && (
|
|
<button
|
|
className="vp-error"
|
|
type="button"
|
|
onClick={() => { setError(''); setReloadKey((key) => key + 1); }}
|
|
>
|
|
{error} · 点击重试
|
|
</button>
|
|
)}
|
|
|
|
{ready && !playing && !error && (
|
|
<button className="vp-big-play" type="button" onClick={togglePlay} aria-label="Play">
|
|
<Play size={26} fill="currentColor" />
|
|
</button>
|
|
)}
|
|
|
|
<div className="vp-controls" onPointerDown={(event) => event.stopPropagation()}>
|
|
<div
|
|
className="vp-progress"
|
|
role="slider"
|
|
tabIndex={0}
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
aria-valuenow={Math.round(percent)}
|
|
aria-label="Progress"
|
|
onPointerDown={(event) => {
|
|
draggingRef.current = true;
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
seekFromPointer(event);
|
|
}}
|
|
onPointerMove={(event) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
if (rect.width) setHoverRatio(Math.min(Math.max((event.clientX - rect.left) / rect.width, 0), 1));
|
|
if (draggingRef.current) seekFromPointer(event);
|
|
}}
|
|
onPointerLeave={() => setHoverRatio(null)}
|
|
onPointerUp={() => { draggingRef.current = false; }}
|
|
onPointerCancel={() => { draggingRef.current = false; }}
|
|
>
|
|
<span className="vp-progress-track">
|
|
<i className="vp-buffered" style={{ width: `${bufferedPercent}%` }} />
|
|
<i className="vp-played" style={{ width: `${percent}%` }} />
|
|
</span>
|
|
<span className="vp-thumb" style={{ left: `${percent}%` }} />
|
|
{hoverRatio !== null && duration > 0 && (
|
|
<span className="vp-progress-tip" style={{ left: `${hoverRatio * 100}%` }}>
|
|
{formatTime(hoverRatio * duration)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="vp-row">
|
|
<button className="vp-btn" type="button" onClick={togglePlay} aria-label={playing ? 'Pause' : 'Play'}>
|
|
{playing ? <Pause size={18} fill="currentColor" /> : <Play size={18} fill="currentColor" />}
|
|
</button>
|
|
<span className="vp-time">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
|
<span className="vp-spacer" />
|
|
|
|
<div className="vp-volume" onMouseEnter={() => setVolumeOpen(true)} onMouseLeave={() => setVolumeOpen(false)}>
|
|
<button className="vp-btn" type="button" onClick={toggleMute} aria-label={isMuted ? 'Unmute' : 'Mute'}>
|
|
{isMuted ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
|
</button>
|
|
<span className={`vp-volume-slider ${volumeOpen ? 'open' : ''}`}>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={isMuted ? 0 : volume}
|
|
onChange={changeVolume}
|
|
aria-label="Volume"
|
|
/>
|
|
</span>
|
|
</div>
|
|
|
|
<button className="vp-btn" type="button" onClick={toggleFullscreen} aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
|
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|