优化前端,修改返回逻辑,添加视频实时背景,音量倍速持久化

This commit is contained in:
2025-10-21 16:33:16 +08:00
parent 9d1598a5ab
commit 200283b21b
231 changed files with 744 additions and 62 deletions
+355 -17
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
export const emojiList = [
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
]
+27 -1
View File
@@ -63,6 +63,32 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
})),
};
// Compute prev/next neighbors by created_at across videos and image posts
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at) as unknown as Date;
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
prisma.video.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
prisma.imagePost.findFirst({ where: { created_at: { gt: currentCreatedAt } }, orderBy: { created_at: "asc" }, select: { aweme_id: true, created_at: true } }),
prisma.video.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
prisma.imagePost.findFirst({ where: { created_at: { lt: currentCreatedAt } }, orderBy: { created_at: "desc" }, select: { aweme_id: true, created_at: true } }),
]);
const pickPrev = (() => {
const cands: { aweme_id: string; created_at: Date }[] = [];
if (newerVideo) cands.push({ aweme_id: newerVideo.aweme_id, created_at: newerVideo.created_at as unknown as Date });
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at as unknown as Date });
if (cands.length === 0) return null;
cands.sort((a, b) => +a.created_at - +b.created_at);
return { aweme_id: cands[0].aweme_id };
})();
const pickNext = (() => {
const cands: { aweme_id: string; created_at: Date }[] = [];
if (olderVideo) cands.push({ aweme_id: olderVideo.aweme_id, created_at: olderVideo.created_at as unknown as Date });
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at as unknown as Date });
if (cands.length === 0) return null;
cands.sort((a, b) => +b.created_at - +a.created_at);
return { aweme_id: cands[0].aweme_id };
})();
const neighbors: { prev: { aweme_id: string } | null; next: { aweme_id: string } | null } = { prev: pickPrev, next: pickNext };
return (
<main className="min-h-screen w-full overflow-hidden">
{/* 顶部条改为悬浮在媒体区域之上,避免 sticky 造成 Y 方向滚动条 */}
@@ -72,7 +98,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
className="inline-flex items-center justify-center w-9 h-9 rounded-full bg-white/15 text-white border border-white/20 backdrop-blur hover:bg-white/25"
/>
</div>
<AwemeDetailClient data={data as any} />
<AwemeDetailClient data={data as any} neighbors={neighbors as any} />
</main>
);
}