diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index 1c7468c..b4d2d52 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -29,6 +29,26 @@ import { 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 })[] = []; @@ -117,15 +137,25 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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 [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); @@ -316,6 +346,33 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; }; }, [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; @@ -488,7 +545,20 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; const toggleFullscreen = () => { if (!document.fullscreenElement) { - document.body.requestFullscreen().catch(() => { }); + 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(() => { }); } @@ -547,21 +617,54 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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(" "); + // ====== 评论内容组件 - 使用 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, data.author, data.created_at]); // ====== 预取上/下一条路由,提高切换流畅度 ====== useEffect(() => { @@ -600,6 +703,72 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; 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; @@ -610,11 +779,18 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; // 更新 canvas 尺寸以匹配视口 const updateCanvasSize = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + canvas.width = Math.floor(window.innerWidth / 10); + canvas.height = Math.floor(window.innerHeight / 10); }; updateCanvasSize(); - window.addEventListener("resize", updateCanvasSize); + + // 防抖处理 resize 事件(300ms) + let resizeTimeout: NodeJS.Timeout; + const debouncedResize = () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(updateCanvasSize, 300); + }; + window.addEventListener("resize", debouncedResize); // 绘制媒体内容到 canvas(cover 策略) const drawMediaToCanvas = () => { @@ -672,12 +848,12 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); }; - // 使用较高频率的定时器以保持背景连贯(20ms ~= 50fps) const intervalId = setInterval(drawMediaToCanvas, 20); return () => { clearInterval(intervalId); - window.removeEventListener("resize", updateCanvasSize); + window.removeEventListener("resize", debouncedResize); + clearTimeout(resizeTimeout); }; }, [isVideo, idx]); @@ -717,7 +893,6 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; objectFit, }} playsInline - autoPlay loop={loopMode === "loop"} onClick={togglePlay} /> @@ -761,15 +936,30 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; )} + {/* 暂停状态时显示的播放图标 */} + {!isPlaying && ( +
+
+ +
+
+ )} + {/* 统一控制条:desc 在上、进度在下 */}
{/* 描述行 */}
- + {data.author.nickname} + + · + + + {formatRelativeTime(data.created_at)} +
{data.desc ? ( @@ -824,41 +1014,20 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
)} - {/* 控制按钮行 */} -
-
+ {/* 控制按钮行 - 响应式布局 */} +
+ {/* 左侧:播放控制 + 时间/进度 */} +
- - {/* 倍速仅视频展示 */} - {isVideo ? ( - <> - - - - {/* 旋转:向左/向右各 90° */} - - ) : null} - - {/* 播放进度显示 */} -
+ {/* 播放进度显示 - 所有设备都显示 */} +
{isVideo ? ( (() => { const v = videoRef.current; @@ -870,11 +1039,29 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData; `${idx + 1} / ${totalSegments}` )}
+ + {/* 倍速 - 中等屏幕以上显示,仅视频 */} + {isVideo && ( + + )}
-
+ {/* 中间:音量控制 - 中等屏幕以上显示 */} +
+ {/* 旋转按钮 - 小屏幕以上显示 */}
-
- {/* 循环模式切换 */} + {/* 右侧:功能按钮组 */} +
+ + {/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */} + + {/* 循环模式 - 中等屏幕以上显示 */} + + + {/* 适配模式 - 小屏幕以上显示 */} + + {/* 下载 - 中等屏幕以上显示 */} + + {/* 全屏 - 所有设备都显示 */} +
+ 评论 {comments.length > 0 ? `(${comments.length})` : ""} +
-
-
- {data.author.avatar_url ? ( - // eslint-disable-next-line @next/next/no-img-element - avatar - ) : null} -
-
-
{data.author.nickname}
-
发布于 {new Date(data.created_at).toLocaleString()}
-
-
- -
    - {comments.map((c) => ( -
  • -
    - {c.user.avatar_url ? ( - // eslint-disable-next-line @next/next/no-img-element - avatar - ) : null} -
    -
    -
    - {c.user.nickname} - {new Date(c.created_at).toLocaleString()} -
    -

    - -

    -
    - - {c.digg_count} -
    -
    -
  • - ))} - {comments.length === 0 ?
  • 暂无评论
  • : null} -
+ {commentContent}
+ + {/* 竖屏评论面板:bottom sheet */} +
); } diff --git a/app/layout.tsx b/app/layout.tsx index 7323c88..12ed953 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -26,7 +26,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +