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,216 @@
|
||||
// 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} />;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Author: fruit.com.cn
|
||||
import { Lock } from 'lucide-react';
|
||||
import { useLocale } from '../../../app/providers/AppProviders';
|
||||
|
||||
export function episodeLabel(episode, index, code) {
|
||||
const number = episode?.number ?? index + 1;
|
||||
return code === 'zh' ? `第${number}集` : `Episode ${number}`;
|
||||
}
|
||||
|
||||
export function EpisodeRail({ episodes, currentId, image, onSelect }) {
|
||||
const { locale } = useLocale();
|
||||
if (!episodes.length) return null;
|
||||
const currentIndex = Math.max(0, episodes.findIndex((episode) => episode.id === currentId));
|
||||
|
||||
return (
|
||||
<aside className="watch-side">
|
||||
<h2>
|
||||
{locale.episodes}
|
||||
<span className="watch-episode-count">{currentIndex + 1} / {episodes.length}</span>
|
||||
</h2>
|
||||
<div className="watch-episode-list">
|
||||
{episodes.map((episode, index) => (
|
||||
<button
|
||||
className={`watch-episode-item ${episode.id === currentId ? 'active' : ''}`}
|
||||
key={episode.id}
|
||||
onClick={() => onSelect(episode)}
|
||||
type="button"
|
||||
>
|
||||
<span className="watch-episode-thumb">
|
||||
<img src={image} alt="" loading="lazy" />
|
||||
<b>{episode.number ?? index + 1}</b>
|
||||
{episode.locked && <span className="watch-episode-lock"><Lock size={16} /></span>}
|
||||
</span>
|
||||
<span className="watch-episode-copy">
|
||||
<b>{episodeLabel(episode, index, locale.code)}</b>
|
||||
<small>{episode.duration || ''}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user