优化图文页
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>
|
||||
|
||||
Reference in New Issue
Block a user