优化图文页
This commit is contained in:
+167
-100
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 图片预览灯箱 */}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 上下切换按钮(右侧胶囊形状) */}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
// 持久化音量
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user