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:
@@ -1,6 +1,6 @@
|
||||
// Hooks 导出
|
||||
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
||||
export { useCommentState } from "./useCommentState";
|
||||
export { useSidePanelState } from "./useSidePanelState";
|
||||
export { useImageCarousel } from "./useImageCarousel";
|
||||
export { useNavigation } from "./useNavigation";
|
||||
export { usePlayerState } from "./usePlayerState";
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStringFromStorage, saveToStorage } from "../utils";
|
||||
|
||||
export function useCommentState() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// 从 localStorage 恢复评论区状态
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = getStringFromStorage("aweme_player_comments_open", "false");
|
||||
if (saved === "true") {
|
||||
setOpen(true);
|
||||
}
|
||||
// 短暂延迟后标记为已挂载,启用动画
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setMounted(true);
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 持久化评论区状态
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
saveToStorage("aweme_player_comments_open", open.toString());
|
||||
}, [open]);
|
||||
|
||||
return { open, setOpen, mounted };
|
||||
}
|
||||
@@ -197,7 +197,7 @@ export function useImageCarousel({
|
||||
|
||||
if (fromIndex >= currentImages.length - 1) {
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
router.push(neighbors.next.href);
|
||||
return;
|
||||
}
|
||||
idxRef.current = 0;
|
||||
|
||||
@@ -50,8 +50,8 @@ export function useNavigation({
|
||||
// 预取路由
|
||||
useEffect(() => {
|
||||
if (!neighbors) return;
|
||||
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
|
||||
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
|
||||
if (neighbors.next) router.prefetch(neighbors.next.href);
|
||||
if (neighbors.prev) router.prefetch(neighbors.prev.href);
|
||||
}, [neighbors, router]);
|
||||
|
||||
// 鼠标滚轮切换
|
||||
@@ -72,10 +72,10 @@ export function useNavigation({
|
||||
|
||||
if (dy > 0 && neighbors?.next) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
router.push(neighbors.next.href);
|
||||
} else if (dy < 0 && neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -83,6 +83,90 @@ export function useNavigation({
|
||||
return () => el.removeEventListener("wheel", onWheel);
|
||||
}, [neighbors, mediaContainerRef, router]);
|
||||
|
||||
// 触屏上下滑切换(上滑下一条,下滑上一条)。只认明显的竖向手势,
|
||||
// 不影响图文的左右滑动;从按钮、链接、控制栏(data-no-swipe)上开始的触摸不处理。
|
||||
useEffect(() => {
|
||||
const el = mediaContainerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
let start: { x: number; y: number; t: number } | null = null;
|
||||
let vertical = false;
|
||||
|
||||
const onTouchStart = (e: TouchEvent): void => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
e.touches.length !== 1 ||
|
||||
target?.closest(
|
||||
"button, a, input, textarea, select, [role='slider'], [data-no-swipe]",
|
||||
)
|
||||
) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
start = { x: touch.clientX, y: touch.clientY, t: performance.now() };
|
||||
vertical = false;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: TouchEvent): void => {
|
||||
if (!start) return;
|
||||
if (e.touches.length !== 1) {
|
||||
start = null;
|
||||
return;
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - start.x;
|
||||
const dy = touch.clientY - start.y;
|
||||
if (!vertical && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx) * 1.5) {
|
||||
vertical = true;
|
||||
}
|
||||
// 竖向手势由我们处理,阻止浏览器下拉刷新 / 回弹
|
||||
if (vertical && e.cancelable) e.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: TouchEvent): void => {
|
||||
if (!start) return;
|
||||
const touch = e.changedTouches[0];
|
||||
const dy = touch.clientY - start.y;
|
||||
const elapsed = performance.now() - start.t;
|
||||
const wasVertical = vertical;
|
||||
start = null;
|
||||
vertical = false;
|
||||
if (!wasVertical) return;
|
||||
|
||||
// 快速轻扫,或者慢慢拖过屏幕高度的四分之一
|
||||
const flick = Math.abs(dy) > 60 && elapsed < 800;
|
||||
const longDrag = Math.abs(dy) > window.innerHeight * 0.25;
|
||||
if (!flick && !longDrag) return;
|
||||
|
||||
const now = performance.now();
|
||||
if (now - wheelCooldownRef.current < 500) return;
|
||||
if (dy < 0 && neighbors?.next) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(neighbors.next.href);
|
||||
} else if (dy > 0 && neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchCancel = (): void => {
|
||||
start = null;
|
||||
vertical = false;
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", onTouchStart, { passive: true });
|
||||
el.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
el.addEventListener("touchend", onTouchEnd);
|
||||
el.addEventListener("touchcancel", onTouchCancel);
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", onTouchStart);
|
||||
el.removeEventListener("touchmove", onTouchMove);
|
||||
el.removeEventListener("touchend", onTouchEnd);
|
||||
el.removeEventListener("touchcancel", onTouchCancel);
|
||||
};
|
||||
}, [neighbors, mediaContainerRef, router]);
|
||||
|
||||
// 键盘快捷键(支持向右箭头 / D 键区分短按与长按 2 倍速,以及视频 [ ] 设置 AB 循环)
|
||||
useEffect(() => {
|
||||
const clearLongPressTimer = (): void => {
|
||||
@@ -124,7 +208,7 @@ export function useNavigation({
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
if (neighbors?.prev) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
||||
router.push(neighbors.prev.href);
|
||||
}
|
||||
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
|
||||
e.preventDefault();
|
||||
@@ -132,7 +216,7 @@ export function useNavigation({
|
||||
if (now - wheelCooldownRef.current < 700) return;
|
||||
if (neighbors?.next) {
|
||||
wheelCooldownRef.current = now;
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
router.push(neighbors.next.href);
|
||||
}
|
||||
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PlaylistItem, PlaylistResponse } from "@/lib/playback-context";
|
||||
|
||||
// 侧栏列表(作者作品 / 搜索结果 / hashtag)缓存在模块里:切换作品时播放页会重新挂载,
|
||||
// 但客户端导航不会清掉 JS 内存,所以在列表里上下切换时可以立刻拿到列表,不用每次重新请求。
|
||||
const STALE_MS = 60_000;
|
||||
|
||||
type CacheEntry = PlaylistResponse & { fetchedAt: number };
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<PlaylistResponse>>();
|
||||
|
||||
function loadPlaylist(query: string): Promise<PlaylistResponse> {
|
||||
const pending = inflight.get(query);
|
||||
if (pending) return pending;
|
||||
const promise = fetch(`/api/playlist${query}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`加载列表失败 (${res.status})`);
|
||||
const data = (await res.json()) as PlaylistResponse;
|
||||
cache.set(query, { ...data, fetchedAt: Date.now() });
|
||||
return data;
|
||||
})
|
||||
.finally(() => inflight.delete(query));
|
||||
inflight.set(query, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export type PlaylistState = {
|
||||
items: PlaylistItem[] | null;
|
||||
/** 列表总数(可能多于 items:侧栏最多列出 500 个) */
|
||||
total: number;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按播放上下文(/api/playlist 的查询串,含 ?)取作品列表。
|
||||
* query 为空或 enabled 为 false 时不请求。
|
||||
*/
|
||||
export function usePlaylist(query: string, enabled: boolean): PlaylistState {
|
||||
const [state, setState] = useState<PlaylistState>(() => {
|
||||
const cached = query ? cache.get(query) : undefined;
|
||||
return {
|
||||
items: cached?.items ?? null,
|
||||
total: cached?.total ?? 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!query || !enabled) return;
|
||||
const cached = cache.get(query);
|
||||
setState({
|
||||
items: cached?.items ?? null,
|
||||
total: cached?.total ?? 0,
|
||||
loading: !cached,
|
||||
error: null,
|
||||
});
|
||||
if (cached && Date.now() - cached.fetchedAt < STALE_MS) return;
|
||||
|
||||
let cancelled = false;
|
||||
loadPlaylist(query)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
items: data.items,
|
||||
total: data.total,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : "加载失败",
|
||||
}));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, enabled]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
/** comments:评论;works:TA的作品;list:搜索结果 / hashtag / 标签列表 */
|
||||
export type SidePanelTab = "comments" | "works" | "list";
|
||||
|
||||
// 侧栏开关沿用评论区原来的 localStorage 键;停在哪个标签页、是否处于作者模式只在本次会话内记住,
|
||||
// 这样下次从首页点进来不会莫名其妙停在作者模式。
|
||||
// 切换作品时播放页会整体重新挂载,靠这些存储把状态带到下一个作品。
|
||||
const OPEN_KEY = "aweme_player_comments_open";
|
||||
const TAB_KEY = "aweme_player_panel_tab";
|
||||
const AUTHOR_MODE_KEY = "aweme_player_author_mode";
|
||||
|
||||
function readStorage(storage: () => Storage, key: string): string | null {
|
||||
try {
|
||||
return storage().getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(
|
||||
storage: () => Storage,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) {
|
||||
try {
|
||||
if (value === null) storage().removeItem(key);
|
||||
else storage().setItem(key, value);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const local = () => window.localStorage;
|
||||
const session = () => window.sessionStorage;
|
||||
|
||||
export function isPortraitViewport(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(orientation: portrait)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏状态与作者模式。
|
||||
* 作者模式和侧栏开关是分开的:打开「TA的作品」进入作者模式(上下切换在该作者的作品里移动),
|
||||
* 关掉侧栏不会退出,只有点顶部提示的 ✕、或切到「搜索结果 / #hashtag」列表才退出。
|
||||
*/
|
||||
export function useSidePanelState(authorSecUid: string | undefined) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<SidePanelTab>("comments");
|
||||
// 处于作者模式时记下是哪位作者;切到别的作者的作品(例如浏览器后退)时自动不生效
|
||||
const [authorModeFor, setAuthorModeFor] = useState<string | null>(null);
|
||||
const [restored, setRestored] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(readStorage(local, OPEN_KEY) === "true");
|
||||
const savedTab = readStorage(session, TAB_KEY);
|
||||
setTab(savedTab === "works" || savedTab === "list" ? savedTab : "comments");
|
||||
setAuthorModeFor(readStorage(session, AUTHOR_MODE_KEY));
|
||||
setRestored(true);
|
||||
// 恢复完成后再开启过渡动画,避免进入页面时侧栏滑入
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => setMounted(true));
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restored) return;
|
||||
writeStorage(local, OPEN_KEY, String(open));
|
||||
writeStorage(session, TAB_KEY, tab);
|
||||
writeStorage(session, AUTHOR_MODE_KEY, authorModeFor);
|
||||
}, [open, tab, authorModeFor, restored]);
|
||||
|
||||
// 切到哪个列表标签页,上下切换就改用哪个列表的顺序;「评论」不影响顺序
|
||||
const applyTabMode = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
if (nextTab === "works" && authorSecUid) setAuthorModeFor(authorSecUid);
|
||||
else if (nextTab === "list") setAuthorModeFor(null);
|
||||
},
|
||||
[authorSecUid],
|
||||
);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
setTab(nextTab);
|
||||
applyTabMode(nextTab);
|
||||
},
|
||||
[applyTabMode],
|
||||
);
|
||||
|
||||
const show = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
selectTab(nextTab);
|
||||
setOpen(true);
|
||||
},
|
||||
[selectTab],
|
||||
);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
/** 打开到指定标签页;已经打开在这个标签页时则关闭(关闭不影响顺序) */
|
||||
const toggle = useCallback(
|
||||
(nextTab: SidePanelTab) => {
|
||||
if (open && tab === nextTab) close();
|
||||
else show(nextTab);
|
||||
},
|
||||
[close, open, show, tab],
|
||||
);
|
||||
|
||||
const exitAuthorMode = useCallback(() => setAuthorModeFor(null), []);
|
||||
|
||||
return {
|
||||
open,
|
||||
tab,
|
||||
mounted,
|
||||
/** 上下切换在当前作者的作品里移动 */
|
||||
authorMode: Boolean(authorSecUid) && authorModeFor === authorSecUid,
|
||||
/** 已从存储里恢复完状态(之后才能安全地自动打开侧栏) */
|
||||
restored,
|
||||
selectTab,
|
||||
show,
|
||||
close,
|
||||
toggle,
|
||||
exitAuthorMode,
|
||||
};
|
||||
}
|
||||
@@ -181,7 +181,7 @@ export function useVideoPlayer({
|
||||
}
|
||||
|
||||
if (loopMode === "sequential" && neighbors?.next) {
|
||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
||||
router.push(neighbors.next.href);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user