"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 formatRelativeTime(date: string | Date): string { const now = new Date(); const target = new Date(date); const diffMs = now.getTime() - target.getTime(); const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); const diffMonths = Math.floor(diffDays / 30); const diffYears = Math.floor(diffDays / 365); if (diffYears > 0) return `${diffYears}年前`; if (diffMonths > 0) return `${diffMonths}月前`; if (diffDays > 0) return `${diffDays}天前`; if (diffHours > 0) return `${diffHours}小时前`; if (diffMinutes > 0) return `${diffMinutes}分钟前`; return '刚刚'; } // 处理评论文本中的表情占位符 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(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏) const [mounted, setMounted] = useState(false); // 用于跳过首次加载的动画 const comments = useMemo(() => data.comments ?? [], [data]); // ====== 从 localStorage 恢复评论区状态(仅客户端) ====== useEffect(() => { if (typeof window === "undefined") return; const saved = localStorage.getItem("aweme_player_comments_open"); if (saved === "true") { setOpen(true); } // 短暂延迟后标记为已挂载,启用动画 requestAnimationFrame(() => { requestAnimationFrame(() => { setMounted(true); }); }); }, []); // ====== 媒体引用 ====== 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) return; // 检测自动播放是否成功 const checkAutoplay = async () => { try { await v.play(); setIsPlaying(true); } catch (error) { // 自动播放失败(通常是浏览器策略限制) console.log("自动播放被阻止,需要用户交互"); setIsPlaying(false); } }; // 等待元数据加载后尝试播放 if (v.readyState >= 1) { checkAutoplay(); } else { v.addEventListener("loadedmetadata", checkAutoplay, { once: true }); return () => v.removeEventListener("loadedmetadata", checkAutoplay); } }, [isVideo, data.aweme_id]); // 依赖 aweme_id 确保切换视频时重新检查 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) { if (document.body.requestFullscreen) { document.body.requestFullscreen().catch(() => { }); return } const vRef = videoRef.current; if (vRef && vRef.requestFullscreen) { vRef.requestFullscreen().catch(() => { }); return } // @ts-ignore if (vRef && vRef.webkitEnterFullscreen) { // @ts-ignore vRef.webkitEnterFullscreen(); } } 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')}`; }; // ====== 评论内容组件 - 使用 useMemo 避免不必要的重新渲染 ====== const commentContent = useMemo(() => ( <>
{data.author.avatar_url ? ( // eslint-disable-next-line @next/next/no-img-element avatar ) : null}
{data.author.nickname}
发布于 {formatRelativeTime(data.created_at)}
    {comments.map((c) => (
  • {c.user.avatar_url ? ( // eslint-disable-next-line @next/next/no-img-element avatar ) : null}
    {c.user.nickname} {formatRelativeTime(c.created_at)}

    {c.digg_count}
  • ))} {comments.length === 0 ?
  • 暂无评论
  • : null}
), [comments, data.author, data.created_at]); // ====== 预取上/下一条路由,提高切换流畅度 ====== 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]); // ====== 键盘快捷键 ====== useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { // 如果焦点在输入框等元素上,不处理快捷键 const target = e.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } const key = e.key.toLowerCase(); // 上下方向键 / w s:切换上一条/下一条视频 if (key === 'arrowup' || key === 'w') { e.preventDefault(); const now = performance.now(); if (now - wheelCooldownRef.current < 700) return; // 冷却 700ms if (neighbors?.prev) { wheelCooldownRef.current = now; router.push(`/aweme/${neighbors.prev.aweme_id}`); } } else if (key === 'arrowdown' || key === 's') { e.preventDefault(); const now = performance.now(); if (now - wheelCooldownRef.current < 700) return; // 冷却 700ms if (neighbors?.next) { wheelCooldownRef.current = now; router.push(`/aweme/${neighbors.next.aweme_id}`); } } // 左右方向键 / a d:快进快退(视频) 或 切换图片(图文) else if (key === 'arrowleft' || key === 'a') { e.preventDefault(); if (isVideo) { // 视频:后退 5 秒 const v = videoRef.current; if (v && v.duration) { v.currentTime = Math.max(0, v.currentTime - 5); } } else { // 图文:上一张 prevImg(); } } else if (key === 'arrowright' || key === 'd') { e.preventDefault(); if (isVideo) { // 视频:前进 5 秒 const v = videoRef.current; if (v && v.duration) { v.currentTime = Math.min(v.duration, v.currentTime + 5); } } else { // 图文:下一张 nextImg(); } } // 空格:播放/暂停 else if (key === ' ') { e.preventDefault(); togglePlay(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [isVideo, neighbors?.prev?.aweme_id, neighbors?.next?.aweme_id, router]); // ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ====== useEffect(() => { const canvas = backgroundCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // 更新 canvas 尺寸以匹配视口 const updateCanvasSize = () => { canvas.width = Math.floor(window.innerWidth / 10); canvas.height = Math.floor(window.innerHeight / 10); }; updateCanvasSize(); // 防抖处理 resize 事件(300ms) let resizeTimeout: NodeJS.Timeout; const debouncedResize = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(updateCanvasSize, 300); }; window.addEventListener("resize", debouncedResize); // 绘制媒体内容到 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); }; const intervalId = setInterval(drawMediaToCanvas, 20); return () => { clearInterval(intervalId); window.removeEventListener("resize", debouncedResize); clearTimeout(resizeTimeout); }; }, [isVideo, idx]); return (
{/* 动态模糊背景 canvas */} {/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
{/* 主媒体区域 */}
{isVideo ? (
{/* 横屏评论面板:并排分栏 */}
{/* 竖屏评论面板:bottom sheet */}
); }