"use client"; import { Play } from "lucide-react"; import { useRouter } from "next/navigation"; import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { CommentPanel } from "./components/CommentPanel"; import { ImageCarousel } from "./components/ImageCarousel"; import { ImageNavigationButtons } from "./components/ImageNavigationButtons"; import { MediaControls } from "./components/MediaControls"; import { NavigationButtons } from "./components/NavigationButtons"; import { TranscriptPanel } from "./components/TranscriptPanel"; import { VideoPlayer } from "./components/VideoPlayer"; import { VideoTagEditor } from "./components/VideoTagEditor"; import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas"; import { useCommentState } from "./hooks/useCommentState"; import { useImageCarousel } from "./hooks/useImageCarousel"; import { useNavigation } from "./hooks/useNavigation"; import { usePlayerState } from "./hooks/usePlayerState"; import { useVideoPlayer } from "./hooks/useVideoPlayer"; import type { AwemeData, ImageData, Neighbors, VideoData, VideoTranscript, } from "./types.ts"; const SEGMENT_MS = 4000; type WebkitVideoElement = HTMLVideoElement & { webkitEnterFullscreen?: () => void; }; type MediaPositionStateInput = { duration: number; position: number; playbackRate?: number; }; type MediaSessionWithPositionState = MediaSession & { setPositionState?: (state: MediaPositionStateInput) => void; }; interface AwemeDetailClientProps { data: AwemeData; neighbors: Neighbors; transcript: VideoTranscript | null; } export default function AwemeDetailClient({ data, neighbors, transcript, }: AwemeDetailClientProps): ReactElement { const router = useRouter(); const isVideo = data.type === "video"; // 状态管理 const playerState = usePlayerState(); const commentState = useCommentState(); const [transcriptOpen, setTranscriptOpen] = useState(false); const [contentTags, setContentTags] = useState( 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): 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, 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: playerState.rate, loopMode: playerState.loopMode, 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 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 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({ neighbors, isVideo, mediaContainerRef, videoRef, prevImg, nextImg, togglePlay, }); // 背景画布(仅视频且播放时采样绘制) 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: playerState.rate ?? 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: 1 }); } } 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); } }, [ imageCarouselState.totalDurationMs, images?.length, isVideo, playerState.isPlaying, playerState.rate, ]); // 绑定系统媒体控件的操作处理程序(不依赖 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(`/aweme/${neighbors.prev.aweme_id}`); } }); ms.setActionHandler("nexttrack", () => { if (!isVideo && images?.length > 1) { nextImg(); } else if (neighbors.next) { router.push(`/aweme/${neighbors.next.aweme_id}`); } }); 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 ? ( ) : ( )}
{/* 暂停图标 */} {!playerState.isPlaying && (
)} {/* 图文切换按钮 */} {!isVideo && images.length > 1 && ( )} {/* 媒体控制栏 */} {isVideo && ( )}
{/* 导航按钮 */}
{/* 评论面板 */} {/* 转录面板 */}
); }