refactor: 企业级目录重构与播放/关于页体验升级

- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
This commit is contained in:
Zhou Weidong committed 2026-11-12 12:32:29 +08:00
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>
);
}