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