refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件 - 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分 - 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈 - 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口 - 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题 - 语言按本地偏好与 IP 识别,国旗改为内联 SVG - 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
1 parent
8dd5283892
commit
ecfbd3b8b5
90 files changed
+4535
-886
No files matched your search
@@ -0,0 +1,392 @@
|
||||
// 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, onProgress }) {
|
||||
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 [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);
|
||||
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);
|
||||
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 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)}
|
||||
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" />}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user