1261 lines
48 KiB
TypeScript
1261 lines
48 KiB
TypeScript
"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);
|
||
|
||
// 绘制媒体内容到 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]);
|
||
|
||
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>
|
||
);
|
||
}
|