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
@@ -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>
);
}
});