优化前端,修改返回逻辑,添加视频实时背景,音量倍速持久化
This commit is contained in:
+355
-17
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -14,11 +15,67 @@ import {
|
||||
MessageSquareText,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Maximize2,
|
||||
Minimize,
|
||||
} 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;
|
||||
@@ -45,26 +102,53 @@ type ImageData = {
|
||||
|
||||
const SEGMENT_MS = 5000; // 图文每段 5s
|
||||
|
||||
export default function AwemeDetailClient(props: { data: VideoData | ImageData }) {
|
||||
const { data } = props;
|
||||
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 [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(1); // 视频音量 / 图文BGM音量
|
||||
const [rate, setRate] = useState(1); // 仅视频使用
|
||||
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 images = (data as any).images as ImageData["images"] | undefined;
|
||||
@@ -80,6 +164,104 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
|
||||
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]);
|
||||
|
||||
// ====== 恢复视频播放进度(带有效期) ======
|
||||
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;
|
||||
@@ -143,6 +325,30 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
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;
|
||||
@@ -241,14 +447,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) await v.play();
|
||||
if (v.paused) await v.play().catch(() => { });
|
||||
else v.pause();
|
||||
return;
|
||||
}
|
||||
const el = audioRef.current;
|
||||
if (!isPlaying) {
|
||||
setIsPlaying(true);
|
||||
try { await el?.play(); } catch { }
|
||||
try { await el?.play().catch(() => { }); } catch { }
|
||||
} else {
|
||||
setIsPlaying(false);
|
||||
el?.pause();
|
||||
@@ -256,10 +462,8 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen().catch(() => { });
|
||||
document.body.requestFullscreen().catch(() => { });
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => { });
|
||||
}
|
||||
@@ -303,8 +507,132 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
: "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]);
|
||||
|
||||
// ====== 动态模糊背景:每 0.2s 截取当前媒体内容绘制到背景 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);
|
||||
|
||||
// 绘制媒体内容到 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 清空画布并绘制
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 主媒体区域 */}
|
||||
@@ -317,11 +645,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
ref={videoRef}
|
||||
src={(data as VideoData).video_url}
|
||||
className={[
|
||||
// 旋转 0/180:充满容器盒子,用 object-contain;
|
||||
// 旋转 90/270:用中心定位 + 100vh/100vw + object-cover,保证铺满全屏
|
||||
// 旋转 0/180:充满容器盒子;
|
||||
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
|
||||
rotation % 180 === 0
|
||||
? "absolute inset-0 h-full w-full object-contain bg-black/70"
|
||||
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-contain bg-black/70",
|
||||
? `absolute inset-0 h-full w-full object-${objectFit} bg-black/70 cursor-pointer`
|
||||
: `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-${objectFit} bg-black/70 cursor-pointer`,
|
||||
].join(" ")}
|
||||
style={{
|
||||
transform:
|
||||
@@ -332,7 +660,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
}}
|
||||
playsInline
|
||||
autoPlay
|
||||
loop
|
||||
loop onClick={togglePlay}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0">
|
||||
@@ -347,7 +675,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
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 object-contain" />
|
||||
<img src={img.url} alt="image" className={`absolute inset-0 w-full h-full object-${objectFit} cursor-pointer`} onClick={togglePlay} draggable={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -506,6 +834,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
</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"
|
||||
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"
|
||||
onClick={toggleFullscreen}
|
||||
@@ -584,7 +920,9 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData }
|
||||
<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">{c.text}</p>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user