"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { ChevronLeft, ChevronRight, Pause, Play, Volume2, VolumeX, Maximize, Minimize2, MessageSquare, ThumbsUp, MessageSquareText, RotateCcw, RotateCw, Maximize2, Minimize, ChevronUp, ChevronDown, X, Repeat, Repeat1, ArrowDownUp, Download, } from "lucide-react"; type User = { nickname: string; avatar_url: string | null }; type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User }; // 处理评论文本中的表情占位符 function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] { const parts: (string | { type: "emoji"; name: string })[] = []; const regex = /\[([^\]]+)\]/g; let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { // 添加表情前的文本 if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } // 添加表情 parts.push({ type: "emoji", name: match[1] }); lastIndex = regex.lastIndex; } // 添加剩余文本 if (lastIndex < text.length) { parts.push(text.slice(lastIndex)); } return parts; } // 渲染评论文本(包含表情) function CommentText({ text }: { text: string }) { const parts = parseCommentText(text); return ( <> {parts.map((part, idx) => { if (typeof part === "string") { return {part}; } return ( {part.name} { // 如果图片加载失败,显示原始文本 e.currentTarget.style.display = "none"; const textNode = document.createTextNode(`[${part.name}]`); e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget); }} /> ); })} ); } type VideoData = { type: "video"; aweme_id: string; desc: string; created_at: string | Date; duration_ms?: number | null; video_url: string; width?: number | null; height?: number | null; author: User; comments: Comment[]; }; type ImageData = { type: "image"; aweme_id: string; desc: string; created_at: string | Date; images: { id: string; url: string; width?: number; height?: number }[]; music_url?: string | null; author: User; comments: Comment[]; }; const SEGMENT_MS = 5000; // 图文每段 5s type Neighbors = { prev: { aweme_id: string } | null; next: { aweme_id: string } | null }; export default function AwemeDetailClient(props: { data: VideoData | ImageData; neighbors?: Neighbors }) { const { data, neighbors } = props; const isVideo = data.type === "video"; const router = useRouter(); // ====== 布局 & 评论 ====== const [open, setOpen] = useState(() => { // 从 localStorage 读取评论区状态,默认 false if (typeof window === "undefined") return false; const saved = localStorage.getItem("aweme_player_comments_open"); if (!saved) return false; return saved === "true"; }); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏) const comments = useMemo(() => data.comments ?? [], [data]); // ====== 媒体引用 ====== const mediaContainerRef = useRef(null); const videoRef = useRef(null); const audioRef = useRef(null); const wheelCooldownRef = useRef(0); const backgroundCanvasRef = useRef(null); // ====== 统一控制状态 ====== const [isPlaying, setIsPlaying] = useState(true); // 视频=播放;图文=是否自动切换 const [isFullscreen, setIsFullscreen] = useState(false); const [volume, setVolume] = useState(() => { // 从 localStorage 读取音量,默认 1 if (typeof window === "undefined") return 1; const saved = localStorage.getItem("aweme_player_volume"); if (!saved) return 1; const parsed = parseFloat(saved); return Number.isNaN(parsed) ? 1 : Math.max(0, Math.min(1, parsed)); }); const [rate, setRate] = useState(() => { // 从 localStorage 读取倍速,默认 1 if (typeof window === "undefined") return 1; const saved = localStorage.getItem("aweme_player_rate"); if (!saved) return 1; const parsed = parseFloat(saved); return Number.isNaN(parsed) ? 1 : parsed; }); const [progress, setProgress] = useState(0); // 0..1 总进度 const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270 const [progressRestored, setProgressRestored] = useState(false); // 标记进度是否已恢复 const [objectFit, setObjectFit] = useState<"contain" | "cover">("contain"); // 媒体显示模式 const [loopMode, setLoopMode] = useState<"loop" | "sequential">(() => { // 从 localStorage 读取循环模式,默认 loop if (typeof window === "undefined") return "loop"; const saved = localStorage.getItem("aweme_player_loop_mode"); if (!saved) return "loop"; return saved === "sequential" ? "sequential" : "loop"; }); // ====== 图文专用(分段) ====== const images = (data as any).images as ImageData["images"] | undefined; const totalSegments = images?.length ?? 0; const [idx, setIdx] = useState(0); // 当前图片索引 const scrollerRef = useRef(null); // 用 ref 解决“闪回” const segStartRef = useRef(null); // 当前段开始时间戳 const idxRef = useRef(0); const rafRef = useRef(null); const [segProgress, setSegProgress] = useState(0); // 段内 0..1 const [, forceUpdate] = useState(0); // 用于强制更新时间显示 useEffect(() => { idxRef.current = idx; }, [idx]); // ====== 持久化音量到 localStorage ====== useEffect(() => { if (typeof window === "undefined") return; localStorage.setItem("aweme_player_volume", volume.toString()); }, [volume]); // ====== 持久化倍速到 localStorage ====== useEffect(() => { if (typeof window === "undefined") return; localStorage.setItem("aweme_player_rate", rate.toString()); }, [rate]); // ====== 持久化评论区状态到 localStorage ====== useEffect(() => { if (typeof window === "undefined") return; localStorage.setItem("aweme_player_comments_open", open.toString()); }, [open]); // ====== 持久化循环模式到 localStorage ====== useEffect(() => { if (typeof window === "undefined") return; localStorage.setItem("aweme_player_loop_mode", loopMode); }, [loopMode]); // ====== 恢复视频播放进度(带有效期) ====== useEffect(() => { if (!isVideo || progressRestored) return; const v = videoRef.current; if (!v) return; const onLoadedMetadata = () => { if (progressRestored) return; try { const key = `aweme_progress_${data.aweme_id}`; const saved = localStorage.getItem(key); if (!saved) { setProgressRestored(true); return; } const { time, timestamp } = JSON.parse(saved); const now = Date.now(); const fiveMinutes = 5 * 60 * 1000; // 检查是否在 5 分钟有效期内 if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) { v.currentTime = time; console.log(`恢复播放进度: ${Math.round(time)}s`); } else if (now - timestamp >= fiveMinutes) { // 过期则清除 localStorage.removeItem(key); } } catch (e) { console.error("恢复播放进度失败", e); } setProgressRestored(true); }; if (v.readyState >= 1) { // 元数据已加载 onLoadedMetadata(); } else { v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true }); return () => v.removeEventListener("loadedmetadata", onLoadedMetadata); } }, [isVideo, data.aweme_id, progressRestored]); // ====== 实时保存视频播放进度到 localStorage ====== useEffect(() => { if (!isVideo) return; const v = videoRef.current; if (!v) return; const saveProgress = () => { if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return; try { const key = `aweme_progress_${data.aweme_id}`; const value = JSON.stringify({ time: v.currentTime, timestamp: Date.now(), }); localStorage.setItem(key, value); } catch (e) { console.error("保存播放进度失败", e); } }; // 每 2 秒保存一次进度 const interval = setInterval(saveProgress, 2000); // 页面卸载时也保存一次 const onBeforeUnload = () => saveProgress(); window.addEventListener("beforeunload", onBeforeUnload); return () => { clearInterval(interval); window.removeEventListener("beforeunload", onBeforeUnload); saveProgress(); // 组件卸载时保存 }; }, [isVideo, data.aweme_id]); // ====== 视频:进度/播放/倍速/音量 ====== useEffect(() => { if (!isVideo) return; const v = videoRef.current; if (!v) return; const onTime = () => { if (!v.duration || Number.isNaN(v.duration)) return; setProgress(v.currentTime / v.duration); }; const onPlay = () => setIsPlaying(true); const onPause = () => setIsPlaying(false); const onEnded = () => { // 顺序播放模式下,视频结束时自动跳转到下一条 if (loopMode === "sequential" && neighbors?.next) { router.push(`/aweme/${neighbors.next.aweme_id}`); } }; v.addEventListener("timeupdate", onTime); v.addEventListener("loadedmetadata", onTime); v.addEventListener("play", onPlay); v.addEventListener("pause", onPause); v.addEventListener("ended", onEnded); return () => { v.removeEventListener("timeupdate", onTime); v.removeEventListener("loadedmetadata", onTime); v.removeEventListener("play", onPlay); v.removeEventListener("pause", onPause); v.removeEventListener("ended", onEnded); }; }, [isVideo, loopMode, neighbors?.next?.aweme_id, router]); useEffect(() => { if (!isVideo) return; const v = videoRef.current; if (v) v.volume = volume; }, [volume, isVideo]); useEffect(() => { if (!isVideo) return; const v = videoRef.current; if (v) v.playbackRate = rate; }, [rate, isVideo]); // ====== 视频:定期更新时间显示 ====== useEffect(() => { if (!isVideo) return; const interval = setInterval(() => { forceUpdate(n => n + 1); }, 100); // 每 100ms 更新一次显示 return () => clearInterval(interval); }, [isVideo]); // ====== 图文:BGM & 初次自动播放尝试 ====== useEffect(() => { if (isVideo) return; const el = audioRef.current; if (!el) return; el.volume = volume; if (isPlaying) { el.play().catch(() => {/* 被策略阻止无妨,用户点播放即可 */ }); } else { el.pause(); } }, [isVideo]); // 初次挂载 useEffect(() => { if (isVideo) return; const el = audioRef.current; if (el) el.volume = volume; }, [volume, isVideo]); // ====== 全屏状态监听 ====== useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener("fullscreenchange", onFsChange); return () => document.removeEventListener("fullscreenchange", onFsChange); }, []); // ====== 监听浏览器返回事件,尝试关闭页面 ====== useEffect(() => { // 在 history 中添加一个状态,用于拦截返回事件 window.history.pushState({ interceptBack: true }, ""); const handlePopState = (e: PopStateEvent) => { // 尝试关闭窗口 window.close(); // 如果关闭失败(100ms 后页面仍可见),则导航到首页 setTimeout(() => { if (!document.hidden) { router.push("/"); } }, 100); }; window.addEventListener("popstate", handlePopState); return () => { window.removeEventListener("popstate", handlePopState); }; }, [router]); // ====== 图文:自动切页 ====== useEffect(() => { if (isVideo || !images?.length) return; if (segStartRef.current == null) segStartRef.current = performance.now(); let lastTs = performance.now(); const tick = (ts: number) => { if (!images?.length) return; if (!isPlaying) segStartRef.current! += ts - lastTs; lastTs = ts let start = segStartRef.current!; let localIdx = idxRef.current; // 前进时间:处理跨多段情况(极少见,但更稳妥) let elapsed = ts - start; while (elapsed >= SEGMENT_MS) { elapsed -= SEGMENT_MS; // 检查是否到达最后一张 if (localIdx >= images.length - 1) { // 顺序播放模式:跳转到下一条作品 if (loopMode === "sequential" && neighbors?.next) { router.push(`/aweme/${neighbors.next.aweme_id}`); return; // 停止当前动画循环 } // 循环播放模式:回到第一张 localIdx = 0; } else { localIdx = localIdx + 1; } } segStartRef.current = ts - elapsed; if (localIdx !== idxRef.current) { idxRef.current = localIdx; setIdx(localIdx); const el = scrollerRef.current; if (el) el.scrollTo({ left: localIdx * el.clientWidth, behavior: "smooth" }); } const localSeg = Math.max(0, Math.min(1, elapsed / SEGMENT_MS)); setSegProgress(localSeg); setProgress((localIdx + localSeg) / images.length); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = null; }; }, [isVideo, images?.length, isPlaying, loopMode, neighbors?.next?.aweme_id, router]); // ====== 统一操作 ====== const seekTo = (ratio: number) => { ratio = Math.min(1, Math.max(0, ratio)); if (isVideo) { const v = videoRef.current; if (!v || !v.duration) return; v.currentTime = v.duration * ratio; return; } if (!images?.length) return; const total = images.length; const exact = ratio * total; const targetIdx = Math.min(total - 1, Math.floor(exact)); const remainder = exact - targetIdx; idxRef.current = targetIdx; setIdx(targetIdx); segStartRef.current = performance.now() - remainder * SEGMENT_MS; setSegProgress(remainder); setProgress((targetIdx + remainder) / total); const el = scrollerRef.current; if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" }); }; const togglePlay = async () => { if (isVideo) { const v = videoRef.current; if (!v) return; if (v.paused) await v.play().catch(() => { }); else v.pause(); return; } const el = audioRef.current; if (!isPlaying) { setIsPlaying(true); try { await el?.play().catch(() => { }); } catch { } } else { setIsPlaying(false); el?.pause(); } }; const toggleFullscreen = () => { if (!document.fullscreenElement) { document.body.requestFullscreen().catch(() => { }); } else { document.exitFullscreen().catch(() => { }); } }; const prevImg = () => { if (!images?.length) return; const next = Math.max(0, idxRef.current - 1); idxRef.current = next; setIdx(next); segStartRef.current = performance.now(); setSegProgress(0); const el = scrollerRef.current; if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" }); }; const nextImg = () => { if (!images?.length) return; const next = Math.min(images.length - 1, idxRef.current + 1); idxRef.current = next; setIdx(next); segStartRef.current = performance.now(); setSegProgress(0); const el = scrollerRef.current; if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" }); }; // ====== 下载功能 ====== const handleDownload = () => { 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[idx]; const link = document.createElement("a"); link.href = currentImage.url; link.download = `image_${data.aweme_id}_${idx + 1}.jpg`; document.body.appendChild(link); link.click(); document.body.removeChild(link); } }; // ====== 格式化时间显示 ====== const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; // ====== 侧栏(横屏)/ 抽屉(竖屏)样式(Tailwind) const asideClasses = [ "z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white", // 竖屏:bottom sheet,从下向上弹出 "portrait:fixed portrait:inset-x-0 portrait:top-110 portrait:w-full portrait:h-[min(80vh,88dvh)]", "portrait:transition-transform portrait:duration-200 portrait:ease-out", open ? "portrait:translate-y-0" : "portrait:translate-y-full", "portrait:border-t portrait:border-white/10", // 横屏:并排分栏,宽度过渡 "landscape:relative landscape:h-full landscape:overflow-hidden", "landscape:transition-[width] landscape:duration-200 landscape:ease-out", open ? "landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10" : "landscape:w-0", ].join(" "); // ====== 预取上/下一条路由,提高切换流畅度 ====== useEffect(() => { if (!neighbors) return; if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`); if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`); }, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]); // ====== 鼠标滚轮切换上一条/下一条(纵向滚动) ====== useEffect(() => { const el = mediaContainerRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { // 避免缩放/横向滚动干扰 if (e.ctrlKey) return; const now = performance.now(); if (now - wheelCooldownRef.current < 700) return; // 冷却 700ms const dy = e.deltaY; if (Math.abs(dy) < 40) return; // 过滤轻微滚轮 // 有上一条/下一条才拦截默认行为 if ((dy > 0 && neighbors?.next) || (dy < 0 && neighbors?.prev)) { e.preventDefault(); } if (dy > 0 && neighbors?.next) { wheelCooldownRef.current = now; router.push(`/aweme/${neighbors.next.aweme_id}`); } else if (dy < 0 && neighbors?.prev) { wheelCooldownRef.current = now; router.push(`/aweme/${neighbors.prev.aweme_id}`); } }; // 需非被动监听以便 preventDefault el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel as any); }, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]); // ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ====== useEffect(() => { const canvas = backgroundCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // 更新 canvas 尺寸以匹配视口 const updateCanvasSize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }; updateCanvasSize(); window.addEventListener("resize", updateCanvasSize); // 绘制媒体内容到 canvas(cover 策略) const drawMediaToCanvas = () => { if (!ctx) return; let sourceElement: HTMLVideoElement | HTMLImageElement | null = null; // 获取当前媒体元素 if (isVideo) { sourceElement = videoRef.current; } else { // 对于图文,获取当前显示的图片 const scroller = scrollerRef.current; if (scroller) { const currentImgContainer = scroller.children[idx] as HTMLElement; if (currentImgContainer) { sourceElement = currentImgContainer.querySelector("img"); } } } // 不要在帧不可用时清空画布,避免出现黑屏闪烁 // 仅当有可绘制帧时才进行绘制,画面会被完整覆盖,无需提前 clear if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return; const canvasWidth = canvas.width; const canvasHeight = canvas.height; const sourceWidth = sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth; const sourceHeight = sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight; // 图片在切换时可能尚未完成解码(naturalWidth/Height 为 0),此时保持上一帧 if (!sourceWidth || !sourceHeight) return; // 计算 cover 模式的尺寸和位置 const canvasRatio = canvasWidth / canvasHeight; const sourceRatio = sourceWidth / sourceHeight; let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number; if (canvasRatio > sourceRatio) { // canvas 更宽,按宽度填充 drawWidth = canvasWidth; drawHeight = canvasWidth / sourceRatio; offsetX = 0; offsetY = (canvasHeight - drawHeight) / 2; } else { // canvas 更高,按高度填充 drawHeight = canvasHeight; drawWidth = canvasHeight * sourceRatio; offsetX = (canvasWidth - drawWidth) / 2; offsetY = 0; } // 直接绘制一帧即可(cover 会完全覆盖整个画布,无需 clearRect) ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); }; // 使用较高频率的定时器以保持背景连贯(20ms ~= 50fps) const intervalId = setInterval(drawMediaToCanvas, 20); return () => { clearInterval(intervalId); window.removeEventListener("resize", updateCanvasSize); }; }, [isVideo, idx]); return (
{/* 动态模糊背景 canvas */} {/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
{/* 主媒体区域 */}
{isVideo ? (
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}
); }