Files
ymb-site/src/features/watch/WatchPage.jsx
T
Zhou Weidong ecfbd3b8b5 refactor: 企业级目录重构与播放/关于页体验升级
- 按 app / shared / entities / features 分层,路由懒加载并抽出布局组件
- 请求层改为可插拔拦截器,统一错误类型、超时取消与端点拆分
- 抽取播放器、骨架屏、网格等复用组件,点赞收藏接入统一交互反馈
- 关于页重做为无边框极光风格,补充 iOS / Google Play 下载入口
- 修复播放页滚轮切集吞事件、页面误滚动、后台持续播放与声音记忆问题
- 语言按本地偏好与 IP 识别,国旗改为内联 SVG
- 清理未使用的旧站点文件,补充 ESLint 配置
2026-11-12 12:32:29 +08:00

217 lines
10 KiB
React

// Author: fruit.com.cn
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Bookmark, MessageCircle, Share2, ThumbsUp } from 'lucide-react';
import { useLocale } from '../../app/providers/AppProviders';
import { CommentsSheet } from '../../shared/components/CommentsSheet';
import { VideoPlayer } from '../../shared/components/player/VideoPlayer';
import { WatchPageSkeleton } from '../../shared/components/skeletons';
import { useEngagement } from '../../shared/hooks/useEngagement';
import { useFollowedChannels } from '../../shared/hooks/useFollowedChannels';
import { useRequest } from '../../shared/hooks/useRequest';
import { useToast } from '../../shared/hooks/useToast';
import { formatCount, formatViews } from '../../shared/lib/format';
import { selectPlaybackUrl } from '../../shared/lib/media';
import { reportWatchProgress } from '../../shared/lib/watchProgress';
import { normalizeDrama } from '../../entities/drama/model';
import { dramaApi } from '../../entities/drama/api';
import { EpisodeRail, episodeLabel } from './components/EpisodeRail';
function collectEpisodes(video) {
if (!video.episodes?.length) return video.preview ? [video.preview] : [];
const hasPreview = video.preview && !video.episodes.some((item) => item.id === video.preview.id);
return hasPreview ? [video.preview, ...video.episodes] : video.episodes;
}
function WatchContent({ video }) {
const { locale } = useLocale();
const episodes = collectEpisodes(video);
const [episodeId, setEpisodeId] = useState(video.preview?.id || episodes[0]?.id);
const episodeIndex = Math.max(0, episodes.findIndex((item) => item.id === episodeId));
const currentEpisode = episodes[episodeIndex] || episodes[0] || null;
const nextEpisode = episodes[episodeIndex + 1];
const previousEpisode = episodes[episodeIndex - 1];
const toast = useToast();
const playback = useRequest(
({ signal }) => dramaApi.playback(currentEpisode.id, { signal }),
[currentEpisode?.id],
{ enabled: Boolean(currentEpisode?.id) && !currentEpisode?.locked },
);
const stats = useRequest(
({ signal }) => dramaApi.stats(currentEpisode.id, { signal }),
[currentEpisode?.id],
{ enabled: Boolean(currentEpisode?.id) },
);
const engagement = useEngagement({
likeTargetType: 'EPISODE',
likeTargetId: currentEpisode?.id,
dramaId: video.id,
initialLikeCount: currentEpisode?.likeCount,
initialSaveCount: video.favoriteCount,
});
const { channels, toggle } = useFollowedChannels();
const followed = channels.includes(video.channel);
const [commentsOpen, setCommentsOpen] = useState(false);
const swipeStart = useRef(null);
const playerRef = useRef(null);
const wheelRef = useRef({ accum: 0, locked: false, lastAt: 0, lastAbs: 0 });
useEffect(() => {
engagement.syncStats(stats.data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stats.data]);
const changeEpisode = (episode, scroll = false) => {
if (!episode || episode.id === currentEpisode?.id) return;
setEpisodeId(episode.id);
if (scroll) playerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
const goNext = () => changeEpisode(nextEpisode);
const goPrevious = () => changeEpisode(previousEpisode);
useEffect(() => {
// 捕获阶段接管画面内的滚轮,任何幅度都不交给页面滚动
const onWheel = (event) => {
if (!event.deltaY) return;
// 用坐标判断是否在播放区内:切集后 video 节点会被替换,事件 target 可能失效
const rect = playerRef.current?.getBoundingClientRect();
const inside = rect
? event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom
: Boolean(event.target?.closest?.('.watch-stage'));
if (!inside) return;
event.preventDefault();
const state = wheelRef.current;
const now = performance.now();
// 兼容按行滚动的设备(Firefox 等),统一成像素
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const magnitude = Math.abs(delta);
// 新手势:停顿超过 60ms,或惯性衰减中突然重新加速(新的一次滑动)
if (now - state.lastAt > 60 || magnitude > state.lastAbs * 2.2 + 8) state.locked = false;
state.lastAt = now;
state.lastAbs = magnitude;
if (state.accum && Math.sign(delta) !== Math.sign(state.accum)) state.accum = delta;
else state.accum += delta;
if (state.locked || Math.abs(state.accum) < 24) return;
const forward = state.accum > 0;
if (forward ? !nextEpisode : !previousEpisode) return;
state.locked = true;
state.accum = 0;
if (forward) goNext(); else goPrevious();
};
window.addEventListener('wheel', onWheel, { passive: false, capture: true });
return () => window.removeEventListener('wheel', onWheel, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nextEpisode, previousEpisode]);
if (!currentEpisode) return <div className="api-note warning">{locale.playbackUnavailable}</div>;
const src = currentEpisode.locked ? '' : selectPlaybackUrl(playback.data, currentEpisode.playUrl);
const views = formatViews(video.views, locale.code);
const tag = video.genre ? `#${video.genre.replace(/\s+/g, '')}` : '';
const commentCount = Number(stats.data?.commentCount ?? currentEpisode.commentCount) || 0;
const label = episodeLabel(currentEpisode, episodeIndex, locale.code);
const commentVideo = { ...video, preview: currentEpisode };
const onTouchStart = (event) => {
const touch = event.touches[0];
swipeStart.current = { x: touch.clientX, y: touch.clientY };
};
const onTouchEnd = (event) => {
const start = swipeStart.current;
swipeStart.current = null;
if (!start) return;
const touch = event.changedTouches[0];
const dx = start.x - touch.clientX;
const dy = start.y - touch.clientY;
if (Math.abs(dy) < 50 || Math.abs(dx) > Math.abs(dy)) return;
if (dy > 0) goNext(); else goPrevious();
};
const share = async () => {
const url = `${window.location.origin}/watch/${video.id}`;
if (navigator.share) {
try {
await navigator.share({ title: video.title, url });
return;
} catch (error) {
if (error?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast.show(locale.linkCopied);
} catch {
toast.show(url);
}
};
return (
<div className="watch-page">
<div className="watch-primary">
<div ref={playerRef} className="watch-stage" style={{ '--watch-poster': `url("${video.image}")` }}>
<div className="watch-player" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
{src
? <VideoPlayer key={currentEpisode.id} src={src} poster={video.image} autoPlay onProgress={(position, duration, flush) => reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} />
: <div className="watch-player-placeholder"><img src={video.image} alt={video.title} /><span>{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}</span></div>}
<span className="watch-swipe-tip">{locale.episodeSwipeHint}</span>
<div className="watch-arrows">
<button onClick={goPrevious} disabled={!previousEpisode} aria-label="Previous episode" type="button">↑</button>
<button onClick={goNext} disabled={!nextEpisode} aria-label="Next episode" type="button">↓</button>
</div>
</div>
</div>
<div className="watch-meta">
<span className="watch-badge">{label}</span>
<span>{views}{tag ? ` · ${tag}` : ''}</span>
</div>
<h1 className="watch-title">{video.title}</h1>
<div className="watch-toolbar">
<div className="watch-channel">
<span className="watch-avatar">{video.channel?.[0] || 'Y'}</span>
<div className="watch-channel-copy"><b>{video.channel}</b></div>
<button className={`watch-subscribe ${followed ? 'followed' : ''}`} onClick={() => toggle(video.channel)} type="button">{followed ? locale.subscribed : locale.subscribe}</button>
</div>
<div className="watch-actions">
<button className={engagement.liked ? 'active' : ''} onClick={engagement.toggleLike} type="button"><ThumbsUp size={18} fill={engagement.liked ? 'currentColor' : 'none'} /><span>{formatCount(engagement.likeCount, locale.code)}</span></button>
<button className={engagement.saved ? 'active' : ''} onClick={engagement.toggleSave} type="button"><Bookmark size={18} fill={engagement.saved ? 'currentColor' : 'none'} /><span>{formatCount(engagement.saveCount, locale.code)}</span></button>
<button onClick={() => setCommentsOpen(true)} type="button"><MessageCircle size={18} /><span>{formatCount(commentCount, locale.code)}</span></button>
<button onClick={share} type="button"><Share2 size={18} /><span>{locale.share}</span></button>
</div>
</div>
{video.description && (
<div className="watch-description">
<p>{video.description}</p>
</div>
)}
</div>
<EpisodeRail episodes={episodes} currentId={currentEpisode.id} image={video.image} onSelect={(episode) => changeEpisode(episode, true)} />
{commentsOpen && <CommentsSheet video={commentVideo} locale={locale} onClose={() => setCommentsOpen(false)} />}
{toast.message && <div className="shorts-toast">{toast.message}</div>}
</div>
);
}
export function WatchPage() {
const { id } = useParams();
const { locale } = useLocale();
const drama = useRequest(
({ signal }) => dramaApi.get(id, { signal }).then((data) => normalizeDrama(data, locale.code)),
[id, locale.code],
);
useEffect(() => {
window.scrollTo({ top: 0 });
const timer = window.setTimeout(() => window.scrollTo({ top: 0 }), 0);
return () => window.clearTimeout(timer);
}, [id]);
if (!drama.data && drama.loading) return <WatchPageSkeleton label={locale.loading} />;
if (!drama.data) return <div className="api-note warning">{drama.error?.message || locale.empty}</div>;
return <WatchContent key={drama.data.id} video={drama.data} />;
}