1261 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 <span key={idx}>{part}</span>;
}
return (
<img
key={idx}
src={`/emojis/${part.name}.webp`}
alt={part.name}
className="inline-block w-5 h-5 align-text-bottom mx-0.5"
onError={(e) => {
// 如果图片加载失败,显示原始文本
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<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const wheelCooldownRef = useRef<number>(0);
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(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<HTMLDivElement | null>(null);
// 用 ref 解决“闪回”
const segStartRef = useRef<number | null>(null); // 当前段开始时间戳
const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(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(() => (
<>
<header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{data.author.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={data.author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
) : null}
</div>
<div>
<div className="font-medium text-white/95 text-sm sm:text-base">{data.author.nickname}</div>
<div className="text-xs text-white/50" title={new Date(data.created_at).toLocaleString()}>
{formatRelativeTime(data.created_at)}
</div>
</div>
</header>
<ul className="space-y-4 sm:space-y-5">
{comments.map((c) => (
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
{c.user.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
<span className="text-xs text-white/50" title={new Date(c.created_at).toLocaleString()}>
{formatRelativeTime(c.created_at)}
</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
</p>
<div className="mt-2 inline-flex items-center gap-1 text-xs text-white/70">
<ThumbsUp size={14} />
<span>{c.digg_count}</span>
</div>
</div>
</li>
))}
{comments.length === 0 ? <li className="text-sm text-white/60"></li> : null}
</ul>
</>
), [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);
// 绘制媒体内容到 canvascover 策略)
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 (
<div className="h-screen w-full">
{/* 动态模糊背景 canvas */}
<canvas
ref={backgroundCanvasRef}
className="fixed inset-0 w-full h-full -z-10"
style={{ filter: "blur(40px)" }}
/>
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet */}
<div className="relative h-full landscape:flex landscape:flex-row">
{/* 主媒体区域 */}
<section ref={mediaContainerRef} className="relative h-screen landscape:flex-1">
<div
className="relative h-screen overflow-hidden"
>
{isVideo ? (
<video
ref={videoRef}
src={(data as VideoData).video_url}
className={[
// 旋转 0/180充满容器盒子
// 旋转 90/270用中心定位 + 100vh/100vw保证铺满全屏
rotation % 180 === 0
? `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:
rotation % 180 === 0
? `rotate(${rotation}deg)`
: `translate(-50%, -50%) rotate(${rotation}deg)`,
transformOrigin: "center center",
objectFit,
}}
playsInline
loop={loopMode === "loop"}
onClick={togglePlay}
/>
) : (
<div className="absolute inset-0">
<div
ref={scrollerRef}
className="absolute inset-0 overflow-x-auto overflow-y-hidden snap-x snap-mandatory flex no-scrollbar"
>
{(data as ImageData).images.map((img) => (
<div
key={img.id}
className="snap-center shrink-0 w-full h-screen relative bg-black/70"
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.url} alt="image" className={`absolute inset-0 w-full h-full cursor-pointer`} style={{ objectFit }} onClick={togglePlay} draggable={false} />
</div>
))}
</div>
{/* 左右切图(多张时显示) */}
{images && images.length > 1 ? (
<>
<button
className="absolute top-5/11 left-3 -translate-y-1/2 w-12 h-12 flex items-center justify-center text-white hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed border-b border-white/20 bg-black/40 backdrop-blur-sm border overflow-hidden rounded-full"
onClick={prevImg}
aria-label="上一张"
>
<ChevronLeft />
</button>
<button
className="absolute top-5/11 right-3 -translate-y-1/2 w-12 h-12 flex items-center justify-center text-white hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed border-b border-white/20 bg-black/40 backdrop-blur-sm border overflow-hidden rounded-full"
onClick={nextImg}
aria-label="下一张"
>
<ChevronRight />
</button>
</>
) : null}
</div>
)}
{/* 暂停状态时显示的播放图标 */}
{!isPlaying && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div className="w-20 h-20 rounded-full bg-black/40 backdrop-blur-sm border border-white/30 flex items-center justify-center">
<Play size={40} className="text-white/90 ml-1" />
</div>
</div>
)}
{/* 统一控制条desc 在上、进度在下 */}
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
{/* 描述行 */}
<div className="pointer-events-none flex items-center gap-2.5 mb-1">
<img src={data.author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
{data.author.nickname}
</span>
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
·
</span>
<span className="text-[11px] leading-tight text-white/95 drop-shadow" title={new Date(data.created_at).toLocaleString()}>
{formatRelativeTime(data.created_at)}
</span>
</div>
{data.desc ? (
<div className="pointer-events-none">
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
{data.desc}
</p>
</div>
) : null}
{/* 进度条:图文=分段;视频=单段 */}
{!isVideo && totalSegments > 0 ? (
<div
className="relative h-1.5 cursor-pointer"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
seekTo((e.clientX - rect.left) / rect.width);
}}
>
<div className="flex gap-1.5 h-full">
{Array.from({ length: totalSegments }).map((_, i) => {
let fill = 0;
if (i < idx) fill = 1;
else if (i === idx) fill = segProgress;
return (
<div
key={i}
aria-label={`${i + 1}`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
>
<div
className="h-full origin-left bg-white"
style={{ transform: `scaleX(${fill})` }}
/>
</div>
);
})}
</div>
</div>
) : (
<div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
seekTo((e.clientX - rect.left) / rect.width);
}}
>
<div
className="origin-left h-full bg-white"
style={{ transform: `scaleX(${progress || 0})` }}
/>
</div>
)}
{/* 控制按钮行 - 响应式布局 */}
<div className="flex items-center justify-between gap-1.5 sm:gap-2.5">
{/* 左侧:播放控制 + 时间/进度 */}
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
<button
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={togglePlay}
aria-label={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
{/* 播放进度显示 - 所有设备都显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{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}`
)}
</div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
{isVideo && (
<button
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0"
onClick={() => {
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
const i = steps.indexOf(rate);
const next = steps[(i + 1) % steps.length];
setRate(next);
}}
aria-label="切换倍速"
>
{rate}x
</button>
)}
</div>
{/* 中间:音量控制 - 中等屏幕以上显示 */}
<div className="hidden md:inline-flex items-center gap-2 shrink-0">
{/* 旋转按钮 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setRotation((r) => (r + 270) % 360)}
aria-label="向左旋转 90 度"
title="向左旋转 90 度"
>
<RotateCcw size={18} />
</button>
<button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setVolume((v) => (v > 0 ? 0 : 1))}
aria-label={volume > 0 ? "静音" : "取消静音"}
>
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="w-20 lg:w-28 accent-white cursor-pointer"
aria-label="音量"
/>
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setRotation((r) => (r + 90) % 360)}
aria-label="向右旋转 90 度"
title="向右旋转 90 度"
>
<RotateCw size={18} />
</button>
</div>
{/* 右侧:功能按钮组 */}
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
<button
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setVolume((v) => (v > 0 ? 0 : 1))}
aria-label={volume > 0 ? "静音" : "取消静音"}
>
{volume > 0 ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button>
{/* 循环模式 - 中等屏幕以上显示 */}
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setLoopMode((m) => (m === "loop" ? "sequential" : "loop"))}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
>
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
</button>
{/* 适配模式 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => setObjectFit((f) => (f === "contain" ? "cover" : "contain"))}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
>
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
</button>
{/* 下载 - 中等屏幕以上显示 */}
<button
className="hidden md:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={handleDownload}
aria-label={isVideo ? "下载视频" : "下载当前图片"}
title={isVideo ? "下载视频" : "下载当前图片"}
>
<Download size={18} />
</button>
{/* 全屏 - 所有设备都显示 */}
<button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={toggleFullscreen}
aria-label="切换全屏"
>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
{/* 图文 BGM隐藏控件仅用于播放 */}
{!isVideo && "music_url" in data && data.music_url ? (
<audio ref={audioRef} src={data.music_url ?? undefined} loop preload="metadata" />
) : null}
</div>
</div>
{/* 评论开关(竖屏:底部居中;横屏:右侧中部悬浮) */}
<button
className={[
"z-10 grid place-items-center w-[54px] h-[54px] rounded-full ",
"absolute right-4 top-2/3 -translate-y-1/2",
].join(" ")}
onClick={() => setOpen((v) => !v)}
aria-label="切换评论"
aria-expanded={open}
>
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
<span className="text-xl text-white">
{comments.length > 0 ? `${comments.length}` : "暂无评论"}
</span>
</div>
</button>
{/* 上下切换视频按钮(右侧胶囊形状) */}
<div className="absolute right-4 top-6/11 -translate-y-1/2 z-10">
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden">
<button
className="w-10 h-12 flex items-center justify-center text-white hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed border-b border-white/20"
onClick={() => neighbors?.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)}
disabled={!neighbors?.prev}
aria-label="上一个视频"
title="上一个视频"
>
<ChevronUp size={20} />
</button>
<button
className="w-10 h-12 flex items-center justify-center text-white hover:bg-white/10 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => neighbors?.next && router.push(`/aweme/${neighbors.next.aweme_id}`)}
disabled={!neighbors?.next}
aria-label="下一个视频"
title="下一个视频"
>
<ChevronDown size={20} />
</button>
</div>
</div>
</section>
{/* 横屏评论面板:并排分栏 */}
<aside
className={`
hidden landscape:flex
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
relative h-full overflow-hidden
${mounted ? "transition-[width] duration-200 ease-out" : ""}
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={() => setOpen(false)}
aria-label="关闭评论"
>
<X size={18} />
</button>
<div className="text-white font-semibold">
{comments.length > 0 ? `(${comments.length})` : ""}
</div>
</div>
<div className="p-3 overflow-auto">
{commentContent}
</div>
</aside>
</div>
{/* 竖屏评论面板bottom sheet */}
<aside
className={`
landscape:hidden
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
${mounted ? "transition-transform duration-200 ease-out" : ""}
border-t border-white/10
${open ? "translate-y-0" : "translate-y-full"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold">
{comments.length > 0 ? `(${comments.length})` : ""}
</div>
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={() => setOpen(false)}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div className="p-3 overflow-auto">
{commentContent}
</div>
</aside>
</div>
);
}