feat: add manual video tagging
This commit is contained in:
@@ -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}
|
||||
@@ -600,6 +623,7 @@ export default function AwemeDetailClient({
|
||||
open={transcriptOpen}
|
||||
onClose={() => setTranscriptOpen(false)}
|
||||
transcript={transcript}
|
||||
contentTags={contentTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,17 @@ interface TranscriptPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
transcript: VideoTranscript | null;
|
||||
contentTags?: string[];
|
||||
}
|
||||
|
||||
export function TranscriptPanel({
|
||||
open,
|
||||
onClose,
|
||||
transcript,
|
||||
contentTags,
|
||||
}: TranscriptPanelProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const displayTags = contentTags ?? transcript?.content_tags ?? [];
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -94,9 +97,9 @@ export function TranscriptPanel({
|
||||
语言: {transcript.language}
|
||||
</p>
|
||||
)}
|
||||
{transcript.content_tags?.length > 0 && (
|
||||
{displayTags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{transcript.content_tags.map((tag) => (
|
||||
{displayTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-blue-500/15 px-2 py-0.5 text-xs text-blue-200"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Tag, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
|
||||
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type VideoTagEditorProps = {
|
||||
awemeId: string;
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
export function VideoTagEditor({
|
||||
awemeId,
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: VideoTagEditorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftTags, setDraftTags] = useState<string[]>(tags);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDraftTags(tags);
|
||||
}, [open, tags]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setDraftTags((current) =>
|
||||
current.includes(tag)
|
||||
? current.filter((item) => item !== tag)
|
||||
: [...current, tag],
|
||||
);
|
||||
};
|
||||
|
||||
const saveTags = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
awemeId,
|
||||
contentTags: draftTags,
|
||||
mode: "set",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
const updated = data.updated?.find((item) => item.aweme_id === awemeId);
|
||||
onTagsChange(updated?.content_tags ?? draftTags);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "保存标签失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3 text-sm font-medium text-white shadow-lg backdrop-blur transition-colors hover:bg-black/50"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Tag size={16} />
|
||||
{tags.length > 0 ? tags.join("、") : "打标签"}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">手动标签</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-full p-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="关闭标签编辑"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ASR_CONTENT_TAGS.map((tag) => {
|
||||
const selected = draftTags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-blue-400 bg-blue-500/25 text-blue-100"
|
||||
: "border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{selected && <Check size={13} />}
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-3 text-xs text-rose-300">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftTags([])}
|
||||
className="rounded-full px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveTags}
|
||||
disabled={saving}
|
||||
className="rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { LoopMode, Neighbors } from "../types.ts";
|
||||
|
||||
interface UseVideoPlayerProps {
|
||||
enabled?: boolean;
|
||||
awemeId: string;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
volume: number;
|
||||
@@ -17,6 +18,7 @@ interface UseVideoPlayerProps {
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
enabled = true,
|
||||
awemeId,
|
||||
videoRef,
|
||||
volume,
|
||||
@@ -32,6 +34,7 @@ export function useVideoPlayer({
|
||||
|
||||
// 恢复播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (progressRestored) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
@@ -74,10 +77,11 @@ export function useVideoPlayer({
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
}
|
||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
||||
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||
|
||||
// 保存播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -105,10 +109,11 @@ export function useVideoPlayer({
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
saveProgress();
|
||||
};
|
||||
}, [awemeId, videoRef]);
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
// 监听播放状态和进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -137,10 +142,19 @@ export function useVideoPlayer({
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
||||
}, [
|
||||
enabled,
|
||||
loopMode,
|
||||
neighbors?.next,
|
||||
router,
|
||||
videoRef,
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
]);
|
||||
|
||||
// 自动播放检测
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -148,7 +162,7 @@ export function useVideoPlayer({
|
||||
try {
|
||||
await v.play();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.log("自动播放被阻止,需要用户交互");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
@@ -160,16 +174,18 @@ export function useVideoPlayer({
|
||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||
}
|
||||
}, [awemeId, videoRef, setIsPlaying]);
|
||||
}, [enabled, videoRef, setIsPlaying]);
|
||||
|
||||
// 更新音量和倍速
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, videoRef]);
|
||||
}, [enabled, volume, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, videoRef]);
|
||||
}, [enabled, rate, videoRef]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { Metadata } from "next";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import { resolveEffectiveContentTags } from "@/lib/asr-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { AwemeData, VideoTranscript } from "./types";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { AwemeData, VideoTranscript } from "./types";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -53,7 +54,7 @@ export default async function AwemeDetail({
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true },
|
||||
include: { author: true, transcript: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
@@ -63,65 +64,77 @@ export default async function AwemeDetail({
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const isVideo = Boolean(video);
|
||||
|
||||
// 获取评论总数
|
||||
const commentsCount = await prisma.comment.count({
|
||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||
});
|
||||
const aweme = isVideo ? video : post;
|
||||
|
||||
const data: AwemeData = {
|
||||
aweme_id: aweme!.aweme_id,
|
||||
desc: aweme!.desc,
|
||||
created_at: aweme!.created_at,
|
||||
likesCount: Number(aweme!.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: aweme!.author.nickname,
|
||||
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: aweme!.author.sec_uid,
|
||||
},
|
||||
...(() => {
|
||||
if (isVideo) {
|
||||
const aweme = video!;
|
||||
return {
|
||||
type: "video" as const,
|
||||
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
||||
duration_ms: aweme!.duration_ms,
|
||||
video_url: getFileUrl(aweme!.video_url),
|
||||
width: aweme!.width ?? null,
|
||||
height: aweme!.height ?? null,
|
||||
};
|
||||
} else {
|
||||
const aweme = post!;
|
||||
return {
|
||||
type: "image" as const,
|
||||
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
|
||||
cover_size: {
|
||||
h: aweme!.images[0].height ?? 0,
|
||||
w: aweme!.images[0].width ?? 0,
|
||||
},
|
||||
images: aweme!.images.map((img) => ({
|
||||
...img,
|
||||
url: getFileUrl(img.url),
|
||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||
})),
|
||||
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
|
||||
};
|
||||
}
|
||||
})(),
|
||||
};
|
||||
let data: AwemeData;
|
||||
let currentCreatedAt: Date;
|
||||
let transcript: VideoTranscript | null = null;
|
||||
|
||||
const transcript: VideoTranscript | null = isVideo
|
||||
? await prisma.videoTranscript.findUnique({
|
||||
where: { videoId: id },
|
||||
})
|
||||
: null;
|
||||
if (video) {
|
||||
transcript = video.transcript ?? null;
|
||||
currentCreatedAt = video.created_at;
|
||||
data = {
|
||||
type: "video",
|
||||
aweme_id: video.aweme_id,
|
||||
desc: video.desc,
|
||||
created_at: video.created_at,
|
||||
likesCount: Number(video.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: video.author.nickname,
|
||||
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: video.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||
duration_ms: video.duration_ms,
|
||||
video_url: getFileUrl(video.video_url),
|
||||
width: video.width ?? null,
|
||||
height: video.height ?? null,
|
||||
content_tags: resolveEffectiveContentTags(
|
||||
video.manual_content_tags,
|
||||
transcript?.content_tags,
|
||||
),
|
||||
manual_content_tags: video.manual_content_tags,
|
||||
asr_content_tags: transcript?.content_tags ?? [],
|
||||
};
|
||||
} else if (post) {
|
||||
const coverImage = post.images[0];
|
||||
currentCreatedAt = post.created_at;
|
||||
data = {
|
||||
type: "image",
|
||||
aweme_id: post.aweme_id,
|
||||
desc: post.desc,
|
||||
created_at: post.created_at,
|
||||
likesCount: Number(post.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: post.author.nickname,
|
||||
avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: post.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
|
||||
cover_size: {
|
||||
h: coverImage?.height ?? 0,
|
||||
w: coverImage?.width ?? 0,
|
||||
},
|
||||
images: post.images.map((img) => ({
|
||||
...img,
|
||||
url: getFileUrl(img.url),
|
||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||
})),
|
||||
music_url: getFileUrl(post.music_url || "default-music.mp3"),
|
||||
};
|
||||
} else {
|
||||
return <main className="p-8">找不到该作品</main>;
|
||||
}
|
||||
|
||||
// Compute prev/next neighbors by created_at across videos and image posts
|
||||
const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
|
||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||
prisma.video.findFirst({
|
||||
where: { created_at: { gt: currentCreatedAt } },
|
||||
|
||||
@@ -24,6 +24,9 @@ export type VideoData = {
|
||||
video_url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
asr_content_tags: string[];
|
||||
author: User;
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
|
||||
Reference in New Issue
Block a user