diff --git a/app/aweme/[awemeId]/Client.tsx b/app/aweme/[awemeId]/Client.tsx index fda41f4..790220d 100644 --- a/app/aweme/[awemeId]/Client.tsx +++ b/app/aweme/[awemeId]/Client.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Pause, Play } from "lucide-react"; import type { AwemeData, ImageData, Neighbors, VideoData, VideoTranscript } from "./types.ts"; import { BackgroundCanvas } from "./components/BackgroundCanvas"; @@ -223,6 +223,211 @@ export default function AwemeDetailClient({ data, neighbors, transcript }: Aweme backgroundCanvasRef, }); + // Media Session API 集成 + useEffect(() => { + if (typeof window === "undefined") return; + if (!("mediaSession" in navigator)) return; + + const ms = (navigator as any).mediaSession as MediaSession; + + // 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像) + const title = data.desc || `作品 ${data.aweme_id}`; + const artist = data.author.nickname || "作者"; + const album = new Date(data.created_at).toLocaleString(); + // 单一封面图:使用作品 cover_url + const coverUrl = (data as AwemeData).cover_url as string | undefined; + const coverSize = (data as AwemeData).cover_size as { w: number; h: number } | undefined; + const artwork = coverUrl ? [{ src: coverUrl, size: `${coverSize?.w || 512}x${coverSize?.h || 512}` }] : []; + + try { + ms.metadata = new MediaMetadata({ title, artist, album, artwork }); + } catch { } + + // 更新播放状态 + try { + ms.playbackState = playerState.isPlaying ? "playing" : "paused"; + } catch { } + + const getImagesTotalMs = () => + (images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0); + + 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 + ms.setPositionState({ + duration: Math.max(0, v.duration), + position: Math.max(0, v.currentTime), + playbackRate: playerState.rate ?? 1, + }); + } else if (images?.length) { + const totalMs = getImagesTotalMs(); + const duration = totalMs / 1000; + const position = Math.max(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 = () => { + try { ms.playbackState = "playing"; } catch { } + updatePosition(); + }; + const onPause = () => { + try { ms.playbackState = "paused"; } catch { } + updatePosition(); + }; + const onTimeUpdate = () => updatePosition(); + + v.addEventListener("play", onPlay); + v.addEventListener("pause", onPause); + v.addEventListener("timeupdate", onTimeUpdate); + // 初始同步 + updatePosition(); + + // 清理 + timeUpdateTimer = null; + return () => { + v.removeEventListener("play", onPlay); + v.removeEventListener("pause", onPause); + v.removeEventListener("timeupdate", onTimeUpdate); + }; + } else if (!isVideo && images?.length) { + // 图文用定时器定期同步位置 + const id = window.setInterval(updatePosition, 1000); + // 初始同步 + updatePosition(); + return () => window.clearInterval(id); + } + // 依赖:当这些变化时刷新元信息与位置状态 + }, [ + isVideo, + data.aweme_id, + data.desc, + data.author.nickname, + data.author.avatar_url, + data.created_at, + imageCarouselState.idx, + images, + playerState.isPlaying, + playerState.progress, + playerState.rate, + ]); + + // 绑定系统媒体控件的操作处理程序 + useEffect(() => { + if (typeof window === "undefined") return; + if (!("mediaSession" in navigator)) return; + const ms = (navigator as any).mediaSession as MediaSession; + + const getImagesTotalMs = () => + (images || []).reduce((sum, img) => sum + (img.duration ?? SEGMENT_MS), 0); + + const handlePlay = async () => { + if (isVideo) { + const v = videoRef.current; + try { await v?.play(); } catch { } + } else { + playerState.setIsPlaying(true); + try { await audioRef.current?.play(); } catch { } + } + }; + const handlePause = () => { + if (isVideo) { + const v = videoRef.current; + v?.pause(); + } else { + playerState.setIsPlaying(false); + audioRef.current?.pause(); + } + }; + const handleStop = () => { + if (isVideo) { + const v = videoRef.current; + if (v) { v.pause(); v.currentTime = 0; } + } else { + playerState.setIsPlaying(false); + audioRef.current?.pause(); + seekTo(0); + } + }; + const handleSeekDelta = (deltaSec: number) => { + if (isVideo) { + const v = videoRef.current; + if (v) v.currentTime = Math.max(0, Math.min(v.duration || Infinity, v.currentTime + deltaSec)); + } else if (images?.length) { + const totalMs = getImagesTotalMs(); + if (totalMs <= 0) return; + const deltaRatio = (deltaSec * 1000) / totalMs; + seekTo(playerState.progress + deltaRatio); + } + }; + const handleSeekTo = (seekTimeSec: number) => { + if (isVideo) { + const v = videoRef.current; + if (v && isFinite(v.duration)) { + v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec)); + } + } else if (images?.length) { + const totalMs = getImagesTotalMs(); + const totalSec = totalMs / 1000; + if (totalSec > 0) seekTo(seekTimeSec / totalSec); + } + }; + + ms.setActionHandler("play", handlePlay); + ms.setActionHandler("pause", handlePause); + ms.setActionHandler("stop", handleStop); + ms.setActionHandler("seekbackward", (details: any) => handleSeekDelta(-((details?.seekOffset as number) || 10))); + ms.setActionHandler("seekforward", (details: any) => handleSeekDelta((details?.seekOffset as number) || 10)); + ms.setActionHandler("seekto", (details: any) => { + if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime); + }); + ms.setActionHandler("previoustrack", () => { + if (!isVideo && images?.length > 1) { + prevImg(); + } else if (neighbors.prev) { + router.push(`/aweme/${neighbors.prev.aweme_id}`); + } + }); + ms.setActionHandler("nexttrack", () => { + if (!isVideo && images?.length > 1) { + nextImg(); + } else if (neighbors.next) { + router.push(`/aweme/${neighbors.next.aweme_id}`); + } + }); + + return () => { + try { + ms.setActionHandler("play", null); + ms.setActionHandler("pause", null); + ms.setActionHandler("stop", null); + ms.setActionHandler("seekbackward", null); + ms.setActionHandler("seekforward", null); + ms.setActionHandler("seekto", null); + ms.setActionHandler("previoustrack", null); + ms.setActionHandler("nexttrack", null); + } catch { } + }; + }, [ + isVideo, + images, + neighbors.prev, + neighbors.next, + router, + playerState.progress, + ]); + return (
diff --git a/app/aweme/[awemeId]/page.tsx b/app/aweme/[awemeId]/page.tsx index 36e72cf..e293114 100644 --- a/app/aweme/[awemeId]/page.tsx +++ b/app/aweme/[awemeId]/page.tsx @@ -58,7 +58,6 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI const commentsCount = await prisma.comment.count({ where: isVideo ? { videoId: id } : { imagePostId: id }, }); - const aweme = isVideo ? video : post; const data: AwemeData = { @@ -73,6 +72,8 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI 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, @@ -82,7 +83,9 @@ export default async function AwemeDetail({ params }: { params: Promise<{ awemeI const aweme = post! return { type: "image" as const, - images: aweme!.images.map(img => ({ ...img, url: getFileUrl(img.url), animated: getFileUrl(img.animated ?? 'default-animated.png'), })), + 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'), }; } diff --git a/app/aweme/[awemeId]/types.ts b/app/aweme/[awemeId]/types.ts index 21c317f..a49580a 100644 --- a/app/aweme/[awemeId]/types.ts +++ b/app/aweme/[awemeId]/types.ts @@ -12,6 +12,8 @@ export type Comment = { export type VideoData = { type: "video"; aweme_id: string; + cover_url: string; + cover_size: {w: number; h: number}; desc: string; created_at: string | Date; duration_ms: number | null; @@ -26,6 +28,8 @@ export type VideoData = { export type ImageData = { type: "image"; aweme_id: string; + cover_url: string; + cover_size: {w: number; h: number}; desc: string; created_at: string | Date; images: { id: string; url: string; width: number | null; height: number | null; animated: string | null; duration: number | null }[]; diff --git a/app/search/SearchClient.tsx b/app/search/SearchClient.tsx new file mode 100644 index 0000000..4fbb1cf --- /dev/null +++ b/app/search/SearchClient.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { FormEvent, useCallback, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import Link from "next/link"; +import { Search, ArrowLeft, X, MessageSquare } from "lucide-react"; + +// 匹配搜索结果类型(适配后端API) +type SearchResultItem = { + id: string; + awemeId: string; + type: 'video' | 'image'; + rank: number; + snippet: string; // 后端返回的高亮片段,已包含标签 + video?: { + aweme_id: string; + desc: string; + cover_url: string | null; + video_url: string | null; + duration_ms: number; + author: { + sec_uid: string; + nickname: string; + avatar_url: string | null; + }; + }; + imagePost?: { + aweme_id: string; + desc: string; + cover_url: string | null; + author: { + sec_uid: string; + nickname: string; + avatar_url: string | null; + }; + }; +}; +export default function SearchClient({ initialQuery }: { initialQuery: string }) { + const searchParams = useSearchParams(); + const router = useRouter(); + + const [query, setQuery] = useState(initialQuery); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [searched, setSearched] = useState(false); + + const performSearch = useCallback(async (q: string) => { + if (!q.trim()) { + setResults([]); + setSearched(false); + return; + } + setLoading(true); + setSearched(true); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`); + if (!res.ok) throw new Error("Search failed"); + const data = await res.json(); + setResults(data.results || []); + } catch (err) { + console.error("Search error:", err); + setResults([]); + } finally { + setLoading(false); + } + }, []); + + // 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥) + useEffect(() => { + const q = searchParams.get("q") || ""; + setQuery(q); + if (q) performSearch(q); + }, [searchParams, performSearch]); + + const handleSearch = (e: FormEvent) => { + e.preventDefault(); + const trimmed = query.trim(); + if (!trimmed) return; + router.push(`/search?q=${encodeURIComponent(trimmed)}`); + // 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索 + // performSearch(trimmed); + }; + + const handleClear = () => { + setQuery(""); + setResults([]); + setSearched(false); + router.push("/search"); + }; + + return ( +
+ {/* 顶部搜索栏 */} +
+
+ + +
+
+ setQuery(e.target.value)} + placeholder="搜索视频、图文描述或语音内容..." + className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all" + autoFocus + /> + + {query && ( + + )} +
+ +
+
+
+ + {/* 结果区域 */} +
+ {loading && ( +
+
+
+

正在搜索...

+
+
+ )} + + {!loading && searched && results.length === 0 && ( +
+ +

未找到相关内容

+

试试其他关键词

+
+ )} + + {!loading && !searched && !query && ( +
+ +

输入关键词开始搜索

+

支持搜索视频描述、图文描述及语音转写内容

+
+ )} + + {!loading && results.length > 0 && ( +
+
+

+ 找到 {results.length} 个结果 +

+
+ + {/* 单列列表布局 */} +
+ {results.map((item) => { + const content = item.type === 'video' ? item.video : item.imagePost; + if (!content) return null; + + return ( +
+ {/* 主内容区 */} +
+ {/* 左侧:封面预览 */} + + {content.desc} + + + {/* 右侧:信息区 */} +
+ {/* 类型标签 */} +
+ + {item.type === 'video' ? '视频' : '图文'} + + + + 匹配度: {(item.rank * 100).toFixed(1)}% + +
+ + {/* 描述 */} + +

+ {content.desc || "无描述"} +

+ + + {/* 作者信息 */} +
+ {content.author.nickname} + + {content.author.nickname} + +
+ + {/* 匹配详情:展示后端返回的snippet(已包含标签) */} +
+
+

+ + 匹配内容 +

+
+
+
+
+
+
+ ); + })} +
+
+ )} +
+
+ ); +} diff --git a/app/search/page.tsx b/app/search/page.tsx index 4d40b9e..184116c 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -1,268 +1,12 @@ -"use client"; - -import { useCallback, useEffect, useState } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import { Search, ArrowLeft, X, MessageSquare } from "lucide-react"; - -// 匹配搜索结果类型(适配后端API) -type SearchResultItem = { - id: string; - awemeId: string; - type: 'video' | 'image'; - rank: number; - snippet: string; // 后端返回的高亮片段,已包含标签 - video?: { - aweme_id: string; - desc: string; - cover_url: string | null; - video_url: string | null; - duration_ms: number; - author: { - sec_uid: string; - nickname: string; - avatar_url: string | null; - }; - }; - imagePost?: { - aweme_id: string; - desc: string; - cover_url: string | null; - author: { - sec_uid: string; - nickname: string; - avatar_url: string | null; - }; - }; -}; - -export default function SearchPage() { - const searchParams = useSearchParams(); - const router = useRouter(); - const queryParam = searchParams.get("q") || ""; - - const [query, setQuery] = useState(queryParam); - const [results, setResults] = useState([]); - const [loading, setLoading] = useState(false); - const [searched, setSearched] = useState(false); - - const performSearch = useCallback(async (q: string) => { - if (!q.trim()) { - setResults([]); - setSearched(false); - return; - } - - setLoading(true); - setSearched(true); - try { - const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`); - if (!res.ok) throw new Error("Search failed"); - const data = await res.json(); - setResults(data.results || []); - } catch (err) { - console.error("Search error:", err); - setResults([]); - } finally { - setLoading(false); - } - }, []); - - // 初始查询 - useEffect(() => { - if (queryParam) { - performSearch(queryParam); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = query.trim(); - if (!trimmed) return; - // 更新 URL - router.push(`/search?q=${encodeURIComponent(trimmed)}`); - performSearch(trimmed); - }; - - const handleClear = () => { - setQuery(""); - setResults([]); - setSearched(false); - router.push("/search"); - }; +// 不要写 "use client" +import { Suspense } from "react"; +import SearchClient from "./SearchClient"; +export default function Page({ searchParams }: { searchParams: { q?: string } }) { + const q = typeof searchParams.q === "string" ? searchParams.q : ""; return ( -
- {/* 顶部搜索栏 */} -
-
- - -
-
- setQuery(e.target.value)} - placeholder="搜索视频、图文描述或语音内容..." - className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all" - autoFocus - /> - - {query && ( - - )} -
- -
-
-
- - {/* 结果区域 */} -
- {loading && ( -
-
-
-

正在搜索...

-
-
- )} - - {!loading && searched && results.length === 0 && ( -
- -

未找到相关内容

-

试试其他关键词

-
- )} - - {!loading && !searched && !queryParam && ( -
- -

输入关键词开始搜索

-

支持搜索视频描述、图文描述及语音转写内容

-
- )} - - {!loading && results.length > 0 && ( -
-
-

- 找到 {results.length} 个结果 -

-
- - {/* 单列列表布局 */} -
- {results.map((item) => { - const content = item.type === 'video' ? item.video : item.imagePost; - if (!content) return null; - - return ( -
- {/* 主内容区 */} -
- {/* 左侧:封面预览 */} - - {content.desc} - - - {/* 右侧:信息区 */} -
- {/* 类型标签 */} -
- - {item.type === 'video' ? '视频' : '图文'} - - - - 匹配度: {(item.rank * 100).toFixed(1)}% - -
- - {/* 描述 */} - -

- {content.desc || "无描述"} -

- - - {/* 作者信息 */} -
- {content.author.nickname} - - {content.author.nickname} - -
- - {/* 匹配详情:展示后端返回的snippet(已包含标签) */} -
-
-

- - 匹配内容 -

-
-
-
-
-
-
- ); - })} -
-
- )} -
-
+ Loading…
}> + + ); }