// 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
{locale.playbackUnavailable}
; 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 (
{src ? reportWatchProgress(currentEpisode.id, position, duration, 'watch', flush)} /> :
{video.title}{currentEpisode.locked ? locale.lockedEpisode : playback.loading ? locale.loading : playback.error?.message || locale.playbackUnavailable}
} {locale.episodeSwipeHint}
{label} {views}{tag ? ` · ${tag}` : ''}

{video.title}

{video.channel?.[0] || 'Y'}
{video.channel}
{video.description && (

{video.description}

)}
changeEpisode(episode, true)} /> {commentsOpen && setCommentsOpen(false)} />} {toast.message &&
{toast.message}
}
); } 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 ; if (!drama.data) return
{drama.error?.message || locale.empty}
; return ; }