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:
+249
-22
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 转录面板 */}
|
||||
|
||||
Reference in New Issue
Block a user