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