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
+82
View File
@@ -0,0 +1,82 @@
// 播放上下文:播放页「上一条 / 下一条」在哪个列表里移动。
// 它放在 /aweme/<id> 的查询参数里,刷新、新标签页打开、后退都不会丢:
// (无) 全部作品,按发布时间倒序(和首页一致)
// ?ctx=tag&tag=美女 某个标签
// ?ctx=author&author=<secUid> 某个作者的作品
// ?ctx=search&q=百姓&tag=… 某次搜索的结果
// ?ctx=hashtag&hashtag=丰川祥子 描述里带某个 hashtag 的作品
// 这个文件只放纯函数,客户端组件也可以引用;查数据库的部分在 playback-context.server.ts。
import { normalizeHashtag } from "@/lib/hashtag";
import { isVideoContentTag } from "@/lib/video-tags";
export type PlaybackContext =
| { kind: "search"; q: string; tag: string | null }
| { kind: "tag"; tag: string }
| { kind: "hashtag"; name: string }
| { kind: "author"; secUid: string };
/** 播放页侧栏网格里的一项(/api/playlist 的返回) */
export type PlaylistItem = {
aweme_id: string;
type: "video" | "image";
desc: string;
cover_url: string | null;
likes: number;
};
export type PlaylistResponse = { items: PlaylistItem[]; total: number };
type ParamSource =
| URLSearchParams
| Record<string, string | string[] | undefined>;
function readParam(source: ParamSource, key: string): string {
const value =
source instanceof URLSearchParams ? source.get(key) : source[key];
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
}
export function parsePlaybackContext(
source: ParamSource,
): PlaybackContext | null {
const kind = readParam(source, "ctx");
const tag = readParam(source, "tag");
const validTag = tag && isVideoContentTag(tag) ? tag : null;
if (kind === "search") {
const q = readParam(source, "q");
if (!q && !validTag) return null;
return { kind: "search", q, tag: validTag };
}
if (kind === "tag" && validTag) return { kind: "tag", tag: validTag };
if (kind === "hashtag") {
const name = normalizeHashtag(readParam(source, "hashtag"));
if (name) return { kind: "hashtag", name };
}
if (kind === "author") {
const secUid = readParam(source, "author");
if (secUid) return { kind: "author", secUid };
}
return null;
}
/** 生成带上下文的查询串(含开头的 ?);没有上下文时返回空串。 */
export function playbackContextQuery(ctx: PlaybackContext | null): string {
if (!ctx) return "";
const params = new URLSearchParams({ ctx: ctx.kind });
if (ctx.kind === "search") {
if (ctx.q) params.set("q", ctx.q);
if (ctx.tag) params.set("tag", ctx.tag);
} else if (ctx.kind === "tag") {
params.set("tag", ctx.tag);
} else if (ctx.kind === "hashtag") {
params.set("hashtag", ctx.name);
} else {
params.set("author", ctx.secUid);
}
return `?${params.toString()}`;
}
export function awemeHref(awemeId: string, query = ""): string {
return `/aweme/${awemeId}${query}`;
}