From fe9bc8fd6c19943a6d1fece78591f8e9974896ed Mon Sep 17 00:00:00 2001 From: feie9456 Date: Wed, 22 Oct 2025 20:14:11 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84client=EF=BC=8C=E6=8B=86?= =?UTF-8?q?=E5=88=86=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 + app/aweme/[awemeId]/Client.tsx | 1311 +++-------------- .../[awemeId]/components/BackgroundCanvas.tsx | 15 + .../[awemeId]/components/CommentList.tsx | 56 + .../[awemeId]/components/CommentPanel.tsx | 71 + .../[awemeId]/components/CommentText.tsx | 30 + .../[awemeId]/components/ImageCarousel.tsx | 39 + .../components/ImageNavigationButtons.tsx | 56 + .../[awemeId]/components/MediaControls.tsx | 256 ++++ .../components/NavigationButtons.tsx | 60 + .../[awemeId]/components/ProgressBar.tsx | 18 + .../components/SegmentedProgressBar.tsx | 40 + .../[awemeId]/components/VideoPlayer.tsx | 41 + app/aweme/[awemeId]/components/index.ts | 12 + app/aweme/[awemeId]/hooks/index.ts | 7 + .../[awemeId]/hooks/useBackgroundCanvas.ts | 95 ++ app/aweme/[awemeId]/hooks/useCommentState.ts | 30 + app/aweme/[awemeId]/hooks/useImageCarousel.ts | 113 ++ app/aweme/[awemeId]/hooks/useNavigation.ts | 138 ++ app/aweme/[awemeId]/hooks/usePlayerState.ts | 61 + app/aweme/[awemeId]/hooks/useVideoPlayer.ts | 171 +++ app/aweme/[awemeId]/types.ts | 43 + app/aweme/[awemeId]/utils.ts | 72 + 23 files changed, 1601 insertions(+), 1137 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/aweme/[awemeId]/components/BackgroundCanvas.tsx create mode 100644 app/aweme/[awemeId]/components/CommentList.tsx create mode 100644 app/aweme/[awemeId]/components/CommentPanel.tsx create mode 100644 app/aweme/[awemeId]/components/CommentText.tsx create mode 100644 app/aweme/[awemeId]/components/ImageCarousel.tsx create mode 100644 app/aweme/[awemeId]/components/ImageNavigationButtons.tsx create mode 100644 app/aweme/[awemeId]/components/MediaControls.tsx create mode 100644 app/aweme/[awemeId]/components/NavigationButtons.tsx create mode 100644 app/aweme/[awemeId]/components/ProgressBar.tsx create mode 100644 app/aweme/[awemeId]/components/SegmentedProgressBar.tsx create mode 100644 app/aweme/[awemeId]/components/VideoPlayer.tsx create mode 100644 app/aweme/[awemeId]/components/index.ts create mode 100644 app/aweme/[awemeId]/hooks/index.ts create mode 100644 app/aweme/[awemeId]/hooks/useBackgroundCanvas.ts create mode 100644 app/aweme/[awemeId]/hooks/useCommentState.ts create mode 100644 app/aweme/[awemeId]/hooks/useImageCarousel.ts create mode 100644 app/aweme/[awemeId]/hooks/useNavigation.ts create mode 100644 app/aweme/[awemeId]/hooks/usePlayerState.ts create mode 100644 app/aweme/[awemeId]/hooks/useVideoPlayer.ts create mode 100644 app/aweme/[awemeId]/types.ts create mode 100644 app/aweme/[awemeId]/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ff30c44 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.tabSize": 2 +} \ No newline at end of file diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index b4d2d52..bebfc53 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -1,506 +1,75 @@ "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"; +import { useMemo, useRef } from "react"; +import { Pause, Play } from "lucide-react"; +import type { AwemeData, ImageData, Neighbors, VideoData } from "./types"; +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 { VideoPlayer } from "./components/VideoPlayer"; +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"; -type User = { nickname: string; avatar_url: string | null }; -type Comment = { cid: string; text: string; digg_count: number; created_at: string | Date; user: User }; +const SEGMENT_MS = 5000; -// 格式化相对时间 -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 '刚刚'; +interface AwemeDetailClientProps { + data: AwemeData; + neighbors: Neighbors; } -// 处理评论文本中的表情占位符 -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"; +export default function AwemeDetailClient({ data, neighbors }: AwemeDetailClientProps) { const router = useRouter(); + const isVideo = data.type === "video"; - // ====== 布局 & 评论 ====== - const [open, setOpen] = useState(false); // 评论是否展开(竖屏为 bottom sheet;横屏为并排分栏) - const [mounted, setMounted] = useState(false); // 用于跳过首次加载的动画 - const comments = useMemo(() => data.comments ?? [], [data]); + // 状态管理 + const playerState = usePlayerState(); + const commentState = useCommentState(); - // ====== 从 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 scrollerRef = useRef(null); 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 = isVideo ? [] : (data as ImageData).images; + const imageCarouselState = useImageCarousel({ + images, + isPlaying: playerState.isPlaying, + loopMode: playerState.loopMode, + neighbors, + volume: playerState.volume, + audioRef, + scrollerRef, + setProgress: playerState.setProgress, }); - // ====== 图文专用(分段) ====== - const images = (data as any).images as ImageData["images"] | undefined; - const totalSegments = images?.length ?? 0; - const [idx, setIdx] = useState(0); // 当前图片索引 - const scrollerRef = useRef(null); + // 视频播放器 hooks + if (isVideo) { + useVideoPlayer({ + 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, + }); + } - // 用 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) { @@ -515,11 +84,10 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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); + imageCarouselState.idxRef.current = targetIdx; + imageCarouselState.setIdx(targetIdx); + imageCarouselState.segStartRef.current = performance.now() - remainder * SEGMENT_MS; + playerState.setProgress((targetIdx + remainder) / total); const el = scrollerRef.current; if (el) el.scrollTo({ left: targetIdx * el.clientWidth, behavior: "smooth" }); @@ -529,16 +97,18 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; if (isVideo) { const v = videoRef.current; if (!v) return; - if (v.paused) await v.play().catch(() => { }); + 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 { } + if (!playerState.isPlaying) { + playerState.setIsPlaying(true); + try { + await el?.play().catch(() => {}); + } catch {} } else { - setIsPlaying(false); + playerState.setIsPlaying(false); el?.pause(); } }; @@ -546,13 +116,13 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; const toggleFullscreen = () => { if (!document.fullscreenElement) { if (document.body.requestFullscreen) { - document.body.requestFullscreen().catch(() => { }); - return + document.body.requestFullscreen().catch(() => {}); + return; } const vRef = videoRef.current; if (vRef && vRef.requestFullscreen) { - vRef.requestFullscreen().catch(() => { }); - return + vRef.requestFullscreen().catch(() => {}); + return; } // @ts-ignore if (vRef && vRef.webkitEnterFullscreen) { @@ -560,36 +130,32 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; vRef.webkitEnterFullscreen(); } } else { - document.exitFullscreen().catch(() => { }); + 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 next = Math.max(0, imageCarouselState.idxRef.current - 1); + imageCarouselState.idxRef.current = next; + imageCarouselState.setIdx(next); + imageCarouselState.segStartRef.current = performance.now(); 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 next = Math.min(images.length - 1, imageCarouselState.idxRef.current + 1); + imageCarouselState.idxRef.current = next; + imageCarouselState.setIdx(next); + imageCarouselState.segStartRef.current = performance.now(); 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; @@ -598,663 +164,134 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; link.click(); document.body.removeChild(link); } else { - // 下载当前图片 if (!images?.length) return; - const currentImage = images[idx]; + const currentImage = images[imageCarouselState.idx]; const link = document.createElement("a"); link.href = currentImage.url; - link.download = `image_${data.aweme_id}_${idx + 1}.jpg`; + link.download = `image_${data.aweme_id}_${imageCarouselState.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')}`; - }; + // 导航相关 + useNavigation({ + neighbors, + isVideo, + mediaContainerRef, + videoRef, + prevImg, + nextImg, + togglePlay, + }); - // ====== 评论内容组件 - 使用 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]); + // 背景画布 + useBackgroundCanvas({ + isVideo, + idx: imageCarouselState.idx, + videoRef, + scrollerRef, + backgroundCanvasRef, + }); return (
- {/* 动态模糊背景 canvas */} - + - {/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
{/* 主媒体区域 */}
-
+
{isVideo ? ( -
- {/* 横屏评论面板:并排分栏 */} - + {/* 评论面板 */} + commentState.setOpen(false)} + author={data.author} + createdAt={data.created_at} + comments={data.comments} + mounted={commentState.mounted} + />
- - {/* 竖屏评论面板:bottom sheet */} -
); } diff --git a/app/aweme/[awemeId]/components/BackgroundCanvas.tsx b/app/aweme/[awemeId]/components/BackgroundCanvas.tsx new file mode 100644 index 0000000..eef2011 --- /dev/null +++ b/app/aweme/[awemeId]/components/BackgroundCanvas.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from "react"; + +interface BackgroundCanvasProps {} + +export const BackgroundCanvas = forwardRef((props, ref) => { + return ( + + ); +}); + +BackgroundCanvas.displayName = "BackgroundCanvas"; diff --git a/app/aweme/[awemeId]/components/CommentList.tsx b/app/aweme/[awemeId]/components/CommentList.tsx new file mode 100644 index 0000000..7fa42ab --- /dev/null +++ b/app/aweme/[awemeId]/components/CommentList.tsx @@ -0,0 +1,56 @@ +import { ThumbsUp } from "lucide-react"; +import type { Comment, User } from "../types"; +import { formatRelativeTime } from "../utils"; +import { CommentText } from "./CommentText"; + +interface CommentListProps { + author: User; + createdAt: string | Date; + comments: Comment[]; +} + +export function CommentList({ author, createdAt, comments }: CommentListProps) { + return ( + <> +
+
+ {author.avatar_url ? ( + avatar + ) : null} +
+
+
{author.nickname}
+
+ 发布于 {formatRelativeTime(createdAt)} +
+
+
+ +
    + {comments.map((c) => ( +
  • +
    + {c.user.avatar_url ? ( + avatar + ) : null} +
    +
    +
    + {c.user.nickname} + {formatRelativeTime(c.created_at)} +
    +

    + +

    +
    + + {c.digg_count} +
    +
    +
  • + ))} + {comments.length === 0 ?
  • 暂无评论
  • : null} +
+ + ); +} diff --git a/app/aweme/[awemeId]/components/CommentPanel.tsx b/app/aweme/[awemeId]/components/CommentPanel.tsx new file mode 100644 index 0000000..f50223e --- /dev/null +++ b/app/aweme/[awemeId]/components/CommentPanel.tsx @@ -0,0 +1,71 @@ +import { X } from "lucide-react"; +import type { Comment, User } from "../types"; +import { CommentList } from "./CommentList"; + +interface CommentPanelProps { + open: boolean; + onClose: () => void; + author: User; + createdAt: string | Date; + comments: Comment[]; + mounted: boolean; +} + +export function CommentPanel({ open, onClose, author, createdAt, comments, mounted }: CommentPanelProps) { + return ( + <> + {/* 横屏评论面板:并排分栏 */} + + + {/* 竖屏评论面板:bottom sheet */} + + + ); +} diff --git a/app/aweme/[awemeId]/components/CommentText.tsx b/app/aweme/[awemeId]/components/CommentText.tsx new file mode 100644 index 0000000..50b264e --- /dev/null +++ b/app/aweme/[awemeId]/components/CommentText.tsx @@ -0,0 +1,30 @@ +import { parseCommentText } from "../utils"; + +// 渲染评论文本(包含表情) +export function CommentText({ text }: { text: string }) { + const parts = parseCommentText(text); + + return ( + <> + {parts.map((part, idx: number) => { + 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); + }} + /> + ); + })} + + ); +} diff --git a/app/aweme/[awemeId]/components/ImageCarousel.tsx b/app/aweme/[awemeId]/components/ImageCarousel.tsx new file mode 100644 index 0000000..20fc976 --- /dev/null +++ b/app/aweme/[awemeId]/components/ImageCarousel.tsx @@ -0,0 +1,39 @@ +import { forwardRef } from "react"; +import type { ImageData } from "../types"; + +interface ImageCarouselProps { + images: ImageData["images"]; + currentIndex: number; + onTogglePlay: () => void; +} + +export const ImageCarousel = forwardRef( + ({ images, currentIndex, onTogglePlay }, ref) => { + return ( +
+ {images.map((img, i) => ( +
+ {`image-${i +
+ ))} +
+ ); + } +); + +ImageCarousel.displayName = "ImageCarousel"; diff --git a/app/aweme/[awemeId]/components/ImageNavigationButtons.tsx b/app/aweme/[awemeId]/components/ImageNavigationButtons.tsx new file mode 100644 index 0000000..2c86faa --- /dev/null +++ b/app/aweme/[awemeId]/components/ImageNavigationButtons.tsx @@ -0,0 +1,56 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface ImageNavigationButtonsProps { + onPrev: () => void; + onNext: () => void; + currentIndex: number; + totalImages: number; +} + +export function ImageNavigationButtons({ + onPrev, + onNext, + currentIndex, + totalImages, +}: ImageNavigationButtonsProps) { + const hasPrev = currentIndex > 0; + const hasNext = currentIndex < totalImages - 1; + + return ( + <> + {/* 左侧按钮 */} + + + {/* 右侧按钮 */} + + + ); +} diff --git a/app/aweme/[awemeId]/components/MediaControls.tsx b/app/aweme/[awemeId]/components/MediaControls.tsx new file mode 100644 index 0000000..05bdb1e --- /dev/null +++ b/app/aweme/[awemeId]/components/MediaControls.tsx @@ -0,0 +1,256 @@ +import { + ArrowDownUp, + Download, + Maximize, + Maximize2, + Minimize, + Minimize2, + Pause, + Play, + Repeat1, + RotateCcw, + RotateCw, + Volume2, + VolumeX, +} from "lucide-react"; +import type { RefObject } from "react"; +import type { LoopMode, ObjectFit, User } from "../types"; +import { formatRelativeTime, formatTime } from "../utils"; +import { ProgressBar } from "./ProgressBar"; +import { SegmentedProgressBar } from "./SegmentedProgressBar"; + +interface MediaControlsProps { + isVideo: boolean; + isPlaying: boolean; + progress: number; + volume: number; + rate: number; + rotation: number; + objectFit: ObjectFit; + loopMode: LoopMode; + isFullscreen: boolean; + author: User; + createdAt: string | Date; + desc: string; + // 视频专用 + videoRef?: RefObject; + // 图文专用 + currentIndex?: number; + totalSegments?: number; + segmentProgress?: number; + musicUrl?: string | null; + audioRef?: RefObject; + // 回调 + onTogglePlay: () => void; + onSeek: (ratio: number) => void; + onVolumeChange: (volume: number) => void; + onRateChange: (rate: number) => void; + onRotationChange: (rotation: number) => void; + onObjectFitChange: (fit: ObjectFit) => void; + onLoopModeChange: (mode: LoopMode) => void; + onDownload: () => void; + onToggleFullscreen: () => void; +} + +export function MediaControls({ + isVideo, + isPlaying, + progress, + volume, + rate, + rotation, + objectFit, + loopMode, + isFullscreen, + author, + createdAt, + desc, + videoRef, + currentIndex = 0, + totalSegments = 0, + segmentProgress = 0, + musicUrl, + audioRef, + onTogglePlay, + onSeek, + onVolumeChange, + onRateChange, + onRotationChange, + onObjectFitChange, + onLoopModeChange, + onDownload, + onToggleFullscreen, +}: MediaControlsProps) { + return ( +
+ {/* 描述行 */} +
+ + {author.nickname} + · + + {formatRelativeTime(createdAt)} + +
+ + {desc ? ( +
+

+ {desc} +

+
+ ) : null} + + {/* 进度条:图文=分段;视频=单段 */} + {!isVideo && totalSegments > 0 ? ( + + ) : ( + + )} + + {/* 控制按钮行 - 响应式布局 */} +
+ {/* 左侧:播放控制 + 时间/进度 */} +
+ + + {/* 播放进度显示 - 所有设备都显示 */} +
+ {isVideo ? ( + (() => { + const v = videoRef?.current; + const current = v?.currentTime ?? 0; + const total = v?.duration ?? 0; + return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--"; + })() + ) : ( + `${currentIndex + 1} / ${totalSegments}` + )} +
+ + {/* 倍速 - 中等屏幕以上显示,仅视频 */} + {isVideo && ( + + )} +
+ + {/* 中间:音量控制 - 中等屏幕以上显示 */} +
+ {/* 旋转按钮 - 小屏幕以上显示 */} + + + onVolumeChange(parseFloat(e.target.value))} + className="w-20 lg:w-28 accent-white cursor-pointer" + aria-label="音量" + /> + +
+ + {/* 右侧:功能按钮组 */} +
+ {/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */} + + + {/* 循环模式 - 中等屏幕以上显示 */} + + + {/* 适配模式 - 小屏幕以上显示 */} + + + {/* 下载 - 中等屏幕以上显示 */} + + + {/* 全屏 - 所有设备都显示 */} + +
+
+ + {/* 图文 BGM(隐藏控件,仅用于播放) */} + {!isVideo && musicUrl ?
+ ); +} diff --git a/app/aweme/[awemeId]/components/NavigationButtons.tsx b/app/aweme/[awemeId]/components/NavigationButtons.tsx new file mode 100644 index 0000000..c3edf9c --- /dev/null +++ b/app/aweme/[awemeId]/components/NavigationButtons.tsx @@ -0,0 +1,60 @@ +import { ChevronDown, ChevronUp, MessageSquareText } from "lucide-react"; +import type { Neighbors } from "../types"; + +interface NavigationButtonsProps { + neighbors: Neighbors; + commentsCount: number; + onNavigatePrev: () => void; + onNavigateNext: () => void; + onToggleComments: () => void; +} + +export function NavigationButtons({ + neighbors, + commentsCount, + onNavigatePrev, + onNavigateNext, + onToggleComments, +}: NavigationButtonsProps) { + return ( + <> + {/* 评论开关(右侧中部) */} + + + {/* 上下切换按钮(右侧胶囊形状) */} +
+
+ + +
+
+ + ); +} diff --git a/app/aweme/[awemeId]/components/ProgressBar.tsx b/app/aweme/[awemeId]/components/ProgressBar.tsx new file mode 100644 index 0000000..a5af462 --- /dev/null +++ b/app/aweme/[awemeId]/components/ProgressBar.tsx @@ -0,0 +1,18 @@ +interface ProgressBarProps { + progress: number; + onSeek: (ratio: number) => void; +} + +export function ProgressBar({ progress, onSeek }: ProgressBarProps) { + return ( +
{ + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + onSeek((e.clientX - rect.left) / rect.width); + }} + > +
+
+ ); +} diff --git a/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx b/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx new file mode 100644 index 0000000..1fa416d --- /dev/null +++ b/app/aweme/[awemeId]/components/SegmentedProgressBar.tsx @@ -0,0 +1,40 @@ +interface SegmentedProgressBarProps { + totalSegments: number; + currentIndex: number; + segmentProgress: number; + onSeek: (ratio: number) => void; +} + +export function SegmentedProgressBar({ + totalSegments, + currentIndex, + segmentProgress, + onSeek, +}: SegmentedProgressBarProps) { + return ( +
{ + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + onSeek((e.clientX - rect.left) / rect.width); + }} + > +
+ {Array.from({ length: totalSegments }).map((_, i) => { + let fill = 0; + if (i < currentIndex) fill = 1; + else if (i === currentIndex) fill = segmentProgress; + return ( +
+
+
+ ); + })} +
+
+ ); +} diff --git a/app/aweme/[awemeId]/components/VideoPlayer.tsx b/app/aweme/[awemeId]/components/VideoPlayer.tsx new file mode 100644 index 0000000..9e8bf3b --- /dev/null +++ b/app/aweme/[awemeId]/components/VideoPlayer.tsx @@ -0,0 +1,41 @@ +import { forwardRef } from "react"; +import type { ObjectFit } from "../types"; + +interface VideoPlayerProps { + videoUrl: string; + rotation: number; + objectFit: ObjectFit; + loop: boolean; + onTogglePlay: () => void; +} + +export const VideoPlayer = forwardRef( + ({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => { + return ( +