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
@@ -16,7 +16,8 @@ import {
VolumeX,
} from "lucide-react";
import Link from "next/link";
import { memo, type ReactNode, type RefObject } from "react";
import { memo, type ReactNode, type RefObject, useMemo } from "react";
import { parseDescHashtags } from "@/lib/hashtag";
import type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts";
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
@@ -59,6 +60,10 @@ interface MediaControlsProps {
onLoopModeChange: (mode: LoopMode) => void;
onDownload: () => void;
onToggleFullscreen: () => void;
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
onAuthorClick?: () => void;
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
onHashtagClick?: (name: string) => void;
}
const CONTROL_BTN_CLASS =
@@ -96,7 +101,10 @@ export const MediaControls = memo(function MediaControls({
onLoopModeChange,
onDownload,
onToggleFullscreen,
onAuthorClick,
onHashtagClick,
}: MediaControlsProps) {
const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]);
const normalizedLoopMode =
isVideo && loopMode === "single" ? "loop" : loopMode;
const loopLabel = isVideo
@@ -129,10 +137,29 @@ export const MediaControls = memo(function MediaControls({
};
return (
<div className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5">
<div
data-no-swipe
className="absolute left-0 right-0 bottom-0 z-40 px-3 pb-4 pt-2 bg-gradient-to-b from-black/0 via-black/45 to-black/65 flex flex-col gap-2.5"
>
{/* 描述行 */}
<div className="flex items-center gap-2.5 mb-1 pointer-events-none">
{author.sec_uid ? (
{author.sec_uid && onAuthorClick ? (
<button
type="button"
onClick={onAuthorClick}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity cursor-pointer"
title="查看TA的作品"
>
<img
src={author.avatar_url ?? ""}
alt=""
className="w-8 h-8 rounded-full"
/>
<span className="text-[15px] leading-tight text-white/95 drop-shadow font-medium">
{author.nickname}
</span>
</button>
) : author.sec_uid ? (
<Link
href={`/author/${author.sec_uid}`}
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
@@ -172,7 +199,23 @@ export const MediaControls = memo(function MediaControls({
{desc ? (
<div className="pointer-events-none">
<p className="text-[13px] leading-tight text-white/95 max-h-[3.9em] overflow-hidden [display:-webkit-box] [-webkit-line-clamp:3] [-webkit-box-orient:vertical] drop-shadow">
{desc}
{descParts.map((part, i) =>
part.type === "hashtag" && onHashtagClick ? (
<button
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
key={i}
type="button"
onClick={() => onHashtagClick(part.name)}
className="pointer-events-auto font-semibold text-white hover:underline cursor-pointer"
title={`查看所有带 #${part.name} 的作品`}
>
{part.text}
</button>
) : (
// biome-ignore lint/suspicious/noArrayIndexKey: 描述片段顺序固定
<span key={i}>{part.text}</span>
),
)}
</p>
</div>
) : null}