"use client"; import { FastForward, Play } from "lucide-react"; import { useRouter } from "next/navigation"; import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { awemeHref, type PlaylistItem, playbackContextQuery, } from "@/lib/playback-context"; import { AuthorWorksTab } from "./components/AuthorWorksTab"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; import { ContextChip } from "./components/ContextChip"; import { ContextListTab } from "./components/ContextListTab"; import { ImageCarousel } from "./components/ImageCarousel"; import { ImageNavigationButtons } from "./components/ImageNavigationButtons"; import { MediaControls } from "./components/MediaControls"; import { NavigationButtons } from "./components/NavigationButtons"; import { SidePanel } from "./components/SidePanel"; import { TranscriptPanel } from "./components/TranscriptPanel"; import { VideoPlayer } from "./components/VideoPlayer"; import { VideoTagEditor } from "./components/VideoTagEditor"; import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas"; import { useImageCarousel } from "./hooks/useImageCarousel"; import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; import { usePlaylist } from "./hooks/usePlaylist"; import { isPortraitViewport, useSidePanelState, } from "./hooks/useSidePanelState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; import type { AbLoop, AwemeData, ImageData, Neighbors, PlaybackContextInfo, VideoData, VideoTranscript, } from "./types.ts"; const SEGMENT_MS = 4000; // 每个搜索 / hashtag 列表只在第一次进入时自动打开侧栏,之后尊重你自己的开关 const LIST_AUTO_OPENED_KEY = "aweme_player_list_auto_opened"; type WebkitVideoElement = HTMLVideoElement & { webkitEnterFullscreen?: () => void; }; type MediaPositionStateInput = { duration: number; position: number; playbackRate?: number; }; type MediaSessionWithPositionState = MediaSession & { setPositionState?: (state: MediaPositionStateInput) => void; }; interface AwemeDetailClientProps { data: AwemeData; /** 服务端按 URL 上的播放上下文算出的上一条/下一条 */ neighbors: Neighbors; /** URL 上的上下文(搜索/标签/作者)生效时的提示信息 */ playbackContext: PlaybackContextInfo | null; /** URL 上原本的上下文查询串,作者模式下跳转时原样带上 */ contextQuery: string; authorWorksCount: number; transcript: VideoTranscript | null; } export default function AwemeDetailClient({ data, neighbors: serverNeighbors, playbackContext, contextQuery, authorWorksCount, transcript, }: AwemeDetailClientProps): ReactElement { const router = useRouter(); const isVideo = data.type === "video"; // 状态管理 const playerState = usePlayerState(); const panel = useSidePanelState(data.author.sec_uid); // 搜索 / hashtag / 标签上下文才有「列表」标签页(作者上下文和「TA的作品」重复) const listContext = playbackContext && playbackContext.kind !== "author" ? playbackContext : null; const activeTab = panel.tab === "list" && !listContext ? "comments" : panel.tab; const authorQuery = data.author.sec_uid ? playbackContextQuery({ kind: "author", secUid: data.author.sec_uid }) : ""; const authorWorks = usePlaylist( authorQuery, panel.authorMode || (panel.open && activeTab === "works"), ); const contextList = usePlaylist( listContext ? contextQuery : "", panel.open && activeTab === "list", ); // 作者模式(打开「TA的作品」时进入,关掉侧栏不退出):上下切换在该作者的作品里移动; // 点顶部提示的 ✕ 退出后,回到服务端按 URL 上下文算好的顺序,当前视频不变。 const authorPlayback = useMemo(() => { if (!panel.authorMode) return null; const items = authorWorks.items; // 列表还没加载出来时先不让上下切换,避免误按到另一个顺序里 if (!items) { return { neighbors: { prev: null, next: null } as Neighbors, position: 0, total: 0, }; } const index = items.findIndex((item) => item.aweme_id === data.aweme_id); if (index === -1) return null; const link = (item: PlaylistItem | undefined) => item ? { aweme_id: item.aweme_id, href: awemeHref(item.aweme_id, contextQuery), } : null; return { neighbors: { prev: link(items[index - 1]), next: link(items[index + 1]), } as Neighbors, position: index + 1, total: items.length, }; }, [panel.authorMode, authorWorks.items, data.aweme_id, contextQuery]); const neighbors = authorPlayback?.neighbors ?? serverNeighbors; const [transcriptOpen, setTranscriptOpen] = useState(false); const [isFastForwarding, setIsFastForwarding] = useState(false); const [abLoop, setAbLoop] = useState({ a: null, b: null }); const [contentTags, setContentTags] = useState( isVideo ? (data as VideoData).content_tags : [], ); const effectiveVideoRate = isFastForwarding ? 2 : playerState.rate; const effectiveImageRate = isFastForwarding ? 2 : 1; const hasAbLoop = isVideo && (abLoop.a !== null || abLoop.b !== null); // 同步跨作品状态重置 useEffect(() => { setContentTags(isVideo ? (data as VideoData).content_tags : []); setTranscriptOpen(false); setIsFastForwarding(false); setAbLoop({ a: null, b: null }); 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): void => { const audioEl = audioRef.current; if (audioEl) { audioEl.pause(); if (reset) audioEl.currentTime = 0; } const videos = scrollerRef.current?.querySelectorAll("video"); if (videos) { for (const videoEl of videos) { videoEl.pause(); if (reset) videoEl.currentTime = 0; } } }, []); // 图文轮播状态 const images = useMemo( () => (isVideo ? [] : (data as ImageData).images), [data, isVideo], ); const imageCount = images.length; const imageCarouselState = useImageCarousel({ images, isPlaying: playerState.isPlaying, playbackRate: effectiveImageRate, loopMode: playerState.loopMode, neighbors, volume: playerState.volume, audioRef, setProgress: playerState.setProgress, segmentMs: SEGMENT_MS, }); // 视频播放器 hooks useVideoPlayer({ enabled: isVideo, awemeId: data.aweme_id, videoRef, volume: playerState.volume, rate: effectiveVideoRate, loopMode: playerState.loopMode, abLoop, neighbors, progressRestored: playerState.progressRestored, setIsPlaying: playerState.setIsPlaying, setProgress: playerState.setProgress, setProgressRestored: playerState.setProgressRestored, }); // 媒体操作函数 const seekTo = useCallback( (ratio: number): void => { const clampedRatio = Math.min(1, Math.max(0, ratio)); if (isVideo) { const v = videoRef.current; if (!v || !v.duration) return; v.currentTime = v.duration * clampedRatio; return; } if (!imageCount) return; imageCarouselState.seekTo(clampedRatio); }, [imageCarouselState.seekTo, imageCount, isVideo], ); 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); }, [imageCarouselState.goToIndex, imageCount], ); const handleControlSeek = useCallback( (ratio: number): void => { if (isVideo) { seekTo(ratio); return; } seekImageByVisualRatio(ratio); }, [isVideo, seekImageByVisualRatio, seekTo], ); const togglePlay = useCallback((): void => { if (isVideo) { const v = videoRef.current; if (!v) return; if (v.paused) { void v.play().catch(() => {}); } else { v.pause(); } return; } const el = audioRef.current; if (!playerState.isPlaying) { playerState.setIsPlaying(true); void el?.play().catch(() => {}); } else { playerState.setIsPlaying(false); pauseImageMedia(); } }, [ isVideo, pauseImageMedia, playerState.isPlaying, playerState.setIsPlaying, ]); const toggleFullscreen = useCallback((): void => { if (!document.fullscreenElement) { if (document.body.requestFullscreen) { void document.body.requestFullscreen().catch(() => {}); return; } const vRef = videoRef.current as WebkitVideoElement | null; if (vRef?.requestFullscreen) { void vRef.requestFullscreen().catch(() => {}); return; } if (vRef?.webkitEnterFullscreen) { vRef.webkitEnterFullscreen(); } } else { void document.exitFullscreen().catch(() => {}); } }, []); 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((): void => { if (!imageCount) return; const next = Math.min( imageCount - 1, imageCarouselState.idxRef.current + 1, ); imageCarouselState.goToIndex(next); }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); const handleSetAbStart = useCallback((): void => { if (!isVideo) return; const v = videoRef.current; if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return; const currentTime = Math.max(0, Math.min(v.duration, v.currentTime)); setAbLoop((prev) => { if (prev.a !== null && Math.abs(prev.a - currentTime) < 0.2) { return { a: null, b: prev.b }; } const nextB = prev.b !== null && currentTime >= prev.b - 0.1 ? null : prev.b; return { a: currentTime, b: nextB }; }); }, [isVideo]); const handleSetAbEnd = useCallback((): void => { if (!isVideo) return; const v = videoRef.current; if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return; const currentTime = Math.max(0, Math.min(v.duration, v.currentTime)); setAbLoop((prev) => { if (prev.b !== null && Math.abs(prev.b - currentTime) < 0.2) { return { a: prev.a, b: null }; } const nextA = prev.a !== null && currentTime <= prev.a + 0.1 ? null : prev.a; const startTime = Math.max(0, nextA ?? 0); v.currentTime = startTime; if (v.paused) { void v.play().catch(() => {}); } return { a: nextA, b: currentTime }; }); }, [isVideo]); const handleClearAbLoop = useCallback((): void => { setAbLoop({ a: null, b: null }); }, []); const handleDownload = useCallback((): void => { if (isVideo) { const videoUrl = (data as VideoData).video_url; const link = document.createElement("a"); link.href = videoUrl; link.download = `video_${data.aweme_id}.mp4`; document.body.appendChild(link); link.click(); document.body.removeChild(link); } else { if (!images?.length) return; 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.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 handleToggleComments = useCallback((): void => { panel.toggle("comments"); }, [panel.toggle]); const handleAuthorClick = useCallback((): void => { panel.toggle("works"); }, [panel.toggle]); // 竖屏(底部抽屉)点了作品后关掉抽屉露出视频;顺序不受影响,点顶部提示可以再打开 const handleWorkSelect = useCallback((): void => { if (isPortraitViewport()) panel.close(); }, [panel.close]); const handleExitUrlContext = useCallback((): void => { router.replace(awemeHref(data.aweme_id)); }, [data.aweme_id, router]); const showWorks = useCallback((): void => { panel.show("works"); }, [panel.show]); // 退出作者模式:回到 URL 上原来的顺序(搜索 / hashtag / 全部作品);作品网格还开着的话一并关掉 const exitAuthorMode = useCallback((): void => { panel.exitAuthorMode(); if (panel.open && panel.tab === "works") panel.close(); }, [panel.exitAuthorMode, panel.open, panel.tab, panel.close]); const showList = useCallback((): void => { panel.show("list"); }, [panel.show]); // 点描述里的 hashtag:停在当前视频,上下切换改为在带这个 hashtag 的作品里移动,并打开列表 const handleHashtagClick = useCallback( (name: string): void => { panel.exitAuthorMode(); router.replace( awemeHref( data.aweme_id, playbackContextQuery({ kind: "hashtag", name }), ), { scroll: false }, ); }, [data.aweme_id, router, panel.exitAuthorMode], ); // 从搜索结果进来、或点了 hashtag 时自动打开列表标签页 const listKind = listContext?.kind; useEffect(() => { if (!panel.restored) return; if (listKind !== "search" && listKind !== "hashtag") return; try { if (sessionStorage.getItem(LIST_AUTO_OPENED_KEY) === contextQuery) return; sessionStorage.setItem(LIST_AUTO_OPENED_KEY, contextQuery); } catch {} panel.show("list"); }, [panel.restored, panel.show, listKind, contextQuery]); const searchPageHref = useMemo(() => { if (listKind !== "search") return null; const params = new URLSearchParams(contextQuery); params.delete("ctx"); return `/search?${params.toString()}`; }, [listKind, contextQuery]); const hashtagName = useMemo( () => listKind === "hashtag" ? new URLSearchParams(contextQuery).get("hashtag") : null, [listKind, contextQuery], ); const listVisible = panel.open && activeTab === "list"; const worksVisible = panel.open && activeTab === "works"; const chip = authorPlayback && authorPlayback.total > 0 ? { label: `${data.author.nickname} 的作品`, position: authorPlayback.position, total: authorPlayback.total, // 作品网格没在显示时,点提示可以重新打开 onExpand: worksVisible ? undefined : showWorks, expandLabel: "查看TA的作品", onExit: exitAuthorMode, exitLabel: "退出作者模式", } : !panel.authorMode && playbackContext ? { ...playbackContext, // 列表没在显示时,点提示可以打开侧栏里的完整列表 onExpand: listContext && !listVisible ? showList : undefined, expandLabel: `查看${playbackContext.listTitle}`, onExit: handleExitUrlContext, exitLabel: "退出当前列表,按全部作品切换", } : null; const handleNavigatePrev = useCallback((): void => { if (neighbors.prev) { router.push(neighbors.prev.href); } }, [neighbors.prev, router]); const handleNavigateNext = useCallback((): void => { if (neighbors.next) { router.push(neighbors.next.href); } }, [neighbors.next, router]); // 导航相关 useNavigation({ neighbors, isVideo, mediaContainerRef, videoRef, prevImg, nextImg, togglePlay, onFastForwardChange: setIsFastForwarding, onSetAbStart: handleSetAbStart, onSetAbEnd: handleSetAbEnd, onClearAbLoop: handleClearAbLoop, }); // 背景画布(仅视频且播放时采样绘制) useBackgroundCanvas({ isVideo, isPlaying: playerState.isPlaying, videoRef, backgroundCanvasRef, }); useEffect(() => { if (isVideo) return; const pauseForPageLifecycle = (): void => { playerState.setIsPlaying(false); pauseImageMedia(); }; const handleVisibilityChange = (): void => { if (document.hidden) pauseForPageLifecycle(); }; window.addEventListener("pagehide", pauseForPageLifecycle); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { window.removeEventListener("pagehide", pauseForPageLifecycle); document.removeEventListener("visibilitychange", handleVisibilityChange); pauseImageMedia(); }; }, [isVideo, pauseImageMedia, playerState.setIsPlaying]); // Media Session API:元信息仅在作品切换时设置一次,避免高频触发 iOS IPC 卡顿 useEffect(() => { if (typeof window === "undefined") return; if (!("mediaSession" in navigator)) return; const ms = navigator.mediaSession; const title = data.desc || `作品 ${data.aweme_id}`; const artist = data.author.nickname || "作者"; const album = new Date(data.created_at).toLocaleString(); const coverUrl = data.cover_url; const coverSize = data.cover_size; const artwork = coverUrl ? [ { src: coverUrl, sizes: `${coverSize?.w || 512}x${coverSize?.h || 512}`, }, ] : []; 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 updatePosition = (): void => { try { if (!ms.setPositionState) return; if (isVideo) { const v = videoRef.current; if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) { return; } ms.setPositionState({ duration: Math.max(0, v.duration), position: Math.max(0, v.currentTime), playbackRate: effectiveVideoRate ?? 1, }); } else if (images?.length) { const totalMs = imageCarouselState.totalDurationMs; const duration = totalMs / 1000; const position = Math.max( 0, Math.min(duration, (progressRef.current * totalMs) / 1000), ); ms.setPositionState({ duration, position, playbackRate: effectiveImageRate, }); } } catch {} }; const v = videoRef.current; if (isVideo && v) { const onPlay = (): void => { try { ms.playbackState = "playing"; } catch {} updatePosition(); }; const onPause = (): void => { try { ms.playbackState = "paused"; } catch {} updatePosition(); }; const onSeeked = (): void => updatePosition(); v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); 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("seeked", onSeeked); }; } if (!isVideo && images?.length) { const id = window.setInterval(updatePosition, 1000); updatePosition(); return () => window.clearInterval(id); } }, [ effectiveImageRate, effectiveVideoRate, imageCarouselState.totalDurationMs, images?.length, isVideo, playerState.isPlaying, ]); // 绑定系统媒体控件的操作处理程序(不依赖 progress state,仅在作品切换时注册一次) useEffect(() => { if (typeof window === "undefined") return; if (!("mediaSession" in navigator)) return; const ms = navigator.mediaSession; const handlePlay = async (): Promise => { if (isVideo) { const v = videoRef.current; try { await v?.play(); } catch {} } else { playerState.setIsPlaying(true); try { await audioRef.current?.play(); } catch {} } }; const handlePause = (): void => { if (isVideo) { const v = videoRef.current; v?.pause(); } else { playerState.setIsPlaying(false); pauseImageMedia(); } }; const handleStop = (): void => { if (isVideo) { const v = videoRef.current; if (v) { v.pause(); v.currentTime = 0; } } else { playerState.setIsPlaying(false); pauseImageMedia(true); seekTo(0); } }; const handleSeekDelta = (deltaSec: number): void => { if (isVideo) { const v = videoRef.current; if (v) { v.currentTime = Math.max( 0, Math.min(v.duration || Infinity, v.currentTime + deltaSec), ); } } else if (images?.length) { const totalMs = imageCarouselState.totalDurationMs; if (totalMs <= 0) return; const deltaRatio = (deltaSec * 1000) / totalMs; seekTo(progressRef.current + deltaRatio); } }; 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 totalSec = imageCarouselState.totalDurationMs / 1000; if (totalSec > 0) seekTo(seekTimeSec / totalSec); } }; ms.setActionHandler("play", handlePlay); ms.setActionHandler("pause", handlePause); ms.setActionHandler("stop", handleStop); ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) => handleSeekDelta(-(details?.seekOffset ?? 10)), ); ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) => handleSeekDelta(details?.seekOffset ?? 10), ); ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => { if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime); }); ms.setActionHandler("previoustrack", () => { if (!isVideo && images?.length > 1) { prevImg(); } else if (neighbors.prev) { router.push(neighbors.prev.href); } }); ms.setActionHandler("nexttrack", () => { if (!isVideo && images?.length > 1) { nextImg(); } else if (neighbors.next) { router.push(neighbors.next.href); } }); return () => { try { ms.setActionHandler("play", null); ms.setActionHandler("pause", null); ms.setActionHandler("stop", null); ms.setActionHandler("seekbackward", null); ms.setActionHandler("seekforward", null); ms.setActionHandler("seekto", null); ms.setActionHandler("previoustrack", null); ms.setActionHandler("nexttrack", null); } catch {} }; }, [ isVideo, images?.length, neighbors.prev, neighbors.next, router, playerState.setIsPlaying, pauseImageMedia, imageCarouselState.totalDurationMs, nextImg, prevImg, seekTo, ]); return (
{isVideo ? : null}
{/* 主媒体区域 */}
{isVideo ? ( ) : ( )}
{/* 播放上下文提示:上下切换当前在哪个列表里移动 */} {chip && ( )} {/* 长按 2x 快进状态指示 */} {isFastForwarding && (
2.0x 快进中
)} {/* 暂停图标 */} {!playerState.isPlaying && (
)} {/* 图文切换按钮 */} {!isVideo && images.length > 1 && ( )} {/* 媒体控制栏 */} {isVideo && ( )}
{/* 导航按钮 */}
{/* 侧栏:评论 / TA的作品 / 搜索结果(或 hashtag) */} ), }, { key: "works", label: "TA的作品", count: authorWorks.items?.length ?? authorWorksCount, content: ( ), }, ...(listContext ? [ { key: "list" as const, label: listContext.listTitle, count: listContext.total, content: ( ), }, ] : []), ]} /> {/* 转录面板 */}
); }