优化图文页

This commit is contained in:
2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions
@@ -2,7 +2,10 @@ import { forwardRef } from "react";
interface BackgroundCanvasProps {}
export const BackgroundCanvas = forwardRef<HTMLCanvasElement, BackgroundCanvasProps>((props, ref) => {
export const BackgroundCanvas = forwardRef<
HTMLCanvasElement,
BackgroundCanvasProps
>((props, ref) => {
return (
<canvas
ref={ref}
+26 -7
View File
@@ -18,12 +18,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<header className="flex items-center gap-4 mb-5">
<div className="size-10 rounded-full overflow-hidden bg-zinc-700/60">
{author.avatar_url ? (
<img src={author.avatar_url} alt="avatar" className="w-full h-full object-cover" />
<img
src={author.avatar_url}
alt="avatar"
className="w-full h-full object-cover"
/>
) : null}
</div>
<div>
<div className="font-medium text-white/95 text-sm sm:text-base">{author.nickname}</div>
<div className="text-xs text-white/50" title={formatAbsoluteUTC(createdAt)}>
<div className="font-medium text-white/95 text-sm sm:text-base">
{author.nickname}
</div>
<div
className="text-xs text-white/50"
title={formatAbsoluteUTC(createdAt)}
>
发布于 {formatRelativeTime(createdAt)}
</div>
</div>
@@ -34,13 +43,21 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
<li key={c.cid} className="flex items-start gap-3 sm:gap-4">
<div className="size-8 rounded-full overflow-hidden bg-zinc-700/60 shrink-0">
{c.user.avatar_url ? (
<img src={c.user.avatar_url} alt="avatar" className="w-full h-full object-cover" />
<img
src={c.user.avatar_url}
alt="avatar"
className="w-full h-full object-cover"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white/95 text-sm">{c.user.nickname}</span>
<span className="text-xs text-white/50">{formatRelativeTime(c.created_at)}</span>
<span className="font-medium text-white/95 text-sm">
{c.user.nickname}
</span>
<span className="text-xs text-white/50">
{formatRelativeTime(c.created_at)}
</span>
</div>
<p className="mt-1 text-sm leading-relaxed text-white/90 break-words">
<CommentText text={c.text} />
@@ -75,7 +92,9 @@ export function CommentList({ author, createdAt, comments }: CommentListProps) {
</div>
</li>
))}
{comments.length === 0 ? <li className="text-sm text-white/60">暂无评论</li> : null}
{comments.length === 0 ? (
<li className="text-sm text-white/60">暂无评论</li>
) : null}
</ul>
{/* 图片预览灯箱 */}
+124 -79
View File
@@ -12,13 +12,23 @@ interface CommentPanelProps {
mounted: boolean;
}
export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounted }: CommentPanelProps) {
export function CommentPanel({
open,
onClose,
author,
createdAt,
awemeId,
mounted,
}: CommentPanelProps) {
const [comments, setComments] = useState<Comment[]>([]);
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 [rankParams, setRankParams] = useState<null | {
seed: string;
snapshot: string;
}>(null);
// 两套滚动容器与哨兵,分别对应横屏与竖屏面板
const scrollRefLandscape = useRef<HTMLDivElement>(null);
const sentinelRefLandscape = useRef<HTMLDivElement>(null);
@@ -27,67 +37,81 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
const loadingRef = useRef(false);
// 加载评论
const loadComments = useCallback(async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
loadingRef.current = true;
setLoading(true);
const loadComments = useCallback(
async (reset = false) => {
if (loadingRef.current || (!reset && !hasMore)) return;
try {
const skip = reset ? 0 : comments.length;
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 response = await fetch(`/api/comments/${awemeId}?${query.toString()}`);
const data = await response.json();
loadingRef.current = true;
setLoading(true);
// 统一做一次基于 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;
try {
const skip = reset ? 0 : comments.length;
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 response = await fetch(
`/api/comments/${awemeId}?${query.toString()}`,
);
const data = await response.json();
// 统一做一次基于 cid 的去重,避免分页偶发重复
if (reset) {
setComments(() => {
const seen = new Set<string>();
return (data.comments as Comment[]).filter((c) => {
if (seen.has(c.cid)) return false;
seen.add(c.cid);
return true;
});
});
});
} else {
setComments((prev) => {
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;
} 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;
});
});
});
}
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),
}
);
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
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) };
});
} else if (reset) {
setRankParams(null);
}
} catch (error) {
console.error("加载评论失败:", error);
} finally {
setLoading(false);
loadingRef.current = false;
}
}, [awemeId, comments.length, hasMore]);
},
[awemeId, comments.length, hasMore],
);
// 面板打开时加载初始评论
useEffect(() => {
@@ -102,7 +126,10 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
const observers: IntersectionObserver[] = [];
const setup = (rootEl: HTMLDivElement | null, targetEl: HTMLDivElement | null) => {
const setup = (
rootEl: HTMLDivElement | null,
targetEl: HTMLDivElement | null,
) => {
if (!rootEl || !targetEl) return;
const io = new IntersectionObserver(
(entries) => {
@@ -113,9 +140,9 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
},
{
root: rootEl,
rootMargin: '0px 0px 200px 0px', // 距底部 200px 触发
rootMargin: "0px 0px 200px 0px", // 距底部 200px 触发
threshold: 0,
}
},
);
io.observe(targetEl);
observers.push(io);
@@ -180,19 +207,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
</div>
</div>
<div ref={scrollRefLandscape} className="p-3 overflow-auto comment-scroll">
<CommentList author={author} createdAt={createdAt} comments={comments} />
<div
ref={scrollRefLandscape}
className="p-3 overflow-auto comment-scroll"
>
<CommentList
author={author}
createdAt={createdAt}
comments={comments}
/>
{/* 底部加载触发区 */}
{hasMore && (
<div ref={sentinelRefLandscape} className="h-1 w-full" />
)}
{hasMore && <div ref={sentinelRefLandscape} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
<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 className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</div>
</aside>
@@ -221,19 +257,28 @@ export function CommentPanel({ open, onClose, author, createdAt, awemeId, mounte
</button>
</div>
<div ref={scrollRefPortrait} className="p-3 overflow-auto comment-scroll">
<CommentList author={author} createdAt={createdAt} comments={comments} />
<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" />
)}
{hasMore && <div ref={sentinelRefPortrait} className="h-1 w-full" />}
{loading && (
<div className="py-4 text-center text-white/60 text-sm">加载中...</div>
<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 className="py-4 text-center text-white/40 text-sm">
没有更多评论了
</div>
)}
</div>
</aside>
@@ -20,7 +20,10 @@ export function CommentText({ text }: { text: string }) {
// 如果图片加载失败,显示原始文本
e.currentTarget.style.display = "none";
const textNode = document.createTextNode(`[${part.name}]`);
e.currentTarget.parentNode?.insertBefore(textNode, e.currentTarget);
e.currentTarget.parentNode?.insertBefore(
textNode,
e.currentTarget,
);
}}
/>
);
+159 -71
View File
@@ -1,78 +1,134 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import type { ImageData } from "../types.ts";
import {
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { ImageData, LoopMode } from "../types.ts";
interface ImageCarouselProps {
images: ImageData["images"];
currentIndex: number;
isPlaying: boolean;
segmentProgress: number;
mediaSyncToken: number;
loopMode: LoopMode;
onTogglePlay: () => void;
onAnimatedDuration?: (imageId: string, durationMs: number) => void;
}
export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
({ images, currentIndex, onTogglePlay }, ref) => {
const [offset, setOffset] = useState(0);
const [isTransitioning, setIsTransitioning] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
(
{
images,
currentIndex,
isPlaying,
segmentProgress,
mediaSyncToken,
loopMode,
onTogglePlay,
onAnimatedDuration,
},
ref,
) => {
const videoRefs = useRef<Map<string, HTMLVideoElement>>(new Map());
const playedVideos = useRef<Set<string>>(new Set());
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 {}
}
},
[],
);
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 visibleIndices = (() => {
const visibleIndices = useMemo(() => {
if (useLightweightMode) return [currentIndex];
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]);
// 当 currentIndex 变化时,触发滚动动画
// iOS Safari 对多个同屏 video 很敏感,这里只让当前动图持有 video 元素并跟随全局播放状态。
useEffect(() => {
setIsTransitioning(true);
setOffset(-currentIndex * 100);
const currentImage = images[currentIndex];
if (!currentImage?.animated) {
activeVideoIdRef.current = null;
videoRefs.current.forEach((videoEl) => videoEl.pause());
return;
}
const timer = setTimeout(() => {
setIsTransitioning(false);
}, 300); // 与 CSS transition 时间匹配
const videoEl = videoRefs.current.get(currentImage.id);
return () => clearTimeout(timer);
}, [currentIndex]);
videoRefs.current.forEach((itemVideoEl, imageId) => {
if (imageId !== currentImage.id || !isPlaying) {
itemVideoEl.pause();
}
});
if (videoEl) {
if (activeVideoIdRef.current !== currentImage.id) {
activeVideoIdRef.current = currentImage.id;
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
}
if (isPlaying) {
videoEl.play().catch(() => {});
} else {
videoEl.pause();
}
}
}, [currentIndex, images, isPlaying, loopMode, syncVideoToSegmentProgress]);
// 管理动图播放:进入视口时播放一次
useEffect(() => {
const currentImage = images[currentIndex];
if (!currentImage?.animated) return;
const videoKey = currentImage.id;
const videoEl = videoRefs.current.get(videoKey);
if (videoEl) {
// 检查是否已经播放过
if (!playedVideos.current.has(videoKey)) {
// 重置并播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
playedVideos.current.add(videoKey);
} else {
// 已播放过,重置到开头但不自动播放
videoEl.currentTime = 0;
videoEl.play().catch(() => {});
}
}
}, [currentIndex, images]);
const videoEl = videoRefs.current.get(currentImage.id);
if (!videoEl) return;
// 当切换到其他图片时,清除已播放标记(切回来会重新播放)
useEffect(() => {
const currentImage = images[currentIndex];
if (currentImage?.animated) {
const videoKey = currentImage.id;
// 清除其他视频的播放记录
playedVideos.current.forEach(key => {
if (key !== videoKey) {
playedVideos.current.delete(key);
}
});
}
}, [currentIndex, images]);
syncVideoToSegmentProgress(videoEl, segmentProgressRef.current, true);
if (isPlaying) videoEl.play().catch(() => {});
}, [
currentIndex,
images,
isPlaying,
loopMode,
mediaSyncToken,
syncVideoToSegmentProgress,
]);
const handleVideoRef = (el: HTMLVideoElement | null, imageId: string) => {
if (el) {
@@ -83,49 +139,81 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
};
return (
<div
ref={ref}
className="relative h-full w-full overflow-hidden"
>
<div
ref={containerRef}
className="flex h-full w-full"
style={{
transform: `translateX(${offset}%)`,
transition: isTransitioning ? 'transform 300ms ease-out' : 'none',
}}
>
<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}
className="relative h-full min-w-full flex items-center justify-center bg-black/70 cursor-pointer"
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 * 100}%)`,
position: 'absolute',
left: 0,
top: 0,
width: '100%',
transform: useLightweightMode
? "translateX(0)"
: `translateX(${(i - currentIndex) * 100}%)`,
transition: useLightweightMode
? "none"
: "transform 240ms ease-out",
zIndex: isCurrent ? 1 : 0,
}}
onClick={onTogglePlay}
>
{img.animated ? (
{animatedSrc ? (
<video
ref={(el) => handleVideoRef(el, img.id)}
src={img.animated}
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) => {
// 播放结束后停留在最后一帧
e.currentTarget.pause();
if (loopMode !== "single") e.currentTarget.pause();
}}
/>
) : (
<img
src={img.url}
alt={`image-${i + 1}`}
loading={isCurrent ? "eager" : "lazy"}
decoding="async"
className="max-w-full max-h-full object-contain"
style={{
width: img.width ? `${img.width}px` : undefined,
@@ -139,7 +227,7 @@ export const ImageCarousel = forwardRef<HTMLDivElement, ImageCarouselProps>(
</div>
</div>
);
}
},
);
ImageCarousel.displayName = "ImageCarousel";
+100 -34
View File
@@ -8,6 +8,7 @@ import {
Minimize2,
Pause,
Play,
Repeat,
Repeat1,
RotateCcw,
RotateCw,
@@ -89,25 +90,70 @@ export function MediaControls({
onDownload,
onToggleFullscreen,
}: MediaControlsProps) {
const normalizedLoopMode =
isVideo && loopMode === "single" ? "loop" : loopMode;
const loopLabel = isVideo
? normalizedLoopMode === "loop"
? "循环播放"
: "顺序播放"
: loopMode === "single"
? "单页循环"
: loopMode === "loop"
? "图文循环"
: "顺序播放";
const handleLoopModeToggle = () => {
if (isVideo) {
onLoopModeChange(normalizedLoopMode === "loop" ? "sequential" : "loop");
return;
}
onLoopModeChange(
loopMode === "loop"
? "single"
: loopMode === "single"
? "sequential"
: "loop",
);
};
const renderLoopIcon = () => {
if (!isVideo && loopMode === "single") return <Repeat1 size={18} />;
if (normalizedLoopMode === "loop") return <Repeat size={18} />;
return <ArrowDownUp size={18} />;
};
return (
<div className="absolute left-0 right-0 bottom-0 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
<div className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
{/* 描述行 */}
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
{author.sec_uid ? (
<Link
href={`/author/${author.sec_uid}`}
<Link
href={`/author/${author.sec_uid}`}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
>
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">{author.nickname}</span>
<img
src={author.avatar_url!}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
{author.nickname}
</span>
</Link>
) : (
<div className="flex items-center gap-2.5">
<img src={author.avatar_url!} alt="" className="w-8 h-8 rounded-full" />
<span className="text-[15px] leading-tight text-white/95 drop-shadow">{author.nickname}</span>
<img
src={author.avatar_url!}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow">
{author.nickname}
</span>
</div>
)}
<span className="text-[13px] leading-tight text-white/95 drop-shadow">·</span>
<span className="text-[13px] leading-tight text-white/95 drop-shadow">
·
</span>
<span
className="text-[11px] leading-tight text-white/95 drop-shadow"
title={formatAbsoluteUTC(createdAt)}
@@ -150,16 +196,16 @@ export function MediaControls({
{/* 播放进度显示 - 所有设备都显示 */}
<div className="text-[13px] text-white/90 font-mono min-w-[70px] sm:min-w-[80px]">
{isVideo ? (
(() => {
const v = videoRef?.current;
const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0;
return total > 0 ? `${formatTime(current)} / ${formatTime(total)}` : "--:-- / --:--";
})()
) : (
`${currentIndex + 1} / ${totalSegments}`
)}
{isVideo
? (() => {
const v = videoRef?.current;
const current = v?.currentTime ?? 0;
const total = v?.duration ?? 0;
return total > 0
? `${formatTime(current)} / ${formatTime(total)}`
: "--:-- / --:--";
})()
: `${currentIndex + 1} / ${totalSegments}`}
</div>
{/* 倍速 - 中等屏幕以上显示,仅视频 */}
@@ -231,21 +277,31 @@ 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"
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
aria-label={loopMode === "loop" ? "循环播放" : "顺序播放"}
title={loopMode === "loop" ? "循环播放" : "顺序播放"}
onClick={handleLoopModeToggle}
aria-label={loopLabel}
title={loopLabel}
>
{loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
{renderLoopIcon()}
</button>
{/* 适配模式 - 小屏幕以上显示 */}
<button
className="hidden sm:inline-flex w-[34px] h-[34px] items-center justify-center rounded-full bg-white/15 text-white border border-white/20 backdrop-blur-sm cursor-pointer"
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
aria-label={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
title={objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"}
onClick={() =>
onObjectFitChange(objectFit === "contain" ? "cover" : "contain")
}
aria-label={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
title={
objectFit === "contain" ? "切换到填充模式" : "切换到适应模式"
}
>
{objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
{objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)}
</button>
{/* 转录文本 - 仅视频且有转录时显示,中等屏幕以上 */}
@@ -277,18 +333,28 @@ export function MediaControls({
{/* 小屏幕隐藏的适配模式 */}
<div className="sm:hidden">
<MoreMenuItem
icon={objectFit === "contain" ? <Maximize2 size={18} /> : <Minimize size={18} />}
icon={
objectFit === "contain" ? (
<Maximize2 size={18} />
) : (
<Minimize size={18} />
)
}
label={objectFit === "contain" ? "填充模式" : "适应模式"}
onClick={() => onObjectFitChange(objectFit === "contain" ? "cover" : "contain")}
onClick={() =>
onObjectFitChange(
objectFit === "contain" ? "cover" : "contain",
)
}
/>
</div>
{/* 中等屏幕以下隐藏的循环模式 */}
<div className="md:hidden">
<MoreMenuItem
icon={loopMode === "loop" ? <Repeat1 size={18} /> : <ArrowDownUp size={18} />}
label={loopMode === "loop" ? "循环播放" : "顺序播放"}
onClick={() => onLoopModeChange(loopMode === "loop" ? "sequential" : "loop")}
icon={renderLoopIcon()}
label={loopLabel}
onClick={handleLoopModeToggle}
/>
</div>
@@ -338,13 +404,13 @@ export function MediaControls({
>
{isFullscreen ? <Minimize2 size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
{/* 图文 BGM(隐藏控件,仅用于播放) */}
{!isVideo && musicUrl ? <audio ref={audioRef} src={musicUrl} loop preload="metadata" /> : null}
{!isVideo && musicUrl ? (
<audio ref={audioRef} src={musicUrl} loop preload="metadata" />
) : null}
</div>
);
}
+1 -3
View File
@@ -51,9 +51,7 @@ export function MoreMenu({ children }: MoreMenuProps) {
className="absolute bottom-full right-0 mb-2 bg-zinc-900/95 backdrop-blur-xl border border-white/20 rounded-xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ minWidth: "200px" }}
>
<div className="p-2 flex flex-col gap-1">
{children}
</div>
<div className="p-2 flex flex-col gap-1">{children}</div>
</div>
)}
</div>
@@ -1,4 +1,9 @@
import { ChevronDown, ChevronUp, MessageSquareText, ThumbsUp } from "lucide-react";
import {
ChevronDown,
ChevronUp,
MessageSquareText,
ThumbsUp,
} from "lucide-react";
import type { Neighbors } from "../types.ts";
interface NavigationButtonsProps {
@@ -21,14 +26,13 @@ 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 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="" />
<span className="text-[16px] font-semibold text-white/90 drop-shadow">{likesCount}</span>
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
{likesCount}
</span>
</div>
</button>
{/* 评论开关(右侧中部) */}
@@ -40,13 +44,17 @@ export function NavigationButtons({
<div className="grid place-items-center gap-1 drop-shadow-lg">
<MessageSquareText size={40} className="" />
{commentsCount > 0 ? <span className="text-[16px] font-semibold text-white/90 drop-shadow">{commentsCount}</span> :
<span className="text-[12px] font-semibold text-white/90 drop-shadow">暂无评论</span>
}
{commentsCount > 0 ? (
<span className="text-[16px] font-semibold text-white/90 drop-shadow">
{commentsCount}
</span>
) : (
<span className="text-[12px] font-semibold text-white/90 drop-shadow">
暂无评论
</span>
)}
</div>
</button>
</div>
{/* 上下切换按钮(右侧胶囊形状) */}
+30 -4
View File
@@ -4,15 +4,41 @@ interface ProgressBarProps {
}
export function ProgressBar({ progress, onSeek }: ProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
onSeek((clientX - rect.left) / rect.width);
};
return (
<div
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer"
className="relative h-1.5 rounded-full bg-white/25 overflow-hidden cursor-pointer touch-none"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
}}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
>
<div className="origin-left h-full bg-white" style={{ transform: `scaleX(${progress || 0})` }} />
<div
className="origin-left h-full bg-white"
style={{ transform: `scaleX(${progress || 0})` }}
/>
</div>
);
}
@@ -11,12 +11,35 @@ export function SegmentedProgressBar({
segmentProgress,
onSeek,
}: SegmentedProgressBarProps) {
const seekFromClientX = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
onSeek((clientX - rect.left) / rect.width);
};
return (
<div
className="relative h-1.5 cursor-pointer"
className="relative h-1.5 cursor-pointer touch-none"
onClick={(e) => {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
onSeek((e.clientX - rect.left) / rect.width);
seekFromClientX(e.clientX, e.currentTarget as HTMLElement);
}}
onPointerDown={(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
seekFromClientX(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
onPointerCancel={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}}
>
<div className="flex gap-1.5 h-full">
@@ -30,7 +53,12 @@ export function SegmentedProgressBar({
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(${fill})` }} />
<div
className="h-full origin-left bg-white"
style={{
transform: `scaleX(${Math.max(0, Math.min(1, fill))})`,
}}
/>
</div>
);
})}
@@ -10,7 +10,11 @@ interface TranscriptPanelProps {
transcript: VideoTranscript | null;
}
export function TranscriptPanel({ open, onClose, transcript }: TranscriptPanelProps) {
export function TranscriptPanel({
open,
onClose,
transcript,
}: TranscriptPanelProps) {
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [copiedAll, setCopiedAll] = useState(false);
@@ -35,7 +35,7 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(
onClick={onTogglePlay}
/>
);
}
},
);
VideoPlayer.displayName = "VideoPlayer";