添加视频循环模式和下载功能,优化页面元数据和样式
This commit is contained in:
parent
200283b21b
commit
5c98257366
@ -17,6 +17,13 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize,
|
Minimize,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
X,
|
||||||
|
Repeat,
|
||||||
|
Repeat1,
|
||||||
|
ArrowDownUp,
|
||||||
|
Download,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
type User = { nickname: string; avatar_url: string | null };
|
type User = { nickname: string; avatar_url: string | null };
|
||||||
@ -50,7 +57,7 @@ function parseCommentText(text: string): (string | { type: "emoji"; name: string
|
|||||||
// 渲染评论文本(包含表情)
|
// 渲染评论文本(包含表情)
|
||||||
function CommentText({ text }: { text: string }) {
|
function CommentText({ text }: { text: string }) {
|
||||||
const parts = parseCommentText(text);
|
const parts = parseCommentText(text);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{parts.map((part, idx) => {
|
{parts.map((part, idx) => {
|
||||||
@ -149,6 +156,13 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
const [rotation, setRotation] = useState(0); // 视频旋转角度:0/90/180/270
|
||||||
const [progressRestored, setProgressRestored] = useState(false); // 标记进度是否已恢复
|
const [progressRestored, setProgressRestored] = useState(false); // 标记进度是否已恢复
|
||||||
const [objectFit, setObjectFit] = useState<"contain" | "cover">("contain"); // 媒体显示模式
|
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 images = (data as any).images as ImageData["images"] | undefined;
|
||||||
@ -161,6 +175,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
const idxRef = useRef<number>(0);
|
const idxRef = useRef<number>(0);
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
const [segProgress, setSegProgress] = useState(0); // 段内 0..1
|
const [segProgress, setSegProgress] = useState(0); // 段内 0..1
|
||||||
|
const [, forceUpdate] = useState(0); // 用于强制更新时间显示
|
||||||
|
|
||||||
useEffect(() => { idxRef.current = idx; }, [idx]);
|
useEffect(() => { idxRef.current = idx; }, [idx]);
|
||||||
|
|
||||||
@ -182,6 +197,12 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
localStorage.setItem("aweme_player_comments_open", open.toString());
|
localStorage.setItem("aweme_player_comments_open", open.toString());
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// ====== 持久化循环模式到 localStorage ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem("aweme_player_loop_mode", loopMode);
|
||||||
|
}, [loopMode]);
|
||||||
|
|
||||||
// ====== 恢复视频播放进度(带有效期) ======
|
// ====== 恢复视频播放进度(带有效期) ======
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideo || progressRestored) return;
|
if (!isVideo || progressRestored) return;
|
||||||
@ -190,7 +211,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
|
|
||||||
const onLoadedMetadata = () => {
|
const onLoadedMetadata = () => {
|
||||||
if (progressRestored) return;
|
if (progressRestored) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = `aweme_progress_${data.aweme_id}`;
|
const key = `aweme_progress_${data.aweme_id}`;
|
||||||
const saved = localStorage.getItem(key);
|
const saved = localStorage.getItem(key);
|
||||||
@ -214,7 +235,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("恢复播放进度失败", e);
|
console.error("恢复播放进度失败", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgressRestored(true);
|
setProgressRestored(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -235,7 +256,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
|
|
||||||
const saveProgress = () => {
|
const saveProgress = () => {
|
||||||
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const key = `aweme_progress_${data.aweme_id}`;
|
const key = `aweme_progress_${data.aweme_id}`;
|
||||||
const value = JSON.stringify({
|
const value = JSON.stringify({
|
||||||
@ -274,18 +295,26 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
};
|
};
|
||||||
const onPlay = () => setIsPlaying(true);
|
const onPlay = () => setIsPlaying(true);
|
||||||
const onPause = () => setIsPlaying(false);
|
const onPause = () => setIsPlaying(false);
|
||||||
|
const onEnded = () => {
|
||||||
|
// 顺序播放模式下,视频结束时自动跳转到下一条
|
||||||
|
if (loopMode === "sequential" && neighbors?.next) {
|
||||||
|
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
v.addEventListener("timeupdate", onTime);
|
v.addEventListener("timeupdate", onTime);
|
||||||
v.addEventListener("loadedmetadata", onTime);
|
v.addEventListener("loadedmetadata", onTime);
|
||||||
v.addEventListener("play", onPlay);
|
v.addEventListener("play", onPlay);
|
||||||
v.addEventListener("pause", onPause);
|
v.addEventListener("pause", onPause);
|
||||||
|
v.addEventListener("ended", onEnded);
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("timeupdate", onTime);
|
v.removeEventListener("timeupdate", onTime);
|
||||||
v.removeEventListener("loadedmetadata", onTime);
|
v.removeEventListener("loadedmetadata", onTime);
|
||||||
v.removeEventListener("play", onPlay);
|
v.removeEventListener("play", onPlay);
|
||||||
v.removeEventListener("pause", onPause);
|
v.removeEventListener("pause", onPause);
|
||||||
|
v.removeEventListener("ended", onEnded);
|
||||||
};
|
};
|
||||||
}, [isVideo]);
|
}, [isVideo, loopMode, neighbors?.next?.aweme_id, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideo) return;
|
if (!isVideo) return;
|
||||||
@ -299,6 +328,15 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
if (v) v.playbackRate = rate;
|
if (v) v.playbackRate = rate;
|
||||||
}, [rate, isVideo]);
|
}, [rate, isVideo]);
|
||||||
|
|
||||||
|
// ====== 视频:定期更新时间显示 ======
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideo) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
forceUpdate(n => n + 1);
|
||||||
|
}, 100); // 每 100ms 更新一次显示
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isVideo]);
|
||||||
|
|
||||||
// ====== 图文:BGM & 初次自动播放尝试 ======
|
// ====== 图文:BGM & 初次自动播放尝试 ======
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVideo) return;
|
if (isVideo) return;
|
||||||
@ -349,33 +387,38 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
};
|
};
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
// ====== 图文:自动切页(消除“闪回”)======
|
// ====== 图文:自动切页 ======
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVideo || !images?.length) return;
|
if (isVideo || !images?.length) return;
|
||||||
|
|
||||||
if (segStartRef.current == null) segStartRef.current = performance.now();
|
if (segStartRef.current == null) segStartRef.current = performance.now();
|
||||||
|
let lastTs = performance.now();
|
||||||
const tick = (ts: number) => {
|
const tick = (ts: number) => {
|
||||||
if (!images?.length) return;
|
if (!images?.length) return;
|
||||||
|
|
||||||
|
if (!isPlaying) segStartRef.current! += ts - lastTs;
|
||||||
|
lastTs = ts
|
||||||
|
|
||||||
let start = segStartRef.current!;
|
let start = segStartRef.current!;
|
||||||
let localIdx = idxRef.current;
|
let localIdx = idxRef.current;
|
||||||
|
|
||||||
// 暂停时只更新 UI,不推进时间
|
|
||||||
if (!isPlaying) {
|
|
||||||
const elapsed = Math.max(0, ts - start);
|
|
||||||
const localSeg = Math.min(1, elapsed / SEGMENT_MS);
|
|
||||||
setSegProgress(localSeg);
|
|
||||||
setProgress((localIdx + localSeg) / images.length);
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 前进时间:处理跨多段情况(极少见,但更稳妥)
|
// 前进时间:处理跨多段情况(极少见,但更稳妥)
|
||||||
let elapsed = ts - start;
|
let elapsed = ts - start;
|
||||||
while (elapsed >= SEGMENT_MS) {
|
while (elapsed >= SEGMENT_MS) {
|
||||||
elapsed -= SEGMENT_MS;
|
elapsed -= SEGMENT_MS;
|
||||||
localIdx = (localIdx + 1) % images.length;
|
|
||||||
|
// 检查是否到达最后一张
|
||||||
|
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;
|
segStartRef.current = ts - elapsed;
|
||||||
|
|
||||||
@ -398,25 +441,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
rafRef.current = null;
|
rafRef.current = null;
|
||||||
};
|
};
|
||||||
}, [isVideo, images?.length, isPlaying]);
|
}, [isVideo, images?.length, isPlaying, loopMode, neighbors?.next?.aweme_id, router]);
|
||||||
|
|
||||||
// 横向滚动同步 idx(且重置段起点)
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const onScroll = () => {
|
|
||||||
const i = Math.round(el.scrollLeft / el.clientWidth);
|
|
||||||
if (i !== idxRef.current) {
|
|
||||||
idxRef.current = i;
|
|
||||||
setIdx(i);
|
|
||||||
segStartRef.current = performance.now();
|
|
||||||
setSegProgress(0);
|
|
||||||
setProgress(images && images.length ? i / images.length : 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
el.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener("scroll", onScroll);
|
|
||||||
}, [images?.length]);
|
|
||||||
|
|
||||||
// ====== 统一操作 ======
|
// ====== 统一操作 ======
|
||||||
const seekTo = (ratio: number) => {
|
const seekTo = (ratio: number) => {
|
||||||
@ -491,11 +516,42 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
if (el) el.scrollTo({ left: next * el.clientWidth, behavior: "smooth" });
|
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)
|
// ====== 侧栏(横屏)/ 抽屉(竖屏)样式(Tailwind)
|
||||||
const asideClasses = [
|
const asideClasses = [
|
||||||
"z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white",
|
"z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white",
|
||||||
// 竖屏:bottom sheet,从下向上弹出
|
// 竖屏:bottom sheet,从下向上弹出
|
||||||
"portrait:fixed portrait:inset-x-0 portrait:bottom-0 portrait:w-full portrait:h-[min(80vh,88dvh)]",
|
"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",
|
"portrait:transition-transform portrait:duration-200 portrait:ease-out",
|
||||||
open ? "portrait:translate-y-0" : "portrait:translate-y-full",
|
open ? "portrait:translate-y-0" : "portrait:translate-y-full",
|
||||||
"portrait:border-t portrait:border-white/10",
|
"portrait:border-t portrait:border-white/10",
|
||||||
@ -544,7 +600,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
return () => el.removeEventListener("wheel", onWheel as any);
|
return () => el.removeEventListener("wheel", onWheel as any);
|
||||||
}, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]);
|
}, [neighbors?.next?.aweme_id, neighbors?.prev?.aweme_id]);
|
||||||
|
|
||||||
// ====== 动态模糊背景:每 0.2s 截取当前媒体内容绘制到背景 canvas ======
|
// ====== 动态模糊背景:定时截取当前媒体内容绘制到背景 canvas ======
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = backgroundCanvasRef.current;
|
const canvas = backgroundCanvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
@ -580,15 +636,16 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) {
|
// 不要在帧不可用时清空画布,避免出现黑屏闪烁
|
||||||
return;
|
// 仅当有可绘制帧时才进行绘制,画面会被完整覆盖,无需提前 clear
|
||||||
}
|
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
|
||||||
|
|
||||||
const canvasWidth = canvas.width;
|
const canvasWidth = canvas.width;
|
||||||
const canvasHeight = canvas.height;
|
const canvasHeight = canvas.height;
|
||||||
const sourceWidth = sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
|
const sourceWidth = sourceElement instanceof HTMLVideoElement ? sourceElement.videoWidth : sourceElement.naturalWidth;
|
||||||
const sourceHeight = sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
|
const sourceHeight = sourceElement instanceof HTMLVideoElement ? sourceElement.videoHeight : sourceElement.naturalHeight;
|
||||||
|
|
||||||
|
// 图片在切换时可能尚未完成解码(naturalWidth/Height 为 0),此时保持上一帧
|
||||||
if (!sourceWidth || !sourceHeight) return;
|
if (!sourceWidth || !sourceHeight) return;
|
||||||
|
|
||||||
// 计算 cover 模式的尺寸和位置
|
// 计算 cover 模式的尺寸和位置
|
||||||
@ -611,11 +668,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
offsetY = 0;
|
offsetY = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空画布并绘制
|
// 直接绘制一帧即可(cover 会完全覆盖整个画布,无需 clearRect)
|
||||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
||||||
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 使用较高频率的定时器以保持背景连贯(20ms ~= 50fps)
|
||||||
const intervalId = setInterval(drawMediaToCanvas, 20);
|
const intervalId = setInterval(drawMediaToCanvas, 20);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@ -632,7 +689,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
className="fixed inset-0 w-full h-full -z-10"
|
className="fixed inset-0 w-full h-full -z-10"
|
||||||
style={{ filter: "blur(40px)" }}
|
style={{ filter: "blur(40px)" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
{/* 横屏下使用并排布局;竖屏下为单栏(评论为 bottom sheet) */}
|
||||||
<div className="relative h-full landscape:flex landscape:flex-row">
|
<div className="relative h-full landscape:flex landscape:flex-row">
|
||||||
{/* 主媒体区域 */}
|
{/* 主媒体区域 */}
|
||||||
@ -648,8 +705,8 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
// 旋转 0/180:充满容器盒子;
|
// 旋转 0/180:充满容器盒子;
|
||||||
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
|
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
|
||||||
rotation % 180 === 0
|
rotation % 180 === 0
|
||||||
? `absolute inset-0 h-full w-full object-${objectFit} bg-black/70 cursor-pointer`
|
? `absolute inset-0 h-full w-full bg-black/70 cursor-pointer`
|
||||||
: `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] object-${objectFit} bg-black/70 cursor-pointer`,
|
: `absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer`,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
transform:
|
transform:
|
||||||
@ -657,16 +714,18 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
? `rotate(${rotation}deg)`
|
? `rotate(${rotation}deg)`
|
||||||
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||||
transformOrigin: "center center",
|
transformOrigin: "center center",
|
||||||
|
objectFit,
|
||||||
}}
|
}}
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay
|
autoPlay
|
||||||
loop onClick={togglePlay}
|
loop={loopMode === "loop"}
|
||||||
|
onClick={togglePlay}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<div
|
<div
|
||||||
ref={scrollerRef}
|
ref={scrollerRef}
|
||||||
className="absolute inset-0 overflow-x-auto overflow-y-hidden snap-x snap-mandatory flex no-scrollbar"
|
className="absolute inset-0 overflow-x-auto overflow-y-hidden snap-x snap-mandatory flex no-scrollbar"
|
||||||
>
|
>
|
||||||
{(data as ImageData).images.map((img) => (
|
{(data as ImageData).images.map((img) => (
|
||||||
<div
|
<div
|
||||||
@ -675,7 +734,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
style={{ aspectRatio: img.width && img.height ? `${img.width}/${img.height}` : undefined }}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={img.url} alt="image" className={`absolute inset-0 w-full h-full object-${objectFit} cursor-pointer`} onClick={togglePlay} draggable={false} />
|
<img src={img.url} alt="image" className={`absolute inset-0 w-full h-full cursor-pointer`} style={{ objectFit }} onClick={togglePlay} draggable={false} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -684,14 +743,14 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
{images && images.length > 1 ? (
|
{images && images.length > 1 ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="absolute top-1/2 -translate-y-1/2 left-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
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}
|
onClick={prevImg}
|
||||||
aria-label="上一张"
|
aria-label="上一张"
|
||||||
>
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="absolute top-1/2 -translate-y-1/2 right-3 w-[42px] h-[42px] grid place-items-center rounded-full bg-black/15 text-white border border-white/20 drop-shadow-lg cursor-pointer"
|
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}
|
onClick={nextImg}
|
||||||
aria-label="下一张"
|
aria-label="下一张"
|
||||||
>
|
>
|
||||||
@ -752,7 +811,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="relative h-1 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
seekTo((e.clientX - rect.left) / rect.width);
|
seekTo((e.clientX - rect.left) / rect.width);
|
||||||
@ -769,18 +828,19 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
<div className="flex items-center justify-between gap-2.5">
|
<div className="flex items-center justify-between gap-2.5">
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<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"
|
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}
|
onClick={togglePlay}
|
||||||
aria-label={isPlaying ? "暂停" : "播放"}
|
aria-label={isPlaying ? "暂停" : "播放"}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
{/* 倍速仅视频展示 */}
|
{/* 倍速仅视频展示 */}
|
||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm"
|
className="h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
|
||||||
const i = steps.indexOf(rate);
|
const i = steps.indexOf(rate);
|
||||||
@ -792,14 +852,29 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
{rate}x
|
{rate}x
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
{/* 旋转:向左/向右各 90° */}
|
{/* 旋转:向左/向右各 90° */}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : 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>
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
<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"
|
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)}
|
onClick={() => setRotation((r) => (r + 270) % 360)}
|
||||||
aria-label="向左旋转 90 度"
|
aria-label="向左旋转 90 度"
|
||||||
title="向左旋转 90 度"
|
title="向左旋转 90 度"
|
||||||
@ -807,7 +882,7 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
<RotateCcw size={18} />
|
<RotateCcw size={18} />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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))}
|
onClick={() => setVolume((v) => (v > 0 ? 0 : 1))}
|
||||||
aria-label={volume > 0 ? "静音" : "取消静音"}
|
aria-label={volume > 0 ? "静音" : "取消静音"}
|
||||||
>
|
>
|
||||||
@ -820,11 +895,11 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
step={0.05}
|
step={0.05}
|
||||||
value={volume}
|
value={volume}
|
||||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||||
className="w-28 accent-white"
|
className="w-28 accent-white cursor-pointer"
|
||||||
aria-label="音量"
|
aria-label="音量"
|
||||||
/>
|
/>
|
||||||
<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"
|
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)}
|
onClick={() => setRotation((r) => (r + 90) % 360)}
|
||||||
aria-label="向右旋转 90 度"
|
aria-label="向右旋转 90 度"
|
||||||
title="向右旋转 90 度"
|
title="向右旋转 90 度"
|
||||||
@ -834,8 +909,17 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inline-flex items-center gap-2">
|
<div className="inline-flex items-center gap-2">
|
||||||
|
{/* 循环模式切换 */}
|
||||||
<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"
|
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"))}
|
onClick={() => setObjectFit((f) => (f === "contain" ? "cover" : "contain"))}
|
||||||
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
||||||
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
|
||||||
@ -843,7 +927,15 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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}
|
onClick={toggleFullscreen}
|
||||||
aria-label="切换全屏"
|
aria-label="切换全屏"
|
||||||
>
|
>
|
||||||
@ -876,20 +968,46 @@ export default function AwemeDetailClient(props: { data: VideoData | ImageData;
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</section>
|
||||||
|
|
||||||
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}
|
{/* 评论面板:竖屏 bottom sheet;横屏并排分栏 */}
|
||||||
<aside className={asideClasses}>
|
<aside className={asideClasses}>
|
||||||
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
|
||||||
<button
|
{/* 竖屏:评论在左,关闭按钮在右 */}
|
||||||
className="text-white/90 text-xs px-2 py-1 rounded-lg bg-white/15 border border-white/20"
|
<div className="text-white font-semibold portrait:order-1 landscape:order-2">
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
<div className="text-white font-semibold">
|
|
||||||
评论 {comments.length > 0 ? `(${comments.length})` : ""}
|
评论 {comments.length > 0 ? `(${comments.length})` : ""}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-3 overflow-auto">
|
<div className="p-3 overflow-auto">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
import AwemeDetailClient from "./Client";
|
import AwemeDetailClient from "./Client";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
function ms(v?: number | null) {
|
function ms(v?: number | null) {
|
||||||
if (!v) return "";
|
if (!v) return "";
|
||||||
@ -10,6 +11,37 @@ function ms(v?: number | null) {
|
|||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
|
||||||
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
|
const [video, post] = await Promise.all([
|
||||||
|
prisma.video.findUnique({
|
||||||
|
where: { aweme_id: id },
|
||||||
|
select: { desc: true, author: { select: { nickname: true } } },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findUnique({
|
||||||
|
where: { aweme_id: id },
|
||||||
|
select: { desc: true, author: { select: { nickname: true } } },
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = video || post;
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
title: "作品不存在",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const desc = data.desc || "查看作品详情";
|
||||||
|
const author = data.author.nickname;
|
||||||
|
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description: desc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
export default async function AwemeDetail({ params }: { params: Promise<{ awemeId: string }> }) {
|
||||||
const id = (await params).awemeId;
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
|
|||||||
@ -28,4 +28,12 @@ body {
|
|||||||
|
|
||||||
/* 滚动条隐藏 */
|
/* 滚动条隐藏 */
|
||||||
.no-scrollbar::-webkit-scrollbar { display: none; }
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|
||||||
|
.h-screen{
|
||||||
|
height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-h-screen{
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
@ -13,8 +13,11 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
default: "抖歪 - 记录当下时代",
|
||||||
|
template: "%s - 抖歪",
|
||||||
|
},
|
||||||
|
description: "记录当下时代的精彩瞬间",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import FeedMasonry from "./components/FeedMasonry";
|
import FeedMasonry from "./components/FeedMasonry";
|
||||||
import type { FeedItem } from "./types/feed";
|
import type { FeedItem } from "./types/feed";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "作品集 - 抖歪",
|
||||||
|
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
|
|||||||
@ -34,6 +34,14 @@ export default function TasksPage() {
|
|||||||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||||||
const [, setTick] = useState(0); // 用于强制更新计时显示
|
const [, setTick] = useState(0); // 用于强制更新计时显示
|
||||||
|
|
||||||
|
// 设置页面标题
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "任务管理 - 抖歪";
|
||||||
|
return () => {
|
||||||
|
document.title = "抖歪 - 记录当下时代";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const inProgressUrls = useMemo(
|
const inProgressUrls = useMemo(
|
||||||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
||||||
[tasks]
|
[tasks]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user