From 5c982573668c78991f36458ec061449cef77e948 Mon Sep 17 00:00:00 2001 From: feie9456 Date: Tue, 21 Oct 2025 18:36:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A7=86=E9=A2=91=E5=BE=AA?= =?UTF-8?q?=E7=8E=AF=E6=A8=A1=E5=BC=8F=E5=92=8C=E4=B8=8B=E8=BD=BD=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E9=A1=B5=E9=9D=A2=E5=85=83?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=92=8C=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/aweme/[awemeId]/Client.tsx | 254 ++++++++++++++++++++++++--------- app/aweme/[awemeId]/page.tsx | 32 +++++ app/globals.css | 10 +- app/layout.tsx | 7 +- app/page.tsx | 6 + app/tasks/page.tsx | 8 ++ 6 files changed, 246 insertions(+), 71 deletions(-) diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 8cf1910..1c7468c 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -17,6 +17,13 @@ import { RotateCw, Maximize2, Minimize, + ChevronUp, + ChevronDown, + X, + Repeat, + Repeat1, + ArrowDownUp, + Download, } from "lucide-react"; type User = { nickname: string; avatar_url: string | null }; @@ -50,7 +57,7 @@ function parseCommentText(text: string): (string | { type: "emoji"; name: string // 渲染评论文本(包含表情) function CommentText({ text }: { text: string }) { const parts = parseCommentText(text); - + return ( <> {parts.map((part, idx) => { @@ -149,6 +156,13 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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; @@ -161,6 +175,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; const idxRef = useRef(0); const rafRef = useRef(null); const [segProgress, setSegProgress] = useState(0); // 段内 0..1 + const [, forceUpdate] = useState(0); // 用于强制更新时间显示 useEffect(() => { idxRef.current = idx; }, [idx]); @@ -182,6 +197,12 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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; @@ -190,7 +211,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; const onLoadedMetadata = () => { if (progressRestored) return; - + try { const key = `aweme_progress_${data.aweme_id}`; const saved = localStorage.getItem(key); @@ -214,7 +235,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; } catch (e) { console.error("恢复播放进度失败", e); } - + setProgressRestored(true); }; @@ -235,7 +256,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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({ @@ -274,18 +295,26 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; }; 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]); + }, [isVideo, loopMode, neighbors?.next?.aweme_id, router]); useEffect(() => { if (!isVideo) return; @@ -299,6 +328,15 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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; @@ -349,33 +387,38 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; }; }, [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; - // 暂停时只更新 UI,不推进时间 - if (!isPlaying) { - const elapsed = Math.max(0, ts - start); - const localSeg = Math.min(1, elapsed / SEGMENT_MS); - setSegProgress(localSeg); - setProgress((localIdx + localSeg) / images.length); - rafRef.current = requestAnimationFrame(tick); - return; - } - // 前进时间:处理跨多段情况(极少见,但更稳妥) let elapsed = ts - start; while (elapsed >= SEGMENT_MS) { elapsed -= SEGMENT_MS; - localIdx = (localIdx + 1) % images.length; + + // 检查是否到达最后一张 + 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; @@ -398,25 +441,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = null; }; - }, [isVideo, images?.length, isPlaying]); - - // 横向滚动同步 idx(且重置段起点) - useEffect(() => { - const el = scrollerRef.current; - if (!el) return; - const onScroll = () => { - const i = Math.round(el.scrollLeft / el.clientWidth); - if (i !== idxRef.current) { - idxRef.current = i; - setIdx(i); - segStartRef.current = performance.now(); - setSegProgress(0); - setProgress(images && images.length ? i / images.length : 0); - } - }; - el.addEventListener("scroll", onScroll, { passive: true }); - return () => el.removeEventListener("scroll", onScroll); - }, [images?.length]); + }, [isVideo, images?.length, isPlaying, loopMode, neighbors?.next?.aweme_id, router]); // ====== 统一操作 ====== const seekTo = (ratio: number) => { @@ -491,11 +516,42 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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:bottom-0 portrait:w-full portrait:h-[min(80vh,88dvh)]", + "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", @@ -544,7 +600,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; return () => el.removeEventListener("wheel", onWheel as any); }, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]); - // ====== 动态模糊背景:每 0.2s 截取当前媒体内容绘制到背景 canvas ====== + // ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ====== useEffect(() => { const canvas = backgroundCanvasRef.current; if (!canvas) return; @@ -580,15 +636,16 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; } } - if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) { - return; - } + // 不要在帧不可用时清空画布,避免出现黑屏闪烁 + // 仅当有可绘制帧时才进行绘制,画面会被完整覆盖,无需提前 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 模式的尺寸和位置 @@ -611,11 +668,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; offsetY = 0; } - // 清空画布并绘制 - ctx.clearRect(0, 0, canvasWidth, canvasHeight); + // 直接绘制一帧即可(cover 会完全覆盖整个画布,无需 clearRect) ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); }; + // 使用较高频率的定时器以保持背景连贯(20ms ~= 50fps) const intervalId = setInterval(drawMediaToCanvas, 20); return () => { @@ -632,7 +689,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; className="fixed inset-0 w-full h-full -z-10" style={{ filter: "blur(40px)" }} /> - + {/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
{/* 主媒体区域 */} @@ -648,8 +705,8 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; // 旋转 0/180:充满容器盒子; // 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏 rotation % 180 === 0 - ? `absolute inset-0 h-full w-full object-${objectFit} bg-black/70 cursor-pointer` - : `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-${objectFit} bg-black/70 cursor-pointer`, + ? `absolute inset-0 h-full w-full bg-black/70 cursor-pointer` + : `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer`, ].join(" ")} style={{ transform: @@ -657,16 +714,18 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; ? `rotate(${rotation}deg)` : `translate(-50%, -50%) rotate(${rotation}deg)`, transformOrigin: "center center", + objectFit, }} playsInline autoPlay - loop onClick={togglePlay} + loop={loopMode === "loop"} + onClick={togglePlay} /> ) : (
{(data as ImageData).images.map((img) => (
{/* eslint-disable-next-line @next/next/no-img-element */} - image + image
))}
@@ -684,14 +743,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; {images && images.length > 1 ? ( <>
) : (
{ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); seekTo((e.clientX - rect.left) / rect.width); @@ -769,18 +828,19 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
+ {/* 倍速仅视频展示 */} {isVideo ? ( <> + {/* 旋转:向左/向右各 90° */} ) : null} + + {/* 播放进度显示 */} +
+ {isVideo ? ( + (() => { + const v = videoRef.current; + const current = v?.currentTime ?? 0; + const total = v?.duration ?? 0; + return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : '--:-- / --:--'; + })() + ) : ( + `${idx + 1} / ${totalSegments}` + )} +
+ {/* 循环模式切换 */} + +
+ + {/* 上下切换视频按钮(右侧胶囊形状) */} +
+
+ + +
+
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}