// 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
{video.description}