refactor: 重构作品播放页性能、媒体同步与内存管理

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-04 01:31:10 +08:00
co-authored by Cursor
parent 990e1b1804
commit 10f2520279
19 changed files with 1351 additions and 813 deletions
+164 -96
View File
@@ -2,7 +2,13 @@
import { Play } from "lucide-react"; import { Play } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import {
type ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { BackgroundCanvas } from "./components/BackgroundCanvas"; import { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel"; import { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel"; import { ImageCarousel } from "./components/ImageCarousel";
@@ -52,7 +58,7 @@ export default function AwemeDetailClient({
data, data,
neighbors, neighbors,
transcript, transcript,
}: AwemeDetailClientProps) { }: AwemeDetailClientProps): ReactElement {
const router = useRouter(); const router = useRouter();
const isVideo = data.type === "video"; const isVideo = data.type === "video";
@@ -64,24 +70,44 @@ export default function AwemeDetailClient({
isVideo ? (data as VideoData).content_tags : [], isVideo ? (data as VideoData).content_tags : [],
); );
// 同步跨作品状态重置
useEffect(() => {
setContentTags(isVideo ? (data as VideoData).content_tags : []);
setTranscriptOpen(false);
playerState.setProgress(0);
playerState.setProgressRestored(false);
playerState.setRotation(0);
}, [
data,
isVideo,
playerState.setProgress,
playerState.setProgressRestored,
playerState.setRotation,
]);
// 引用 // 引用
const mediaContainerRef = useRef<HTMLDivElement | null>(null); const mediaContainerRef = useRef<HTMLDivElement | null>(null);
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
const scrollerRef = useRef<HTMLDivElement | null>(null); const scrollerRef = useRef<HTMLDivElement | null>(null);
const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null); const backgroundCanvasRef = useRef<HTMLCanvasElement | null>(null);
const progressRef = useRef<number>(playerState.progress);
progressRef.current = playerState.progress;
const pauseImageMedia = useCallback((reset = false) => { const pauseImageMedia = useCallback((reset = false): void => {
const audioEl = audioRef.current; const audioEl = audioRef.current;
if (audioEl) { if (audioEl) {
audioEl.pause(); audioEl.pause();
if (reset) audioEl.currentTime = 0; if (reset) audioEl.currentTime = 0;
} }
scrollerRef.current?.querySelectorAll("video").forEach((videoEl) => { const videos = scrollerRef.current?.querySelectorAll("video");
if (videos) {
for (const videoEl of videos) {
videoEl.pause(); videoEl.pause();
if (reset) videoEl.currentTime = 0; if (reset) videoEl.currentTime = 0;
}); }
}
}, []); }, []);
// 图文轮播状态 // 图文轮播状态
@@ -115,7 +141,7 @@ export default function AwemeDetailClient({
// 媒体操作函数 // 媒体操作函数
const seekTo = useCallback( const seekTo = useCallback(
(ratio: number) => { (ratio: number): void => {
const clampedRatio = Math.min(1, Math.max(0, ratio)); const clampedRatio = Math.min(1, Math.max(0, ratio));
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
@@ -129,70 +155,85 @@ export default function AwemeDetailClient({
[imageCarouselState.seekTo, imageCount, isVideo], [imageCarouselState.seekTo, imageCount, isVideo],
); );
const seekImageByVisualRatio = (ratio: number) => { const seekImageByVisualRatio = useCallback(
(ratio: number): void => {
if (!imageCount) return; if (!imageCount) return;
const clampedRatio = Math.min(1, Math.max(0, ratio)); const clampedRatio = Math.min(1, Math.max(0, ratio));
const rawSegment = clampedRatio * imageCount; const rawSegment = clampedRatio * imageCount;
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment)); const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex)); const segmentProgress = Math.min(
1,
Math.max(0, rawSegment - targetIndex),
);
imageCarouselState.goToIndex(targetIndex, segmentProgress); imageCarouselState.goToIndex(targetIndex, segmentProgress);
}; },
[imageCarouselState.goToIndex, imageCount],
);
const handleControlSeek = (ratio: number) => { const handleControlSeek = useCallback(
(ratio: number): void => {
if (isVideo) { if (isVideo) {
seekTo(ratio); seekTo(ratio);
return; return;
} }
seekImageByVisualRatio(ratio); seekImageByVisualRatio(ratio);
}; },
[isVideo, seekImageByVisualRatio, seekTo],
);
const togglePlay = async () => { const togglePlay = useCallback((): void => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
if (v.paused) await v.play().catch(() => {}); if (v.paused) {
else v.pause(); void v.play().catch(() => {});
} else {
v.pause();
}
return; return;
} }
const el = audioRef.current; const el = audioRef.current;
if (!playerState.isPlaying) { if (!playerState.isPlaying) {
playerState.setIsPlaying(true); playerState.setIsPlaying(true);
try { void el?.play().catch(() => {});
await el?.play().catch(() => {});
} catch {}
} else { } else {
playerState.setIsPlaying(false); playerState.setIsPlaying(false);
pauseImageMedia(); pauseImageMedia();
} }
}; }, [
isVideo,
pauseImageMedia,
playerState.isPlaying,
playerState.setIsPlaying,
]);
const toggleFullscreen = () => { const toggleFullscreen = useCallback((): void => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
if (document.body.requestFullscreen) { if (document.body.requestFullscreen) {
document.body.requestFullscreen().catch(() => {}); void document.body.requestFullscreen().catch(() => {});
return; return;
} }
const vRef = videoRef.current as WebkitVideoElement | null; const vRef = videoRef.current as WebkitVideoElement | null;
if (vRef?.requestFullscreen) { if (vRef?.requestFullscreen) {
vRef.requestFullscreen().catch(() => {}); void vRef.requestFullscreen().catch(() => {});
return; return;
} }
if (vRef?.webkitEnterFullscreen) { if (vRef?.webkitEnterFullscreen) {
vRef.webkitEnterFullscreen(); vRef.webkitEnterFullscreen();
} }
} else { } else {
document.exitFullscreen().catch(() => {}); void document.exitFullscreen().catch(() => {});
} }
}; }, []);
const prevImg = useCallback(() => { const prevImg = useCallback((): void => {
if (!imageCount) return; if (!imageCount) return;
const next = Math.max(0, imageCarouselState.idxRef.current - 1); const next = Math.max(0, imageCarouselState.idxRef.current - 1);
imageCarouselState.goToIndex(next); imageCarouselState.goToIndex(next);
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const nextImg = useCallback(() => { const nextImg = useCallback((): void => {
if (!imageCount) return; if (!imageCount) return;
const next = Math.min( const next = Math.min(
imageCount - 1, imageCount - 1,
@@ -201,7 +242,7 @@ export default function AwemeDetailClient({
imageCarouselState.goToIndex(next); imageCarouselState.goToIndex(next);
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]); }, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const handleDownload = () => { const handleDownload = useCallback((): void => {
if (isVideo) { if (isVideo) {
const videoUrl = (data as VideoData).video_url; const videoUrl = (data as VideoData).video_url;
const link = document.createElement("a"); const link = document.createElement("a");
@@ -212,15 +253,44 @@ export default function AwemeDetailClient({
document.body.removeChild(link); document.body.removeChild(link);
} else { } else {
if (!images?.length) return; if (!images?.length) return;
const currentImage = images[imageCarouselState.idx]; const currentImage = images[imageCarouselState.idxRef.current];
if (!currentImage) return;
const link = document.createElement("a"); const link = document.createElement("a");
link.href = currentImage.url; link.href = currentImage.url;
link.download = `image_${data.aweme_id}_${imageCarouselState.idx + 1}.jpg`; link.download = `image_${data.aweme_id}_${imageCarouselState.idxRef.current + 1}.jpg`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
}; }, [data, imageCarouselState.idxRef, images, isVideo]);
const handleShowTranscript = useCallback((): void => {
setTranscriptOpen(true);
}, []);
const handleCloseTranscript = useCallback((): void => {
setTranscriptOpen(false);
}, []);
const handleCloseComments = useCallback((): void => {
commentState.setOpen(false);
}, [commentState.setOpen]);
const handleToggleComments = useCallback((): void => {
commentState.setOpen((v) => !v);
}, [commentState.setOpen]);
const handleNavigatePrev = useCallback((): void => {
if (neighbors.prev) {
router.push(`/aweme/${neighbors.prev.aweme_id}`);
}
}, [neighbors.prev, router]);
const handleNavigateNext = useCallback((): void => {
if (neighbors.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
}
}, [neighbors.next, router]);
// 导航相关 // 导航相关
useNavigation({ useNavigation({
@@ -233,23 +303,22 @@ export default function AwemeDetailClient({
togglePlay, togglePlay,
}); });
// 背景画布 // 背景画布(仅视频且播放时采样绘制)
useBackgroundCanvas({ useBackgroundCanvas({
isVideo, isVideo,
idx: imageCarouselState.idx, isPlaying: playerState.isPlaying,
videoRef, videoRef,
scrollerRef,
backgroundCanvasRef, backgroundCanvasRef,
}); });
useEffect(() => { useEffect(() => {
if (isVideo) return; if (isVideo) return;
const pauseForPageLifecycle = () => { const pauseForPageLifecycle = (): void => {
playerState.setIsPlaying(false); playerState.setIsPlaying(false);
pauseImageMedia(); pauseImageMedia();
}; };
const handleVisibilityChange = () => { const handleVisibilityChange = (): void => {
if (document.hidden) pauseForPageLifecycle(); if (document.hidden) pauseForPageLifecycle();
}; };
@@ -263,27 +332,22 @@ export default function AwemeDetailClient({
}; };
}, [isVideo, pauseImageMedia, playerState.setIsPlaying]); }, [isVideo, pauseImageMedia, playerState.setIsPlaying]);
// Media Session API 集成 // Media Session API:元信息仅在作品切换时设置一次,避免高频触发 iOS IPC 卡顿
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (!("mediaSession" in navigator)) return; if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession as MediaSessionWithPositionState; const ms = navigator.mediaSession;
// 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像)
const title = data.desc || `作品 ${data.aweme_id}`; const title = data.desc || `作品 ${data.aweme_id}`;
const artist = data.author.nickname || "作者"; const artist = data.author.nickname || "作者";
const album = new Date(data.created_at).toLocaleString(); const album = new Date(data.created_at).toLocaleString();
// 单一封面图:使用作品 cover_url const coverUrl = data.cover_url;
const coverUrl = (data as AwemeData).cover_url as string | undefined; const coverSize = data.cover_size;
const coverSize = (data as AwemeData).cover_size as
| { w: number; h: number }
| undefined;
const artwork = coverUrl const artwork = coverUrl
? [ ? [
{ {
src: coverUrl, src: coverUrl,
size: `${coverSize?.w || 512}x${coverSize?.h || 512}`, sizes: `${coverSize?.w || 512}x${coverSize?.h || 512}`,
}, },
] ]
: []; : [];
@@ -291,15 +355,20 @@ export default function AwemeDetailClient({
try { try {
ms.metadata = new MediaMetadata({ title, artist, album, artwork }); ms.metadata = new MediaMetadata({ title, artist, album, artwork });
} catch {} } catch {}
}, [data]);
// Media Session API:播放状态与低频位置同步(不依赖 progress state,消除 30fps 重绑开销)
useEffect(() => {
if (typeof window === "undefined") return;
if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession as MediaSessionWithPositionState;
// 更新播放状态
try { try {
ms.playbackState = playerState.isPlaying ? "playing" : "paused"; ms.playbackState = playerState.isPlaying ? "playing" : "paused";
} catch {} } catch {}
const getImagesTotalMs = () => imageCarouselState.totalDurationMs; const updatePosition = (): void => {
const updatePosition = () => {
try { try {
if (!ms.setPositionState) return; if (!ms.setPositionState) return;
if (isVideo) { if (isVideo) {
@@ -313,73 +382,67 @@ export default function AwemeDetailClient({
playbackRate: playerState.rate ?? 1, playbackRate: playerState.rate ?? 1,
}); });
} else if (images?.length) { } else if (images?.length) {
const totalMs = getImagesTotalMs(); const totalMs = imageCarouselState.totalDurationMs;
const duration = totalMs / 1000; const duration = totalMs / 1000;
const position = Math.max( const position = Math.max(
0, 0,
Math.min(duration, (playerState.progress * totalMs) / 1000), Math.min(duration, (progressRef.current * totalMs) / 1000),
); );
ms.setPositionState({ duration, position, playbackRate: 1 }); ms.setPositionState({ duration, position, playbackRate: 1 });
} }
} catch {} } catch {}
}; };
// 绑定视频事件以同步状态
const v = videoRef.current; const v = videoRef.current;
if (isVideo && v) { if (isVideo && v) {
const onPlay = () => { const onPlay = (): void => {
try { try {
ms.playbackState = "playing"; ms.playbackState = "playing";
} catch {} } catch {}
updatePosition(); updatePosition();
}; };
const onPause = () => { const onPause = (): void => {
try { try {
ms.playbackState = "paused"; ms.playbackState = "paused";
} catch {} } catch {}
updatePosition(); updatePosition();
}; };
const onTimeUpdate = () => updatePosition(); const onSeeked = (): void => updatePosition();
v.addEventListener("play", onPlay); v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause); v.addEventListener("pause", onPause);
v.addEventListener("timeupdate", onTimeUpdate); v.addEventListener("seeked", onSeeked);
// 初始同步 const id = window.setInterval(updatePosition, 1000);
updatePosition(); updatePosition();
// 清理
return () => { return () => {
window.clearInterval(id);
v.removeEventListener("play", onPlay); v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause); v.removeEventListener("pause", onPause);
v.removeEventListener("timeupdate", onTimeUpdate); v.removeEventListener("seeked", onSeeked);
}; };
} else if (!isVideo && images?.length) { }
// 图文用定时器定期同步位置
if (!isVideo && images?.length) {
const id = window.setInterval(updatePosition, 1000); const id = window.setInterval(updatePosition, 1000);
// 初始同步
updatePosition(); updatePosition();
return () => window.clearInterval(id); return () => window.clearInterval(id);
} }
// 依赖:当这些变化时刷新元信息与位置状态
}, [ }, [
data,
imageCarouselState.totalDurationMs, imageCarouselState.totalDurationMs,
images, images?.length,
isVideo, isVideo,
playerState.isPlaying, playerState.isPlaying,
playerState.progress,
playerState.rate, playerState.rate,
]); ]);
// 绑定系统媒体控件的操作处理程序 // 绑定系统媒体控件的操作处理程序(不依赖 progress state,仅在作品切换时注册一次)
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (!("mediaSession" in navigator)) return; if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession; const ms = navigator.mediaSession;
const getImagesTotalMs = () => imageCarouselState.totalDurationMs; const handlePlay = async (): Promise<void> => {
const handlePlay = async () => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
try { try {
@@ -392,7 +455,7 @@ export default function AwemeDetailClient({
} catch {} } catch {}
} }
}; };
const handlePause = () => { const handlePause = (): void => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
v?.pause(); v?.pause();
@@ -401,7 +464,7 @@ export default function AwemeDetailClient({
pauseImageMedia(); pauseImageMedia();
} }
}; };
const handleStop = () => { const handleStop = (): void => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (v) { if (v) {
@@ -414,30 +477,30 @@ export default function AwemeDetailClient({
seekTo(0); seekTo(0);
} }
}; };
const handleSeekDelta = (deltaSec: number) => { const handleSeekDelta = (deltaSec: number): void => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (v) if (v) {
v.currentTime = Math.max( v.currentTime = Math.max(
0, 0,
Math.min(v.duration || Infinity, v.currentTime + deltaSec), Math.min(v.duration || Infinity, v.currentTime + deltaSec),
); );
}
} else if (images?.length) { } else if (images?.length) {
const totalMs = getImagesTotalMs(); const totalMs = imageCarouselState.totalDurationMs;
if (totalMs <= 0) return; if (totalMs <= 0) return;
const deltaRatio = (deltaSec * 1000) / totalMs; const deltaRatio = (deltaSec * 1000) / totalMs;
seekTo(playerState.progress + deltaRatio); seekTo(progressRef.current + deltaRatio);
} }
}; };
const handleSeekTo = (seekTimeSec: number) => { const handleSeekTo = (seekTimeSec: number): void => {
if (isVideo) { if (isVideo) {
const v = videoRef.current; const v = videoRef.current;
if (v && Number.isFinite(v.duration)) { if (v && Number.isFinite(v.duration)) {
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec)); v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
} }
} else if (images?.length) { } else if (images?.length) {
const totalMs = getImagesTotalMs(); const totalSec = imageCarouselState.totalDurationMs / 1000;
const totalSec = totalMs / 1000;
if (totalSec > 0) seekTo(seekTimeSec / totalSec); if (totalSec > 0) seekTo(seekTimeSec / totalSec);
} }
}; };
@@ -446,10 +509,10 @@ export default function AwemeDetailClient({
ms.setActionHandler("pause", handlePause); ms.setActionHandler("pause", handlePause);
ms.setActionHandler("stop", handleStop); ms.setActionHandler("stop", handleStop);
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) => ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
handleSeekDelta(-((details?.seekOffset as number) || 10)), handleSeekDelta(-(details?.seekOffset ?? 10)),
); );
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) => ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
handleSeekDelta((details?.seekOffset as number) || 10), handleSeekDelta(details?.seekOffset ?? 10),
); );
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => { ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime); if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
@@ -483,11 +546,10 @@ export default function AwemeDetailClient({
}; };
}, [ }, [
isVideo, isVideo,
images, images?.length,
neighbors.prev, neighbors.prev,
neighbors.next, neighbors.next,
router, router,
playerState.progress,
playerState.setIsPlaying, playerState.setIsPlaying,
pauseImageMedia, pauseImageMedia,
imageCarouselState.totalDurationMs, imageCarouselState.totalDurationMs,
@@ -498,7 +560,7 @@ export default function AwemeDetailClient({
return ( return (
<div className="h-screen w-full"> <div className="h-screen w-full">
<BackgroundCanvas ref={backgroundCanvasRef} /> {isVideo ? <BackgroundCanvas ref={backgroundCanvasRef} /> : null}
<div className="relative h-full landscape:flex landscape:flex-row"> <div className="relative h-full landscape:flex landscape:flex-row">
{/* 主媒体区域 */} {/* 主媒体区域 */}
@@ -510,6 +572,7 @@ export default function AwemeDetailClient({
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
{isVideo ? ( {isVideo ? (
<VideoPlayer <VideoPlayer
key={data.aweme_id}
ref={videoRef} ref={videoRef}
videoUrl={(data as VideoData).video_url} videoUrl={(data as VideoData).video_url}
rotation={playerState.rotation} rotation={playerState.rotation}
@@ -519,15 +582,24 @@ export default function AwemeDetailClient({
/> />
) : ( ) : (
<ImageCarousel <ImageCarousel
key={data.aweme_id}
ref={scrollerRef} ref={scrollerRef}
images={images} images={images}
currentIndex={imageCarouselState.idx} currentIndex={imageCarouselState.idx}
isPlaying={playerState.isPlaying} isPlaying={playerState.isPlaying}
segmentProgress={imageCarouselState.segProgress} segmentProgressRef={imageCarouselState.segProgressRef}
mediaSyncToken={imageCarouselState.mediaSyncToken} mediaSyncToken={imageCarouselState.mediaSyncToken}
loopMode={playerState.loopMode} loopMode={playerState.loopMode}
hasNextNeighbor={Boolean(neighbors.next)}
rotation={playerState.rotation}
objectFit={playerState.objectFit}
failedAnimatedMap={imageCarouselState.failedAnimatedMap}
onTogglePlay={togglePlay} onTogglePlay={togglePlay}
onAnimatedDuration={imageCarouselState.setSegmentDuration} onAnimatedDuration={imageCarouselState.setSegmentDuration}
onMediaReady={imageCarouselState.markMediaReady}
onAnimatedFailed={imageCarouselState.markAnimatedFailed}
onAnimatedProgress={imageCarouselState.handleAnimatedProgress}
onAnimatedEnded={imageCarouselState.handleAnimatedEnded}
/> />
)} )}
</div> </div>
@@ -535,7 +607,7 @@ export default function AwemeDetailClient({
{/* 暂停图标 */} {/* 暂停图标 */}
{!playerState.isPlaying && ( {!playerState.isPlaying && (
<div className="pointer-events-none absolute inset-0 z-30 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"> <div className="rounded-full bg-black/50 p-6">
<Play size={64} className="text-white" fill="white" /> <Play size={64} className="text-white" fill="white" />
</div> </div>
</div> </div>
@@ -579,8 +651,8 @@ export default function AwemeDetailClient({
segmentProgress={imageCarouselState.segProgress} segmentProgress={imageCarouselState.segProgress}
musicUrl={isVideo ? undefined : (data as ImageData).music_url} musicUrl={isVideo ? undefined : (data as ImageData).music_url}
audioRef={audioRef} audioRef={audioRef}
hasTranscript={isVideo && !!transcript} hasTranscript={isVideo && Boolean(transcript)}
onShowTranscript={() => setTranscriptOpen(true)} onShowTranscript={handleShowTranscript}
onTogglePlay={togglePlay} onTogglePlay={togglePlay}
onSeek={handleControlSeek} onSeek={handleControlSeek}
onVolumeChange={playerState.setVolume} onVolumeChange={playerState.setVolume}
@@ -598,20 +670,16 @@ export default function AwemeDetailClient({
neighbors={neighbors} neighbors={neighbors}
commentsCount={data.commentsCount} commentsCount={data.commentsCount}
likesCount={data.likesCount} likesCount={data.likesCount}
onNavigatePrev={() => onNavigatePrev={handleNavigatePrev}
neighbors.prev && router.push(`/aweme/${neighbors.prev.aweme_id}`) onNavigateNext={handleNavigateNext}
} onToggleComments={handleToggleComments}
onNavigateNext={() =>
neighbors.next && router.push(`/aweme/${neighbors.next.aweme_id}`)
}
onToggleComments={() => commentState.setOpen((v) => !v)}
/> />
</section> </section>
{/* 评论面板 */} {/* 评论面板 */}
<CommentPanel <CommentPanel
open={commentState.open} open={commentState.open}
onClose={() => commentState.setOpen(false)} onClose={handleCloseComments}
author={data.author} author={data.author}
createdAt={data.created_at} createdAt={data.created_at}
awemeId={data.aweme_id} awemeId={data.aweme_id}
@@ -621,7 +689,7 @@ export default function AwemeDetailClient({
{/* 转录面板 */} {/* 转录面板 */}
<TranscriptPanel <TranscriptPanel
open={transcriptOpen} open={transcriptOpen}
onClose={() => setTranscriptOpen(false)} onClose={handleCloseTranscript}
transcript={transcript} transcript={transcript}
/> />
</div> </div>
@@ -1,18 +1,15 @@
import { forwardRef } from "react"; import { forwardRef, memo } from "react";
interface BackgroundCanvasProps {} export const BackgroundCanvas = memo(
forwardRef<HTMLCanvasElement>((_props, ref) => {
export const BackgroundCanvas = forwardRef<
HTMLCanvasElement,
BackgroundCanvasProps
>((props, ref) => {
return ( return (
<canvas <canvas
ref={ref} ref={ref}
className="fixed inset-0 w-full h-full -z-10 scale-110" className="pointer-events-none fixed inset-0 w-full h-full -z-10 scale-110"
style={{ filter: "blur(40px)" }} style={{ filter: "blur(40px)" }}
/> />
); );
}); }),
);
BackgroundCanvas.displayName = "BackgroundCanvas"; BackgroundCanvas.displayName = "BackgroundCanvas";
+15 -9
View File
@@ -1,7 +1,7 @@
import { ThumbsUp, X } from "lucide-react"; import { ThumbsUp, X } from "lucide-react";
import { useState } from "react"; import { memo, useState } from "react";
import type { Comment, User } from "../types.ts"; import type { Comment, User } from "../types.ts";
import { formatRelativeTime, formatAbsoluteUTC } from "../utils"; import { formatAbsoluteUTC, formatRelativeTime } from "../utils";
import { CommentText } from "./CommentText"; import { CommentText } from "./CommentText";
interface CommentListProps { interface CommentListProps {
@@ -10,7 +10,11 @@ interface CommentListProps {
comments: Comment[]; comments: Comment[];
} }
export function CommentList({ author, createdAt, comments }: CommentListProps) { export const CommentList = memo(function CommentList({
author,
createdAt,
comments,
}: CommentListProps) {
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
return ( return (
@@ -21,6 +25,8 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<img <img
src={author.avatar_url} src={author.avatar_url}
alt="avatar" alt="avatar"
loading="lazy"
decoding="async"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : null} ) : null}
@@ -46,6 +52,8 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<img <img
src={c.user.avatar_url} src={c.user.avatar_url}
alt="avatar" alt="avatar"
loading="lazy"
decoding="async"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : null} ) : null}
@@ -66,13 +74,12 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2"> <div className="mt-2 grid grid-cols-3 gap-1.5 sm:gap-2">
{c.images.map((img, idx) => ( {c.images.map((img, idx) => (
<button <button
key={idx} key={`${c.cid}-img-${idx}`}
type="button" type="button"
onClick={() => setPreviewImage(img.url)} onClick={() => setPreviewImage(img.url)}
className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity" className="block overflow-hidden rounded-md bg-zinc-800/60 cursor-pointer hover:opacity-80 transition-opacity"
title="点击预览大图" title="点击预览大图"
> >
{/* 以真实比例显示:利用 width/height 属性提供固有尺寸,CSS 设定宽度 100% 高度自适应 */}
<img <img
src={img.url} src={img.url}
alt="comment-image" alt="comment-image"
@@ -80,6 +87,7 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
height={img.height} height={img.height}
className="w-full h-auto" className="w-full h-auto"
loading="lazy" loading="lazy"
decoding="async"
/> />
</button> </button>
))} ))}
@@ -100,10 +108,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
{/* 图片预览灯箱 */} {/* 图片预览灯箱 */}
{previewImage && ( {previewImage && (
<div <div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)} onClick={() => setPreviewImage(null)}
> >
{/* 关闭按钮 */}
<button <button
type="button" type="button"
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/15 text-white border border-white/20 hover:bg-white/25 transition-colors flex items-center justify-center" className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/15 text-white border border-white/20 hover:bg-white/25 transition-colors flex items-center justify-center"
@@ -113,7 +120,6 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<X size={20} /> <X size={20} />
</button> </button>
{/* 大图 */}
<img <img
src={previewImage} src={previewImage}
alt="preview" alt="preview"
@@ -124,4 +130,4 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
)} )}
</> </>
); );
} });
+126 -183
View File
@@ -1,5 +1,5 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useState, useEffect, useRef, useCallback } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { Comment, User } from "../types.ts"; import type { Comment, User } from "../types.ts";
import { CommentList } from "./CommentList"; import { CommentList } from "./CommentList";
@@ -12,7 +12,16 @@ interface CommentPanelProps {
mounted: boolean; mounted: boolean;
} }
export function CommentPanel({ interface CommentsApiResponse {
comments?: Comment[];
total?: number;
hasMore?: boolean;
mode?: string;
seed?: string | number;
snapshot?: string | number;
}
export const CommentPanel = memo(function CommentPanel({
open, open,
onClose, onClose,
author, author,
@@ -24,201 +33,184 @@ export function CommentPanel({
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
// ranked 排序的稳定参数(由后端返回,前端透传保证会话内稳定) const [hasOpened, setHasOpened] = useState(open);
const [rankParams, setRankParams] = useState<null | {
seed: string; const scrollRef = useRef<HTMLDivElement>(null);
snapshot: string; const sentinelRef = useRef<HTMLDivElement>(null);
}>(null);
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
const scrollRefLandscape = useRef<HTMLDivElement>(null);
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
const scrollRefPortrait = useRef<HTMLDivElement>(null);
const sentinelRefPortrait = useRef<HTMLDivElement>(null);
const loadingRef = useRef(false); const loadingRef = useRef(false);
const abortControllerRef = useRef<AbortController | null>(null);
const commentsCountRef = useRef(0);
const hasMoreRef = useRef(true);
const rankParamsRef = useRef<{ seed: string; snapshot: string } | null>(null);
useEffect(() => {
if (open) setHasOpened(true);
}, [open]);
// 加载评论
const loadComments = useCallback( const loadComments = useCallback(
async (reset = false) => { async (reset = false): Promise<void> => {
if (loadingRef.current || (!reset && !hasMore)) return; if (loadingRef.current && !reset) return;
if (!reset && !hasMoreRef.current) return;
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
loadingRef.current = true; loadingRef.current = true;
setLoading(true); setLoading(true);
try { try {
const skip = reset ? 0 : comments.length; const skip = reset ? 0 : commentsCountRef.current;
const query = new URLSearchParams({ const query = new URLSearchParams({
skip: String(skip), skip: String(skip),
take: String(20), take: String(20),
mode: "ranked", mode: "ranked",
}); });
if (rankParams) { const currentRank = reset ? null : rankParamsRef.current;
query.set("seed", rankParams.seed); if (currentRank) {
query.set("snapshot", rankParams.snapshot); query.set("seed", currentRank.seed);
query.set("snapshot", currentRank.snapshot);
} }
const response = await fetch( const response = await fetch(
`/api/comments/${awemeId}?${query.toString()}`, `/api/comments/${awemeId}?${query.toString()}`,
{ signal: controller.signal },
); );
const data = await response.json(); const data = (await response.json()) as CommentsApiResponse;
if (controller.signal.aborted) return;
const incoming = Array.isArray(data.comments) ? data.comments : [];
// 统一做一次基于 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) => { setComments((prev) => {
const merged = [...prev, ...(data.comments as Comment[])]; const source = reset ? incoming : [...prev, ...incoming];
const seen = new Set<string>(); const seen = new Set<string>();
// 保留首次出现的项,既保证顺序也避免重复 const deduped = source.filter((c) => {
return merged.filter((c) => {
if (seen.has(c.cid)) return false; if (seen.has(c.cid)) return false;
seen.add(c.cid); seen.add(c.cid);
return true; return true;
}); });
commentsCountRef.current = deduped.length;
return deduped;
}); });
}
setTotal(data.total); setTotal(typeof data.total === "number" ? data.total : 0);
setHasMore(data.hasMore); const nextHasMore = Boolean(data.hasMore);
hasMoreRef.current = nextHasMore;
setHasMore(nextHasMore);
if (data.mode === "ranked" && data.seed && data.snapshot) { if (data.mode === "ranked" && data.seed && data.snapshot) {
// 初始化或重置时更新稳定参数 if (reset || !rankParamsRef.current) {
setRankParams((prev) => { rankParamsRef.current = {
if (reset)
return {
seed: String(data.seed), seed: String(data.seed),
snapshot: String(data.snapshot), snapshot: String(data.snapshot),
}; };
return (
prev ?? {
seed: String(data.seed),
snapshot: String(data.snapshot),
} }
);
});
} else if (reset) { } else if (reset) {
setRankParams(null); rankParamsRef.current = null;
} }
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
console.error("加载评论失败:", error); console.error("加载评论失败:", error);
} finally { } finally {
if (abortControllerRef.current === controller) {
setLoading(false); setLoading(false);
loadingRef.current = false; loadingRef.current = false;
} }
}
}, },
[awemeId, comments.length, hasMore], [awemeId],
); );
// 面板打开时加载初始评论 // 切换作品时清理旧评论状态与未完成请求
useEffect(() => { useEffect(() => {
if (open && comments.length === 0) { abortControllerRef.current?.abort();
loadComments(true); loadingRef.current = false;
} commentsCountRef.current = 0;
}, [open]); hasMoreRef.current = true;
rankParamsRef.current = null;
setComments([]);
setTotal(0);
setHasMore(true);
setLoading(false);
// Intersection Observer 监听底部触发加载(针对横屏与竖屏两个容器分别监听) if (open) {
void loadComments(true);
}
return () => {
abortControllerRef.current?.abort();
};
}, [awemeId, open, loadComments]);
// Intersection Observer 监听底部触发分页加载(单容器)
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const rootEl = scrollRef.current;
const observers: IntersectionObserver[] = []; const targetEl = sentinelRef.current;
const setup = (
rootEl: HTMLDivElement | null,
targetEl: HTMLDivElement | null,
) => {
if (!rootEl || !targetEl) return; if (!rootEl || !targetEl) return;
const io = new IntersectionObserver( const io = new IntersectionObserver(
(entries) => { (entries) => {
const entry = entries[0]; const entry = entries[0];
if (entry.isIntersecting && !loadingRef.current && hasMore) { if (
loadComments(); entry?.isIntersecting &&
!loadingRef.current &&
hasMoreRef.current
) {
void loadComments(false);
} }
}, },
{ {
root: rootEl, root: rootEl,
rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发 rootMargin: "0px 0px 200px 0px",
threshold: 0, threshold: 0,
}, },
); );
io.observe(targetEl); io.observe(targetEl);
observers.push(io); return () => io.disconnect();
};
setup(scrollRefLandscape.current, sentinelRefLandscape.current);
setup(scrollRefPortrait.current, sentinelRefPortrait.current);
return () => {
for (const io of observers) io.disconnect();
};
}, [open, hasMore, loadComments]); }, [open, hasMore, loadComments]);
return ( return (
<aside
className={`
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)] border-t border-white/10
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
${
open
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0"
}
`}
>
<div className="flex items-center justify-between landscape:flex-row-reverse px-3 py-3 border-b border-white/10 shrink-0">
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
<button
type="button"
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
{(open || hasOpened) && (
<> <>
<style jsx global>{`
/* 自定义滚动条样式 */
.comment-scroll::-webkit-scrollbar {
width: 6px;
}
.comment-scroll::-webkit-scrollbar-track {
background: transparent;
}
.comment-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.comment-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Firefox */
.comment-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
`}</style>
{/* 横屏评论面板:并排分栏 */}
<aside
className={`
hidden landscape:flex
z-30 flex-col bg-[rgba(22,22,22,0.92)] text-white
relative h-full overflow-hidden
${mounted ? "transition-[width] duration-200 ease-out" : ""}
${open ? "w-[min(420px,36vw)] border-l border-white/10" : "w-0"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
</div>
<div
ref={scrollRefLandscape}
className="p-3 overflow-auto comment-scroll"
>
<CommentList <CommentList
author={author} author={author}
createdAt={createdAt} createdAt={createdAt}
comments={comments} comments={comments}
/> />
{/* 底部加载触发区 */} {hasMore && <div ref={sentinelRef} className="h-1 w-full" />}
{hasMore && <div ref={sentinelRefLandscape} className="h-1 w-full" />}
{loading && ( {loading && (
<div className="py-4 text-center text-white/60 text-sm"> <div className="py-4 text-center text-white/60 text-sm">
@@ -230,58 +222,9 @@ export function CommentPanel({
没有更多评论了 没有更多评论了
</div> </div>
)} )}
</div>
</aside>
{/* 竖屏评论面板:bottom sheet */}
<aside
className={`
landscape:hidden
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)]
${mounted ? "transition-transform duration-200 ease-out" : ""}
border-t border-white/10
${open ? "translate-y-0" : "translate-y-full"}
`}
>
<div className="flex items-center justify-between px-3 py-3 border-b border-white/10">
<div className="text-white font-semibold">
评论 {total > 0 ? `(${total})` : ""}
</div>
<button
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors"
onClick={onClose}
aria-label="关闭评论"
>
<X size={18} />
</button>
</div>
<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" />}
{loading && (
<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>
</aside>
</> </>
)}
</div>
</aside>
); );
} });
+339 -90
View File
@@ -1,92 +1,142 @@
import { import {
forwardRef, forwardRef,
memo,
type RefObject,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
import type { ImageData, LoopMode } from "../types.ts"; import type { ImageData, LoopMode, ObjectFit } from "../types.ts";
import { releaseMediaElement } from "../utils";
interface ImageCarouselProps { interface ImageCarouselProps {
images: ImageData["images"]; images: ImageData["images"];
currentIndex: number; currentIndex: number;
isPlaying: boolean; isPlaying: boolean;
segmentProgress: number; segmentProgressRef: RefObject<number>;
mediaSyncToken: number; mediaSyncToken: number;
loopMode: LoopMode; loopMode: LoopMode;
hasNextNeighbor: boolean;
rotation: number;
objectFit: ObjectFit;
failedAnimatedMap: Record<string, boolean>;
onTogglePlay: () => void; onTogglePlay: () => void;
onAnimatedDuration?: (imageId: string, durationMs: number) => void; onAnimatedDuration: (imageId: string, durationMs: number) => void;
onMediaReady: (imageId: string) => void;
onAnimatedFailed: (imageId: string) => void;
onAnimatedProgress: (imageId: string, progress: number) => void;
onAnimatedEnded: (imageId: string) => void;
} }
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>( export const ImageCarousel = memo(
forwardRef<HTMLDivElement, ImageCarouselProps>(
( (
{ {
images, images,
currentIndex, currentIndex,
isPlaying, isPlaying,
segmentProgress, segmentProgressRef,
mediaSyncToken, mediaSyncToken,
loopMode, loopMode,
hasNextNeighbor,
rotation,
objectFit,
failedAnimatedMap,
onTogglePlay, onTogglePlay,
onAnimatedDuration, onAnimatedDuration,
onMediaReady,
onAnimatedFailed,
onAnimatedProgress,
onAnimatedEnded,
}, },
ref, ref,
) => { ) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map()); const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const activeVideoIdRef = useRef<string | null>(null); const activeVideoIdRef = useRef<string | null>(null);
const segmentProgressRef = useRef(segmentProgress); const lastMediaSyncTokenRef = useRef(mediaSyncToken);
const [useLightweightMode, setUseLightweightMode] = useState(false); const pendingSeekRef = useRef<{
imageId: string;
progress: number;
} | null>(null);
const [readyVideoId, setReadyVideoId] = useState<string | null>(null);
useEffect(() => { const shouldLoopCurrentVideo =
segmentProgressRef.current = segmentProgress; loopMode === "single" ||
}, [segmentProgress]); (images.length === 1 &&
(loopMode !== "sequential" || !hasNextNeighbor));
const syncVideoToSegmentProgress = useCallback( const seekVideoToProgress = useCallback(
(videoEl: HTMLVideoElement, progress: number, force = false) => { (videoEl: HTMLVideoElement, progress: number): boolean => {
const duration = videoEl.duration; const duration = videoEl.duration;
if (!Number.isFinite(duration) || duration <= 0) return; if (!Number.isFinite(duration) || duration <= 0) return false;
const clampedProgress = Math.max(0, Math.min(1, progress)); const clampedProgress = Math.max(0, Math.min(1, progress));
const maxTime = Math.max(0, duration - 0.05); const maxTime = Math.max(0, duration - 0.05);
const targetTime = Math.min(maxTime, duration * clampedProgress); const targetTime = Math.min(maxTime, duration * clampedProgress);
if (force || Math.abs(videoEl.currentTime - targetTime) > 0.12) { if (Math.abs(videoEl.currentTime - targetTime) > 0.08) {
try { try {
videoEl.currentTime = targetTime; videoEl.currentTime = targetTime;
} catch {} } catch {
return false;
} }
}
return true;
}, },
[], [],
); );
useEffect(() => { const tryPlayVideo = useCallback(
const userAgent = navigator.userAgent; (videoEl: HTMLVideoElement, imageId: string): void => {
const isIOS = videoEl.play().catch((err: unknown) => {
/iPad|iPhone|iPod/.test(userAgent) || const errorName = err instanceof DOMException ? err.name : "";
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); if (
const isSafari = errorName === "NotAllowedError" ||
/Safari/.test(userAgent) && !/CriOS|FxiOS|EdgiOS/.test(userAgent); errorName === "NotSupportedError"
setUseLightweightMode(isIOS && isSafari); ) {
}, []); onAnimatedFailed(imageId);
}
// 虚拟滚动:只渲染当前图片和前后各一张 });
const visibleIndices = useMemo(() => { },
if (useLightweightMode) return [currentIndex]; [onAnimatedFailed],
);
// 虚拟窗口:同时渲染前一张、当前张、后一张静态图以保证切页无白屏,同时仅当前张挂载单个 <video>
const visibleIndices = useMemo((): number[] => {
const indices: number[] = []; const indices: number[] = [];
if (currentIndex > 0) indices.push(currentIndex - 1); if (currentIndex > 0) indices.push(currentIndex - 1);
if (currentIndex >= 0 && currentIndex < images.length) {
indices.push(currentIndex); indices.push(currentIndex);
}
if (currentIndex < images.length - 1) indices.push(currentIndex + 1); if (currentIndex < images.length - 1) indices.push(currentIndex + 1);
return indices; return indices;
}, [currentIndex, images.length, useLightweightMode]); }, [currentIndex, images.length]);
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。 // 组件卸载时释放所有残留的 <video> 硬件解码缓冲
useEffect(() => {
const map = videoRefs.current;
return () => {
map.forEach((videoEl) => {
releaseMediaElement(videoEl);
});
map.clear();
};
}, []);
// 管理当前动图的播放、暂停与切换
useEffect(() => { useEffect(() => {
const currentImage = images[currentIndex]; const currentImage = images[currentIndex];
if (!currentImage?.animated) { const isAnimated =
Boolean(currentImage?.animated) &&
!failedAnimatedMap[currentImage?.id ?? ""];
if (!currentImage || !isAnimated) {
activeVideoIdRef.current = null; activeVideoIdRef.current = null;
videoRefs.current.forEach((videoEl) => videoEl.pause()); videoRefs.current.forEach((videoEl) => {
videoEl.pause();
});
return; return;
} }
@@ -98,129 +148,327 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
} }
}); });
if (videoEl) { if (!videoEl) return;
videoEl.loop = shouldLoopCurrentVideo;
if (activeVideoIdRef.current !== currentImage.id) { if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id; activeVideoIdRef.current = currentImage.id;
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true); const initialProgress = segmentProgressRef.current;
if (initialProgress > 0.01) {
const applied = seekVideoToProgress(videoEl, initialProgress);
if (!applied) {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: initialProgress,
};
}
} else {
pendingSeekRef.current = null;
if (videoEl.currentTime > 0.05) {
try {
videoEl.currentTime = 0;
} catch {}
}
}
} else if (
isPlaying &&
(videoEl.ended ||
(Number.isFinite(videoEl.duration) &&
videoEl.duration > 0 &&
videoEl.currentTime >= videoEl.duration - 0.05))
) {
try {
videoEl.currentTime = 0;
} catch {}
} }
if (isPlaying) { if (isPlaying) {
videoEl.play().catch(() => {}); tryPlayVideo(videoEl, currentImage.id);
} else { } else {
videoEl.pause(); videoEl.pause();
} }
}
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
useEffect(() => {
const currentImage = images[currentIndex];
if (!currentImage?.animated) return;
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) return;
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
if (isPlaying) videoEl.play().catch(() => {});
}, [ }, [
currentIndex, currentIndex,
failedAnimatedMap,
images, images,
isPlaying, isPlaying,
loopMode, seekVideoToProgress,
mediaSyncToken, segmentProgressRef,
syncVideoToSegmentProgress, shouldLoopCurrentVideo,
tryPlayVideo,
]); ]);
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => { // 响应用户主动 Seek 或外部强制同步
if (el) { useEffect(() => {
videoRefs.current.set(imageId, el); if (lastMediaSyncTokenRef.current === mediaSyncToken) return;
lastMediaSyncTokenRef.current = mediaSyncToken;
const currentImage = images[currentIndex];
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
return;
}
const targetProgress = segmentProgressRef.current;
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) {
pendingSeekRef.current = {
imageId: currentImage.id,
progress: targetProgress,
};
return;
}
const applied = seekVideoToProgress(videoEl, targetProgress);
if (applied) {
pendingSeekRef.current = null;
} else { } else {
videoRefs.current.delete(imageId); pendingSeekRef.current = {
imageId: currentImage.id,
progress: targetProgress,
};
}
if (isPlaying) {
tryPlayVideo(videoEl, currentImage.id);
}
}, [
currentIndex,
failedAnimatedMap,
images,
isPlaying,
mediaSyncToken,
seekVideoToProgress,
segmentProgressRef,
tryPlayVideo,
]);
// 使用 rAF 跟踪当前动图真实播放进度,只有动图实际走帧时进度条才前进
useEffect(() => {
if (!isPlaying) return;
const currentImage = images[currentIndex];
if (!currentImage?.animated || failedAnimatedMap[currentImage.id]) {
return;
}
let rafId: number | null = null;
const imageId = currentImage.id;
const updateLoop = (): void => {
const videoEl = videoRefs.current.get(imageId);
if (videoEl) {
const duration = videoEl.duration;
if (Number.isFinite(duration) && duration > 0 && !videoEl.paused) {
const progress = Math.max(
0,
Math.min(1, videoEl.currentTime / duration),
);
onAnimatedProgress(imageId, progress);
}
}
rafId = window.requestAnimationFrame(updateLoop);
};
rafId = window.requestAnimationFrame(updateLoop);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
} }
}; };
}, [
currentIndex,
failedAnimatedMap,
images,
isPlaying,
onAnimatedProgress,
]);
const handleVideoRef = useCallback(
(el: HTMLVideoElement | null, imageId: string): void => {
if (el) {
videoRefs.current.set(imageId, el);
if (Number.isFinite(el.duration) && el.duration > 0) {
onAnimatedDuration(imageId, Math.round(el.duration * 1000));
}
if (el.readyState >= 2) {
setReadyVideoId(imageId);
}
} else {
const prevEl = videoRefs.current.get(imageId);
if (prevEl) {
releaseMediaElement(prevEl);
videoRefs.current.delete(imageId);
}
setReadyVideoId((current) =>
current === imageId ? null : current,
);
}
},
[onAnimatedDuration],
);
const handleImgRef = useCallback(
(el: HTMLImageElement | null, imageId: string): void => {
if (el?.complete) {
onMediaReady(imageId);
}
},
[onMediaReady],
);
const mediaStyle = useMemo(
() => ({
transform:
rotation % 180 === 0
? `rotate(${rotation}deg)`
: `rotate(${rotation}deg) scale(0.85)`,
objectFit,
}),
[objectFit, rotation],
);
return ( return (
<div ref={ref} className="relative h-full w-full overflow-hidden"> <div ref={ref} className="relative h-full w-full overflow-hidden">
<div className="relative h-full w-full"> <div className="relative h-full w-full">
{visibleIndices.map((i) => { {visibleIndices.map((i) => {
const img = images[i]; const img = images[i];
if (!img) return null;
const isCurrent = i === currentIndex; const isCurrent = i === currentIndex;
const animatedSrc = isCurrent ? img.animated : null; const canShowAnimated =
isCurrent &&
Boolean(img.animated) &&
!failedAnimatedMap[img.id];
const animatedSrc = canShowAnimated ? img.animated : null;
const isVideoVisible = canShowAnimated && readyVideoId === img.id;
return ( return (
<div <div
key={img.id} key={img.id}
data-active={isCurrent ? "true" : undefined} data-active={isCurrent ? "true" : undefined}
className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer" className="absolute inset-0 h-full w-full flex items-center justify-center bg-black/70 cursor-pointer"
style={{ style={{
transform: useLightweightMode transform: `translateX(${(i - currentIndex) * 100}%)`,
? "translateX(0)" transition: "transform 240ms ease-out",
: `translateX(${(i - currentIndex) * 100}%)`,
transition: useLightweightMode
? "none"
: "transform 240ms ease-out",
zIndex: isCurrent ? 1 : 0, zIndex: isCurrent ? 1 : 0,
}} }}
onClick={onTogglePlay} onClick={onTogglePlay}
> >
{/* 底层静态图:作为静态页本体及动图首帧就绪前的无缝封面 */}
<img
ref={(el) => handleImgRef(el, img.id)}
src={img.url}
alt={`image-${i + 1}`}
loading="eager"
decoding="async"
onLoad={() => onMediaReady(img.id)}
onError={() => onMediaReady(img.id)}
className={`max-w-full max-h-full transition-opacity duration-150 ${
isVideoVisible ? "opacity-0" : "opacity-100"
}`}
style={{
width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined,
...mediaStyle,
}}
/>
{/* 仅当前激活页挂载单个 <video>,避免 iOS 多解码器并发卡死 */}
{animatedSrc ? ( {animatedSrc ? (
<video <video
ref={(el) => handleVideoRef(el, img.id)} ref={(el) => handleVideoRef(el, img.id)}
src={animatedSrc} src={animatedSrc}
poster={img.url}
muted muted
playsInline playsInline
loop={loopMode === "single"} loop={shouldLoopCurrentVideo}
preload={useLightweightMode ? "none" : "metadata"} preload="auto"
controls={false} controls={false}
disablePictureInPicture disablePictureInPicture
className="max-w-full max-h-full object-contain" className={`absolute max-w-full max-h-full transition-opacity duration-150 ${
isVideoVisible ? "opacity-100" : "opacity-0"
}`}
style={{ style={{
width: img.width ? `${img.width}px` : undefined, width: img.width ? `${img.width}px` : undefined,
height: img.height ? `${img.height}px` : undefined, height: img.height ? `${img.height}px` : undefined,
...mediaStyle,
}} }}
onLoadedMetadata={(e) => { onLoadedMetadata={(e) => {
const duration = e.currentTarget.duration; const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) { if (Number.isFinite(duration) && duration > 0) {
onAnimatedDuration?.( onAnimatedDuration(
img.id, img.id,
Math.round(duration * 1000), Math.round(duration * 1000),
); );
if (isCurrent) { if (
syncVideoToSegmentProgress( pendingSeekRef.current &&
pendingSeekRef.current.imageId === img.id
) {
const applied = seekVideoToProgress(
e.currentTarget, e.currentTarget,
segmentProgressRef.current, pendingSeekRef.current.progress,
true,
); );
if (applied) {
pendingSeekRef.current = null;
} }
} }
}
}}
onLoadedData={() => {
setReadyVideoId(img.id);
onMediaReady(img.id);
}} }}
onCanPlay={(e) => { onCanPlay={(e) => {
if (isCurrent) { setReadyVideoId(img.id);
syncVideoToSegmentProgress( onMediaReady(img.id);
if (
pendingSeekRef.current &&
pendingSeekRef.current.imageId === img.id
) {
const applied = seekVideoToProgress(
e.currentTarget, e.currentTarget,
segmentProgressRef.current, pendingSeekRef.current.progress,
);
if (applied) {
pendingSeekRef.current = null;
}
}
if (isCurrent && isPlaying && e.currentTarget.paused) {
tryPlayVideo(e.currentTarget, img.id);
}
}}
onTimeUpdate={(e) => {
const duration = e.currentTarget.duration;
if (Number.isFinite(duration) && duration > 0) {
onAnimatedProgress(
img.id,
Math.max(
0,
Math.min(
1,
e.currentTarget.currentTime / duration,
),
),
); );
} }
if (isCurrent && isPlaying)
e.currentTarget.play().catch(() => {});
}} }}
onEnded={(e) => { onEnded={(e) => {
// 播放结束后停留在最后一帧 if (shouldLoopCurrentVideo) {
if (loopMode !== "single") e.currentTarget.pause(); try {
e.currentTarget.currentTime = 0;
} catch {}
if (isPlaying) {
tryPlayVideo(e.currentTarget, img.id);
}
onAnimatedEnded(img.id);
return;
}
e.currentTarget.pause();
onAnimatedEnded(img.id);
}}
onError={() => {
onAnimatedFailed(img.id);
}} }}
/> />
) : ( ) : null}
<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,
height: img.height ? `${img.height}px` : undefined,
}}
/>
)}
</div> </div>
); );
})} })}
@@ -228,6 +476,7 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
</div> </div>
); );
}, },
),
); );
ImageCarousel.displayName = "ImageCarousel"; ImageCarousel.displayName = "ImageCarousel";
@@ -1,4 +1,5 @@
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { memo } from "react";
interface ImageNavigationButtonsProps { interface ImageNavigationButtonsProps {
onPrev: () => void; onPrev: () => void;
@@ -7,7 +8,7 @@ interface ImageNavigationButtonsProps {
totalImages: number; totalImages: number;
} }
export function ImageNavigationButtons({ export const ImageNavigationButtons = memo(function ImageNavigationButtons({
onPrev, onPrev,
onNext, onNext,
currentIndex, currentIndex,
@@ -20,13 +21,14 @@ export function ImageNavigationButtons({
<> <>
{/* 左侧按钮 */} {/* 左侧按钮 */}
<button <button
type="button"
className={` className={`
absolute left-4 top-6/17 -translate-y-1/2 z-20 absolute left-4 top-6/17 -translate-y-1/2 z-20
w-12 h-12 rounded-full w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20 bg-black/55 border border-white/20
flex items-center justify-center flex items-center justify-center
text-white transition-all text-white transition-opacity
${hasPrev ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"} ${hasPrev ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`} `}
onClick={onPrev} onClick={onPrev}
disabled={!hasPrev} disabled={!hasPrev}
@@ -37,13 +39,14 @@ export function ImageNavigationButtons({
{/* 右侧按钮 */} {/* 右侧按钮 */}
<button <button
type="button"
className={` className={`
absolute right-4 top-6/17 -translate-y-1/2 z-20 absolute right-4 top-6/17 -translate-y-1/2 z-20
w-12 h-12 rounded-full w-12 h-12 rounded-full
bg-black/40 backdrop-blur-sm border border-white/20 bg-black/55 border border-white/20
flex items-center justify-center flex items-center justify-center
text-white transition-all text-white transition-opacity
${hasNext ? "opacity-100 hover:bg-black/60 cursor-pointer" : "opacity-30 cursor-not-allowed"} ${hasNext ? "opacity-100 hover:bg-black/70 cursor-pointer" : "opacity-30 cursor-not-allowed"}
`} `}
onClick={onNext} onClick={onNext}
disabled={!hasNext} disabled={!hasNext}
@@ -53,4 +56,4 @@ export function ImageNavigationButtons({
</button> </button>
</> </>
); );
} });
@@ -1,7 +1,7 @@
import Link from "next/link";
import { import {
ArrowDownUp, ArrowDownUp,
Download, Download,
FileText,
Maximize, Maximize,
Maximize2, Maximize2,
Minimize, Minimize,
@@ -14,14 +14,14 @@ import {
RotateCw, RotateCw,
Volume2, Volume2,
VolumeX, VolumeX,
FileText,
} from "lucide-react"; } from "lucide-react";
import type { RefObject } from "react"; import Link from "next/link";
import { memo, type ReactNode, type RefObject } from "react";
import type { LoopMode, ObjectFit, User } from "../types.ts"; import type { LoopMode, ObjectFit, User } from "../types.ts";
import { formatRelativeTime, formatTime, formatAbsoluteUTC } from "../utils"; import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
import { ProgressBar } from "./ProgressBar"; import { ProgressBar } from "./ProgressBar";
import { SegmentedProgressBar } from "./SegmentedProgressBar"; import { SegmentedProgressBar } from "./SegmentedProgressBar";
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
interface MediaControlsProps { interface MediaControlsProps {
isVideo: boolean; isVideo: boolean;
@@ -59,7 +59,10 @@ interface MediaControlsProps {
onToggleFullscreen: () => void; onToggleFullscreen: () => void;
} }
export function MediaControls({ const CONTROL_BTN_CLASS =
"w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 cursor-pointer hover:bg-white/25 transition-colors";
export const MediaControls = memo(function MediaControls({
isVideo, isVideo,
isPlaying, isPlaying,
progress, progress,
@@ -101,7 +104,7 @@ export function MediaControls({
: loopMode === "loop" : loopMode === "loop"
? "图文循环" ? "图文循环"
: "顺序播放"; : "顺序播放";
const handleLoopModeToggle = () => { const handleLoopModeToggle = (): void => {
if (isVideo) { if (isVideo) {
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop"); onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
return; return;
@@ -115,7 +118,7 @@ export function MediaControls({
: "loop", : "loop",
); );
}; };
const renderLoopIcon = () => { const renderLoopIcon = (): ReactNode => {
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />; if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
if (normalizedLoopMode === "loop") return <Repeat size={18} />; if (normalizedLoopMode === "loop") return <Repeat size={18} />;
return <ArrowDownUp size={18} />; return <ArrowDownUp size={18} />;
@@ -131,7 +134,7 @@ export function MediaControls({
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity" className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
> >
<img <img
src={author.avatar_url!} src={author.avatar_url ?? ""}
alt="" alt=""
className="w-8 h-8 rounded-full" className="w-8 h-8 rounded-full"
/> />
@@ -142,7 +145,7 @@ export function MediaControls({
) : ( ) : (
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<img <img
src={author.avatar_url!} src={author.avatar_url ?? ""}
alt="" alt=""
className="w-8 h-8 rounded-full" className="w-8 h-8 rounded-full"
/> />
@@ -187,7 +190,8 @@ export function MediaControls({
{/* 左侧:播放控制 + 时间/进度 */} {/* 左侧:播放控制 + 时间/进度 */}
<div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0"> <div className="inline-flex items-center gap-1.5 sm:gap-2 min-w-0">
<button <button
className="w-[34px] h-[34px] shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`${CONTROL_BTN_CLASS} shrink-0`}
onClick={onTogglePlay} onClick={onTogglePlay}
aria-label={isPlaying ? "暂停" : "播放"} aria-label={isPlaying ? "暂停" : "播放"}
> >
@@ -211,7 +215,8 @@ export function MediaControls({
{/* 倍速 - 中等屏幕以上显示,仅视频 */} {/* 倍速 - 中等屏幕以上显示,仅视频 */}
{isVideo && ( {isVideo && (
<button <button
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer shrink-0" type="button"
className="hidden md:block h-[30px] px-[10px] rounded-full text-[12px] bg-white/15 text-white border border-white/20 cursor-pointer shrink-0 hover:bg-white/25 transition-colors"
onClick={() => { onClick={() => {
const steps = [1, 1.25, 1.5, 2, 0.75, 0.5]; const steps = [1, 1.25, 1.5, 2, 0.75, 0.5];
const i = steps.indexOf(rate); const i = steps.indexOf(rate);
@@ -229,7 +234,8 @@ export function MediaControls({
<div className="hidden md:inline-flex items-center gap-2 shrink-0"> <div className="hidden md:inline-flex items-center gap-2 shrink-0">
{/* 旋转按钮 - 小屏幕以上显示 */} {/* 旋转按钮 - 小屏幕以上显示 */}
<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" type="button"
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={() => onRotationChange((rotation + 270) % 360)} onClick={() => onRotationChange((rotation + 270) % 360)}
aria-label="向左旋转 90 度" aria-label="向左旋转 90 度"
title="向左旋转 90 度" title="向左旋转 90 度"
@@ -237,7 +243,8 @@ export function MediaControls({
<RotateCcw size={18} /> <RotateCcw size={18} />
</button> </button>
<button <button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={CONTROL_BTN_CLASS}
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)} onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"} aria-label={volume > 0 ? "静音" : "取消静音"}
> >
@@ -254,7 +261,8 @@ export function MediaControls({
aria-label="音量" aria-label="音量"
/> />
<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" type="button"
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={() => onRotationChange((rotation + 90) % 360)} onClick={() => onRotationChange((rotation + 90) % 360)}
aria-label="向右旋转 90 度" aria-label="向右旋转 90 度"
title="向右旋转 90 度" title="向右旋转 90 度"
@@ -267,7 +275,8 @@ export function MediaControls({
<div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0"> <div className="inline-flex items-center gap-1 sm:gap-1.5 lg:gap-2 shrink-0">
{/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */} {/* 音量按钮 - 仅在小屏幕显示(中等屏幕以上有滑块) */}
<button <button
className="md:hidden w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={`md:hidden ${CONTROL_BTN_CLASS}`}
onClick={() => onVolumeChange(volume > 0 ? 0 : 1)} onClick={() => onVolumeChange(volume > 0 ? 0 : 1)}
aria-label={volume > 0 ? "静音" : "取消静音"} aria-label={volume > 0 ? "静音" : "取消静音"}
> >
@@ -276,7 +285,8 @@ export function MediaControls({
{/* 循环模式 - 中等屏幕以上显示 */} {/* 循环模式 - 中等屏幕以上显示 */}
<button <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" type="button"
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={handleLoopModeToggle} onClick={handleLoopModeToggle}
aria-label={loopLabel} aria-label={loopLabel}
title={loopLabel} title={loopLabel}
@@ -286,7 +296,8 @@ export function MediaControls({
{/* 适配模式 - 小屏幕以上显示 */} {/* 适配模式 - 小屏幕以上显示 */}
<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" type="button"
className={`hidden sm:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={() => onClick={() =>
onObjectFitChange(objectFit === "contain" ? "cover" : "contain") onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
} }
@@ -307,7 +318,8 @@ export function MediaControls({
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */} {/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
{isVideo && hasTranscript && onShowTranscript && ( {isVideo && hasTranscript && onShowTranscript && (
<button <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" type="button"
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={onShowTranscript} onClick={onShowTranscript}
aria-label="显示转录文本" aria-label="显示转录文本"
title="显示转录文本" title="显示转录文本"
@@ -318,7 +330,8 @@ export function MediaControls({
{/* 下载 - 中等屏幕以上显示 */} {/* 下载 - 中等屏幕以上显示 */}
<button <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" type="button"
className={`hidden md:inline-flex ${CONTROL_BTN_CLASS}`}
onClick={onDownload} onClick={onDownload}
aria-label={isVideo ? "下载视频" : "下载当前图片"} aria-label={isVideo ? "下载视频" : "下载当前图片"}
title={isVideo ? "下载视频" : "下载当前图片"} title={isVideo ? "下载视频" : "下载当前图片"}
@@ -327,10 +340,8 @@ export function MediaControls({
</button> </button>
{/* 更多菜单 - 只在有按钮被折叠时显示 */} {/* 更多菜单 - 只在有按钮被折叠时显示 */}
{/* sm屏幕以下会隐藏适配模式,md屏幕以下会隐藏循环、转录、下载、倍速 */}
<div className="sm:block md:hidden"> <div className="sm:block md:hidden">
<MoreMenu> <MoreMenu>
{/* 小屏幕隐藏的适配模式 */}
<div className="sm:hidden"> <div className="sm:hidden">
<MoreMenuItem <MoreMenuItem
icon={ icon={
@@ -349,7 +360,6 @@ export function MediaControls({
/> />
</div> </div>
{/* 中等屏幕以下隐藏的循环模式 */}
<div className="md:hidden"> <div className="md:hidden">
<MoreMenuItem <MoreMenuItem
icon={renderLoopIcon()} icon={renderLoopIcon()}
@@ -358,7 +368,6 @@ export function MediaControls({
/> />
</div> </div>
{/* 中等屏幕以下隐藏的转录按钮 */}
{isVideo && hasTranscript && onShowTranscript && ( {isVideo && hasTranscript && onShowTranscript && (
<div className="md:hidden"> <div className="md:hidden">
<MoreMenuItem <MoreMenuItem
@@ -369,7 +378,6 @@ export function MediaControls({
</div> </div>
)} )}
{/* 中等屏幕以下隐藏的下载 */}
<div className="md:hidden"> <div className="md:hidden">
<MoreMenuItem <MoreMenuItem
icon={<Download size={18} />} icon={<Download size={18} />}
@@ -378,7 +386,6 @@ export function MediaControls({
/> />
</div> </div>
{/* 仅在视频模式下显示的倍速(中等屏幕以下) */}
{isVideo && ( {isVideo && (
<div className="md:hidden"> <div className="md:hidden">
<MoreMenuItem <MoreMenuItem
@@ -398,7 +405,8 @@ export function MediaControls({
{/* 全屏 - 所有设备都显示 */} {/* 全屏 - 所有设备都显示 */}
<button <button
className="w-[34px] h-[34px] inline-flex items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer" type="button"
className={CONTROL_BTN_CLASS}
onClick={onToggleFullscreen} onClick={onToggleFullscreen}
aria-label="切换全屏" aria-label="切换全屏"
> >
@@ -413,4 +421,4 @@ export function MediaControls({
) : null} ) : null}
</div> </div>
); );
} });
@@ -4,6 +4,7 @@ import {
MessageSquareText, MessageSquareText,
ThumbsUp, ThumbsUp,
} from "lucide-react"; } from "lucide-react";
import { memo } from "react";
import type { Neighbors } from "../types.ts"; import type { Neighbors } from "../types.ts";
interface NavigationButtonsProps { interface NavigationButtonsProps {
@@ -15,7 +16,7 @@ interface NavigationButtonsProps {
onToggleComments: () => void; onToggleComments: () => void;
} }
export function NavigationButtons({ export const NavigationButtons = memo(function NavigationButtons({
neighbors, neighbors,
commentsCount, commentsCount,
likesCount, likesCount,
@@ -26,7 +27,10 @@ export function NavigationButtons({
return ( return (
<> <>
<div className="absolute right-4 top-8/14 flex flex-col items-center gap-8 z-10"> <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
type="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"> <div className="grid place-items-center gap-1 drop-shadow-lg">
<ThumbsUp size={40} className="" /> <ThumbsUp size={40} className="" />
@@ -37,6 +41,7 @@ export function NavigationButtons({
</button> </button>
{/* 评论开关(右侧中部) */} {/* 评论开关(右侧中部) */}
<button <button
type="button"
className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer" className="grid place-items-center w-[54px] h-[54px] rounded-full -translate-y-1/2 cursor-pointer"
onClick={onToggleComments} onClick={onToggleComments}
aria-label="切换评论" aria-label="切换评论"
@@ -59,8 +64,9 @@ export function NavigationButtons({
{/* 上下切换按钮(右侧胶囊形状) */} {/* 上下切换按钮(右侧胶囊形状) */}
<div className="absolute right-4 top-5/11 -translate-y-1/2 z-10"> <div className="absolute right-4 top-5/11 -translate-y-1/2 z-10">
<div className="flex flex-col rounded-full bg-black/40 backdrop-blur-sm border border-white/20 overflow-hidden"> <div className="flex flex-col rounded-full bg-black/55 border border-white/20 overflow-hidden">
<button <button
type="button"
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors" className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors"
onClick={onNavigatePrev} onClick={onNavigatePrev}
disabled={!neighbors.prev} disabled={!neighbors.prev}
@@ -69,6 +75,7 @@ export function NavigationButtons({
<ChevronUp size={24} /> <ChevronUp size={24} />
</button> </button>
<button <button
type="button"
className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20" className="w-[44px] h-[44px] inline-flex items-center justify-center text-white disabled:opacity-40 cursor-pointer disabled:cursor-not-allowed hover:bg-white/10 transition-colors border-t border-white/20"
onClick={onNavigateNext} onClick={onNavigateNext}
disabled={!neighbors.next} disabled={!neighbors.next}
@@ -80,4 +87,4 @@ export function NavigationButtons({
</div> </div>
</> </>
); );
} });
+10 -4
View File
@@ -1,11 +1,17 @@
import { memo } from "react";
interface ProgressBarProps { interface ProgressBarProps {
progress: number; progress: number;
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
} }
export function ProgressBar({ progress, onSeek }: ProgressBarProps) { export const ProgressBar = memo(function ProgressBar({
const seekFromClientX = (clientX: number, element: HTMLElement) => { progress,
onSeek,
}: ProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement): void => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
if (rect.width <= 0) return;
onSeek((clientX - rect.left) / rect.width); onSeek((clientX - rect.left) / rect.width);
}; };
@@ -36,9 +42,9 @@ export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
}} }}
> >
<div <div
className="origin-left h-full bg-white" className="origin-left h-full bg-white will-change-transform"
style={{ transform: `scaleX(${progress || 0})` }} style={{ transform: `scaleX(${progress || 0})` }}
/> />
</div> </div>
); );
} });
@@ -1,3 +1,5 @@
import { memo } from "react";
interface SegmentedProgressBarProps { interface SegmentedProgressBarProps {
totalSegments: number; totalSegments: number;
currentIndex: number; currentIndex: number;
@@ -5,14 +7,37 @@ interface SegmentedProgressBarProps {
onSeek: (ratio: number) => void; onSeek: (ratio: number) => void;
} }
export function SegmentedProgressBar({ const SegmentBarItem = memo(function SegmentBarItem({
index,
fill,
}: {
index: number;
fill: number;
}) {
return (
<div
aria-label={`第 ${index + 1} 段`}
className="relative flex-1 h-full rounded-full bg-white/25 overflow-hidden"
>
<div
className="h-full origin-left bg-white will-change-transform"
style={{
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
}}
/>
</div>
);
});
export const SegmentedProgressBar = memo(function SegmentedProgressBar({
totalSegments, totalSegments,
currentIndex, currentIndex,
segmentProgress, segmentProgress,
onSeek, onSeek,
}: SegmentedProgressBarProps) { }: SegmentedProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => { const seekFromClientX = (clientX: number, element: HTMLElement): void => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
if (rect.width <= 0) return;
onSeek((clientX - rect.left) / rect.width); onSeek((clientX - rect.left) / rect.width);
}; };
@@ -47,22 +72,9 @@ export function SegmentedProgressBar({
let fill = 0; let fill = 0;
if (i < currentIndex) fill = 1; if (i < currentIndex) fill = 1;
else if (i === currentIndex) fill = segmentProgress; else if (i === currentIndex) fill = segmentProgress;
return ( return <SegmentBarItem key={`seg-${i}`} index={i} fill={fill} />;
<div
key={i}
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(${Math.max(0, Math.min(1, fill))})`,
}}
/>
</div>
);
})} })}
</div> </div>
</div> </div>
); );
} });
@@ -1,7 +1,7 @@
"use client"; "use client";
import { X, Copy, CheckCheck, Check } from "lucide-react"; import { Check, CheckCheck, Copy, X } from "lucide-react";
import { useState } from "react"; import { memo, useEffect, useRef, useState } from "react";
import type { VideoTranscript } from "../types"; import type { VideoTranscript } from "../types";
interface TranscriptPanelProps { interface TranscriptPanelProps {
@@ -10,46 +10,59 @@ interface TranscriptPanelProps {
transcript: VideoTranscript | null; transcript: VideoTranscript | null;
} }
export function TranscriptPanel({ export const TranscriptPanel = memo(function TranscriptPanel({
open, open,
onClose, onClose,
transcript, transcript,
}: TranscriptPanelProps) { }: TranscriptPanelProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null); const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false); const [copiedAll, setCopiedAll] = useState(false);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyAllTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
if (copyAllTimerRef.current !== null)
clearTimeout(copyAllTimerRef.current);
};
}, []);
if (!open) return null; if (!open) return null;
const handleCopy = (text: string, index: number) => { const handleCopy = (text: string, index: number): void => {
navigator.clipboard.writeText(text).then(() => { void navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index); setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000); if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current);
copyTimerRef.current = setTimeout(() => setCopiedIndex(null), 2000);
}); });
}; };
const handleCopyAll = () => { const handleCopyAll = (): void => {
if (!transcript?.transcript) return; if (!transcript?.transcript) return;
const allText = transcript.transcript.join("\n"); const allText = transcript.transcript.join("\n");
navigator.clipboard.writeText(allText).then(() => { void navigator.clipboard.writeText(allText).then(() => {
setCopiedAll(true); setCopiedAll(true);
setTimeout(() => setCopiedAll(false), 2000); if (copyAllTimerRef.current !== null)
clearTimeout(copyAllTimerRef.current);
copyAllTimerRef.current = setTimeout(() => setCopiedAll(false), 2000);
}); });
}; };
if (!transcript?.transcript?.length) { if (!transcript?.transcript?.length) {
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onClose} onClick={onClose}
> >
<div <div
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl" className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 border border-white/10 rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-white/10"> <div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">语音转录</h2> <h2 className="text-lg font-semibold text-white">语音转录</h2>
<button <button
type="button"
onClick={onClose} onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors" className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="关闭" aria-label="关闭"
@@ -58,7 +71,6 @@ export function TranscriptPanel({
</button> </button>
</div> </div>
{/* 内容 */}
<div className="p-8 text-center text-white/60"> <div className="p-8 text-center text-white/60">
<p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p> <p>{transcript ? "未识别到文字" : "该视频尚未转写"}</p>
</div> </div>
@@ -69,21 +81,20 @@ export function TranscriptPanel({
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
onClick={onClose} onClick={onClose}
> >
<div <div
className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl flex flex-col" className="relative w-full max-w-2xl max-h-[80vh] m-4 bg-zinc-900/95 border border-white/10 rounded-2xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0"> <div className="flex items-center justify-between p-4 border-b border-white/10 flex-shrink-0">
<div> <div>
<h2 className="text-lg font-semibold text-white">语音转录</h2> <h2 className="text-lg font-semibold text-white">语音转录</h2>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button"
onClick={handleCopyAll} onClick={handleCopyAll}
className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors" className="flex items-center gap-2 px-3 py-2 text-sm bg-blue-600/20 text-blue-300 rounded-lg hover:bg-blue-600/30 transition-colors"
> >
@@ -100,6 +111,7 @@ export function TranscriptPanel({
)} )}
</button> </button>
<button <button
type="button"
onClick={onClose} onClick={onClose}
className="p-2 rounded-full hover:bg-white/10 transition-colors" className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="关闭" aria-label="关闭"
@@ -109,11 +121,10 @@ export function TranscriptPanel({
</div> </div>
</div> </div>
{/* 转录列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-2"> <div className="flex-1 overflow-y-auto p-4 space-y-2">
{transcript.transcript.map((text, index) => ( {transcript.transcript.map((text, index) => (
<div <div
key={index} key={`transcript-${index}`}
className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer" className="group bg-white/5 rounded-lg p-3 hover:bg-white/10 transition-colors cursor-pointer"
onClick={() => handleCopy(text, index)} onClick={() => handleCopy(text, index)}
> >
@@ -129,7 +140,11 @@ export function TranscriptPanel({
</p> </p>
</div> </div>
<button <button
onClick={() => handleCopy(text, index)} type="button"
onClick={(e) => {
e.stopPropagation();
handleCopy(text, index);
}}
className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all" className="flex-shrink-0 p-2 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 transition-all"
aria-label="复制" aria-label="复制"
> >
@@ -144,11 +159,10 @@ export function TranscriptPanel({
))} ))}
</div> </div>
{/* 底部统计 */}
<div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50"> <div className="flex-shrink-0 p-3 border-t border-white/10 text-center text-xs text-white/50">
共 {transcript.transcript.length} 段转录内容 共 {transcript.transcript.length} 段转录内容
</div> </div>
</div> </div>
</div> </div>
); );
} });
@@ -1,4 +1,4 @@
import { forwardRef } from "react"; import { forwardRef, memo } from "react";
import type { ObjectFit } from "../types.ts"; import type { ObjectFit } from "../types.ts";
interface VideoPlayerProps { interface VideoPlayerProps {
@@ -9,19 +9,18 @@ interface VideoPlayerProps {
onTogglePlay: () => void; onTogglePlay: () => void;
} }
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>( export const VideoPlayer = memo(
forwardRef<HTMLVideoElement, VideoPlayerProps>(
({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => { ({ videoUrl, rotation, objectFit, loop, onTogglePlay }, ref) => {
return ( return (
<video <video
ref={ref} ref={ref}
src={videoUrl} src={videoUrl}
className={[ className={
// 旋转 0/180:充满容器盒子;
// 旋转 90/270:用中心定位 + 100vh/100vw,保证铺满全屏
rotation % 180 === 0 rotation % 180 === 0
? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer" ? "absolute inset-0 h-full w-full bg-black/70 cursor-pointer"
: "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer", : "absolute top-1/2 left-1/2 h-[100vw] w-[100vh] bg-black/70 cursor-pointer"
].join(" ")} }
style={{ style={{
transform: transform:
rotation % 180 === 0 rotation % 180 === 0
@@ -36,6 +35,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
/> />
); );
}, },
),
); );
VideoPlayer.displayName = "VideoPlayer"; VideoPlayer.displayName = "VideoPlayer";
@@ -1,7 +1,7 @@
"use client"; "use client";
import { Check, Tag, X } from "lucide-react"; import { Check, Tag, X } from "lucide-react";
import { useEffect, useState } from "react"; import { memo, useEffect, useState } from "react";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags"; import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
type TagUpdateResponse = { type TagUpdateResponse = {
@@ -18,7 +18,7 @@ type VideoTagEditorProps = {
onTagsChange: (tags: string[]) => void; onTagsChange: (tags: string[]) => void;
}; };
export function VideoTagEditor({ export const VideoTagEditor = memo(function VideoTagEditor({
awemeId, awemeId,
tags, tags,
onTagsChange, onTagsChange,
@@ -32,7 +32,7 @@ export function VideoTagEditor({
if (!open) setDraftTags(tags); if (!open) setDraftTags(tags);
}, [open, tags]); }, [open, tags]);
const toggleTag = (tag: string) => { const toggleTag = (tag: string): void => {
setDraftTags((current) => setDraftTags((current) =>
current.includes(tag) current.includes(tag)
? current.filter((item) => item !== tag) ? current.filter((item) => item !== tag)
@@ -40,7 +40,7 @@ export function VideoTagEditor({
); );
}; };
const saveTags = async () => { const saveTags = async (): Promise<void> => {
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
@@ -70,7 +70,7 @@ export function VideoTagEditor({
<button <button
type="button" type="button"
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3 text-sm font-medium text-white shadow-lg backdrop-blur transition-colors hover:bg-black/50" className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/55 px-3 text-sm font-medium text-white shadow-lg transition-colors hover:bg-black/70"
aria-expanded={open} aria-expanded={open}
> >
<Tag size={16} /> <Tag size={16} />
@@ -78,7 +78,7 @@ export function VideoTagEditor({
</button> </button>
{open && ( {open && (
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl"> <div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/95 p-3 text-white shadow-2xl">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium">手动标签</div> <div className="text-sm font-medium">手动标签</div>
<button <button
@@ -135,4 +135,4 @@ export function VideoTagEditor({
)} )}
</div> </div>
); );
} });
@@ -1,74 +1,90 @@
import { useEffect } from "react";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect } from "react";
interface UseBackgroundCanvasProps { interface UseBackgroundCanvasProps {
isVideo: boolean; isVideo: boolean;
idx: number; isPlaying: boolean;
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
scrollerRef: RefObject<HTMLDivElement | null>;
backgroundCanvasRef: RefObject<HTMLCanvasElement | null>; backgroundCanvasRef: RefObject<HTMLCanvasElement | null>;
} }
const CANVAS_DRAW_INTERVAL_MS = 100; // 10fps 对 1/10 分辨率 + blur(40px) 已完全平滑,大幅降低 GPU 负载
export function useBackgroundCanvas({ export function useBackgroundCanvas({
isVideo, isVideo,
idx, isPlaying,
videoRef, videoRef,
scrollerRef,
backgroundCanvasRef, backgroundCanvasRef,
}: UseBackgroundCanvasProps) { }: UseBackgroundCanvasProps): void {
// 初始化尺寸与卸载清理
useEffect(() => { useEffect(() => {
const canvas = backgroundCanvasRef.current; const canvas = backgroundCanvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx || !isVideo) {
canvas.width = 0;
if (!isVideo) { canvas.height = 0;
canvas.width = 1;
canvas.height = 1;
ctx.clearRect(0, 0, 1, 1);
return; return;
} }
const updateCanvasSize = () => { const updateCanvasSize = (): void => {
canvas.width = Math.floor(window.innerWidth / 10); canvas.width = Math.max(1, Math.floor(window.innerWidth / 10));
canvas.height = Math.floor(window.innerHeight / 10); canvas.height = Math.max(1, Math.floor(window.innerHeight / 10));
}; };
updateCanvasSize(); updateCanvasSize();
let resizeTimeout: NodeJS.Timeout; let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const debouncedResize = () => { const debouncedResize = (): void => {
clearTimeout(resizeTimeout); if (resizeTimeout !== null) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCanvasSize, 300); resizeTimeout = setTimeout(updateCanvasSize, 300);
}; };
window.addEventListener("resize", debouncedResize); window.addEventListener("resize", debouncedResize);
const drawMediaToCanvas = () => { return () => {
window.removeEventListener("resize", debouncedResize);
if (resizeTimeout !== null) clearTimeout(resizeTimeout);
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.width = 0;
canvas.height = 0;
};
}, [isVideo, backgroundCanvasRef]);
// 仅在视频实际播放时进行低频采样绘制,暂停时不占 CPU/GPU
useEffect(() => {
if (!isVideo || !isPlaying) return;
const canvas = backgroundCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return; if (!ctx) return;
const drawMediaToCanvas = (): void => {
if (document.hidden) return;
const sourceElement = videoRef.current; const sourceElement = videoRef.current;
if ( if (
!sourceElement || !sourceElement ||
(sourceElement instanceof HTMLVideoElement && sourceElement.readyState < 2 ||
sourceElement.readyState < 2) sourceElement.paused
) ) {
return; return;
}
const canvasWidth = canvas.width; const canvasWidth = canvas.width;
const canvasHeight = canvas.height; const canvasHeight = canvas.height;
const sourceWidth = sourceElement.videoWidth; const sourceWidth = sourceElement.videoWidth;
const sourceHeight = sourceElement.videoHeight; const sourceHeight = sourceElement.videoHeight;
if (!sourceWidth || !sourceHeight) return; if (!canvasWidth || !canvasHeight || !sourceWidth || !sourceHeight) {
return;
}
const canvasRatio = canvasWidth / canvasHeight; const canvasRatio = canvasWidth / canvasHeight;
const sourceRatio = sourceWidth / sourceHeight; const sourceRatio = sourceWidth / sourceHeight;
let drawWidth: number, let drawWidth: number;
drawHeight: number, let drawHeight: number;
offsetX: number, let offsetX: number;
offsetY: number; let offsetY: number;
if (canvasRatio > sourceRatio) { if (canvasRatio > sourceRatio) {
drawWidth = canvasWidth; drawWidth = canvasWidth;
@@ -85,12 +101,14 @@ export function useBackgroundCanvas({
ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight); ctx.drawImage(sourceElement, offsetX, offsetY, drawWidth, drawHeight);
}; };
const intervalId = setInterval(drawMediaToCanvas, 34); drawMediaToCanvas();
const intervalId = window.setInterval(
drawMediaToCanvas,
CANVAS_DRAW_INTERVAL_MS,
);
return () => { return () => {
clearInterval(intervalId); window.clearInterval(intervalId);
window.removeEventListener("resize", debouncedResize);
clearTimeout(resizeTimeout);
}; };
}, [isVideo, idx, videoRef, scrollerRef, backgroundCanvasRef]); }, [isVideo, isPlaying, videoRef, backgroundCanvasRef]);
} }
+177 -83
View File
@@ -1,16 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { RefObject } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ImageData, LoopMode, Neighbors } from "../types.ts"; import type { ImageData, LoopMode, Neighbors } from "../types.ts";
import { releaseMediaElement } from "../utils";
const MIN_SEGMENT_MS = 250; const MIN_SEGMENT_MS = 250;
const UI_UPDATE_INTERVAL_MS = 33; const UI_UPDATE_INTERVAL_MS = 33;
const PROGRESS_EPSILON = 0.0001; const PROGRESS_EPSILON = 0.001;
function normalizeDuration( function normalizeDuration(
duration: number | null | undefined, duration: number | null | undefined,
fallback: number, fallback: number,
) { ): number {
if ( if (
typeof duration !== "number" || typeof duration !== "number" ||
!Number.isFinite(duration) || !Number.isFinite(duration) ||
@@ -21,7 +22,7 @@ function normalizeDuration(
return Math.max(MIN_SEGMENT_MS, Math.round(duration)); return Math.max(MIN_SEGMENT_MS, Math.round(duration));
} }
function sumDurations(durations: number[], end: number) { function sumDurations(durations: number[], end: number): number {
let total = 0; let total = 0;
for (let i = 0; i < end; i++) total += durations[i] ?? 0; for (let i = 0; i < end; i++) total += durations[i] ?? 0;
return total; return total;
@@ -39,6 +40,26 @@ interface UseImageCarouselProps {
segmentMs?: number; segmentMs?: number;
} }
export interface UseImageCarouselReturn {
idx: number;
setIdx: (idx: number) => void;
segProgress: number;
segProgressRef: RefObject<number>;
segmentDurations: number[];
totalDurationMs: number;
mediaSyncToken: number;
segStartRef: RefObject<number | null>;
idxRef: RefObject<number>;
failedAnimatedMap: Record<string, boolean>;
setSegmentDuration: (imageId: string, durationMs: number) => void;
markMediaReady: (imageId: string) => void;
markAnimatedFailed: (imageId: string) => void;
handleAnimatedProgress: (imageId: string, progress: number) => void;
handleAnimatedEnded: (imageId: string) => void;
goToIndex: (index: number, segmentProgress?: number) => void;
seekTo: (ratio: number) => void;
}
export function useImageCarousel({ export function useImageCarousel({
images, images,
isPlaying, isPlaying,
@@ -48,13 +69,19 @@ export function useImageCarousel({
audioRef, audioRef,
setProgress, setProgress,
segmentMs = 5000, segmentMs = 5000,
}: UseImageCarouselProps) { }: UseImageCarouselProps): UseImageCarouselReturn {
const router = useRouter(); const router = useRouter();
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const [segProgress, setSegProgress] = useState(0); const [segProgress, setSegProgress] = useState(0);
const [durationOverrides, setDurationOverrides] = useState< const [durationOverrides, setDurationOverrides] = useState<
Record<string, number> Record<string, number>
>({}); >({});
const [readyMediaMap, setReadyMediaMap] = useState<Record<string, boolean>>(
{},
);
const [failedAnimatedMap, setFailedAnimatedMap] = useState<
Record<string, boolean>
>({});
const [mediaSyncToken, setMediaSyncToken] = useState(0); const [mediaSyncToken, setMediaSyncToken] = useState(0);
const segStartRef = useRef<number | null>(null); const segStartRef = useRef<number | null>(null);
@@ -85,7 +112,7 @@ export function useImageCarousel({
); );
const syncProgress = useCallback( const syncProgress = useCallback(
(index: number, segmentProgress: number, force = false) => { (index: number, segmentProgress: number, force = false): void => {
const segmentDuration = segmentDurations[index] ?? fallbackDuration; const segmentDuration = segmentDurations[index] ?? fallbackDuration;
const completedMs = sumDurations(segmentDurations, index); const completedMs = sumDurations(segmentDurations, index);
const totalProgress = const totalProgress =
@@ -116,7 +143,7 @@ export function useImageCarousel({
); );
const setSegmentDuration = useCallback( const setSegmentDuration = useCallback(
(imageId: string, durationMs: number) => { (imageId: string, durationMs: number): void => {
if (!Number.isFinite(durationMs) || durationMs <= 0) return; if (!Number.isFinite(durationMs) || durationMs <= 0) return;
const normalizedDuration = normalizeDuration( const normalizedDuration = normalizeDuration(
durationMs, durationMs,
@@ -131,8 +158,95 @@ export function useImageCarousel({
[fallbackDuration], [fallbackDuration],
); );
const markMediaReady = useCallback((imageId: string): void => {
setReadyMediaMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
}, []);
const markAnimatedFailed = useCallback((imageId: string): void => {
setFailedAnimatedMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
setReadyMediaMap((current) => {
if (current[imageId]) return current;
return { ...current, [imageId]: true };
});
}, []);
const advanceSegment = useCallback(
(fromIndex: number): void => {
if (!images.length || idxRef.current !== fromIndex) return;
const now = performance.now();
if (loopMode === "single") {
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(fromIndex, 0, true);
return;
}
if (fromIndex >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return;
}
idxRef.current = 0;
setIdx(0);
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(0, 0, true);
if (images.length === 1) {
setMediaSyncToken((token) => token + 1);
}
return;
}
const nextIndex = fromIndex + 1;
idxRef.current = nextIndex;
setIdx(nextIndex);
segStartRef.current = now;
lastUiUpdateRef.current = now;
syncProgress(nextIndex, 0, true);
},
[images.length, loopMode, neighbors?.next, router, syncProgress],
);
const handleAnimatedProgress = useCallback(
(imageId: string, progress: number): void => {
const currentImage = images[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return;
const now = performance.now();
const clamped = Math.max(0, Math.min(1, progress));
const wrapped = clamped < segProgressRef.current - 0.2;
if (
wrapped ||
clamped >= 0.995 ||
now - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = now;
syncProgress(idxRef.current, clamped, wrapped);
}
},
[images, syncProgress],
);
const handleAnimatedEnded = useCallback(
(imageId: string): void => {
const currentImage = images[idxRef.current];
if (!currentImage || currentImage.id !== imageId) return;
advanceSegment(idxRef.current);
},
[advanceSegment, images],
);
const goToIndex = useCallback( const goToIndex = useCallback(
(index: number, segmentProgress = 0) => { (index: number, segmentProgress = 0): void => {
if (!images.length) return; if (!images.length) return;
const nextIndex = Math.max(0, Math.min(images.length - 1, index)); const nextIndex = Math.max(0, Math.min(images.length - 1, index));
@@ -151,7 +265,7 @@ export function useImageCarousel({
); );
const seekTo = useCallback( const seekTo = useCallback(
(ratio: number) => { (ratio: number): void => {
if (!images.length || totalDurationMs <= 0) return; if (!images.length || totalDurationMs <= 0) return;
const clampedRatio = Math.max(0, Math.min(1, ratio)); const clampedRatio = Math.max(0, Math.min(1, ratio));
@@ -197,25 +311,9 @@ export function useImageCarousel({
idxRef.current = idx; idxRef.current = idx;
}, [idx]); }, [idx]);
// 切换作品或图片集合变化时重置所有轮播状态
useEffect(() => { useEffect(() => {
const validIds = new Set(images.map((img) => img.id)); if (!imageKey) return;
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; idxRef.current = 0;
setIdx(0); setIdx(0);
segStartRef.current = null; segStartRef.current = null;
@@ -223,6 +321,9 @@ export function useImageCarousel({
totalProgressRef.current = 0; totalProgressRef.current = 0;
setSegProgress(0); setSegProgress(0);
setProgress(0); setProgress(0);
setDurationOverrides({});
setReadyMediaMap({});
setFailedAnimatedMap({});
setMediaSyncToken((token) => token + 1); setMediaSyncToken((token) => token + 1);
}, [imageKey, setProgress]); }, [imageKey, setProgress]);
@@ -239,90 +340,77 @@ export function useImageCarousel({
}, [audioRef, isPlaying, volume]); }, [audioRef, isPlaying, volume]);
useEffect(() => { useEffect(() => {
const el = audioRef.current;
return () => { return () => {
audioRef.current?.pause(); releaseMediaElement(el);
}; };
}, [audioRef]); }, [audioRef]);
// 自动切页 // 静态图自动切页(仅在当前静态图已加载完成后开始计时;动图由 <video> 自身进度与 onEnded 驱动)
const currentImage = images[idx];
const isCurrentAnimated =
Boolean(currentImage?.animated) &&
!failedAnimatedMap[currentImage?.id ?? ""];
const isCurrentStaticReady = currentImage
? Boolean(readyMediaMap[currentImage.id])
: false;
useEffect(() => { useEffect(() => {
if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) if (!images?.length || !segmentDurations.length || totalDurationMs <= 0) {
return; return;
}
// 当前页为正常动图时,交由 ImageCarousel 内 <video> 的真实播放进度和 ended 事件推进
if (isCurrentAnimated) {
return;
}
// 静态图尚未加载完成或处于暂停态时,冻结分段进度,避免未加载完就偷跑进度裁切展示时长
if (!isPlaying || !isCurrentStaticReady) {
return;
}
const now = performance.now(); const now = performance.now();
const currentSegmentDuration = const currentSegmentDuration = segmentDurations[idx] ?? fallbackDuration;
segmentDurations[idxRef.current] ?? fallbackDuration;
segStartRef.current = now - segProgressRef.current * currentSegmentDuration; segStartRef.current = now - segProgressRef.current * currentSegmentDuration;
lastUiUpdateRef.current = now;
if (!isPlaying) return; const tick = (): void => {
const tick = () => {
if (!images?.length) return; if (!images?.length) return;
const ts = performance.now(); const ts = performance.now();
const start = segStartRef.current ?? ts;
const activeIdx = idxRef.current;
const duration = segmentDurations[activeIdx] ?? fallbackDuration;
const elapsed = ts - start;
let start = segStartRef.current!; if (elapsed >= duration) {
let localIdx = idxRef.current; advanceSegment(activeIdx);
let elapsed = ts - start;
let currentSegmentDuration =
segmentDurations[localIdx] ?? fallbackDuration;
while (elapsed >= currentSegmentDuration) {
if (loopMode === "single") {
elapsed %= currentSegmentDuration;
break;
}
elapsed -= currentSegmentDuration;
if (localIdx >= images.length - 1) {
if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
return; return;
} }
localIdx = 0;
} else {
localIdx = localIdx + 1;
}
currentSegmentDuration = segmentDurations[localIdx] ?? fallbackDuration; const localSeg = Math.max(0, Math.min(1, elapsed / duration));
} if (ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS) {
segStartRef.current = ts - elapsed;
const indexChanged = localIdx !== idxRef.current;
if (indexChanged) {
idxRef.current = localIdx;
setIdx(localIdx);
}
const localSeg = Math.max(
0,
Math.min(1, elapsed / currentSegmentDuration),
);
if (
indexChanged ||
ts - lastUiUpdateRef.current >= UI_UPDATE_INTERVAL_MS
) {
lastUiUpdateRef.current = ts; lastUiUpdateRef.current = ts;
syncProgress(localIdx, localSeg, indexChanged); syncProgress(activeIdx, localSeg, false);
} }
}; };
tick();
timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS); timerRef.current = window.setInterval(tick, UI_UPDATE_INTERVAL_MS);
return () => { return () => {
if (timerRef.current) window.clearInterval(timerRef.current); if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null; timerRef.current = null;
}
}; };
}, [ }, [
advanceSegment,
fallbackDuration, fallbackDuration,
idx,
images, images,
isCurrentAnimated,
isCurrentStaticReady,
isPlaying, isPlaying,
loopMode,
neighbors?.next,
router,
segmentDurations, segmentDurations,
syncProgress, syncProgress,
totalDurationMs, totalDurationMs,
@@ -332,12 +420,18 @@ export function useImageCarousel({
idx, idx,
setIdx, setIdx,
segProgress, segProgress,
segProgressRef,
segmentDurations, segmentDurations,
totalDurationMs, totalDurationMs,
mediaSyncToken, mediaSyncToken,
segStartRef, segStartRef,
idxRef, idxRef,
failedAnimatedMap,
setSegmentDuration, setSegmentDuration,
markMediaReady,
markAnimatedFailed,
handleAnimatedProgress,
handleAnimatedEnded,
goToIndex, goToIndex,
seekTo, seekTo,
}; };
+21 -12
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { RefObject } from "react";
import { useEffect, useRef } from "react";
import type { Neighbors } from "../types.ts"; import type { Neighbors } from "../types.ts";
interface UseNavigationProps { interface UseNavigationProps {
@@ -21,7 +21,7 @@ export function useNavigation({
prevImg, prevImg,
nextImg, nextImg,
togglePlay, togglePlay,
}: UseNavigationProps) { }: UseNavigationProps): void {
const router = useRouter(); const router = useRouter();
const wheelCooldownRef = useRef<number>(0); const wheelCooldownRef = useRef<number>(0);
@@ -37,7 +37,7 @@ export function useNavigation({
const el = mediaContainerRef.current; const el = mediaContainerRef.current;
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent): void => {
if (e.ctrlKey) return; if (e.ctrlKey) return;
const now = performance.now(); const now = performance.now();
if (now - wheelCooldownRef.current < 700) return; if (now - wheelCooldownRef.current < 700) return;
@@ -58,17 +58,18 @@ export function useNavigation({
}; };
el.addEventListener("wheel", onWheel, { passive: false }); el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel as any); return () => el.removeEventListener("wheel", onWheel);
}, [neighbors, mediaContainerRef, router]); }, [neighbors, mediaContainerRef, router]);
// 键盘快捷键 // 键盘快捷键
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent): void => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement | null;
if ( if (
target.tagName === "INPUT" || target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" || target.tagName === "TEXTAREA" ||
target.isContentEditable target.isContentEditable)
) { ) {
return; return;
} }
@@ -121,17 +122,25 @@ export function useNavigation({
return () => window.removeEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown);
}, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]); }, [isVideo, neighbors, router, videoRef, prevImg, nextImg, togglePlay]);
// 浏览器返回事件 // 仅在无历史记录的独立新标签页首次进入时注入一次返回兜底,避免连续刷视频时无限叠加 history 栈
useEffect(() => { useEffect(() => {
window.history.pushState({ interceptBack: true }, ""); const currentState = window.history.state as
| { interceptBack?: boolean }
| null
| undefined;
if (window.history.length <= 1 && !currentState?.interceptBack) {
window.history.pushState({ ...currentState, interceptBack: true }, "");
}
const handlePopState = () => { const handlePopState = (): void => {
if (window.history.length <= 1) {
window.close(); window.close();
setTimeout(() => { setTimeout(() => {
if (!document.hidden) { if (!document.hidden) {
router.push("/"); router.push("/");
} }
}, 100); }, 100);
}
}; };
window.addEventListener("popstate", handlePopState); window.addEventListener("popstate", handlePopState);
+50 -26
View File
@@ -1,7 +1,8 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { RefObject } from "react"; import type { RefObject } from "react";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import type { LoopMode, Neighbors } from "../types.ts"; import type { LoopMode, Neighbors } from "../types.ts";
import { cleanupExpiredProgressStorage, releaseMediaElement } from "../utils";
interface UseVideoPlayerProps { interface UseVideoPlayerProps {
enabled?: boolean; enabled?: boolean;
@@ -29,18 +30,27 @@ export function useVideoPlayer({
setIsPlaying, setIsPlaying,
setProgress, setProgress,
setProgressRestored, setProgressRestored,
}: UseVideoPlayerProps) { }: UseVideoPlayerProps): void {
const router = useRouter(); const router = useRouter();
const restoredAwemeIdRef = useRef<string | null>(null);
const lastProgressRef = useRef(0);
// 挂载时清理过期的播放进度缓存
useEffect(() => {
if (!enabled) return;
cleanupExpiredProgressStorage();
}, [enabled]);
// 恢复播放进度 // 恢复播放进度
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (progressRestored) return; if (progressRestored && restoredAwemeIdRef.current === awemeId) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const onLoadedMetadata = () => { const onLoadedMetadata = (): void => {
if (progressRestored) return; if (restoredAwemeIdRef.current === awemeId) return;
restoredAwemeIdRef.current = awemeId;
try { try {
const key = `aweme_progress_${awemeId}`; const key = `aweme_progress_${awemeId}`;
@@ -50,7 +60,13 @@ export function useVideoPlayer({
return; return;
} }
const { time, timestamp } = JSON.parse(saved); const parsed = JSON.parse(saved) as {
time?: unknown;
timestamp?: unknown;
};
const time = typeof parsed.time === "number" ? parsed.time : 0;
const timestamp =
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
const now = Date.now(); const now = Date.now();
const fiveMinutes = 5 * 60 * 1000; const fiveMinutes = 5 * 60 * 1000;
@@ -60,7 +76,6 @@ export function useVideoPlayer({
time < v.duration - 1 time < v.duration - 1
) { ) {
v.currentTime = time; v.currentTime = time;
console.log(`恢复播放进度: ${Math.round(time)}s`);
} else if (now - timestamp >= fiveMinutes) { } else if (now - timestamp >= fiveMinutes) {
localStorage.removeItem(key); localStorage.removeItem(key);
} }
@@ -73,19 +88,20 @@ export function useVideoPlayer({
if (v.readyState >= 1) { if (v.readyState >= 1) {
onLoadedMetadata(); onLoadedMetadata();
} else { return;
}
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true }); v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata); return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
}
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]); }, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
// 保存播放进度 // 保存播放进度并在卸载/切页时主动释放媒体解码缓冲区
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const saveProgress = () => { const saveProgress = (): void => {
if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return; if (!v.duration || Number.isNaN(v.duration) || v.currentTime < 1) return;
try { try {
@@ -100,14 +116,15 @@ export function useVideoPlayer({
} }
}; };
const interval = setInterval(saveProgress, 2000); const interval = window.setInterval(saveProgress, 2000);
const onBeforeUnload = () => saveProgress(); const onBeforeUnload = (): void => saveProgress();
window.addEventListener("beforeunload", onBeforeUnload); window.addEventListener("beforeunload", onBeforeUnload);
return () => { return () => {
clearInterval(interval); window.clearInterval(interval);
window.removeEventListener("beforeunload", onBeforeUnload); window.removeEventListener("beforeunload", onBeforeUnload);
saveProgress(); saveProgress();
releaseMediaElement(v);
}; };
}, [awemeId, enabled, videoRef]); }, [awemeId, enabled, videoRef]);
@@ -117,13 +134,17 @@ export function useVideoPlayer({
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const onTime = () => { const onTime = (): void => {
if (!v.duration || Number.isNaN(v.duration)) return; if (!v.duration || Number.isNaN(v.duration)) return;
setProgress(v.currentTime / v.duration); const nextProgress = Math.max(0, Math.min(1, v.currentTime / v.duration));
if (Math.abs(nextProgress - lastProgressRef.current) >= 0.001) {
lastProgressRef.current = nextProgress;
setProgress(nextProgress);
}
}; };
const onPlay = () => setIsPlaying(true); const onPlay = (): void => setIsPlaying(true);
const onPause = () => setIsPlaying(false); const onPause = (): void => setIsPlaying(false);
const onEnded = () => { const onEnded = (): void => {
if (loopMode === "sequential" && neighbors?.next) { if (loopMode === "sequential" && neighbors?.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`); router.push(`/aweme/${neighbors.next.aweme_id}`);
} }
@@ -158,23 +179,26 @@ export function useVideoPlayer({
const v = videoRef.current; const v = videoRef.current;
if (!v) return; if (!v) return;
const checkAutoplay = async () => { const checkAutoplay = async (): Promise<void> => {
try { try {
await v.play(); await v.play();
setIsPlaying(true); setIsPlaying(true);
} catch { } catch {
console.log("自动播放被阻止,需要用户交互");
setIsPlaying(false); setIsPlaying(false);
} }
}; };
if (v.readyState >= 1) { if (v.readyState >= 1) {
checkAutoplay(); void checkAutoplay();
} else { return;
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
} }
}, [enabled, videoRef, setIsPlaying]);
const onLoaded = (): void => {
void checkAutoplay();
};
v.addEventListener("loadedmetadata", onLoaded, { once: true });
return () => v.removeEventListener("loadedmetadata", onLoaded);
}, [awemeId, enabled, videoRef, setIsPlaying]);
// 更新音量和倍速 // 更新音量和倍速
useEffect(() => { useEffect(() => {
+56
View File
@@ -91,3 +91,59 @@ export function formatAbsoluteUTC(date: string | Date): string {
const s = String(d.getUTCSeconds()).padStart(2, "0"); const s = String(d.getUTCSeconds()).padStart(2, "0");
return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`; return `${Y}-${M}-${D} ${h}:${m}:${s} UTC`;
} }
// 主动释放 HTMLMediaElement 解码缓冲与网络流,防止 SPA 连续切页导致内存/硬件解码器泄漏
export function releaseMediaElement(
el: HTMLMediaElement | null | undefined,
): void {
if (!el) return;
try {
el.pause();
el.removeAttribute("src");
el.load();
} catch {}
}
const PROGRESS_STORAGE_PREFIX = "aweme_progress_";
const PROGRESS_TTL_MS = 5 * 60 * 1000;
const MAX_PROGRESS_ENTRIES = 30;
// 清理过期或超量的视频播放进度缓存,避免长期浏览堆积 localStorage
export function cleanupExpiredProgressStorage(): void {
if (typeof window === "undefined") return;
try {
const now = Date.now();
const validEntries: { key: string; timestamp: number }[] = [];
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (!key || !key.startsWith(PROGRESS_STORAGE_PREFIX)) continue;
const raw = localStorage.getItem(key);
if (!raw) {
localStorage.removeItem(key);
continue;
}
try {
const parsed = JSON.parse(raw) as { timestamp?: unknown };
const timestamp =
typeof parsed.timestamp === "number" ? parsed.timestamp : 0;
if (now - timestamp >= PROGRESS_TTL_MS) {
localStorage.removeItem(key);
} else {
validEntries.push({ key, timestamp });
}
} catch {
localStorage.removeItem(key);
}
}
if (validEntries.length > MAX_PROGRESS_ENTRIES) {
validEntries.sort((a, b) => b.timestamp - a.timestamp);
for (let i = MAX_PROGRESS_ENTRIES; i < validEntries.length; i++) {
localStorage.removeItem(validEntries[i].key);
}
}
} catch {}
}
+24
View File
@@ -56,3 +56,27 @@ mark {
.min-h-screen { .min-h-screen {
min-height: 100dvh; min-height: 100dvh;
} }
/* 评论面板自定义滚动条样式 */
.comment-scroll::-webkit-scrollbar {
width: 6px;
}
.comment-scroll::-webkit-scrollbar-track {
background: transparent;
}
.comment-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: background 0.2s;
}
.comment-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.comment-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}