feat: 播放页按来源列表切换,新增作者/搜索/hashtag 侧栏

- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条
  在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序
- 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出
- 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换
- 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表
- 从搜索结果进入或点 hashtag 时自动打开对应列表
- 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos)
- 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致
- 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 00:50:09 +08:00
co-authored by Claude Opus 5.5
parent 182ca9cf05
commit b84fcec77b
28 changed files with 1592 additions and 282 deletions
+17 -62
View File
@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import BackButton from "@/app/components/BackButton";
import { getFileUrl } from "@/lib/minio";
import { parsePlaybackContext } from "@/lib/playback-context";
import { resolvePlayback } from "@/lib/playback-context.server";
import { prisma } from "@/lib/prisma";
import AwemeDetailClient from "./Client";
import type { AwemeData, VideoTranscript } from "./types";
@@ -45,8 +47,10 @@ export async function generateMetadata({
export default async function AwemeDetail({
params,
searchParams,
}: {
params: Promise<{ awemeId: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const id = (await params).awemeId;
@@ -71,12 +75,10 @@ export default async function AwemeDetail({
});
let data: AwemeData;
let currentCreatedAt: Date;
let transcript: VideoTranscript | null = null;
if (video) {
transcript = video.transcript ?? null;
currentCreatedAt = video.created_at;
data = {
type: "video",
aweme_id: video.aweme_id,
@@ -100,7 +102,6 @@ export default async function AwemeDetail({
};
} else if (post) {
const coverImage = post.images[0];
currentCreatedAt = post.created_at;
data = {
type: "image",
aweme_id: post.aweme_id,
@@ -129,65 +130,16 @@ export default async function AwemeDetail({
return <main className="p-8">找不到该作品</main>;
}
// Compute prev/next neighbors by created_at across videos and image posts
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
prisma.video.findFirst({
where: { created_at: { gt: currentCreatedAt } },
orderBy: { created_at: "asc" },
select: { aweme_id: true, created_at: true },
}),
prisma.imagePost.findFirst({
where: { created_at: { gt: currentCreatedAt } },
orderBy: { created_at: "asc" },
select: { aweme_id: true, created_at: true },
}),
prisma.video.findFirst({
where: { created_at: { lt: currentCreatedAt } },
orderBy: { created_at: "desc" },
select: { aweme_id: true, created_at: true },
}),
prisma.imagePost.findFirst({
where: { created_at: { lt: currentCreatedAt } },
orderBy: { created_at: "desc" },
select: { aweme_id: true, created_at: true },
}),
const authorId = video?.authorId ?? post?.authorId ?? null;
const [playback, authorWorksCount] = await Promise.all([
resolvePlayback(id, parsePlaybackContext(await searchParams)),
authorId
? Promise.all([
prisma.video.count({ where: { authorId } }),
prisma.imagePost.count({ where: { authorId } }),
]).then(([videos, posts]) => videos + posts)
: 0,
]);
const pickPrev = (() => {
const cands: { aweme_id: string; created_at: Date }[] = [];
if (newerVideo)
cands.push({
aweme_id: newerVideo.aweme_id,
created_at: newerVideo.created_at,
});
if (newerPost)
cands.push({
aweme_id: newerPost.aweme_id,
created_at: newerPost.created_at,
});
if (cands.length === 0) return null;
cands.sort((a, b) => +a.created_at - +b.created_at);
return { aweme_id: cands[0].aweme_id };
})();
const pickNext = (() => {
const cands: { aweme_id: string; created_at: Date }[] = [];
if (olderVideo)
cands.push({
aweme_id: olderVideo.aweme_id,
created_at: olderVideo.created_at,
});
if (olderPost)
cands.push({
aweme_id: olderPost.aweme_id,
created_at: olderPost.created_at,
});
if (cands.length === 0) return null;
cands.sort((a, b) => +b.created_at - +a.created_at);
return { aweme_id: cands[0].aweme_id };
})();
const neighbors: {
prev: { aweme_id: string } | null;
next: { aweme_id: string } | null;
} = { prev: pickPrev, next: pickNext };
return (
<main className="min-h-screen w-full">
@@ -200,7 +152,10 @@ export default async function AwemeDetail({
</div>
<AwemeDetailClient
data={data}
neighbors={neighbors}
neighbors={playback.neighbors}
playbackContext={playback.context}
contextQuery={playback.contextQuery}
authorWorksCount={authorWorksCount}
transcript={transcript}
/>
</main>