fix: 合并服务器未推送改动并保留手动视频标签

This commit is contained in:
2026-10-03 22:09:09 +08:00
29 changed files with 1459 additions and 532 deletions
+90 -67
View File
@@ -1,15 +1,8 @@
"use client";
import { Play } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { Play } from "lucide-react";
import type {
AwemeData,
ImageData,
Neighbors,
VideoData,
VideoTranscript,
} from "./types.ts";
import { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel";
import { ImageCarousel } from "./components/ImageCarousel";
@@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls";
import { NavigationButtons } from "./components/NavigationButtons";
import { TranscriptPanel } from "./components/TranscriptPanel";
import { VideoPlayer } from "./components/VideoPlayer";
import { VideoTagEditor } from "./components/VideoTagEditor";
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
import { useCommentState } from "./hooks/useCommentState";
import { useImageCarousel } from "./hooks/useImageCarousel";
import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState";
import { useVideoPlayer } from "./hooks/useVideoPlayer";
import type {
AwemeData,
ImageData,
Neighbors,
VideoData,
VideoTranscript,
} from "./types.ts";
const SEGMENT_MS = 4000;
type WebkitVideoElement = HTMLVideoElement & {
webkitEnterFullscreen?: () => void;
};
type MediaPositionStateInput = {
duration: number;
position: number;
playbackRate?: number;
};
type MediaSessionWithPositionState = MediaSession & {
setPositionState?: (state: MediaPositionStateInput) => void;
};
interface AwemeDetailClientProps {
data: AwemeData;
neighbors: Neighbors;
@@ -45,6 +60,9 @@ export default function AwemeDetailClient({
const playerState = usePlayerState();
const commentState = useCommentState();
const [transcriptOpen, setTranscriptOpen] = useState(false);
const [contentTags, setContentTags] = useState<string[]>(
isVideo ? (data as VideoData).content_tags : [],
);
// 引用
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
@@ -68,6 +86,7 @@ export default function AwemeDetailClient({
// 图文轮播状态
const images = isVideo ? [] : (data as ImageData).images;
const imageCount = images.length;
const imageCarouselState = useImageCarousel({
images,
isPlaying: playerState.isPlaying,
@@ -80,40 +99,42 @@ export default function AwemeDetailClient({
});
// 视频播放器 hooks
if (isVideo) {
useVideoPlayer({
awemeId: data.aweme_id,
videoRef,
volume: playerState.volume,
rate: playerState.rate,
loopMode: playerState.loopMode,
neighbors,
progressRestored: playerState.progressRestored,
setIsPlaying: playerState.setIsPlaying,
setProgress: playerState.setProgress,
setProgressRestored: playerState.setProgressRestored,
});
}
useVideoPlayer({
enabled: isVideo,
awemeId: data.aweme_id,
videoRef,
volume: playerState.volume,
rate: playerState.rate,
loopMode: playerState.loopMode,
neighbors,
progressRestored: playerState.progressRestored,
setIsPlaying: playerState.setIsPlaying,
setProgress: playerState.setProgress,
setProgressRestored: playerState.setProgressRestored,
});
// 媒体操作函数
const seekTo = (ratio: number) => {
ratio = Math.min(1, Math.max(0, ratio));
if (isVideo) {
const v = videoRef.current;
if (!v || !v.duration) return;
v.currentTime = v.duration * ratio;
return;
}
if (!images?.length) return;
imageCarouselState.seekTo(ratio);
};
const seekTo = useCallback(
(ratio: number) => {
const clampedRatio = Math.min(1, Math.max(0, ratio));
if (isVideo) {
const v = videoRef.current;
if (!v || !v.duration) return;
v.currentTime = v.duration * clampedRatio;
return;
}
if (!imageCount) return;
imageCarouselState.seekTo(clampedRatio);
},
[imageCarouselState.seekTo, imageCount, isVideo],
);
const seekImageByVisualRatio = (ratio: number) => {
if (!images?.length) return;
if (!imageCount) return;
const clampedRatio = Math.min(1, Math.max(0, ratio));
const rawSegment = clampedRatio * images.length;
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
const rawSegment = clampedRatio * imageCount;
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
imageCarouselState.goToIndex(targetIndex, segmentProgress);
};
@@ -152,14 +173,12 @@ export default function AwemeDetailClient({
document.body.requestFullscreen().catch(() => {});
return;
}
const vRef = videoRef.current;
if (vRef && vRef.requestFullscreen) {
const vRef = videoRef.current as WebkitVideoElement | null;
if (vRef?.requestFullscreen) {
vRef.requestFullscreen().catch(() => {});
return;
}
// @ts-ignore
if (vRef && vRef.webkitEnterFullscreen) {
// @ts-ignore
if (vRef?.webkitEnterFullscreen) {
vRef.webkitEnterFullscreen();
}
} else {
@@ -167,20 +186,20 @@ export default function AwemeDetailClient({
}
};
const prevImg = () => {
if (!images?.length) return;
const prevImg = useCallback(() => {
if (!imageCount) return;
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
imageCarouselState.goToIndex(next);
};
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const nextImg = () => {
if (!images?.length) return;
const nextImg = useCallback(() => {
if (!imageCount) return;
const next = Math.min(
images.length - 1,
imageCount - 1,
imageCarouselState.idxRef.current + 1,
);
imageCarouselState.goToIndex(next);
};
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
const handleDownload = () => {
if (isVideo) {
@@ -249,7 +268,7 @@ export default function AwemeDetailClient({
if (typeof window === "undefined") return;
if (!("mediaSession" in navigator)) return;
const ms = (navigator as any).mediaSession as MediaSession;
const ms = navigator.mediaSession as MediaSessionWithPositionState;
// 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像)
const title = data.desc || `作品 ${data.aweme_id}`;
@@ -282,12 +301,12 @@ export default function AwemeDetailClient({
const updatePosition = () => {
try {
// @ts-ignore: setPositionState 可能不存在
if (!ms.setPositionState) return;
if (isVideo) {
const v = videoRef.current;
if (!v || !isFinite(v.duration) || isNaN(v.duration)) return;
// @ts-ignore
if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) {
return;
}
ms.setPositionState({
duration: Math.max(0, v.duration),
position: Math.max(0, v.currentTime),
@@ -300,14 +319,12 @@ export default function AwemeDetailClient({
0,
Math.min(duration, (playerState.progress * totalMs) / 1000),
);
// @ts-ignore
ms.setPositionState({ duration, position, playbackRate: 1 });
}
} catch {}
};
// 绑定视频事件以同步状态
let timeUpdateTimer: number | null = null;
const v = videoRef.current;
if (isVideo && v) {
const onPlay = () => {
@@ -331,7 +348,6 @@ export default function AwemeDetailClient({
updatePosition();
// 清理
timeUpdateTimer = null;
return () => {
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
@@ -346,15 +362,10 @@ export default function AwemeDetailClient({
}
// 依赖:当这些变化时刷新元信息与位置状态
}, [
isVideo,
data.aweme_id,
data.desc,
data.author.nickname,
data.author.avatar_url,
data.created_at,
imageCarouselState.idx,
data,
imageCarouselState.totalDurationMs,
images,
isVideo,
playerState.isPlaying,
playerState.progress,
playerState.rate,
@@ -364,7 +375,7 @@ export default function AwemeDetailClient({
useEffect(() => {
if (typeof window === "undefined") return;
if (!("mediaSession" in navigator)) return;
const ms = (navigator as any).mediaSession as MediaSession;
const ms = navigator.mediaSession;
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
@@ -421,7 +432,7 @@ export default function AwemeDetailClient({
const handleSeekTo = (seekTimeSec: number) => {
if (isVideo) {
const v = videoRef.current;
if (v && isFinite(v.duration)) {
if (v && Number.isFinite(v.duration)) {
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
}
} else if (images?.length) {
@@ -434,13 +445,13 @@ export default function AwemeDetailClient({
ms.setActionHandler("play", handlePlay);
ms.setActionHandler("pause", handlePause);
ms.setActionHandler("stop", handleStop);
ms.setActionHandler("seekbackward", (details: any) =>
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
handleSeekDelta(-((details?.seekOffset as number) || 10)),
);
ms.setActionHandler("seekforward", (details: any) =>
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
handleSeekDelta((details?.seekOffset as number) || 10),
);
ms.setActionHandler("seekto", (details: any) => {
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
});
ms.setActionHandler("previoustrack", () => {
@@ -477,8 +488,12 @@ export default function AwemeDetailClient({
neighbors.next,
router,
playerState.progress,
playerState.setIsPlaying,
pauseImageMedia,
imageCarouselState.totalDurationMs,
nextImg,
prevImg,
seekTo,
]);
return (
@@ -537,6 +552,14 @@ export default function AwemeDetailClient({
)}
{/* 媒体控制栏 */}
{isVideo && (
<VideoTagEditor
awemeId={data.aweme_id}
tags={contentTags}
onTagsChange={setContentTags}
/>
)}
<MediaControls
isVideo={isVideo}
isPlaying={playerState.isPlaying}