1059 lines
41 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 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(() => {
// 从 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 comments = useMemo(() => data.comments ?? [], [data]);
// ====== 媒体引用 ======
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) 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) {
document.body.requestFullscreen().catch(() => { });
} 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')}`;
};
// ====== 侧栏(横屏)/ 抽屉竖屏样式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(" ");
// ====== 预取上/下一条路由,提高切换流畅度 ======
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]);
// ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ======
useEffect(() => {
const canvas = backgroundCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 更新 canvas 尺寸以匹配视口
const updateCanvasSize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
updateCanvasSize();
window.addEventListener("resize", updateCanvasSize);
// 绘制媒体内容到 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);
};
// 使用较高频率的定时器以保持背景连贯20ms ~= 50fps
const intervalId = setInterval(drawMediaToCanvas, 20);
return () => {
clearInterval(intervalId);
window.removeEventListener("resize", updateCanvasSize);
};
}, [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
autoPlay
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>
)}
{/* 统一控制条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-[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.author.nickname}
</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-2.5">
<div className="inline-flex items-center gap-2">
<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={togglePlay}
aria-label={isPlaying ? "暂停" : "播放"}
>
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
</button>
{/* 倍速仅视频展示 */}
{isVideo ? (
<>
<button
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
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>
{/* 旋转:向左/向右各 90° */}
</>
) : null}
{/* 播放进度显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[80px] ml-2">
{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>
</div>
<div className="inline-flex items-center gap-2">
<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={() => 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-28 accent-white cursor-pointer"
aria-label="音量"
/>
<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={() => setRotation((r) => (r + 90) % 360)}
aria-label="向右旋转 90 度"
title="向右旋转 90 度"
>
<RotateCw size={18} />
</button>
</div>
<div className="inline-flex items-center gap-2">
{/* 循环模式切换 */}
<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={() => setLoopMode((m) => (m === "loop" ? "sequential" : "loop"))}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
>
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp 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={() => setObjectFit((f) => (f === "contain" ? "cover" : "contain"))}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
>
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize 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={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>
{/* 评论面板:竖屏 bottom sheet横屏并排分栏 */}
<aside className={asideClasses}>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
{/* 竖屏:评论在左,关闭按钮在右 */}
<div className="text-white font-semibold portrait:order-1 landscape:order-2">
{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 portrait:order-2 landscape:order-1"
onClick={() => setOpen(false)}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div className="p-3 overflow-auto">
<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"> {new Date(data.created_at).toLocaleString()}</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">{new Date(c.created_at).toLocaleString()}</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>
</div>
</aside>
</div>
</div>
);
}