refactor: 重构作品播放页性能、媒体同步与内存管理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user