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 (
+
+ {/* 顶部搜索栏 */}
+
+
+
+
+
+
+
+
+ {/* 结果区域 */}
+
+ {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 (
+
+ {/* 主内容区 */}
+
+ {/* 左侧:封面预览 */}
+
+

+
+
+ {/* 右侧:信息区 */}
+
+ {/* 类型标签 */}
+
+
+ {item.type === 'video' ? '视频' : '图文'}
+
+
+
+ 匹配度: {(item.rank * 100).toFixed(1)}%
+
+
+
+ {/* 描述 */}
+
+
+ {content.desc || "无描述"}
+
+
+
+ {/* 作者信息 */}
+
+

+
+ {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 (
-
- {/* 顶部搜索栏 */}
-
-
-
-
-
-
-
-
- {/* 结果区域 */}
-
- {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 (
-
- {/* 主内容区 */}
-
- {/* 左侧:封面预览 */}
-
-

-
-
- {/* 右侧:信息区 */}
-
- {/* 类型标签 */}
-
-
- {item.type === 'video' ? '视频' : '图文'}
-
-
-
- 匹配度: {(item.rank * 100).toFixed(1)}%
-
-
-
- {/* 描述 */}
-
-
- {content.desc || "无描述"}
-
-
-
- {/* 作者信息 */}
-
-

-
- {content.author.nickname}
-
-
-
- {/* 匹配详情:展示后端返回的snippet(已包含
标签) */}
-
-
-
-
- );
- })}
-
-
- )}
-
-
+ Loading… }>
+
+
);
}