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