diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 02e7a36..e7beea8 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -2,7 +2,13 @@ import { Play } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + type ReactElement, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; import { ImageCarousel } from "./components/ImageCarousel"; @@ -52,7 +58,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript, -}: AwemeDetailClientProps) { +}: AwemeDetailClientProps): ReactElement { const router = useRouter(); const isVideo = data.type === "video"; @@ -64,24 +70,44 @@ export default function AwemeDetailClient({ isVideo ? (data as VideoData).content_tags : [], ); + // 同步跨作品状态重置 + useEffect(() => { + setContentTags(isVideo ? (data as VideoData).content_tags : []); + setTranscriptOpen(false); + playerState.setProgress(0); + playerState.setProgressRestored(false); + playerState.setRotation(0); + }, [ + data, + isVideo, + playerState.setProgress, + playerState.setProgressRestored, + playerState.setRotation, + ]); + // 引用 const mediaContainerRef = useRef(null); const videoRef = useRef(null); const audioRef = useRef(null); const scrollerRef = useRef(null); const backgroundCanvasRef = useRef(null); + const progressRef = useRef(playerState.progress); + progressRef.current = playerState.progress; - const pauseImageMedia = useCallback((reset = false) => { + const pauseImageMedia = useCallback((reset = false): void => { const audioEl = audioRef.current; if (audioEl) { audioEl.pause(); if (reset) audioEl.currentTime = 0; } - scrollerRef.current?.querySelectorAll("video").forEach((videoEl) => { - videoEl.pause(); - if (reset) videoEl.currentTime = 0; - }); + const videos = scrollerRef.current?.querySelectorAll("video"); + if (videos) { + for (const videoEl of videos) { + videoEl.pause(); + if (reset) videoEl.currentTime = 0; + } + } }, []); // 图文轮播状态 @@ -115,7 +141,7 @@ export default function AwemeDetailClient({ // 媒体操作函数 const seekTo = useCallback( - (ratio: number) => { + (ratio: number): void => { const clampedRatio = Math.min(1, Math.max(0, ratio)); if (isVideo) { const v = videoRef.current; @@ -129,70 +155,85 @@ export default function AwemeDetailClient({ [imageCarouselState.seekTo, imageCount, isVideo], ); - const seekImageByVisualRatio = (ratio: number) => { - if (!imageCount) return; + const seekImageByVisualRatio = useCallback( + (ratio: number): void => { + if (!imageCount) return; - const clampedRatio = Math.min(1, Math.max(0, ratio)); - const rawSegment = clampedRatio * imageCount; - const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment)); - const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex)); - imageCarouselState.goToIndex(targetIndex, segmentProgress); - }; + const clampedRatio = Math.min(1, Math.max(0, ratio)); + const rawSegment = clampedRatio * imageCount; + const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment)); + const segmentProgress = Math.min( + 1, + Math.max(0, rawSegment - targetIndex), + ); + imageCarouselState.goToIndex(targetIndex, segmentProgress); + }, + [imageCarouselState.goToIndex, imageCount], + ); - const handleControlSeek = (ratio: number) => { - if (isVideo) { - seekTo(ratio); - return; - } - seekImageByVisualRatio(ratio); - }; + const handleControlSeek = useCallback( + (ratio: number): void => { + if (isVideo) { + seekTo(ratio); + return; + } + seekImageByVisualRatio(ratio); + }, + [isVideo, seekImageByVisualRatio, seekTo], + ); - const togglePlay = async () => { + const togglePlay = useCallback((): void => { if (isVideo) { const v = videoRef.current; if (!v) return; - if (v.paused) await v.play().catch(() => {}); - else v.pause(); + if (v.paused) { + void v.play().catch(() => {}); + } else { + v.pause(); + } return; } const el = audioRef.current; if (!playerState.isPlaying) { playerState.setIsPlaying(true); - try { - await el?.play().catch(() => {}); - } catch {} + void el?.play().catch(() => {}); } else { playerState.setIsPlaying(false); pauseImageMedia(); } - }; + }, [ + isVideo, + pauseImageMedia, + playerState.isPlaying, + playerState.setIsPlaying, + ]); - const toggleFullscreen = () => { + const toggleFullscreen = useCallback((): void => { if (!document.fullscreenElement) { if (document.body.requestFullscreen) { - document.body.requestFullscreen().catch(() => {}); + void document.body.requestFullscreen().catch(() => {}); return; } const vRef = videoRef.current as WebkitVideoElement | null; if (vRef?.requestFullscreen) { - vRef.requestFullscreen().catch(() => {}); + void vRef.requestFullscreen().catch(() => {}); return; } if (vRef?.webkitEnterFullscreen) { vRef.webkitEnterFullscreen(); } } else { - document.exitFullscreen().catch(() => {}); + void document.exitFullscreen().catch(() => {}); } - }; + }, []); - const prevImg = useCallback(() => { + const prevImg = useCallback((): void => { if (!imageCount) return; const next = Math.max(0, imageCarouselState.idxRef.current - 1); imageCarouselState.goToIndex(next); }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); - const nextImg = useCallback(() => { + const nextImg = useCallback((): void => { if (!imageCount) return; const next = Math.min( imageCount - 1, @@ -201,7 +242,7 @@ export default function AwemeDetailClient({ imageCarouselState.goToIndex(next); }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); - const handleDownload = () => { + const handleDownload = useCallback((): void => { if (isVideo) { const videoUrl = (data as VideoData).video_url; const link = document.createElement("a"); @@ -212,15 +253,44 @@ export default function AwemeDetailClient({ document.body.removeChild(link); } else { if (!images?.length) return; - const currentImage = images[imageCarouselState.idx]; + const currentImage = images[imageCarouselState.idxRef.current]; + if (!currentImage) return; const link = document.createElement("a"); link.href = currentImage.url; - link.download = `image_${data.aweme_id}_${imageCarouselState.idx + 1}.jpg`; + link.download = `image_${data.aweme_id}_${imageCarouselState.idxRef.current + 1}.jpg`; document.body.appendChild(link); link.click(); document.body.removeChild(link); } - }; + }, [data, imageCarouselState.idxRef, images, isVideo]); + + const handleShowTranscript = useCallback((): void => { + setTranscriptOpen(true); + }, []); + + const handleCloseTranscript = useCallback((): void => { + setTranscriptOpen(false); + }, []); + + const handleCloseComments = useCallback((): void => { + commentState.setOpen(false); + }, [commentState.setOpen]); + + const handleToggleComments = useCallback((): void => { + commentState.setOpen((v) => !v); + }, [commentState.setOpen]); + + const handleNavigatePrev = useCallback((): void => { + if (neighbors.prev) { + router.push(`/aweme/${neighbors.prev.aweme_id}`); + } + }, [neighbors.prev, router]); + + const handleNavigateNext = useCallback((): void => { + if (neighbors.next) { + router.push(`/aweme/${neighbors.next.aweme_id}`); + } + }, [neighbors.next, router]); // 导航相关 useNavigation({ @@ -233,23 +303,22 @@ export default function AwemeDetailClient({ togglePlay, }); - // 背景画布 + // 背景画布(仅视频且播放时采样绘制) useBackgroundCanvas({ isVideo, - idx: imageCarouselState.idx, + isPlaying: playerState.isPlaying, videoRef, - scrollerRef, backgroundCanvasRef, }); useEffect(() => { if (isVideo) return; - const pauseForPageLifecycle = () => { + const pauseForPageLifecycle = (): void => { playerState.setIsPlaying(false); pauseImageMedia(); }; - const handleVisibilityChange = () => { + const handleVisibilityChange = (): void => { if (document.hidden) pauseForPageLifecycle(); }; @@ -263,27 +332,22 @@ export default function AwemeDetailClient({ }; }, [isVideo, pauseImageMedia, playerState.setIsPlaying]); - // Media Session API 集成 + // Media Session API:元信息仅在作品切换时设置一次,避免高频触发 iOS IPC 卡顿 useEffect(() => { if (typeof window === "undefined") return; if (!("mediaSession" in navigator)) return; - const ms = navigator.mediaSession as MediaSessionWithPositionState; - - // 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像) + const ms = navigator.mediaSession; const title = data.desc || `作品 ${data.aweme_id}`; const artist = data.author.nickname || "作者"; const album = new Date(data.created_at).toLocaleString(); - // 单一封面图:使用作品 cover_url - const coverUrl = (data as AwemeData).cover_url as string | undefined; - const coverSize = (data as AwemeData).cover_size as - | { w: number; h: number } - | undefined; + const coverUrl = data.cover_url; + const coverSize = data.cover_size; const artwork = coverUrl ? [ { src: coverUrl, - size: `${coverSize?.w || 512}x${coverSize?.h || 512}`, + sizes: `${coverSize?.w || 512}x${coverSize?.h || 512}`, }, ] : []; @@ -291,15 +355,20 @@ export default function AwemeDetailClient({ try { ms.metadata = new MediaMetadata({ title, artist, album, artwork }); } catch {} + }, [data]); + + // Media Session API:播放状态与低频位置同步(不依赖 progress state,消除 30fps 重绑开销) + useEffect(() => { + if (typeof window === "undefined") return; + if (!("mediaSession" in navigator)) return; + + const ms = navigator.mediaSession as MediaSessionWithPositionState; - // 更新播放状态 try { ms.playbackState = playerState.isPlaying ? "playing" : "paused"; } catch {} - const getImagesTotalMs = () => imageCarouselState.totalDurationMs; - - const updatePosition = () => { + const updatePosition = (): void => { try { if (!ms.setPositionState) return; if (isVideo) { @@ -313,73 +382,67 @@ export default function AwemeDetailClient({ playbackRate: playerState.rate ?? 1, }); } else if (images?.length) { - const totalMs = getImagesTotalMs(); + const totalMs = imageCarouselState.totalDurationMs; const duration = totalMs / 1000; const position = Math.max( 0, - Math.min(duration, (playerState.progress * totalMs) / 1000), + Math.min(duration, (progressRef.current * totalMs) / 1000), ); ms.setPositionState({ duration, position, playbackRate: 1 }); } } catch {} }; - // 绑定视频事件以同步状态 const v = videoRef.current; if (isVideo && v) { - const onPlay = () => { + const onPlay = (): void => { try { ms.playbackState = "playing"; } catch {} updatePosition(); }; - const onPause = () => { + const onPause = (): void => { try { ms.playbackState = "paused"; } catch {} updatePosition(); }; - const onTimeUpdate = () => updatePosition(); + const onSeeked = (): void => updatePosition(); v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); - v.addEventListener("timeupdate", onTimeUpdate); - // 初始同步 + v.addEventListener("seeked", onSeeked); + const id = window.setInterval(updatePosition, 1000); updatePosition(); - // 清理 return () => { + window.clearInterval(id); v.removeEventListener("play", onPlay); v.removeEventListener("pause", onPause); - v.removeEventListener("timeupdate", onTimeUpdate); + v.removeEventListener("seeked", onSeeked); }; - } else if (!isVideo && images?.length) { - // 图文用定时器定期同步位置 + } + + if (!isVideo && images?.length) { const id = window.setInterval(updatePosition, 1000); - // 初始同步 updatePosition(); return () => window.clearInterval(id); } - // 依赖:当这些变化时刷新元信息与位置状态 }, [ - data, imageCarouselState.totalDurationMs, - images, + images?.length, isVideo, playerState.isPlaying, - playerState.progress, playerState.rate, ]); - // 绑定系统媒体控件的操作处理程序 + // 绑定系统媒体控件的操作处理程序(不依赖 progress state,仅在作品切换时注册一次) useEffect(() => { if (typeof window === "undefined") return; if (!("mediaSession" in navigator)) return; const ms = navigator.mediaSession; - const getImagesTotalMs = () => imageCarouselState.totalDurationMs; - - const handlePlay = async () => { + const handlePlay = async (): Promise => { if (isVideo) { const v = videoRef.current; try { @@ -392,7 +455,7 @@ export default function AwemeDetailClient({ } catch {} } }; - const handlePause = () => { + const handlePause = (): void => { if (isVideo) { const v = videoRef.current; v?.pause(); @@ -401,7 +464,7 @@ export default function AwemeDetailClient({ pauseImageMedia(); } }; - const handleStop = () => { + const handleStop = (): void => { if (isVideo) { const v = videoRef.current; if (v) { @@ -414,30 +477,30 @@ export default function AwemeDetailClient({ seekTo(0); } }; - const handleSeekDelta = (deltaSec: number) => { + const handleSeekDelta = (deltaSec: number): void => { if (isVideo) { const v = videoRef.current; - if (v) + if (v) { v.currentTime = Math.max( 0, Math.min(v.duration || Infinity, v.currentTime + deltaSec), ); + } } else if (images?.length) { - const totalMs = getImagesTotalMs(); + const totalMs = imageCarouselState.totalDurationMs; if (totalMs <= 0) return; const deltaRatio = (deltaSec * 1000) / totalMs; - seekTo(playerState.progress + deltaRatio); + seekTo(progressRef.current + deltaRatio); } }; - const handleSeekTo = (seekTimeSec: number) => { + const handleSeekTo = (seekTimeSec: number): void => { if (isVideo) { const v = videoRef.current; if (v && Number.isFinite(v.duration)) { v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec)); } } else if (images?.length) { - const totalMs = getImagesTotalMs(); - const totalSec = totalMs / 1000; + const totalSec = imageCarouselState.totalDurationMs / 1000; if (totalSec > 0) seekTo(seekTimeSec / totalSec); } }; @@ -446,10 +509,10 @@ export default function AwemeDetailClient({ ms.setActionHandler("pause", handlePause); ms.setActionHandler("stop", handleStop); ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) => - handleSeekDelta(-((details?.seekOffset as number) || 10)), + handleSeekDelta(-(details?.seekOffset ?? 10)), ); ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) => - handleSeekDelta((details?.seekOffset as number) || 10), + handleSeekDelta(details?.seekOffset ?? 10), ); ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => { if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime); @@ -483,11 +546,10 @@ export default function AwemeDetailClient({ }; }, [ isVideo, - images, + images?.length, neighbors.prev, neighbors.next, router, - playerState.progress, playerState.setIsPlaying, pauseImageMedia, imageCarouselState.totalDurationMs, @@ -498,7 +560,7 @@ export default function AwemeDetailClient({ return (
- + {isVideo ? : null}
{/* 主媒体区域 */} @@ -510,6 +572,7 @@ export default function AwemeDetailClient({
{isVideo ? ( ) : ( )}
@@ -535,7 +607,7 @@ export default function AwemeDetailClient({ {/* 暂停图标 */} {!playerState.isPlaying && (
-
+
@@ -579,8 +651,8 @@ export default function AwemeDetailClient({ segmentProgress={imageCarouselState.segProgress} musicUrl={isVideo ? undefined : (data as ImageData).music_url} audioRef={audioRef} - hasTranscript={isVideo && !!transcript} - onShowTranscript={() => setTranscriptOpen(true)} + hasTranscript={isVideo && Boolean(transcript)} + onShowTranscript={handleShowTranscript} onTogglePlay={togglePlay} onSeek={handleControlSeek} onVolumeChange={playerState.setVolume} @@ -598,20 +670,16 @@ export default function AwemeDetailClient({ neighbors={neighbors} commentsCount={data.commentsCount} likesCount={data.likesCount} - onNavigatePrev={() => - neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`) - } - onNavigateNext={() => - neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`) - } - onToggleComments={() => commentState.setOpen((v) => !v)} + onNavigatePrev={handleNavigatePrev} + onNavigateNext={handleNavigateNext} + onToggleComments={handleToggleComments} /> {/* 评论面板 */} commentState.setOpen(false)} + onClose={handleCloseComments} author={data.author} createdAt={data.created_at} awemeId={data.aweme_id} @@ -621,7 +689,7 @@ export default function AwemeDetailClient({ {/* 转录面板 */} setTranscriptOpen(false)} + onClose={handleCloseTranscript} transcript={transcript} />
diff --git a/app/aweme/[awemeId]/components/BackgroundCanvas.tsx b/app/aweme/[awemeId]/components/BackgroundCanvas.tsx index 8fcc2de..2d0f393 100644 --- a/app/aweme/[awemeId]/components/BackgroundCanvas.tsx +++ b/app/aweme/[awemeId]/components/BackgroundCanvas.tsx @@ -1,18 +1,15 @@ -import { forwardRef } from "react"; +import { forwardRef, memo } from "react"; -interface BackgroundCanvasProps {} - -export const BackgroundCanvas = forwardRef< - HTMLCanvasElement, - BackgroundCanvasProps ->((props, ref) => { - return ( - - ); -}); +export const BackgroundCanvas = memo( + forwardRef((_props, ref) => { + return ( + + ); + }), +); BackgroundCanvas.displayName = "BackgroundCanvas"; diff --git a/app/aweme/[awemeId]/components/CommentList.tsx b/app/aweme/[awemeId]/components/CommentList.tsx index 80ebc63..fa5c365 100644 --- a/app/aweme/[awemeId]/components/CommentList.tsx +++ b/app/aweme/[awemeId]/components/CommentList.tsx @@ -1,7 +1,7 @@ import { ThumbsUp, X } from "lucide-react"; -import { useState } from "react"; +import { memo, useState } from "react"; import type { Comment, User } from "../types.ts"; -import { formatRelativeTime, formatAbsoluteUTC } from "../utils"; +import { formatAbsoluteUTC, formatRelativeTime } from "../utils"; import { CommentText } from "./CommentText"; interface CommentListProps { @@ -10,7 +10,11 @@ interface CommentListProps { comments: Comment[]; } -export function CommentList({ author, createdAt, comments }: CommentListProps) { +export const CommentList = memo(function CommentList({ + author, + createdAt, + comments, +}: CommentListProps) { const [previewImage, setPreviewImage] = useState(null); return ( @@ -21,6 +25,8 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) { avatar ) : null} @@ -46,6 +52,8 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) { avatar ) : null} @@ -66,13 +74,12 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
{c.images.map((img, idx) => ( ))} @@ -100,10 +108,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) { {/* 图片预览灯箱 */} {previewImage && (
setPreviewImage(null)} > - {/* 关闭按钮 */} - {/* 大图 */} preview ); -} +}); diff --git a/app/aweme/[awemeId]/components/CommentPanel.tsx b/app/aweme/[awemeId]/components/CommentPanel.tsx index 2e5ff7c..63f8a4c 100644 --- a/app/aweme/[awemeId]/components/CommentPanel.tsx +++ b/app/aweme/[awemeId]/components/CommentPanel.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import type { Comment, User } from "../types.ts"; import { CommentList } from "./CommentList"; @@ -12,7 +12,16 @@ interface CommentPanelProps { mounted: boolean; } -export function CommentPanel({ +interface CommentsApiResponse { + comments?: Comment[]; + total?: number; + hasMore?: boolean; + mode?: string; + seed?: string | number; + snapshot?: string | number; +} + +export const CommentPanel = memo(function CommentPanel({ open, onClose, author, @@ -24,264 +33,198 @@ export function CommentPanel({ const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [hasMore, setHasMore] = useState(true); - // ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定) - const [rankParams, setRankParams] = useState(null); - // 两套滚动容器与哨兵,分别对应横屏与竖屏面板 - const scrollRefLandscape = useRef(null); - const sentinelRefLandscape = useRef(null); - const scrollRefPortrait = useRef(null); - const sentinelRefPortrait = useRef(null); + const [hasOpened, setHasOpened] = useState(open); + + const scrollRef = useRef(null); + const sentinelRef = useRef(null); const loadingRef = useRef(false); + const abortControllerRef = useRef(null); + const commentsCountRef = useRef(0); + const hasMoreRef = useRef(true); + const rankParamsRef = useRef<{ seed: string; snapshot: string } | null>(null); + + useEffect(() => { + if (open) setHasOpened(true); + }, [open]); - // 加载评论 const loadComments = useCallback( - async (reset = false) => { - if (loadingRef.current || (!reset && !hasMore)) return; + async (reset = false): Promise => { + if (loadingRef.current && !reset) return; + if (!reset && !hasMoreRef.current) return; + + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; loadingRef.current = true; setLoading(true); try { - const skip = reset ? 0 : comments.length; + const skip = reset ? 0 : commentsCountRef.current; const query = new URLSearchParams({ skip: String(skip), take: String(20), mode: "ranked", }); - if (rankParams) { - query.set("seed", rankParams.seed); - query.set("snapshot", rankParams.snapshot); + const currentRank = reset ? null : rankParamsRef.current; + if (currentRank) { + query.set("seed", currentRank.seed); + query.set("snapshot", currentRank.snapshot); } + const response = await fetch( `/api/comments/${awemeId}?${query.toString()}`, + { signal: controller.signal }, ); - const data = await response.json(); + const data = (await response.json()) as CommentsApiResponse; + if (controller.signal.aborted) return; - // 统一做一次基于 cid 的去重,避免分页偶发重复 - if (reset) { - setComments(() => { - const seen = new Set(); - return (data.comments as Comment[]).filter((c) => { - if (seen.has(c.cid)) return false; - seen.add(c.cid); - return true; - }); + const incoming = Array.isArray(data.comments) ? data.comments : []; + + setComments((prev) => { + const source = reset ? incoming : [...prev, ...incoming]; + const seen = new Set(); + const deduped = source.filter((c) => { + if (seen.has(c.cid)) return false; + seen.add(c.cid); + return true; }); - } else { - setComments((prev) => { - const merged = [...prev, ...(data.comments as Comment[])]; - const seen = new Set(); - // 保留首次出现的项,既保证顺序也避免重复 - return merged.filter((c) => { - if (seen.has(c.cid)) return false; - seen.add(c.cid); - return true; - }); - }); - } + commentsCountRef.current = deduped.length; + return deduped; + }); + + setTotal(typeof data.total === "number" ? data.total : 0); + const nextHasMore = Boolean(data.hasMore); + hasMoreRef.current = nextHasMore; + setHasMore(nextHasMore); - setTotal(data.total); - setHasMore(data.hasMore); if (data.mode === "ranked" && data.seed && data.snapshot) { - // 初始化或重置时更新稳定参数 - setRankParams((prev) => { - if (reset) - return { - seed: String(data.seed), - snapshot: String(data.snapshot), - }; - return ( - prev ?? { - seed: String(data.seed), - snapshot: String(data.snapshot), - } - ); - }); + if (reset || !rankParamsRef.current) { + rankParamsRef.current = { + seed: String(data.seed), + snapshot: String(data.snapshot), + }; + } } else if (reset) { - setRankParams(null); + rankParamsRef.current = null; } } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } console.error("加载评论失败:", error); } finally { - setLoading(false); - loadingRef.current = false; + if (abortControllerRef.current === controller) { + setLoading(false); + loadingRef.current = false; + } } }, - [awemeId, comments.length, hasMore], + [awemeId], ); - // 面板打开时加载初始评论 + // 切换作品时清理旧评论状态与未完成请求 useEffect(() => { - if (open && comments.length === 0) { - loadComments(true); + abortControllerRef.current?.abort(); + loadingRef.current = false; + commentsCountRef.current = 0; + hasMoreRef.current = true; + rankParamsRef.current = null; + setComments([]); + setTotal(0); + setHasMore(true); + setLoading(false); + + if (open) { + void loadComments(true); } - }, [open]); - - // Intersection Observer 监听底部触发加载(针对横屏与竖屏两个容器分别监听) - useEffect(() => { - if (!open) return; - - const observers: IntersectionObserver[] = []; - - const setup = ( - rootEl: HTMLDivElement | null, - targetEl: HTMLDivElement | null, - ) => { - if (!rootEl || !targetEl) return; - const io = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && !loadingRef.current && hasMore) { - loadComments(); - } - }, - { - root: rootEl, - rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发 - threshold: 0, - }, - ); - io.observe(targetEl); - observers.push(io); - }; - - setup(scrollRefLandscape.current, sentinelRefLandscape.current); - setup(scrollRefPortrait.current, sentinelRefPortrait.current); return () => { - for (const io of observers) io.disconnect(); + abortControllerRef.current?.abort(); }; + }, [awemeId, open, loadComments]); + + // Intersection Observer 监听底部触发分页加载(单容器) + useEffect(() => { + if (!open) return; + const rootEl = scrollRef.current; + const targetEl = sentinelRef.current; + if (!rootEl || !targetEl) return; + + const io = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if ( + entry?.isIntersecting && + !loadingRef.current && + hasMoreRef.current + ) { + void loadComments(false); + } + }, + { + root: rootEl, + rootMargin: "0px 0px 200px 0px", + threshold: 0, + }, + ); + + io.observe(targetEl); + return () => io.disconnect(); }, [open, hasMore, loadComments]); + return ( - <> - - - {/* 横屏评论面板:并排分栏 */} - + {hasMore &&
} - {/* 竖屏评论面板:bottom sheet */} - - + {loading && ( +
+ 加载中... +
+ )} + {!hasMore && comments.length > 0 && ( +
+ 没有更多评论了 +
+ )} + + )} +
+ ); -} +}); diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx index de54fc4..82f7b03 100644 --- a/app/aweme/[awemeId]/components/ImageCarousel.tsx +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -1,233 +1,482 @@ import { forwardRef, + memo, + type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; -import type { ImageData, LoopMode } from "../types.ts"; +import type { ImageData, LoopMode, ObjectFit } from "../types.ts"; +import { releaseMediaElement } from "../utils"; interface ImageCarouselProps { images: ImageData["images"]; currentIndex: number; isPlaying: boolean; - segmentProgress: number; + segmentProgressRef: RefObject; mediaSyncToken: number; loopMode: LoopMode; + hasNextNeighbor: boolean; + rotation: number; + objectFit: ObjectFit; + failedAnimatedMap: Record; onTogglePlay: () => void; - onAnimatedDuration?: (imageId: string, durationMs: number) => void; + onAnimatedDuration: (imageId: string, durationMs: number) => void; + onMediaReady: (imageId: string) => void; + onAnimatedFailed: (imageId: string) => void; + onAnimatedProgress: (imageId: string, progress: number) => void; + onAnimatedEnded: (imageId: string) => void; } -export const ImageCarousel = forwardRef( - ( - { - images, - currentIndex, - isPlaying, - segmentProgress, - mediaSyncToken, - loopMode, - onTogglePlay, - onAnimatedDuration, - }, - ref, - ) => { - const videoRefs = useRef>(new Map()); - const activeVideoIdRef = useRef(null); - const segmentProgressRef = useRef(segmentProgress); - const [useLightweightMode, setUseLightweightMode] = useState(false); - - useEffect(() => { - segmentProgressRef.current = segmentProgress; - }, [segmentProgress]); - - const syncVideoToSegmentProgress = useCallback( - (videoEl: HTMLVideoElement, progress: number, force = false) => { - const duration = videoEl.duration; - if (!Number.isFinite(duration) || duration <= 0) return; - - const clampedProgress = Math.max(0, Math.min(1, progress)); - const maxTime = Math.max(0, duration - 0.05); - const targetTime = Math.min(maxTime, duration * clampedProgress); - - if (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) { - try { - videoEl.currentTime = targetTime; - } catch {} - } +export const ImageCarousel = memo( + forwardRef( + ( + { + images, + currentIndex, + isPlaying, + segmentProgressRef, + mediaSyncToken, + loopMode, + hasNextNeighbor, + rotation, + objectFit, + failedAnimatedMap, + onTogglePlay, + onAnimatedDuration, + onMediaReady, + onAnimatedFailed, + onAnimatedProgress, + onAnimatedEnded, }, - [], - ); + ref, + ) => { + const videoRefs = useRef>(new Map()); + const activeVideoIdRef = useRef(null); + const lastMediaSyncTokenRef = useRef(mediaSyncToken); + const pendingSeekRef = useRef<{ + imageId: string; + progress: number; + } | null>(null); + const [readyVideoId, setReadyVideoId] = useState(null); - useEffect(() => { - const userAgent = navigator.userAgent; - const isIOS = - /iPad|iPhone|iPod/.test(userAgent) || - (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); - const isSafari = - /Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent); - setUseLightweightMode(isIOS && isSafari); - }, []); + const shouldLoopCurrentVideo = + loopMode === "single" || + (images.length === 1 && + (loopMode !== "sequential" || !hasNextNeighbor)); - // 虚拟滚动:只渲染当前图片和前后各一张 - const visibleIndices = useMemo(() => { - if (useLightweightMode) return [currentIndex]; + const seekVideoToProgress = useCallback( + (videoEl: HTMLVideoElement, progress: number): boolean => { + const duration = videoEl.duration; + if (!Number.isFinite(duration) || duration <= 0) return false; - const indices: number[] = []; - if (currentIndex > 0) indices.push(currentIndex - 1); - indices.push(currentIndex); - if (currentIndex < images.length - 1) indices.push(currentIndex + 1); - return indices; - }, [currentIndex, images.length, useLightweightMode]); + const clampedProgress = Math.max(0, Math.min(1, progress)); + const maxTime = Math.max(0, duration - 0.05); + const targetTime = Math.min(maxTime, duration * clampedProgress); - // iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。 - useEffect(() => { - const currentImage = images[currentIndex]; - if (!currentImage?.animated) { - activeVideoIdRef.current = null; - videoRefs.current.forEach((videoEl) => videoEl.pause()); - return; - } + if (Math.abs(videoEl.currentTime - targetTime) > 0.08) { + try { + videoEl.currentTime = targetTime; + } catch { + return false; + } + } + return true; + }, + [], + ); - const videoEl = videoRefs.current.get(currentImage.id); + const tryPlayVideo = useCallback( + (videoEl: HTMLVideoElement, imageId: string): void => { + videoEl.play().catch((err: unknown) => { + const errorName = err instanceof DOMException ? err.name : ""; + if ( + errorName === "NotAllowedError" || + errorName === "NotSupportedError" + ) { + onAnimatedFailed(imageId); + } + }); + }, + [onAnimatedFailed], + ); - videoRefs.current.forEach((itemVideoEl, imageId) => { - if (imageId !== currentImage.id || !isPlaying) { - itemVideoEl.pause(); + // 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个