优化图文页

This commit is contained in:
2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions
+167 -100
View File
@@ -1,9 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { Pause, Play } from "lucide-react";
import type { AwemeData, ImageData, Neighbors, VideoData, VideoTranscript } from "./types.ts";
import { useCallback, useEffect, useRef, useState } from "react";
import { Play } from "lucide-react";
import type {
AwemeData,
ImageData,
Neighbors,
VideoData,
VideoTranscript,
} from "./types.ts";
import { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel";
@@ -18,7 +24,6 @@ import { useImageCarousel } from "./hooks/useImageCarousel";
import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer";
import { Prisma } from "@prisma/client";
const SEGMENT_MS = 4000;
@@ -28,7 +33,11 @@ interface AwemeDetailClientProps {
transcript: VideoTranscript | null;
}
export default function AwemeDetailClient({ data, neighbors, transcript }: AwemeDetailClientProps) {
export default function AwemeDetailClient({
data,
neighbors,
transcript,
}: AwemeDetailClientProps) {
const router = useRouter();
const isVideo = data.type === "video";
@@ -44,6 +53,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
const scrollerRef = useRef<HTMLDivElement | null>(null);
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
const pauseImageMedia = useCallback((reset = false) => {
const audioEl = audioRef.current;
if (audioEl) {
audioEl.pause();
if (reset) audioEl.currentTime = 0;
}
scrollerRef.current?.querySelectorAll("video").forEach((videoEl) => {
videoEl.pause();
if (reset) videoEl.currentTime = 0;
});
}, []);
// 图文轮播状态
const images = isVideo ? [] : (data as ImageData).images;
const imageCarouselState = useImageCarousel({
@@ -53,7 +75,6 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
neighbors,
volume: playerState.volume,
audioRef,
scrollerRef,
setProgress: playerState.setProgress,
segmentMs: SEGMENT_MS,
});
@@ -84,50 +105,32 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
return;
}
if (!images?.length) return;
imageCarouselState.seekTo(ratio);
};
// 计算每张图片的时长
const durations = images.map(img => img.duration ?? SEGMENT_MS);
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
const targetTime = ratio * totalDuration;
const seekImageByVisualRatio = (ratio: number) => {
if (!images?.length) return;
// 找到目标时间对应的图片索引和进度
let accumulatedTime = 0;
let targetIdx = 0;
let remainder = 0;
const clampedRatio = Math.min(1, Math.max(0, ratio));
const rawSegment = clampedRatio * images.length;
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
imageCarouselState.goToIndex(targetIndex, segmentProgress);
};
for (let i = 0; i < images.length; i++) {
if (accumulatedTime + durations[i] > targetTime) {
targetIdx = i;
remainder = (targetTime - accumulatedTime) / durations[i];
break;
}
accumulatedTime += durations[i];
if (i === images.length - 1) {
targetIdx = i;
remainder = 1;
}
const handleControlSeek = (ratio: number) => {
if (isVideo) {
seekTo(ratio);
return;
}
imageCarouselState.idxRef.current = targetIdx;
imageCarouselState.setIdx(targetIdx);
imageCarouselState.segStartRef.current = performance.now() - remainder * durations[targetIdx];
// 重新计算总进度
let totalProgress = 0;
for (let i = 0; i < targetIdx; i++) {
totalProgress += 1;
}
totalProgress += remainder;
playerState.setProgress(totalProgress / images.length);
// 虚拟滚动不需要实际滚动 DOM
seekImageByVisualRatio(ratio);
};
const togglePlay = async () => {
if (isVideo) {
const v = videoRef.current;
if (!v) return;
if (v.paused) await v.play().catch(() => { });
if (v.paused) await v.play().catch(() => {});
else v.pause();
return;
}
@@ -135,23 +138,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
if (!playerState.isPlaying) {
playerState.setIsPlaying(true);
try {
await el?.play().catch(() => { });
} catch { }
await el?.play().catch(() => {});
} catch {}
} else {
playerState.setIsPlaying(false);
el?.pause();
pauseImageMedia();
}
};
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
if (document.body.requestFullscreen) {
document.body.requestFullscreen().catch(() => { });
document.body.requestFullscreen().catch(() => {});
return;
}
const vRef = videoRef.current;
if (vRef && vRef.requestFullscreen) {
vRef.requestFullscreen().catch(() => { });
vRef.requestFullscreen().catch(() => {});
return;
}
// @ts-ignore
@@ -160,26 +163,23 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
vRef.webkitEnterFullscreen();
}
} else {
document.exitFullscreen().catch(() => { });
document.exitFullscreen().catch(() => {});
}
};
const prevImg = () => {
if (!images?.length) return;
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
imageCarouselState.idxRef.current = next;
imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now();
// 虚拟滚动不需要实际滚动 DOM
imageCarouselState.goToIndex(next);
};
const nextImg = () => {
if (!images?.length) return;
const next = Math.min(images.length - 1, imageCarouselState.idxRef.current + 1);
imageCarouselState.idxRef.current = next;
imageCarouselState.setIdx(next);
imageCarouselState.segStartRef.current = performance.now();
// 虚拟滚动不需要实际滚动 DOM
const next = Math.min(
images.length - 1,
imageCarouselState.idxRef.current + 1,
);
imageCarouselState.goToIndex(next);
};
const handleDownload = () => {
@@ -223,6 +223,27 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
backgroundCanvasRef,
});
useEffect(() => {
if (isVideo) return;
const pauseForPageLifecycle = () => {
playerState.setIsPlaying(false);
pauseImageMedia();
};
const handleVisibilityChange = () => {
if (document.hidden) pauseForPageLifecycle();
};
window.addEventListener("pagehide", pauseForPageLifecycle);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("pagehide", pauseForPageLifecycle);
document.removeEventListener("visibilitychange", handleVisibilityChange);
pauseImageMedia();
};
}, [isVideo, pauseImageMedia, playerState.setIsPlaying]);
// Media Session API 集成
useEffect(() => {
if (typeof window === "undefined") return;
@@ -236,20 +257,28 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
const album = new Date(data.created_at).toLocaleString();
// 单一封面图:使用作品 cover_url
const coverUrl = (data as AwemeData).cover_url as string | undefined;
const coverSize = (data as AwemeData).cover_size as { w: number; h: number } | undefined;
const artwork = coverUrl ? [{ src: coverUrl, size: `${coverSize?.w || 512}x${coverSize?.h || 512}` }] : [];
const coverSize = (data as AwemeData).cover_size as
| { w: number; h: number }
| undefined;
const artwork = coverUrl
? [
{
src: coverUrl,
size: `${coverSize?.w || 512}x${coverSize?.h || 512}`,
},
]
: [];
try {
ms.metadata = new MediaMetadata({ title, artist, album, artwork });
} catch { }
} catch {}
// 更新播放状态
try {
ms.playbackState = playerState.isPlaying ? "playing" : "paused";
} catch { }
} catch {}
const getImagesTotalMs = () =>
(images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0);
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
const updatePosition = () => {
try {
@@ -267,11 +296,14 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
} else if (images?.length) {
const totalMs = getImagesTotalMs();
const duration = totalMs / 1000;
const position = Math.max(0, Math.min(duration, (playerState.progress * totalMs) / 1000));
const position = Math.max(
0,
Math.min(duration, (playerState.progress * totalMs) / 1000),
);
// @ts-ignore
ms.setPositionState({ duration, position, playbackRate: 1 });
}
} catch { }
} catch {}
};
// 绑定视频事件以同步状态
@@ -279,15 +311,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
const v = videoRef.current;
if (isVideo && v) {
const onPlay = () => {
try { ms.playbackState = "playing"; } catch { }
try {
ms.playbackState = "playing";
} catch {}
updatePosition();
};
const onPause = () => {
try { ms.playbackState = "paused"; } catch { }
try {
ms.playbackState = "paused";
} catch {}
updatePosition();
};
const onTimeUpdate = () => updatePosition();
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("timeupdate", onTimeUpdate);
@@ -317,6 +353,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
data.author.avatar_url,
data.created_at,
imageCarouselState.idx,
imageCarouselState.totalDurationMs,
images,
playerState.isPlaying,
playerState.progress,
@@ -329,16 +366,19 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
if (!("mediaSession" in navigator)) return;
const ms = (navigator as any).mediaSession as MediaSession;
const getImagesTotalMs = () =>
(images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0);
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
const handlePlay = async () => {
if (isVideo) {
const v = videoRef.current;
try { await v?.play(); } catch { }
try {
await v?.play();
} catch {}
} else {
playerState.setIsPlaying(true);
try { await audioRef.current?.play(); } catch { }
try {
await audioRef.current?.play();
} catch {}
}
};
const handlePause = () => {
@@ -347,23 +387,30 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
v?.pause();
} else {
playerState.setIsPlaying(false);
audioRef.current?.pause();
pauseImageMedia();
}
};
const handleStop = () => {
if (isVideo) {
const v = videoRef.current;
if (v) { v.pause(); v.currentTime = 0; }
if (v) {
v.pause();
v.currentTime = 0;
}
} else {
playerState.setIsPlaying(false);
audioRef.current?.pause();
pauseImageMedia(true);
seekTo(0);
}
};
const handleSeekDelta = (deltaSec: number) => {
if (isVideo) {
const v = videoRef.current;
if (v) v.currentTime = Math.max(0, Math.min(v.duration || Infinity, v.currentTime + deltaSec));
if (v)
v.currentTime = Math.max(
0,
Math.min(v.duration || Infinity, v.currentTime + deltaSec),
);
} else if (images?.length) {
const totalMs = getImagesTotalMs();
if (totalMs <= 0) return;
@@ -387,8 +434,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
ms.setActionHandler("play", handlePlay);
ms.setActionHandler("pause", handlePause);
ms.setActionHandler("stop", handleStop);
ms.setActionHandler("seekbackward", (details: any) => handleSeekDelta(-((details?.seekOffset as number) || 10)));
ms.setActionHandler("seekforward", (details: any) => handleSeekDelta((details?.seekOffset as number) || 10));
ms.setActionHandler("seekbackward", (details: any) =>
handleSeekDelta(-((details?.seekOffset as number) || 10)),
);
ms.setActionHandler("seekforward", (details: any) =>
handleSeekDelta((details?.seekOffset as number) || 10),
);
ms.setActionHandler("seekto", (details: any) => {
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
});
@@ -417,7 +468,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
ms.setActionHandler("seekto", null);
ms.setActionHandler("previoustrack", null);
ms.setActionHandler("nexttrack", null);
} catch { }
} catch {}
};
}, [
isVideo,
@@ -426,6 +477,8 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
neighbors.next,
router,
playerState.progress,
pauseImageMedia,
imageCarouselState.totalDurationMs,
]);
return (
@@ -434,29 +487,39 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
<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 ? (
<VideoPlayer
ref={videoRef}
videoUrl={(data as VideoData).video_url}
rotation={playerState.rotation}
objectFit={playerState.objectFit}
loop={playerState.loopMode === "loop"}
onTogglePlay={togglePlay}
/>
) : (
<ImageCarousel
ref={scrollerRef}
images={images}
currentIndex={imageCarouselState.idx}
onTogglePlay={togglePlay}
/>
)}
<section
ref={mediaContainerRef}
className="relative isolate h-screen landscape:flex-1"
>
<div className="relative isolate h-screen overflow-hidden">
<div className="absolute inset-0 z-0">
{isVideo ? (
<VideoPlayer
ref={videoRef}
videoUrl={(data as VideoData).video_url}
rotation={playerState.rotation}
objectFit={playerState.objectFit}
loop={playerState.loopMode !== "sequential"}
onTogglePlay={togglePlay}
/>
) : (
<ImageCarousel
ref={scrollerRef}
images={images}
currentIndex={imageCarouselState.idx}
isPlaying={playerState.isPlaying}
segmentProgress={imageCarouselState.segProgress}
mediaSyncToken={imageCarouselState.mediaSyncToken}
loopMode={playerState.loopMode}
onTogglePlay={togglePlay}
onAnimatedDuration={imageCarouselState.setSegmentDuration}
/>
)}
</div>
{/* 暂停图标 */}
{!playerState.isPlaying && (
<div className="pointer-events-none absolute inset-0 grid place-items-center">
<div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
<div className="rounded-full bg-black/40 p-6 backdrop-blur-sm">
<Play size={64} className="text-white" fill="white" />
</div>
@@ -496,7 +559,7 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
hasTranscript={isVideo && !!transcript?.speech_detected}
onShowTranscript={() => setTranscriptOpen(true)}
onTogglePlay={togglePlay}
onSeek={seekTo}
onSeek={handleControlSeek}
onVolumeChange={playerState.setVolume}
onRateChange={playerState.setRate}
onRotationChange={playerState.setRotation}
@@ -512,8 +575,12 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme
neighbors={neighbors}
commentsCount={data.commentsCount}
likesCount={data.likesCount}
onNavigatePrev={() => neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)}
onNavigateNext={() => neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)}
onNavigatePrev={() =>
neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`)
}
onNavigateNext={() =>
neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)
}
onToggleComments={() => commentState.setOpen((v) => !v)}
/>
</section>
@@ -2,7 +2,10 @@ import { forwardRef } from "react";
interface BackgroundCanvasProps {}
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
export const BackgroundCanvas = forwardRef<
HTMLCanvasElement,
BackgroundCanvasProps
>((props, ref) => {
return (
<canvas
ref={ref}
+26 -7
View File
@@ -18,12 +18,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{author.avatar_url ? (
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
<img
src={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">{author.nickname}</div>
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}>
<div className="font-medium text-white/95 text-sm sm:text-base">
{author.nickname}
</div>
<div
className="text-xs text-white/50"
title={formatAbsoluteUTC(createdAt)}
>
发布于 {formatRelativeTime(createdAt)}
</div>
</div>
@@ -34,13 +43,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<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 ? (
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
<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">{formatRelativeTime(c.created_at)}</span>
<span className="font-medium text-white/95 text-sm">
{c.user.nickname}
</span>
<span className="text-xs text-white/50">
{formatRelativeTime(c.created_at)}
</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
@@ -75,7 +92,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
</div>
</li>
))}
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
{comments.length === 0 ? (
<li className="text-sm text-white/60">暂无评论</li>
) : null}
</ul>
{/* 图片预览灯箱 */}
+124 -79
View File
@@ -12,13 +12,23 @@ interface CommentPanelProps {
mounted: boolean;
}
export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounted }: CommentPanelProps) {
export function CommentPanel({
open,
onClose,
author,
createdAt,
awemeId,
mounted,
}: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定)
const [rankParams, setRankParams] = useState<null | { seed: string; snapshot: string }>(null);
const [rankParams, setRankParams] = useState<null | {
seed: string;
snapshot: string;
}>(null);
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
const scrollRefLandscape = useRef<HTMLDivElement>(null);
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
@@ -27,67 +37,81 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
const loadingRef = useRef(false);
// 加载评论
const loadComments = useCallback(async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
loadingRef.current = true;
setLoading(true);
const loadComments = useCallback(
async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
try {
const skip = reset ? 0 : comments.length;
const query = new URLSearchParams({
skip: String(skip),
take: String(20),
mode: 'ranked',
});
if (rankParams) {
query.set('seed', rankParams.seed);
query.set('snapshot', rankParams.snapshot);
}
const response = await fetch(`/api/comments/${awemeId}?${query.toString()}`);
const data = await response.json();
loadingRef.current = true;
setLoading(true);
// 统一做一次基于 cid 的去重,避免分页偶发重复
if (reset) {
setComments(() => {
const seen = new Set<string>();
return (data.comments as Comment[]).filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
try {
const skip = reset ? 0 : comments.length;
const query = new URLSearchParams({
skip: String(skip),
take: String(20),
mode: "ranked",
});
if (rankParams) {
query.set("seed", rankParams.seed);
query.set("snapshot", rankParams.snapshot);
}
const response = await fetch(
`/api/comments/${awemeId}?${query.toString()}`,
);
const data = await response.json();
// 统一做一次基于 cid 的去重,避免分页偶发重复
if (reset) {
setComments(() => {
const seen = new Set<string>();
return (data.comments as Comment[]).filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
});
} else {
setComments((prev) => {
const merged = [...prev, ...(data.comments as Comment[])];
const seen = new Set<string>();
// 保留首次出现的项,既保证顺序也避免重复
return merged.filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
} else {
setComments((prev) => {
const merged = [...prev, ...(data.comments as Comment[])];
const seen = new Set<string>();
// 保留首次出现的项,既保证顺序也避免重复
return merged.filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
});
}
setTotal(data.total);
setHasMore(data.hasMore);
if (data.mode === "ranked" && data.seed && data.snapshot) {
// 初始化或重置时更新稳定参数
setRankParams((prev) => {
if (reset)
return {
seed: String(data.seed),
snapshot: String(data.snapshot),
};
return (
prev ?? {
seed: String(data.seed),
snapshot: String(data.snapshot),
}
);
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
setTotal(data.total);
setHasMore(data.hasMore);
if (data.mode === 'ranked' && data.seed && data.snapshot) {
// 初始化或重置时更新稳定参数
setRankParams((prev) => {
if (reset) return { seed: String(data.seed), snapshot: String(data.snapshot) };
return prev ?? { seed: String(data.seed), snapshot: String(data.snapshot) };
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [awemeId, comments.length, hasMore]);
},
[awemeId, comments.length, hasMore],
);
// 面板打开时加载初始评论
useEffect(() => {
@@ -102,7 +126,10 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
const observers: IntersectionObserver[] = [];
const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => {
const setup = (
rootEl: HTMLDivElement | null,
targetEl: HTMLDivElement | null,
) => {
if (!rootEl || !targetEl) return;
const io = new IntersectionObserver(
(entries) => {
@@ -113,9 +140,9 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
},
{
root: rootEl,
rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发
rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发
threshold: 0,
}
},
);
io.observe(targetEl);
observers.push(io);
@@ -180,19 +207,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
</div>
</div>
<div ref={scrollRefLandscape} className="p-3 overflow-auto comment-scroll">
<CommentList author={author} createdAt={createdAt} comments={comments} />
<div
ref={scrollRefLandscape}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */}
{hasMore && (
<div ref={sentinelRefLandscape} className="h-1 w-full" />
)}
{hasMore && <div ref={sentinelRefLandscape} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
<div className="py-4 text-center text-white/60 text-sm">
加载中...
</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm">没有更多评论了</div>
<div className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</div>
</aside>
@@ -221,19 +257,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
</button>
</div>
<div ref={scrollRefPortrait} className="p-3 overflow-auto comment-scroll">
<CommentList author={author} createdAt={createdAt} comments={comments} />
<div
ref={scrollRefPortrait}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */}
{hasMore && (
<div ref={sentinelRefPortrait} className="h-1 w-full" />
)}
{hasMore && <div ref={sentinelRefPortrait} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
<div className="py-4 text-center text-white/60 text-sm">
加载中...
</div>
)}
{!hasMore && comments.length > 0 && (
<div className="py-4 text-center text-white/40 text-sm">没有更多评论了</div>
<div className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</div>
</aside>
@@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
// 如果图片加载失败,显示原始文本
e.currentTarget.style.display = "none";
const textNode = document.createTextNode(`[${part.name}]`);
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
e.currentTarget.parentNode?.insertBefore(
textNode,
e.currentTarget,
);
}}
/>
);
+159 -71
View File
@@ -1,78 +1,134 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import type { ImageData } from "../types.ts";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ImageData, LoopMode } from "../types.ts";
interface ImageCarouselProps {
images: ImageData["images"];
currentIndex: number;
isPlaying: boolean;
segmentProgress: number;
mediaSyncToken: number;
loopMode: LoopMode;
onTogglePlay: () => void;
onAnimatedDuration?: (imageId: string, durationMs: number) => void;
}
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => {
const [offset, setOffset] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
(
{
images,
currentIndex,
isPlaying,
segmentProgress,
mediaSyncToken,
loopMode,
onTogglePlay,
onAnimatedDuration,
},
ref,
) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const playedVideos = useRef<Set<string>>(new Set());
const activeVideoIdRef = useRef<string | null>(null);
const segmentProgressRef = useRef(segmentProgress);
const [useLightweightMode, setUseLightweightMode] = useState(false);
useEffect(() => {
segmentProgressRef.current = segmentProgress;
}, [segmentProgress]);
const syncVideoToSegmentProgress = useCallback(
(videoEl: HTMLVideoElement, progress: number, force = false) => {
const duration = videoEl.duration;
if (!Number.isFinite(duration) || duration <= 0) return;
const clampedProgress = Math.max(0, Math.min(1, progress));
const maxTime = Math.max(0, duration - 0.05);
const targetTime = Math.min(maxTime, duration * clampedProgress);
if (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) {
try {
videoEl.currentTime = targetTime;
} catch {}
}
},
[],
);
useEffect(() => {
const userAgent = navigator.userAgent;
const isIOS =
/iPad|iPhone|iPod/.test(userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const isSafari =
/Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent);
setUseLightweightMode(isIOS && isSafari);
}, []);
// 虚拟滚动:只渲染当前图片和前后各一张
const visibleIndices = (() => {
const visibleIndices = useMemo(() => {
if (useLightweightMode) return [currentIndex];
const indices: number[] = [];
if (currentIndex > 0) indices.push(currentIndex - 1);
indices.push(currentIndex);
if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
return indices;
})();
}, [currentIndex, images.length, useLightweightMode]);
// 当 currentIndex 变化时,触发滚动动画
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
useEffect(() => {
setIsTransitioning(true);
setOffset(-currentIndex * 100);
const currentImage = images[currentIndex];
if (!currentImage?.animated) {
activeVideoIdRef.current = null;
videoRefs.current.forEach((videoEl) => videoEl.pause());
return;
}
const timer = setTimeout(() => {
setIsTransitioning(false);
}, 300); // 与 CSS transition 时间匹配
const videoEl = videoRefs.current.get(currentImage.id);
return () => clearTimeout(timer);
}, [currentIndex]);
videoRefs.current.forEach((itemVideoEl, imageId) => {
if (imageId !== currentImage.id || !isPlaying) {
itemVideoEl.pause();
}
});
if (videoEl) {
if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id;
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
}
if (isPlaying) {
videoEl.play().catch(() => {});
} else {
videoEl.pause();
}
}
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
// 管理动图播放:进入视口时播放一次
useEffect(() => {
const currentImage = images[currentIndex];
if (!currentImage?.animated) return;
const videoKey = currentImage.id;
const videoEl = videoRefs.current.get(videoKey);
if (videoEl) {
// 检查是否已经播放过
if (!playedVideos.current.has(videoKey)) {
// 重置并播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
playedVideos.current.add(videoKey);
} else {
// 已播放过,重置到开头但不自动播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
}
}
}, [currentIndex, images]);
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) return;
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
useEffect(() => {
const currentImage = images[currentIndex];
if (currentImage?.animated) {
const videoKey = currentImage.id;
// 清除其他视频的播放记录
playedVideos.current.forEach(key => {
if (key !== videoKey) {
playedVideos.current.delete(key);
}
});
}
}, [currentIndex, images]);
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
if (isPlaying) videoEl.play().catch(() => {});
}, [
currentIndex,
images,
isPlaying,
loopMode,
mediaSyncToken,
syncVideoToSegmentProgress,
]);
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
if (el) {
@@ -83,49 +139,81 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
};
return (
<div
ref={ref}
className="relative h-full w-full overflow-hidden"
>
<div
ref={containerRef}
className="flex h-full w-full"
style={{
transform: `translateX(${offset}%)`,
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
}}
>
<div ref={ref} className="relative h-full w-full overflow-hidden">
<div className="relative h-full w-full">
{visibleIndices.map((i) => {
const img = images[i];
const isCurrent = i === currentIndex;
const animatedSrc = isCurrent ? img.animated : null;
return (
<div
key={img.id}
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
data-active={isCurrent ? "true" : undefined}
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
style={{
transform: `translateX(${i * 100}%)`,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
transform: useLightweightMode
? "translateX(0)"
: `translateX(${(i - currentIndex) * 100}%)`,
transition: useLightweightMode
? "none"
: "transform 240ms ease-out",
zIndex: isCurrent ? 1 : 0,
}}
onClick={onTogglePlay}
>
{img.animated ? (
{animatedSrc ? (
<video
ref={(el) => handleVideoRef(el, img.id)}
src={img.animated}
src={animatedSrc}
poster={img.url}
muted
playsInline
loop={loopMode === "single"}
preload={useLightweightMode ? "none" : "metadata"}
controls={false}
disablePictureInPicture
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
}}
onLoadedMetadata={(e) => {
const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) {
onAnimatedDuration?.(
img.id,
Math.round(duration * 1000),
);
if (isCurrent) {
syncVideoToSegmentProgress(
e.currentTarget,
segmentProgressRef.current,
true,
);
}
}
}}
onCanPlay={(e) => {
if (isCurrent) {
syncVideoToSegmentProgress(
e.currentTarget,
segmentProgressRef.current,
);
}
if (isCurrent && isPlaying)
e.currentTarget.play().catch(() => {});
}}
onEnded={(e) => {
// 播放结束后停留在最后一帧
e.currentTarget.pause();
if (loopMode !== "single") e.currentTarget.pause();
}}
/>
) : (
<img
src={img.url}
alt={`image-${i + 1}`}
loading={isCurrent ? "eager" : "lazy"}
decoding="async"
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
@@ -139,7 +227,7 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
</div>
</div>
);
}
},
);
ImageCarousel.displayName = "ImageCarousel";
+100 -34
View File
@@ -8,6 +8,7 @@ import {
Minimize2,
Pause,
Play,
Repeat,
Repeat1,
RotateCcw,
RotateCw,
@@ -89,25 +90,70 @@ export function MediaControls({
onDownload,
onToggleFullscreen,
}: MediaControlsProps) {
const normalizedLoopMode =
isVideo && loopMode === "single" ? "loop" : loopMode;
const loopLabel = isVideo
? normalizedLoopMode === "loop"
? "循环播放"
: "顺序播放"
: loopMode === "single"
? "单页循环"
: loopMode === "loop"
? "图文循环"
: "顺序播放";
const handleLoopModeToggle = () => {
if (isVideo) {
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
return;
}
onLoopModeChange(
loopMode === "loop"
? "single"
: loopMode === "single"
? "sequential"
: "loop",
);
};
const renderLoopIcon = () => {
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
return <ArrowDownUp size={18} />;
};
return (
<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="absolute left-0 right-0 bottom-0 z-40 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="flex items-center gap-2.5 mb-1 pointer-events-none">
{author.sec_uid ? (
<Link
href={`/author/${author.sec_uid}`}
<Link
href={`/author/${author.sec_uid}`}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
>
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">{author.nickname}</span>
<img
src={author.avatar_url!}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
{author.nickname}
</span>
</Link>
) : (
<div className="flex items-center gap-2.5">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
<img
src={author.avatar_url!}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
{author.nickname}
</span>
</div>
)}
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</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={formatAbsoluteUTC(createdAt)}
@@ -150,16 +196,16 @@ export function MediaControls({
{/* 播放进度显示 - 所有设备都显示 */}
<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)}` : "--:-- / --:--";
})()
) : (
`${currentIndex + 1} / ${totalSegments}`
)}
{isVideo
? (() => {
const v = videoRef?.current;
const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0;
return total > 0
? `${formatTime(current)} / ${formatTime(total)}`
: "--:-- / --:--";
})()
: `${currentIndex + 1} / ${totalSegments}`}
</div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
@@ -231,21 +277,31 @@ export function MediaControls({
{/* 循环模式 - 中等屏幕以上显示 */}
<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={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
onClick={handleLoopModeToggle}
aria-label={loopLabel}
title={loopLabel}
>
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
{renderLoopIcon()}
</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={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
onClick={() =>
onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
}
aria-label={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
title={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
>
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
{objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)}
</button>
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
@@ -277,18 +333,28 @@ export function MediaControls({
{/* 小屏幕隐藏的适配模式 */}
<div className="sm:hidden">
<MoreMenuItem
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
icon={
objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)
}
label={objectFit === "contain" ? "填充模式" : "适应模式"}
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
onClick={() =>
onObjectFitChange(
objectFit === "contain" ? "cover" : "contain",
)
}
/>
</div>
{/* 中等屏幕以下隐藏的循环模式 */}
<div className="md:hidden">
<MoreMenuItem
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
label={loopMode === "loop" ? "循环播放" : "顺序播放"}
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
icon={renderLoopIcon()}
label={loopLabel}
onClick={handleLoopModeToggle}
/>
</div>
@@ -338,13 +404,13 @@ export function MediaControls({
>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
{/* 图文 BGM(隐藏控件,仅用于播放) */}
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
{!isVideo && musicUrl ? (
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
) : null}
</div>
);
}
+1 -3
View File
@@ -51,9 +51,7 @@ export function MoreMenu({ children }: MoreMenuProps) {
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ minWidth: "200px" }}
>
<div className="p-2 flex flex-col gap-1">
{children}
</div>
<div className="p-2 flex flex-col gap-1">{children}</div>
</div>
)}
</div>
@@ -1,4 +1,9 @@
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react";
import {
ChevronDown,
ChevronUp,
MessageSquareText,
ThumbsUp,
} from "lucide-react";
import type { Neighbors } from "../types.ts";
interface NavigationButtonsProps {
@@ -21,14 +26,13 @@ export function NavigationButtons({
return (
<>
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10">
<button
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2"
>
<button className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2">
<div className="grid place-items-center gap-1 drop-shadow-lg">
<ThumbsUp size={40} className="" />
<span className="text-[16px] font-semibold text-white/90 drop-shadow">{likesCount}</span>
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
{likesCount}
</span>
</div>
</button>
{/* 评论开关(右侧中部) */}
@@ -40,13 +44,17 @@ export function NavigationButtons({
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[16px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span>
}
{commentsCount > 0 ? (
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
{commentsCount}
</span>
) : (
<span className="text-[12px] font-semibold text-white/90 drop-shadow">
暂无评论
</span>
)}
</div>
</button>
</div>
{/* 上下切换按钮(右侧胶囊形状) */}
+30 -4
View File
@@ -4,15 +4,41 @@ interface ProgressBarProps {
}
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
onSeek((clientX - rect.left) / rect.width);
};
return (
<div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer touch-none"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
}}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
>
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
<div
className="origin-left h-full bg-white"
style={{ transform: `scaleX(${progress || 0})` }}
/>
</div>
);
}
@@ -11,12 +11,35 @@ export function SegmentedProgressBar({
segmentProgress,
onSeek,
}: SegmentedProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
onSeek((clientX - rect.left) / rect.width);
};
return (
<div
className="relative h-1.5 cursor-pointer"
className="relative h-1.5 cursor-pointer touch-none"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
}}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
>
<div className="flex gap-1.5 h-full">
@@ -30,7 +53,12 @@ export function SegmentedProgressBar({
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
className="h-full origin-left bg-white"
style={{
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
}}
/>
</div>
);
})}
@@ -10,7 +10,11 @@ interface TranscriptPanelProps {
transcript: VideoTranscript | null;
}
export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) {
export function TranscriptPanel({
open,
onClose,
transcript,
}: TranscriptPanelProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
@@ -35,7 +35,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
onClick={onTogglePlay}
/>
);
}
},
);
VideoPlayer.displayName = "VideoPlayer";
+215 -2
View File
@@ -1,3 +1,216 @@
export const emojiList = [
"微笑", "色", "发呆", "酷拽", "抠鼻", "流泪", "捂脸", "发怒", "呲牙", "尬笑", "害羞", "调皮", "舔屏", "看", "爱心", "比心", "赞", "鼓掌", "感谢", "抱抱你", "玫瑰", "尴尬流汗", "戳手手", "星星眼", "杀马特", "黄脸干杯", "抱紧自己", "拜拜", "热化了", "黄脸祈祷", "懵", "举手", "加功德", "摊手", "无语流汗", "续火花吧", "点火", "哭哭", "吐舌小狗", "送花", "爱心手", "贴贴", "灵机一动", "耶", "打脸", "大笑", "机智", "送心", "666", "闭嘴", "来看我", "一起加油", "哈欠", "震惊", "晕", "衰", "困", "疑问", "泣不成声", "小鼓掌", "大金牙", "偷笑", "石化", "思考", "吐血", "可怜", "嘘", "撇嘴", "笑哭", "奸笑", "得意", "憨笑", "坏笑", "抓狂", "泪奔", "钱", "恐惧", "愉快", "快哭了", "翻白眼", "互粉", "我想静静", "委屈", "鄙视", "飞吻", "再见", "紫薇别走", "听歌", "求抱抱", "绝望的凝视", "不失礼貌的微笑", "不看", "裂开", "干饭人", "庆祝", "吐舌", "呆无辜", "白眼", "猪头", "冷漠", "暗中观察", "二哈", "菜狗", "黑脸", "展开说说", "蜜蜂狗", "柴犬", "摸头", "皱眉", "擦汗", "红脸", "做鬼脸", "强", "如花", "吐", "惊喜", "敲打", "奋斗", "吐彩虹", "大哭", "嘿哈", "惊恐", "囧", "难过", "斜眼", "阴险", "悠闲", "咒骂", "吃瓜群众", "绿帽子", "敢怒不敢言", "求求了", "眼含热泪", "叹气", "好开心", "不是吧", "鞠躬", "躺平", "九转大肠", "不你不想", "一头乱麻", "kisskiss", "你不大行", "噢买尬", "宕机", "苦涩", "逞强落泪", "求机位-黄脸", "求机位3", "点赞", "精选", "强壮", "碰拳", "OK", "击掌", "左上", "握手", "抱拳", "勾引", "拳头", "弱", "胜利", "右边", "左边", "嘴唇", "心碎", "凋谢", "愤怒", "垃圾", "啤酒", "咖啡", "蛋糕", "礼物", "撒花", "加一", "减一", "okk", "V5", "绝", "给力", "红包", "屎", "发", "18禁", "炸弹", "西瓜", "加鸡腿", "握爪", "太阳", "月亮", "给跪了", "蕉绿", "扎心", "胡瓜", "打call", "栓Q", "雪花", "圣诞树", "平安果", "圣诞帽", "气球", "烟花", "福", "candy", "糖葫芦", "鞭炮", "元宝", "灯笼", "锦鲤", "巧克力", "戒指", "棒棒糖", "纸飞机", "粽子"
]
"微笑",
"色",
"发呆",
"酷拽",
"抠鼻",
"流泪",
"捂脸",
"发怒",
"呲牙",
"尬笑",
"害羞",
"调皮",
"舔屏",
"看",
"爱心",
"比心",
"赞",
"鼓掌",
"感谢",
"抱抱你",
"玫瑰",
"尴尬流汗",
"戳手手",
"星星眼",
"杀马特",
"黄脸干杯",
"抱紧自己",
"拜拜",
"热化了",
"黄脸祈祷",
"懵",
"举手",
"加功德",
"摊手",
"无语流汗",
"续火花吧",
"点火",
"哭哭",
"吐舌小狗",
"送花",
"爱心手",
"贴贴",
"灵机一动",
"耶",
"打脸",
"大笑",
"机智",
"送心",
"666",
"闭嘴",
"来看我",
"一起加油",
"哈欠",
"震惊",
"晕",
"衰",
"困",
"疑问",
"泣不成声",
"小鼓掌",
"大金牙",
"偷笑",
"石化",
"思考",
"吐血",
"可怜",
"嘘",
"撇嘴",
"笑哭",
"奸笑",
"得意",
"憨笑",
"坏笑",
"抓狂",
"泪奔",
"钱",
"恐惧",
"愉快",
"快哭了",
"翻白眼",
"互粉",
"我想静静",
"委屈",
"鄙视",
"飞吻",
"再见",
"紫薇别走",
"听歌",
"求抱抱",
"绝望的凝视",
"不失礼貌的微笑",
"不看",
"裂开",
"干饭人",
"庆祝",
"吐舌",
"呆无辜",
"白眼",
"猪头",
"冷漠",
"暗中观察",
"二哈",
"菜狗",
"黑脸",
"展开说说",
"蜜蜂狗",
"柴犬",
"摸头",
"皱眉",
"擦汗",
"红脸",
"做鬼脸",
"强",
"如花",
"吐",
"惊喜",
"敲打",
"奋斗",
"吐彩虹",
"大哭",
"嘿哈",
"惊恐",
"囧",
"难过",
"斜眼",
"阴险",
"悠闲",
"咒骂",
"吃瓜群众",
"绿帽子",
"敢怒不敢言",
"求求了",
"眼含热泪",
"叹气",
"好开心",
"不是吧",
"鞠躬",
"躺平",
"九转大肠",
"不你不想",
"一头乱麻",
"kisskiss",
"你不大行",
"噢买尬",
"宕机",
"苦涩",
"逞强落泪",
"求机位-黄脸",
"求机位3",
"点赞",
"精选",
"强壮",
"碰拳",
"OK",
"击掌",
"左上",
"握手",
"抱拳",
"勾引",
"拳头",
"弱",
"胜利",
"右边",
"左边",
"嘴唇",
"心碎",
"凋谢",
"愤怒",
"垃圾",
"啤酒",
"咖啡",
"蛋糕",
"礼物",
"撒花",
"加一",
"减一",
"okk",
"V5",
"绝",
"给力",
"红包",
"屎",
"发",
"18禁",
"炸弹",
"西瓜",
"加鸡腿",
"握爪",
"太阳",
"月亮",
"给跪了",
"蕉绿",
"扎心",
"胡瓜",
"打call",
"栓Q",
"雪花",
"圣诞树",
"平安果",
"圣诞帽",
"气球",
"烟花",
"福",
"candy",
"糖葫芦",
"鞭炮",
"元宝",
"灯笼",
"锦鲤",
"巧克力",
"戒指",
"棒棒糖",
"纸飞机",
"粽子",
];
@@ -23,6 +23,13 @@ export function useBackgroundCanvas({
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!isVideo) {
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
return;
}
const updateCanvasSize = () => {
canvas.width = Math.floor(window.innerWidth / 10);
canvas.height = Math.floor(window.innerHeight / 10);
@@ -39,40 +46,29 @@ export function useBackgroundCanvas({
const drawMediaToCanvas = () => {
if (!ctx) return;
let sourceElement: HTMLVideoElement | HTMLImageElement | null = null;
const sourceElement = videoRef.current;
if (isVideo) {
sourceElement = videoRef.current;
} else {
const scroller = scrollerRef.current;
if (scroller) {
// 虚拟滚动:查找所有图片容器,找到当前显示的那个
const containers = scroller.querySelectorAll<HTMLElement>('div[style*="translateX"]');
for (const container of containers) {
const img = container.querySelector("img, video");
if (img && container.style.transform.includes(`${idx * 100}%`)) {
sourceElement = img as HTMLImageElement | HTMLVideoElement;
break;
}
}
}
}
if (!sourceElement || (sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2)) return;
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;
const sourceWidth = sourceElement.videoWidth;
const sourceHeight = sourceElement.videoHeight;
if (!sourceWidth || !sourceHeight) return;
const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, drawHeight: number, offsetX: number, offsetY: number;
let drawWidth: number,
drawHeight: number,
offsetX: number,
offsetY: number;
if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth;
+252 -40
View File
@@ -1,8 +1,32 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation";
import type { ImageData, LoopMode, Neighbors } from "../types.ts";
const MIN_SEGMENT_MS = 250;
const UI_UPDATE_INTERVAL_MS = 33;
const PROGRESS_EPSILON = 0.0001;
function normalizeDuration(
duration: number | null | undefined,
fallback: number,
) {
if (
typeof duration !== "number" ||
!Number.isFinite(duration) ||
duration <= 0
) {
return fallback;
}
return Math.max(MIN_SEGMENT_MS, Math.round(duration));
}
function sumDurations(durations: number[], end: number) {
let total = 0;
for (let i = 0; i < end; i++) total += durations[i] ?? 0;
return total;
}
interface UseImageCarouselProps {
images: ImageData["images"];
isPlaying: boolean;
@@ -10,7 +34,6 @@ interface UseImageCarouselProps {
neighbors: Neighbors;
volume: number;
audioRef: RefObject<HTMLAudioElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
setProgress: (progress: number) => void;
/** 单张图片显示时长(毫秒),默认 5000ms */
segmentMs?: number;
@@ -23,22 +46,186 @@ export function useImageCarousel({
neighbors,
volume,
audioRef,
scrollerRef,
setProgress,
segmentMs = 5000,
}: UseImageCarouselProps) {
const router = useRouter();
const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0);
const [durationOverrides, setDurationOverrides] = useState<
Record<string, number>
>({});
const [mediaSyncToken, setMediaSyncToken] = useState(0);
const segStartRef = useRef<number | null>(null);
const idxRef = useRef<number>(0);
const rafRef = useRef<number | null>(null);
const timerRef = useRef<number | null>(null);
const lastUiUpdateRef = useRef(0);
const segProgressRef = useRef(0);
const totalProgressRef = useRef(0);
const fallbackDuration = normalizeDuration(segmentMs, 5000);
const segmentDurations = useMemo(
() =>
images.map((img) =>
normalizeDuration(
durationOverrides[img.id] ?? img.duration,
fallbackDuration,
),
),
[durationOverrides, fallbackDuration, images],
);
const imageKey = useMemo(
() => images.map((img) => img.id).join("|"),
[images],
);
const totalDurationMs = useMemo(
() => segmentDurations.reduce((sum, duration) => sum + duration, 0),
[segmentDurations],
);
const syncProgress = useCallback(
(index: number, segmentProgress: number, force = false) => {
const segmentDuration = segmentDurations[index] ?? fallbackDuration;
const completedMs = sumDurations(segmentDurations, index);
const totalProgress =
totalDurationMs > 0
? (completedMs + segmentDuration * segmentProgress) / totalDurationMs
: 0;
const nextSegProgress = Math.max(0, Math.min(1, segmentProgress));
const nextTotalProgress = Math.max(0, Math.min(1, totalProgress));
if (
force ||
Math.abs(nextSegProgress - segProgressRef.current) > PROGRESS_EPSILON
) {
segProgressRef.current = nextSegProgress;
setSegProgress(nextSegProgress);
}
if (
force ||
Math.abs(nextTotalProgress - totalProgressRef.current) >
PROGRESS_EPSILON
) {
totalProgressRef.current = nextTotalProgress;
setProgress(nextTotalProgress);
}
},
[fallbackDuration, segmentDurations, setProgress, totalDurationMs],
);
const setSegmentDuration = useCallback(
(imageId: string, durationMs: number) => {
if (!Number.isFinite(durationMs) || durationMs <= 0) return;
const normalizedDuration = normalizeDuration(
durationMs,
fallbackDuration,
);
setDurationOverrides((current) => {
if (current[imageId] === normalizedDuration) return current;
return { ...current, [imageId]: normalizedDuration };
});
},
[fallbackDuration],
);
const goToIndex = useCallback(
(index: number, segmentProgress = 0) => {
if (!images.length) return;
const nextIndex = Math.max(0, Math.min(images.length - 1, index));
const nextSegmentProgress = Math.max(0, Math.min(1, segmentProgress));
const now = performance.now();
const segmentDuration = segmentDurations[nextIndex] ?? fallbackDuration;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now - nextSegmentProgress * segmentDuration;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, nextSegmentProgress, true);
setMediaSyncToken((token) => token + 1);
},
[fallbackDuration, images.length, segmentDurations, syncProgress],
);
const seekTo = useCallback(
(ratio: number) => {
if (!images.length || totalDurationMs <= 0) return;
const clampedRatio = Math.max(0, Math.min(1, ratio));
const targetMs = Math.min(
totalDurationMs - 1,
clampedRatio * totalDurationMs,
);
let accumulatedMs = 0;
let targetIndex = 0;
let segmentElapsedMs = 0;
for (let i = 0; i < segmentDurations.length; i++) {
const duration = segmentDurations[i] ?? fallbackDuration;
if (
targetMs < accumulatedMs + duration ||
i === segmentDurations.length - 1
) {
targetIndex = i;
segmentElapsedMs = Math.max(
0,
Math.min(duration, targetMs - accumulatedMs),
);
break;
}
accumulatedMs += duration;
}
const targetDuration = segmentDurations[targetIndex] ?? fallbackDuration;
const nextSegmentProgress =
targetDuration > 0 ? segmentElapsedMs / targetDuration : 0;
goToIndex(targetIndex, nextSegmentProgress);
},
[
fallbackDuration,
goToIndex,
images.length,
segmentDurations,
totalDurationMs,
],
);
useEffect(() => {
idxRef.current = idx;
}, [idx]);
useEffect(() => {
const validIds = new Set(images.map((img) => img.id));
setDurationOverrides((current) => {
let changed = false;
const next: Record<string, number> = {};
for (const [id, duration] of Object.entries(current)) {
if (validIds.has(id)) {
next[id] = duration;
} else {
changed = true;
}
}
return changed ? next : current;
});
}, [images]);
useEffect(() => {
idxRef.current = 0;
setIdx(0);
segStartRef.current = null;
segProgressRef.current = 0;
totalProgressRef.current = 0;
setSegProgress(0);
setProgress(0);
setMediaSyncToken((token) => token + 1);
}, [imageKey, setProgress]);
// BGM 控制
useEffect(() => {
const el = audioRef.current;
@@ -51,33 +238,42 @@ export function useImageCarousel({
}
}, [audioRef, isPlaying, volume]);
useEffect(() => {
return () => {
audioRef.current?.pause();
};
}, [audioRef]);
// 自动切页
useEffect(() => {
if (!images?.length) return;
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0)
return;
if (segStartRef.current == null) segStartRef.current = performance.now();
let lastTs = performance.now();
const now = performance.now();
const currentSegmentDuration =
segmentDurations[idxRef.current] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
const tick = (ts: number) => {
if (!isPlaying) return;
const tick = () => {
if (!images?.length) return;
if (!isPlaying) segStartRef.current! += ts - lastTs;
lastTs = ts;
const ts = performance.now();
let start = segStartRef.current!;
let localIdx = idxRef.current;
let elapsed = ts - start;
// 获取当前图片的显示时长(动图使用其 duration,静态图片使用 segmentMs)
const getCurrentSegmentDuration = (index: number) => {
const img = images[index];
return img?.duration ?? segmentMs;
};
let currentSegmentDuration = getCurrentSegmentDuration(localIdx);
let currentSegmentDuration =
segmentDurations[localIdx] ?? fallbackDuration;
while (elapsed >= currentSegmentDuration) {
if (loopMode === "single") {
elapsed %= currentSegmentDuration;
break;
}
elapsed -= currentSegmentDuration;
if (localIdx >= images.length - 1) {
@@ -89,44 +285,60 @@ export function useImageCarousel({
} else {
localIdx = localIdx + 1;
}
// 更新下一张图片的时长
currentSegmentDuration = getCurrentSegmentDuration(localIdx);
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration;
}
segStartRef.current = ts - elapsed;
if (localIdx !== idxRef.current) {
const indexChanged = localIdx !== idxRef.current;
if (indexChanged) {
idxRef.current = localIdx;
setIdx(localIdx);
// 虚拟滚动不需要实际滚动 DOM
}
const localSeg = Math.max(0, Math.min(1, elapsed / currentSegmentDuration));
setSegProgress(localSeg);
// 计算总进度:已完成的图片 + 当前图片的进度
let totalProgress = 0;
for (let i = 0; i < localIdx; i++) {
totalProgress += 1;
}
totalProgress += localSeg;
setProgress(totalProgress / images.length);
const localSeg = Math.max(
0,
Math.min(1, elapsed / currentSegmentDuration),
);
rafRef.current = requestAnimationFrame(tick);
if (
indexChanged ||
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = ts;
syncProgress(localIdx, localSeg, indexChanged);
}
};
rafRef.current = requestAnimationFrame(tick);
tick();
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
if (timerRef.current) window.clearInterval(timerRef.current);
timerRef.current = null;
};
}, [images, isPlaying, loopMode, neighbors?.next, router, scrollerRef, setProgress, segmentMs]);
}, [
fallbackDuration,
images,
isPlaying,
loopMode,
neighbors?.next,
router,
segmentDurations,
syncProgress,
totalDurationMs,
]);
return {
idx,
setIdx,
segProgress,
segmentDurations,
totalDurationMs,
mediaSyncToken,
segStartRef,
idxRef,
setSegmentDuration,
goToIndex,
seekTo,
};
}
+5 -1
View File
@@ -65,7 +65,11 @@ export function useNavigation({
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
+13 -4
View File
@@ -1,19 +1,28 @@
import { useEffect, useState } from "react";
import type { LoopMode, ObjectFit } from "../types.ts";
import { getNumberFromStorage, getStringFromStorage, saveToStorage } from "../utils";
import {
getNumberFromStorage,
getStringFromStorage,
saveToStorage,
} from "../utils";
export function usePlayerState() {
const [isPlaying, setIsPlaying] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [volume, setVolume] = useState(() => getNumberFromStorage("aweme_player_volume", 1));
const [rate, setRate] = useState(() => getNumberFromStorage("aweme_player_rate", 1));
const [volume, setVolume] = useState(() =>
getNumberFromStorage("aweme_player_volume", 1),
);
const [rate, setRate] = useState(() =>
getNumberFromStorage("aweme_player_rate", 1),
);
const [progress, setProgress] = useState(0);
const [rotation, setRotation] = useState(0);
const [progressRestored, setProgressRestored] = useState(false);
const [objectFit, setObjectFit] = useState<ObjectFit>("contain");
const [loopMode, setLoopMode] = useState<LoopMode>(() => {
const saved = getStringFromStorage("aweme_player_loop_mode", "loop");
return saved === "sequential" ? "sequential" : "loop";
if (saved === "single" || saved === "sequential") return saved;
return "loop";
});
// 持久化音量
+6 -2
View File
@@ -51,7 +51,11 @@ export function useVideoPlayer({
const now = Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (now - timestamp < fiveMinutes && time > 1 && time < v.duration - 1) {
if (
now - timestamp < fiveMinutes &&
time > 1 &&
time < v.duration - 1
) {
v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) {
@@ -125,7 +129,7 @@ export function useVideoPlayer({
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("ended", onEnded);
return () => {
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("loadedmetadata", onTime);
+92 -33
View File
@@ -5,7 +5,11 @@ import type { Metadata } from "next";
import { getFileUrl } from "@/lib/minio";
import { AwemeData, VideoTranscript } from "./types";
export async function generateMetadata({ params }: { params: Promise<{ awemeId: string }> }): Promise<Metadata> {
export async function generateMetadata({
params,
}: {
params: Promise<{ awemeId: string }>;
}): Promise<Metadata> {
const id = (await params).awemeId;
const [video, post] = await Promise.all([
@@ -16,7 +20,7 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
prisma.imagePost.findUnique({
where: { aweme_id: id },
select: { desc: true, author: { select: { nickname: true } } },
})
}),
]);
const data = video || post;
@@ -28,7 +32,10 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
const desc = data.desc || "查看作品详情";
const author = data.author.nickname;
const title = desc.length > 50 ? `${desc.slice(0, 50)}... - ${author}` : `${desc} - ${author}`;
const title =
desc.length > 50
? `${desc.slice(0, 50)}... - ${author}`
: `${desc} - ${author}`;
return {
title,
@@ -36,7 +43,11 @@ export async function generateMetadata({ params }: { params: Promise<{ awemeId:
};
}
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 [video, post] = await Promise.all([
@@ -47,7 +58,7 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
prisma.imagePost.findUnique({
where: { aweme_id: id },
include: { author: true, images: { orderBy: { order: "asc" } } },
})
}),
]);
if (!video && !post) return <main className="p-8">找不到该作品</main>;
@@ -66,65 +77,109 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI
created_at: aweme!.created_at,
likesCount: Number(aweme!.digg_count),
commentsCount,
author: {
nickname: aweme!.author.nickname,
avatar_url: getFileUrl(aweme!.author.avatar_url || 'default-avatar.png'),
sec_uid: aweme!.author.sec_uid
author: {
nickname: aweme!.author.nickname,
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
sec_uid: aweme!.author.sec_uid,
},
...(() => {
if (isVideo) {
const aweme = video!
const aweme = video!;
return {
type: "video" as const,
cover_url: getFileUrl(aweme!.cover_url ?? 'default-cover.png'),
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
duration_ms: aweme!.duration_ms,
video_url: getFileUrl(aweme!.video_url),
width: aweme!.width ?? null,
height: aweme!.height ?? null,
}
};
} else {
const aweme = post!
const aweme = post!;
return {
type: "image" as const,
cover_url: getFileUrl(aweme!.images[0].url ?? 'default-cover.png'),
cover_size: { h: aweme!.images[0].height ?? 0, w: aweme!.images[0].width ?? 0 },
images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: img.animated? getFileUrl(img.animated) : null })),
music_url: getFileUrl(aweme!.music_url || 'default-music.mp3'),
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
cover_size: {
h: aweme!.images[0].height ?? 0,
w: aweme!.images[0].width ?? 0,
},
images: aweme!.images.map((img) => ({
...img,
url: getFileUrl(img.url),
animated: img.animated ? getFileUrl(img.animated) : null,
})),
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
};
}
})()
}
})(),
};
const transcript: VideoTranscript | null = isVideo ? await prisma.videoTranscript.findUnique({
where: { videoId: id },
}) : null;
const transcript: VideoTranscript | null = isVideo
? await prisma.videoTranscript.findUnique({
where: { videoId: id },
})
: null;
// Compute prev/next neighbors by created_at across videos and image posts
const currentCreatedAt = (isVideo ? video!.created_at : post!.created_at);
const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
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 } }),
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 });
if (newerPost) cands.push({ aweme_id: newerPost.aweme_id, created_at: newerPost.created_at });
if (newerVideo)
cands.push({
aweme_id: newerVideo.aweme_id,
created_at: newerVideo.created_at,
});
if (newerPost)
cands.push({
aweme_id: newerPost.aweme_id,
created_at: newerPost.created_at,
});
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 });
if (olderPost) cands.push({ aweme_id: olderPost.aweme_id, created_at: olderPost.created_at });
if (olderVideo)
cands.push({
aweme_id: olderVideo.aweme_id,
created_at: olderVideo.created_at,
});
if (olderPost)
cands.push({
aweme_id: olderPost.aweme_id,
created_at: olderPost.created_at,
});
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 };
const neighbors: {
prev: { aweme_id: string } | null;
next: { aweme_id: string } | null;
} = { prev: pickPrev, next: pickNext };
return (
<main className="min-h-screen w-full">
@@ -135,7 +190,11 @@ 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} neighbors={neighbors} transcript={transcript} />
<AwemeDetailClient
data={data}
neighbors={neighbors}
transcript={transcript}
/>
</main>
);
}
+16 -5
View File
@@ -1,4 +1,8 @@
export type User = { nickname: string; avatar_url: string | null; sec_uid?: string };
export type User = {
nickname: string;
avatar_url: string | null;
sec_uid?: string;
};
export type Comment = {
cid: string;
@@ -13,7 +17,7 @@ export type VideoData = {
type: "video";
aweme_id: string;
cover_url: string;
cover_size: {w: number; h: number};
cover_size: { w: number; h: number };
desc: string;
created_at: string | Date;
duration_ms: number | null;
@@ -29,10 +33,17 @@ export type ImageData = {
type: "image";
aweme_id: string;
cover_url: string;
cover_size: {w: number; h: number};
cover_size: { w: number; h: number };
desc: string;
created_at: string | Date;
images: { id: string; url: string; width: number | null; height: number | null; animated: string | null; duration: number | null }[];
images: {
id: string;
url: string;
width: number | null;
height: number | null;
animated: string | null;
duration: number | null;
}[];
music_url: string | null;
author: User;
commentsCount: number;
@@ -52,5 +63,5 @@ export type Neighbors = {
next: { aweme_id: string } | null;
};
export type LoopMode = "loop" | "sequential";
export type LoopMode = "loop" | "single" | "sequential";
export type ObjectFit = "contain" | "cover";
+11 -3
View File
@@ -26,7 +26,9 @@ export function formatTime(seconds: number): string {
}
// 处理评论文本中的表情占位符
export function parseCommentText(text: string): (string | { type: "emoji"; name: string })[] {
export function parseCommentText(
text: string,
): (string | { type: "emoji"; name: string })[] {
const parts: (string | { type: "emoji"; name: string })[] = [];
const regex = /\[([^\]]+)\]/g;
let lastIndex = 0;
@@ -51,7 +53,10 @@ export function parseCommentText(text: string): (string | { type: "emoji"; name:
}
// 从 localStorage 获取数值
export function getNumberFromStorage(key: string, defaultValue: number): number {
export function getNumberFromStorage(
key: string,
defaultValue: number,
): number {
if (typeof window === "undefined") return defaultValue;
const saved = localStorage.getItem(key);
if (!saved) return defaultValue;
@@ -60,7 +65,10 @@ export function getNumberFromStorage(key: string, defaultValue: number): number
}
// 从 localStorage 获取字符串
export function getStringFromStorage(key: string, defaultValue: string): string {
export function getStringFromStorage(
key: string,
defaultValue: string,
): string {
if (typeof window === "undefined") return defaultValue;
return localStorage.getItem(key) || defaultValue;
}