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
+249 -22
View File
@@ -10,32 +10,49 @@ import {
useRef,
useState,
} from "react";
import {
awemeHref,
type PlaylistItem,
playbackContextQuery,
} from "@/lib/playback-context";
import { AuthorWorksTab } from "./components/AuthorWorksTab";
import { BackgroundCanvas } from "./components/BackgroundCanvas";
import { CommentPanel } from "./components/CommentPanel";
import { ContextChip } from "./components/ContextChip";
import { ContextListTab } from "./components/ContextListTab";
import { ImageCarousel } from "./components/ImageCarousel";
import { ImageNavigationButtons } from "./components/ImageNavigationButtons";
import { MediaControls } from "./components/MediaControls";
import { NavigationButtons } from "./components/NavigationButtons";
import { SidePanel } from "./components/SidePanel";
import { TranscriptPanel } from "./components/TranscriptPanel";
import { VideoPlayer } from "./components/VideoPlayer";
import { VideoTagEditor } from "./components/VideoTagEditor";
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
import { useCommentState } from "./hooks/useCommentState";
import { useImageCarousel } from "./hooks/useImageCarousel";
import { useNavigation } from "./hooks/useNavigation";
import { usePlayerState } from "./hooks/usePlayerState";
import { usePlaylist } from "./hooks/usePlaylist";
import {
isPortraitViewport,
useSidePanelState,
} from "./hooks/useSidePanelState";
import { useVideoPlayer } from "./hooks/useVideoPlayer";
import type {
AbLoop,
AwemeData,
ImageData,
Neighbors,
PlaybackContextInfo,
VideoData,
VideoTranscript,
} from "./types.ts";
const SEGMENT_MS = 4000;
// 每个搜索 / hashtag 列表只在第一次进入时自动打开侧栏,之后尊重你自己的开关
const LIST_AUTO_OPENED_KEY = "aweme_player_list_auto_opened";
type WebkitVideoElement = HTMLVideoElement & {
webkitEnterFullscreen?: () => void;
};
@@ -52,13 +69,22 @@ type MediaSessionWithPositionState = MediaSession & {
interface AwemeDetailClientProps {
data: AwemeData;
/** 服务端按 URL 上的播放上下文算出的上一条/下一条 */
neighbors: Neighbors;
/** URL 上的上下文(搜索/标签/作者)生效时的提示信息 */
playbackContext: PlaybackContextInfo | null;
/** URL 上原本的上下文查询串,作者模式下跳转时原样带上 */
contextQuery: string;
authorWorksCount: number;
transcript: VideoTranscript | null;
}
export default function AwemeDetailClient({
data,
neighbors,
neighbors: serverNeighbors,
playbackContext,
contextQuery,
authorWorksCount,
transcript,
}: AwemeDetailClientProps): ReactElement {
const router = useRouter();
@@ -66,7 +92,59 @@ export default function AwemeDetailClient({
// 状态管理
const playerState = usePlayerState();
const commentState = useCommentState();
const panel = useSidePanelState(data.author.sec_uid);
// 搜索 / hashtag / 标签上下文才有「列表」标签页(作者上下文和「TA的作品」重复)
const listContext =
playbackContext && playbackContext.kind !== "author"
? playbackContext
: null;
const activeTab =
panel.tab === "list" && !listContext ? "comments" : panel.tab;
const authorQuery = data.author.sec_uid
? playbackContextQuery({ kind: "author", secUid: data.author.sec_uid })
: "";
const authorWorks = usePlaylist(
authorQuery,
panel.authorMode || (panel.open && activeTab === "works"),
);
const contextList = usePlaylist(
listContext ? contextQuery : "",
panel.open && activeTab === "list",
);
// 作者模式(打开「TA的作品」时进入,关掉侧栏不退出):上下切换在该作者的作品里移动;
// 点顶部提示的 ✕ 退出后,回到服务端按 URL 上下文算好的顺序,当前视频不变。
const authorPlayback = useMemo(() => {
if (!panel.authorMode) return null;
const items = authorWorks.items;
// 列表还没加载出来时先不让上下切换,避免误按到另一个顺序里
if (!items) {
return {
neighbors: { prev: null, next: null } as Neighbors,
position: 0,
total: 0,
};
}
const index = items.findIndex((item) => item.aweme_id === data.aweme_id);
if (index === -1) return null;
const link = (item: PlaylistItem | undefined) =>
item
? {
aweme_id: item.aweme_id,
href: awemeHref(item.aweme_id, contextQuery),
}
: null;
return {
neighbors: {
prev: link(items[index - 1]),
next: link(items[index + 1]),
} as Neighbors,
position: index + 1,
total: items.length,
};
}, [panel.authorMode, authorWorks.items, data.aweme_id, contextQuery]);
const neighbors = authorPlayback?.neighbors ?? serverNeighbors;
const [transcriptOpen, setTranscriptOpen] = useState(false);
const [isFastForwarding, setIsFastForwarding] = useState(false);
const [abLoop, setAbLoop] = useState<AbLoop>({ a: null, b: null });
@@ -328,23 +406,106 @@ export default function AwemeDetailClient({
setTranscriptOpen(false);
}, []);
const handleCloseComments = useCallback((): void => {
commentState.setOpen(false);
}, [commentState.setOpen]);
const handleToggleComments = useCallback((): void => {
commentState.setOpen((v) => !v);
}, [commentState.setOpen]);
panel.toggle("comments");
}, [panel.toggle]);
const handleAuthorClick = useCallback((): void => {
panel.toggle("works");
}, [panel.toggle]);
// 竖屏(底部抽屉)点了作品后关掉抽屉露出视频;顺序不受影响,点顶部提示可以再打开
const handleWorkSelect = useCallback((): void => {
if (isPortraitViewport()) panel.close();
}, [panel.close]);
const handleExitUrlContext = useCallback((): void => {
router.replace(awemeHref(data.aweme_id));
}, [data.aweme_id, router]);
const showWorks = useCallback((): void => {
panel.show("works");
}, [panel.show]);
// 退出作者模式:回到 URL 上原来的顺序(搜索 / hashtag / 全部作品);作品网格还开着的话一并关掉
const exitAuthorMode = useCallback((): void => {
panel.exitAuthorMode();
if (panel.open && panel.tab === "works") panel.close();
}, [panel.exitAuthorMode, panel.open, panel.tab, panel.close]);
const showList = useCallback((): void => {
panel.show("list");
}, [panel.show]);
// 点描述里的 hashtag:停在当前视频,上下切换改为在带这个 hashtag 的作品里移动,并打开列表
const handleHashtagClick = useCallback(
(name: string): void => {
panel.exitAuthorMode();
router.replace(
awemeHref(
data.aweme_id,
playbackContextQuery({ kind: "hashtag", name }),
),
{ scroll: false },
);
},
[data.aweme_id, router, panel.exitAuthorMode],
);
// 从搜索结果进来、或点了 hashtag 时自动打开列表标签页
const listKind = listContext?.kind;
useEffect(() => {
if (!panel.restored) return;
if (listKind !== "search" && listKind !== "hashtag") return;
try {
if (sessionStorage.getItem(LIST_AUTO_OPENED_KEY) === contextQuery) return;
sessionStorage.setItem(LIST_AUTO_OPENED_KEY, contextQuery);
} catch {}
panel.show("list");
}, [panel.restored, panel.show, listKind, contextQuery]);
const searchPageHref = useMemo(() => {
if (listKind !== "search") return null;
const params = new URLSearchParams(contextQuery);
params.delete("ctx");
return `/search?${params.toString()}`;
}, [listKind, contextQuery]);
const listVisible = panel.open && activeTab === "list";
const worksVisible = panel.open && activeTab === "works";
const chip =
authorPlayback && authorPlayback.total > 0
? {
label: `${data.author.nickname} 的作品`,
position: authorPlayback.position,
total: authorPlayback.total,
// 作品网格没在显示时,点提示可以重新打开
onExpand: worksVisible ? undefined : showWorks,
expandLabel: "查看TA的作品",
onExit: exitAuthorMode,
exitLabel: "退出作者模式",
}
: !panel.authorMode && playbackContext
? {
...playbackContext,
// 列表没在显示时,点提示可以打开侧栏里的完整列表
onExpand: listContext && !listVisible ? showList : undefined,
expandLabel: `查看${playbackContext.listTitle}`,
onExit: handleExitUrlContext,
exitLabel: "退出当前列表,按全部作品切换",
}
: null;
const handleNavigatePrev = useCallback((): void => {
if (neighbors.prev) {
router.push(`/aweme/${neighbors.prev.aweme_id}`);
router.push(neighbors.prev.href);
}
}, [neighbors.prev, router]);
const handleNavigateNext = useCallback((): void => {
if (neighbors.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
router.push(neighbors.next.href);
}
}, [neighbors.next, router]);
@@ -586,14 +747,14 @@ export default function AwemeDetailClient({
if (!isVideo && images?.length > 1) {
prevImg();
} else if (neighbors.prev) {
router.push(`/aweme/${neighbors.prev.aweme_id}`);
router.push(neighbors.prev.href);
}
});
ms.setActionHandler("nexttrack", () => {
if (!isVideo && images?.length > 1) {
nextImg();
} else if (neighbors.next) {
router.push(`/aweme/${neighbors.next.aweme_id}`);
router.push(neighbors.next.href);
}
});
@@ -670,9 +831,24 @@ export default function AwemeDetailClient({
)}
</div>
{/* 播放上下文提示:上下切换当前在哪个列表里移动 */}
{chip && (
<ContextChip
label={chip.label}
position={chip.position}
total={chip.total}
onExpand={chip.onExpand}
expandLabel={chip.expandLabel}
onExit={chip.onExit}
exitLabel={chip.exitLabel}
/>
)}
{/* 长按 2x 快进状态指示 */}
{isFastForwarding && (
<div className="pointer-events-none absolute top-4 left-1/2 -translate-x-1/2 z-40">
<div
className={`pointer-events-none absolute left-1/2 -translate-x-1/2 z-40 ${chip ? "top-24 sm:top-14" : "top-4"}`}
>
<div className="inline-flex items-center gap-1.5 rounded-full bg-black/65 border border-white/20 px-3.5 py-1.5 text-xs font-medium text-white shadow-lg">
<FastForward size={14} className="text-amber-300" />
<span>2.0x 快进中</span>
@@ -740,6 +916,8 @@ export default function AwemeDetailClient({
onLoopModeChange={playerState.setLoopMode}
onDownload={handleDownload}
onToggleFullscreen={toggleFullscreen}
onAuthorClick={handleAuthorClick}
onHashtagClick={handleHashtagClick}
/>
</div>
@@ -754,14 +932,63 @@ export default function AwemeDetailClient({
/>
</section>
{/* 评论面板 */}
<CommentPanel
open={commentState.open}
onClose={handleCloseComments}
author={data.author}
createdAt={data.created_at}
awemeId={data.aweme_id}
mounted={commentState.mounted}
{/* 侧栏:评论 / TA的作品 / 搜索结果(或 hashtag) */}
<SidePanel
open={panel.open}
mounted={panel.mounted}
tab={activeTab}
onTabChange={panel.selectTab}
onClose={panel.close}
tabs={[
{
key: "comments",
label: "评论",
count: data.commentsCount,
content: (
<CommentPanel
open={panel.open && activeTab === "comments"}
author={data.author}
createdAt={data.created_at}
awemeId={data.aweme_id}
/>
),
},
{
key: "works",
label: "TA的作品",
count: authorWorks.items?.length ?? authorWorksCount,
content: (
<AuthorWorksTab
active={worksVisible}
author={data.author}
works={authorWorks}
currentId={data.aweme_id}
contextQuery={contextQuery}
onSelect={handleWorkSelect}
/>
),
},
...(listContext
? [
{
key: "list" as const,
label: listContext.listTitle,
count: listContext.total,
content: (
<ContextListTab
active={panel.open && activeTab === "list"}
context={listContext}
list={contextList}
currentId={data.aweme_id}
contextQuery={contextQuery}
searchPageHref={searchPageHref}
onSelect={handleWorkSelect}
/>
),
},
]
: []),
]}
/>
{/* 转录面板 */}