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:
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { json } from "@/lib/json";
|
||||||
|
import { parsePlaybackContext } from "@/lib/playback-context";
|
||||||
|
import { contextItems } from "@/lib/playback-context.server";
|
||||||
|
|
||||||
|
// 按播放上下文的顺序列出作品,供播放页侧栏的网格使用。
|
||||||
|
// 参数与 /aweme/<id> 上的上下文参数相同:?ctx=search&q=…、?ctx=hashtag&hashtag=…、?ctx=author&author=… 等。
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const ctx = parsePlaybackContext(new URL(req.url).searchParams);
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: "Invalid context" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return json(await contextItems(ctx));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Playlist error:", err);
|
||||||
|
return NextResponse.json({ error: "Playlist failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
+7
-90
@@ -2,26 +2,10 @@ import { NextResponse } from "next/server";
|
|||||||
import { json } from "@/lib/json";
|
import { json } from "@/lib/json";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import {
|
import { buildSnippet, highlightAll, parseSearchQuery } from "@/lib/search";
|
||||||
buildSnippet,
|
import { searchRows } from "@/lib/search-query";
|
||||||
highlightAll,
|
|
||||||
parseSearchQuery,
|
|
||||||
termToPgPattern,
|
|
||||||
} from "@/lib/search";
|
|
||||||
import { isVideoContentTag } from "@/lib/video-tags";
|
import { isVideoContentTag } from "@/lib/video-tags";
|
||||||
|
|
||||||
type SearchRow = {
|
|
||||||
awemeId: string;
|
|
||||||
type: "video" | "image";
|
|
||||||
score: number;
|
|
||||||
inDesc: boolean;
|
|
||||||
inTranscript: boolean;
|
|
||||||
rawDesc: string;
|
|
||||||
rawTranscript: string;
|
|
||||||
contentTags: string[];
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = (searchParams.get("q") || "").trim();
|
const q = (searchParams.get("q") || "").trim();
|
||||||
@@ -43,79 +27,12 @@ export async function GET(req: Request) {
|
|||||||
return json({ results: [], total: 0, page, limit, terms });
|
return json({ results: [], total: 0, page, limit, terms });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关键词已在 parseSearchQuery 里转为小写,这里转成正则后与 lower() 后的文本匹配
|
|
||||||
const patterns = terms.map(termToPgPattern);
|
|
||||||
const excludePatterns = excludes.map(termToPgPattern);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写),
|
const rows = await searchRows(
|
||||||
// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。
|
{ terms, excludes, contentTag },
|
||||||
// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。
|
offset,
|
||||||
// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。
|
limit,
|
||||||
const rows = await prisma.$queryRaw<SearchRow[]>`
|
);
|
||||||
WITH params AS (
|
|
||||||
SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes
|
|
||||||
),
|
|
||||||
docs AS (
|
|
||||||
SELECT
|
|
||||||
v.aweme_id AS "awemeId",
|
|
||||||
'video'::text AS type,
|
|
||||||
v."desc" AS "rawDesc",
|
|
||||||
COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript",
|
|
||||||
v.created_at AS created,
|
|
||||||
v.manual_content_tags AS "contentTags"
|
|
||||||
FROM "Video" v
|
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
|
||||||
WHERE ${contentTag}::text IS NULL
|
|
||||||
OR v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
ip.aweme_id,
|
|
||||||
'image'::text,
|
|
||||||
ip."desc",
|
|
||||||
'',
|
|
||||||
ip.created_at,
|
|
||||||
ARRAY[]::text[]
|
|
||||||
FROM "ImagePost" ip
|
|
||||||
WHERE ${contentTag}::text IS NULL
|
|
||||||
),
|
|
||||||
lowered AS (
|
|
||||||
SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t
|
|
||||||
FROM docs
|
|
||||||
),
|
|
||||||
matched AS (
|
|
||||||
SELECT
|
|
||||||
l.*,
|
|
||||||
COALESCE((
|
|
||||||
SELECT SUM(
|
|
||||||
4 * LEAST(regexp_count(l.d, term), 3)
|
|
||||||
+ LEAST(regexp_count(l.t, term), 10)
|
|
||||||
)
|
|
||||||
FROM unnest(p.terms) term
|
|
||||||
), 0)::float8 AS score,
|
|
||||||
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc",
|
|
||||||
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript"
|
|
||||||
FROM lowered l, params p
|
|
||||||
WHERE NOT EXISTS (
|
|
||||||
SELECT 1 FROM unnest(p.terms) term
|
|
||||||
WHERE l.d !~ term AND l.t !~ term
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM unnest(p.excludes) x
|
|
||||||
WHERE l.d ~ x OR l.t ~ x
|
|
||||||
)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
"awemeId", type, score, "inDesc", "inTranscript",
|
|
||||||
"rawDesc", "rawTranscript", "contentTags",
|
|
||||||
(COUNT(*) OVER ())::int AS total
|
|
||||||
FROM matched
|
|
||||||
ORDER BY score DESC, created DESC, "awemeId"
|
|
||||||
OFFSET ${offset}
|
|
||||||
LIMIT ${limit};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const videoIds = rows
|
const videoIds = rows
|
||||||
.filter((r) => r.type === "video")
|
.filter((r) => r.type === "video")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { playbackContextQuery } from "@/lib/playback-context";
|
||||||
import FeedMasonry from "@/app/components/FeedMasonry";
|
import FeedMasonry from "@/app/components/FeedMasonry";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
import { FeedItem } from "@/app/types/feed";
|
import { FeedItem } from "@/app/types/feed";
|
||||||
@@ -147,6 +148,7 @@ export default async function AuthorPage({
|
|||||||
initialItems={initialItems}
|
initialItems={initialItems}
|
||||||
initialCursor={initialCursor}
|
initialCursor={initialCursor}
|
||||||
fetchUrl={`/api/author/${secUid}/feed`}
|
fetchUrl={`/api/author/${secUid}/feed`}
|
||||||
|
linkQuery={playbackContextQuery({ kind: "author", secUid })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+245
-18
@@ -10,32 +10,49 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import {
|
||||||
|
awemeHref,
|
||||||
|
type PlaylistItem,
|
||||||
|
playbackContextQuery,
|
||||||
|
} from "@/lib/playback-context";
|
||||||
|
import { AuthorWorksTab } from "./components/AuthorWorksTab";
|
||||||
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
||||||
import { CommentPanel } from "./components/CommentPanel";
|
import { CommentPanel } from "./components/CommentPanel";
|
||||||
|
import { ContextChip } from "./components/ContextChip";
|
||||||
|
import { ContextListTab } from "./components/ContextListTab";
|
||||||
import { ImageCarousel } from "./components/ImageCarousel";
|
import { ImageCarousel } from "./components/ImageCarousel";
|
||||||
import { ImageNavigationButtons } from "./components/ImageNavigationButtons";
|
import { ImageNavigationButtons } from "./components/ImageNavigationButtons";
|
||||||
import { MediaControls } from "./components/MediaControls";
|
import { MediaControls } from "./components/MediaControls";
|
||||||
import { NavigationButtons } from "./components/NavigationButtons";
|
import { NavigationButtons } from "./components/NavigationButtons";
|
||||||
|
import { SidePanel } from "./components/SidePanel";
|
||||||
import { TranscriptPanel } from "./components/TranscriptPanel";
|
import { TranscriptPanel } from "./components/TranscriptPanel";
|
||||||
import { VideoPlayer } from "./components/VideoPlayer";
|
import { VideoPlayer } from "./components/VideoPlayer";
|
||||||
import { VideoTagEditor } from "./components/VideoTagEditor";
|
import { VideoTagEditor } from "./components/VideoTagEditor";
|
||||||
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
||||||
import { useCommentState } from "./hooks/useCommentState";
|
|
||||||
import { useImageCarousel } from "./hooks/useImageCarousel";
|
import { useImageCarousel } from "./hooks/useImageCarousel";
|
||||||
import { useNavigation } from "./hooks/useNavigation";
|
import { useNavigation } from "./hooks/useNavigation";
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
|
import { usePlaylist } from "./hooks/usePlaylist";
|
||||||
|
import {
|
||||||
|
isPortraitViewport,
|
||||||
|
useSidePanelState,
|
||||||
|
} from "./hooks/useSidePanelState";
|
||||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||||
import type {
|
import type {
|
||||||
AbLoop,
|
AbLoop,
|
||||||
AwemeData,
|
AwemeData,
|
||||||
ImageData,
|
ImageData,
|
||||||
Neighbors,
|
Neighbors,
|
||||||
|
PlaybackContextInfo,
|
||||||
VideoData,
|
VideoData,
|
||||||
VideoTranscript,
|
VideoTranscript,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const SEGMENT_MS = 4000;
|
const SEGMENT_MS = 4000;
|
||||||
|
|
||||||
|
// 每个搜索 / hashtag 列表只在第一次进入时自动打开侧栏,之后尊重你自己的开关
|
||||||
|
const LIST_AUTO_OPENED_KEY = "aweme_player_list_auto_opened";
|
||||||
|
|
||||||
type WebkitVideoElement = HTMLVideoElement & {
|
type WebkitVideoElement = HTMLVideoElement & {
|
||||||
webkitEnterFullscreen?: () => void;
|
webkitEnterFullscreen?: () => void;
|
||||||
};
|
};
|
||||||
@@ -52,13 +69,22 @@ type MediaSessionWithPositionState = MediaSession & {
|
|||||||
|
|
||||||
interface AwemeDetailClientProps {
|
interface AwemeDetailClientProps {
|
||||||
data: AwemeData;
|
data: AwemeData;
|
||||||
|
/** 服务端按 URL 上的播放上下文算出的上一条/下一条 */
|
||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
|
/** URL 上的上下文(搜索/标签/作者)生效时的提示信息 */
|
||||||
|
playbackContext: PlaybackContextInfo | null;
|
||||||
|
/** URL 上原本的上下文查询串,作者模式下跳转时原样带上 */
|
||||||
|
contextQuery: string;
|
||||||
|
authorWorksCount: number;
|
||||||
transcript: VideoTranscript | null;
|
transcript: VideoTranscript | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AwemeDetailClient({
|
export default function AwemeDetailClient({
|
||||||
data,
|
data,
|
||||||
neighbors,
|
neighbors: serverNeighbors,
|
||||||
|
playbackContext,
|
||||||
|
contextQuery,
|
||||||
|
authorWorksCount,
|
||||||
transcript,
|
transcript,
|
||||||
}: AwemeDetailClientProps): ReactElement {
|
}: AwemeDetailClientProps): ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -66,7 +92,59 @@ export default function AwemeDetailClient({
|
|||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const playerState = usePlayerState();
|
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 [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||||
const [isFastForwarding, setIsFastForwarding] = useState(false);
|
const [isFastForwarding, setIsFastForwarding] = useState(false);
|
||||||
const [abLoop, setAbLoop] = useState<AbLoop>({ a: null, b: null });
|
const [abLoop, setAbLoop] = useState<AbLoop>({ a: null, b: null });
|
||||||
@@ -328,23 +406,106 @@ export default function AwemeDetailClient({
|
|||||||
setTranscriptOpen(false);
|
setTranscriptOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCloseComments = useCallback((): void => {
|
|
||||||
commentState.setOpen(false);
|
|
||||||
}, [commentState.setOpen]);
|
|
||||||
|
|
||||||
const handleToggleComments = useCallback((): void => {
|
const handleToggleComments = useCallback((): void => {
|
||||||
commentState.setOpen((v) => !v);
|
panel.toggle("comments");
|
||||||
}, [commentState.setOpen]);
|
}, [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 => {
|
const handleNavigatePrev = useCallback((): void => {
|
||||||
if (neighbors.prev) {
|
if (neighbors.prev) {
|
||||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
router.push(neighbors.prev.href);
|
||||||
}
|
}
|
||||||
}, [neighbors.prev, router]);
|
}, [neighbors.prev, router]);
|
||||||
|
|
||||||
const handleNavigateNext = useCallback((): void => {
|
const handleNavigateNext = useCallback((): void => {
|
||||||
if (neighbors.next) {
|
if (neighbors.next) {
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
}
|
}
|
||||||
}, [neighbors.next, router]);
|
}, [neighbors.next, router]);
|
||||||
|
|
||||||
@@ -586,14 +747,14 @@ export default function AwemeDetailClient({
|
|||||||
if (!isVideo && images?.length > 1) {
|
if (!isVideo && images?.length > 1) {
|
||||||
prevImg();
|
prevImg();
|
||||||
} else if (neighbors.prev) {
|
} else if (neighbors.prev) {
|
||||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
router.push(neighbors.prev.href);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ms.setActionHandler("nexttrack", () => {
|
ms.setActionHandler("nexttrack", () => {
|
||||||
if (!isVideo && images?.length > 1) {
|
if (!isVideo && images?.length > 1) {
|
||||||
nextImg();
|
nextImg();
|
||||||
} else if (neighbors.next) {
|
} 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>
|
</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 快进状态指示 */}
|
{/* 长按 2x 快进状态指示 */}
|
||||||
{isFastForwarding && (
|
{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">
|
<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" />
|
<FastForward size={14} className="text-amber-300" />
|
||||||
<span>2.0x 快进中</span>
|
<span>2.0x 快进中</span>
|
||||||
@@ -740,6 +916,8 @@ export default function AwemeDetailClient({
|
|||||||
onLoopModeChange={playerState.setLoopMode}
|
onLoopModeChange={playerState.setLoopMode}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
|
onAuthorClick={handleAuthorClick}
|
||||||
|
onHashtagClick={handleHashtagClick}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -754,14 +932,63 @@ export default function AwemeDetailClient({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 评论面板 */}
|
{/* 侧栏:评论 / 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
|
<CommentPanel
|
||||||
open={commentState.open}
|
open={panel.open && activeTab === "comments"}
|
||||||
onClose={handleCloseComments}
|
|
||||||
author={data.author}
|
author={data.author}
|
||||||
createdAt={data.created_at}
|
createdAt={data.created_at}
|
||||||
awemeId={data.aweme_id}
|
awemeId={data.aweme_id}
|
||||||
mounted={commentState.mounted}
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 转录面板 */}
|
{/* 转录面板 */}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { memo } from "react";
|
||||||
|
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||||
|
import type { User } from "../types.ts";
|
||||||
|
import { WorksGrid } from "./WorksGrid";
|
||||||
|
|
||||||
|
interface AuthorWorksTabProps {
|
||||||
|
/** 侧栏打开且停在「TA的作品」标签页 */
|
||||||
|
active: boolean;
|
||||||
|
author: User;
|
||||||
|
works: PlaylistState;
|
||||||
|
currentId: string;
|
||||||
|
/** 跳转时原样带上的播放上下文查询串 */
|
||||||
|
contextQuery: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏「TA的作品」:作者在库里的全部作品。点作品就地切换;点作者名字才去独立的作者页。
|
||||||
|
export const AuthorWorksTab = memo(function AuthorWorksTab({
|
||||||
|
active,
|
||||||
|
author,
|
||||||
|
works,
|
||||||
|
currentId,
|
||||||
|
contextQuery,
|
||||||
|
onSelect,
|
||||||
|
}: AuthorWorksTabProps) {
|
||||||
|
const header = author.sec_uid ? (
|
||||||
|
<Link
|
||||||
|
href={`/author/${author.sec_uid}`}
|
||||||
|
className="group flex min-w-0 flex-1 items-center gap-2.5"
|
||||||
|
title="打开作者主页"
|
||||||
|
>
|
||||||
|
{/* biome-ignore lint/performance/noImgElement: 头像来自对象存储,不走 next/image */}
|
||||||
|
<img
|
||||||
|
src={author.avatar_url ?? ""}
|
||||||
|
alt=""
|
||||||
|
className="h-9 w-9 shrink-0 rounded-full bg-white/10 object-cover"
|
||||||
|
/>
|
||||||
|
<span className="truncate font-semibold text-white group-hover:underline">
|
||||||
|
@{author.nickname}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={16} className="shrink-0 text-white/60" />
|
||||||
|
<span className="ml-auto shrink-0 text-xs text-white/40">作者主页</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-semibold text-white">
|
||||||
|
@{author.nickname}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorksGrid
|
||||||
|
active={active}
|
||||||
|
list={works}
|
||||||
|
currentId={currentId}
|
||||||
|
hrefQuery={contextQuery}
|
||||||
|
onSelect={onSelect}
|
||||||
|
singleHint="库里只存了这位作者的这一个作品"
|
||||||
|
header={header}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import { X } from "lucide-react";
|
|
||||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Comment, User } from "../types.ts";
|
import type { Comment, User } from "../types.ts";
|
||||||
import { CommentList } from "./CommentList";
|
import { CommentList } from "./CommentList";
|
||||||
|
|
||||||
interface CommentPanelProps {
|
interface CommentPanelProps {
|
||||||
|
/** 侧栏打开且停在「评论」标签页 */
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
|
||||||
author: User;
|
author: User;
|
||||||
createdAt: string | Date;
|
createdAt: string | Date;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
mounted: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommentsApiResponse {
|
interface CommentsApiResponse {
|
||||||
@@ -21,16 +19,14 @@ interface CommentsApiResponse {
|
|||||||
snapshot?: string | number;
|
snapshot?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 侧栏「评论」标签页的内容(外壳与标签切换在 SidePanel 里)
|
||||||
export const CommentPanel = memo(function CommentPanel({
|
export const CommentPanel = memo(function CommentPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
|
||||||
author,
|
author,
|
||||||
createdAt,
|
createdAt,
|
||||||
awemeId,
|
awemeId,
|
||||||
mounted,
|
|
||||||
}: CommentPanelProps) {
|
}: CommentPanelProps) {
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [hasOpened, setHasOpened] = useState(open);
|
const [hasOpened, setHasOpened] = useState(open);
|
||||||
@@ -93,7 +89,6 @@ export const CommentPanel = memo(function CommentPanel({
|
|||||||
return deduped;
|
return deduped;
|
||||||
});
|
});
|
||||||
|
|
||||||
setTotal(typeof data.total === "number" ? data.total : 0);
|
|
||||||
const nextHasMore = Boolean(data.hasMore);
|
const nextHasMore = Boolean(data.hasMore);
|
||||||
hasMoreRef.current = nextHasMore;
|
hasMoreRef.current = nextHasMore;
|
||||||
setHasMore(nextHasMore);
|
setHasMore(nextHasMore);
|
||||||
@@ -131,7 +126,6 @@ export const CommentPanel = memo(function CommentPanel({
|
|||||||
hasMoreRef.current = true;
|
hasMoreRef.current = true;
|
||||||
rankParamsRef.current = null;
|
rankParamsRef.current = null;
|
||||||
setComments([]);
|
setComments([]);
|
||||||
setTotal(0);
|
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
@@ -174,33 +168,6 @@ export const CommentPanel = memo(function CommentPanel({
|
|||||||
}, [open, hasMore, loadComments]);
|
}, [open, hasMore, loadComments]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
|
||||||
className={`
|
|
||||||
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
|
|
||||||
fixed inset-x-0 bottom-0 w-full h-[min(80vh,88dvh)] border-t border-white/10
|
|
||||||
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0
|
|
||||||
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
|
|
||||||
${
|
|
||||||
open
|
|
||||||
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
|
||||||
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0"
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between landscape:flex-row-reverse px-3 py-3 border-b border-white/10 shrink-0">
|
|
||||||
<div className="text-white font-semibold">
|
|
||||||
评论 {total > 0 ? `(${total})` : ""}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-8 h-8 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="关闭评论"
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
|
<div ref={scrollRef} className="p-3 overflow-auto comment-scroll flex-1">
|
||||||
{(open || hasOpened) && (
|
{(open || hasOpened) && (
|
||||||
<>
|
<>
|
||||||
@@ -225,6 +192,5 @@ export const CommentPanel = memo(function CommentPanel({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ChevronUp, ListVideo, X } from "lucide-react";
|
||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
interface ContextChipProps {
|
||||||
|
label: string;
|
||||||
|
position: number;
|
||||||
|
total: number;
|
||||||
|
/** 提供时整个标签可点:展开 / 打开侧栏里的完整列表 */
|
||||||
|
onExpand?: () => void;
|
||||||
|
expandLabel?: string;
|
||||||
|
onExit: () => void;
|
||||||
|
exitLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 顶部的模式提示:说明上下切换当前在哪个列表里移动,避免「为什么跳到这里了」的困惑
|
||||||
|
export const ContextChip = memo(function ContextChip({
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
onExpand,
|
||||||
|
expandLabel,
|
||||||
|
onExit,
|
||||||
|
exitLabel,
|
||||||
|
}: ContextChipProps) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<ListVideo size={14} className="shrink-0 text-rose-300" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-white/60">
|
||||||
|
{position}/{total}
|
||||||
|
</span>
|
||||||
|
{onExpand && <ChevronUp size={14} className="shrink-0 text-white/70" />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute left-1/2 top-14 z-40 flex max-w-[calc(100%-1.5rem)] sm:top-3 sm:max-w-[calc(100%-14rem)] -translate-x-1/2 items-center rounded-full border border-white/20 bg-black/60 text-xs font-medium text-white shadow-lg backdrop-blur">
|
||||||
|
{onExpand ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExpand}
|
||||||
|
className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1 cursor-pointer"
|
||||||
|
aria-label={expandLabel}
|
||||||
|
title={expandLabel}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 py-1.5 pl-3 pr-1">
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExit}
|
||||||
|
className="mr-1 grid h-6 w-6 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/15 hover:text-white cursor-pointer"
|
||||||
|
aria-label={exitLabel}
|
||||||
|
title={exitLabel}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { Hash, Search } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { memo } from "react";
|
||||||
|
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||||
|
import type { PlaybackContextInfo } from "../types.ts";
|
||||||
|
import { WorksGrid } from "./WorksGrid";
|
||||||
|
|
||||||
|
interface ContextListTabProps {
|
||||||
|
active: boolean;
|
||||||
|
context: PlaybackContextInfo;
|
||||||
|
list: PlaylistState;
|
||||||
|
currentId: string;
|
||||||
|
contextQuery: string;
|
||||||
|
/** 对应的搜索结果页链接(搜索上下文才有) */
|
||||||
|
searchPageHref: string | null;
|
||||||
|
onSelect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏的「搜索结果 / #hashtag / 标签」列表:和上下切换的顺序完全一致
|
||||||
|
export const ContextListTab = memo(function ContextListTab({
|
||||||
|
active,
|
||||||
|
context,
|
||||||
|
list,
|
||||||
|
currentId,
|
||||||
|
contextQuery,
|
||||||
|
searchPageHref,
|
||||||
|
onSelect,
|
||||||
|
}: ContextListTabProps) {
|
||||||
|
const Icon = context.kind === "hashtag" ? Hash : Search;
|
||||||
|
const header = (
|
||||||
|
<>
|
||||||
|
<Icon size={18} className="shrink-0 text-rose-300" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-semibold text-white">{context.label}</div>
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
|
第 {context.position} 个 · 共 {context.total} 个
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{searchPageHref && (
|
||||||
|
<Link
|
||||||
|
href={searchPageHref}
|
||||||
|
className="shrink-0 text-xs text-white/50 hover:text-white hover:underline"
|
||||||
|
>
|
||||||
|
在搜索页查看
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorksGrid
|
||||||
|
active={active}
|
||||||
|
list={list}
|
||||||
|
currentId={currentId}
|
||||||
|
hrefQuery={contextQuery}
|
||||||
|
onSelect={onSelect}
|
||||||
|
header={header}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
VolumeX,
|
VolumeX,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
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 type { AbLoop, LoopMode, ObjectFit, User } from "../types.ts";
|
||||||
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
|
import { formatAbsoluteUTC, formatRelativeTime, formatTime } from "../utils";
|
||||||
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
import { MoreMenu, MoreMenuItem } from "./MoreMenu";
|
||||||
@@ -59,6 +60,10 @@ interface MediaControlsProps {
|
|||||||
onLoopModeChange: (mode: LoopMode) => void;
|
onLoopModeChange: (mode: LoopMode) => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
|
/** 点作者头像/名字:在侧栏打开「TA的作品」 */
|
||||||
|
onAuthorClick?: () => void;
|
||||||
|
/** 点描述里的 hashtag:切到该 hashtag 的作品列表 */
|
||||||
|
onHashtagClick?: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTROL_BTN_CLASS =
|
const CONTROL_BTN_CLASS =
|
||||||
@@ -96,7 +101,10 @@ export const MediaControls = memo(function MediaControls({
|
|||||||
onLoopModeChange,
|
onLoopModeChange,
|
||||||
onDownload,
|
onDownload,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
|
onAuthorClick,
|
||||||
|
onHashtagClick,
|
||||||
}: MediaControlsProps) {
|
}: MediaControlsProps) {
|
||||||
|
const descParts = useMemo(() => parseDescHashtags(desc || ""), [desc]);
|
||||||
const normalizedLoopMode =
|
const normalizedLoopMode =
|
||||||
isVideo && loopMode === "single" ? "loop" : loopMode;
|
isVideo && loopMode === "single" ? "loop" : loopMode;
|
||||||
const loopLabel = isVideo
|
const loopLabel = isVideo
|
||||||
@@ -129,10 +137,29 @@ export const MediaControls = memo(function MediaControls({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
<Link
|
||||||
href={`/author/${author.sec_uid}`}
|
href={`/author/${author.sec_uid}`}
|
||||||
className="flex items-center gap-2.5 pointer-events-auto hover:opacity-80 transition-opacity"
|
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 ? (
|
{desc ? (
|
||||||
<div className="pointer-events-none">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { memo, type ReactNode } from "react";
|
||||||
|
import type { SidePanelTab } from "../hooks/useSidePanelState";
|
||||||
|
|
||||||
|
export type SidePanelTabConfig = {
|
||||||
|
key: SidePanelTab;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
content: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SidePanelProps {
|
||||||
|
open: boolean;
|
||||||
|
mounted: boolean;
|
||||||
|
tab: SidePanelTab;
|
||||||
|
tabs: SidePanelTabConfig[];
|
||||||
|
onTabChange: (tab: SidePanelTab) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧(竖屏为底部抽屉)的侧栏外壳:评论 / TA的作品 / 搜索结果(或 hashtag)几个标签页
|
||||||
|
export const SidePanel = memo(function SidePanel({
|
||||||
|
open,
|
||||||
|
mounted,
|
||||||
|
tab,
|
||||||
|
tabs,
|
||||||
|
onTabChange,
|
||||||
|
onClose,
|
||||||
|
}: SidePanelProps) {
|
||||||
|
const visibility = open
|
||||||
|
? "translate-y-0 pointer-events-auto landscape:w-[min(420px,36vw)] landscape:border-l landscape:border-white/10"
|
||||||
|
: "translate-y-full pointer-events-none landscape:w-0 landscape:border-l-0";
|
||||||
|
// 竖屏时作品网格不需要那么高,给视频多留点位置
|
||||||
|
const portraitHeight =
|
||||||
|
tab === "comments" ? "h-[min(80vh,88dvh)]" : "h-[min(62vh,64dvh)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
z-30 flex flex-col bg-[rgba(22,22,22,0.92)] text-white overflow-hidden
|
||||||
|
fixed inset-x-0 bottom-0 w-full ${portraitHeight} border-t border-white/10 rounded-t-2xl
|
||||||
|
landscape:relative landscape:inset-auto landscape:h-full landscape:translate-y-0 landscape:border-t-0 landscape:rounded-none
|
||||||
|
${mounted ? "transition-transform landscape:transition-[width] duration-200 ease-out" : ""}
|
||||||
|
${visibility}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 px-3 border-b border-white/10 shrink-0">
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 flex-1 items-center gap-5 overflow-x-auto"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.key}
|
||||||
|
onClick={() => onTabChange(t.key)}
|
||||||
|
className={`relative shrink-0 max-w-[10em] truncate py-3 text-[15px] transition-colors cursor-pointer ${
|
||||||
|
tab === t.key
|
||||||
|
? "font-semibold text-white"
|
||||||
|
: "text-white/55 hover:text-white/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{t.count > 0 && (
|
||||||
|
<span className="ml-1 text-xs font-normal text-white/50">
|
||||||
|
{t.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tab === t.key && (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-rose-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full bg-white/15 text-white/90 border border-white/20 hover:bg-white/25 transition-colors cursor-pointer"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭侧栏"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.key}
|
||||||
|
className={tab === t.key ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||||
|
>
|
||||||
|
{t.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -66,7 +66,10 @@ export const VideoTagEditor = memo(function VideoTagEditor({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2">
|
<div
|
||||||
|
data-no-swipe
|
||||||
|
className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((value) => !value)}
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Heart, Images } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { memo, type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { awemeHref } from "@/lib/playback-context";
|
||||||
|
import type { PlaylistState } from "../hooks/usePlaylist";
|
||||||
|
|
||||||
|
interface WorksGridProps {
|
||||||
|
/** 所在标签页当前可见 */
|
||||||
|
active: boolean;
|
||||||
|
list: PlaylistState;
|
||||||
|
currentId: string;
|
||||||
|
/** 点击作品跳转时带上的播放上下文查询串 */
|
||||||
|
hrefQuery: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
/** 列表只有当前这一个作品时的提示 */
|
||||||
|
singleHint?: string;
|
||||||
|
/** 网格上方的标题栏 */
|
||||||
|
header: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(n: number): string {
|
||||||
|
if (n >= 10000) return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}万`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayingBars() {
|
||||||
|
return (
|
||||||
|
<span className="flex h-4 items-end gap-0.5" aria-hidden>
|
||||||
|
{[60, 100, 40].map((height, i) => (
|
||||||
|
<span
|
||||||
|
key={height}
|
||||||
|
className="w-1 rounded-sm bg-white animate-pulse"
|
||||||
|
style={{ height: `${height}%`, animationDelay: `${i * 150}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 侧栏里的作品封面网格(TA的作品 / 搜索结果 / hashtag 共用):当前作品标「播放中」,点作品就地切换
|
||||||
|
export const WorksGrid = memo(function WorksGrid({
|
||||||
|
active,
|
||||||
|
list,
|
||||||
|
currentId,
|
||||||
|
hrefQuery,
|
||||||
|
onSelect,
|
||||||
|
singleHint,
|
||||||
|
header,
|
||||||
|
}: WorksGridProps) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const currentRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
const items = list.items ?? [];
|
||||||
|
|
||||||
|
// 让「播放中」的作品保持在可见区域(切换作品或列表加载完成时重新定位)
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: currentId / items.length 只用来触发重新定位
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const container = scrollRef.current;
|
||||||
|
const el = currentRef.current;
|
||||||
|
if (!container || !el) return;
|
||||||
|
const top = el.offsetTop - container.offsetTop;
|
||||||
|
const bottom = top + el.offsetHeight;
|
||||||
|
if (
|
||||||
|
top < container.scrollTop ||
|
||||||
|
bottom > container.scrollTop + container.clientHeight
|
||||||
|
) {
|
||||||
|
container.scrollTop =
|
||||||
|
top - (container.clientHeight - el.offsetHeight) / 2;
|
||||||
|
}
|
||||||
|
}, [active, currentId, items.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-3 border-b border-white/10 shrink-0">
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={scrollRef} className="comment-scroll flex-1 overflow-auto p-2">
|
||||||
|
{list.loading && !list.items && (
|
||||||
|
<div className="py-10 text-center text-sm text-white/60">
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.error && !list.items && (
|
||||||
|
<div className="py-10 text-center text-sm text-red-300/80">
|
||||||
|
{list.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isCurrent = item.aweme_id === currentId;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.aweme_id}
|
||||||
|
ref={isCurrent ? currentRef : undefined}
|
||||||
|
href={awemeHref(item.aweme_id, hrefQuery)}
|
||||||
|
scroll={false}
|
||||||
|
onClick={onSelect}
|
||||||
|
title={item.desc}
|
||||||
|
aria-current={isCurrent ? "true" : undefined}
|
||||||
|
className={`relative block aspect-[3/4] overflow-hidden rounded-md bg-white/5 ${
|
||||||
|
isCurrent ? "ring-2 ring-rose-400" : "hover:opacity-90"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.cover_url ? (
|
||||||
|
// biome-ignore lint/performance/noImgElement: 封面来自对象存储,不走 next/image
|
||||||
|
<img
|
||||||
|
src={item.cover_url}
|
||||||
|
alt={item.desc?.slice(0, 20) || ""}
|
||||||
|
loading="lazy"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||||
|
{item.type === "image" && (
|
||||||
|
<span className="absolute right-1 top-1 inline-flex items-center gap-0.5 rounded bg-black/55 px-1 py-0.5 text-[10px] text-white/90">
|
||||||
|
<Images size={10} />
|
||||||
|
图文
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCurrent ? (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/55 text-xs text-white">
|
||||||
|
<PlayingBars />
|
||||||
|
播放中
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="absolute bottom-1 left-1 inline-flex items-center gap-0.5 text-[11px] text-white/90 drop-shadow">
|
||||||
|
<Heart size={11} />
|
||||||
|
{formatCount(item.likes)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{list.items && items.length <= 1 && singleHint && (
|
||||||
|
<div className="py-6 text-center text-xs text-white/40">
|
||||||
|
{singleHint}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.items && list.total > items.length && (
|
||||||
|
<div className="py-6 text-center text-xs text-white/40">
|
||||||
|
只列出前 {items.length} 个,共 {list.total} 个
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Hooks 导出
|
// Hooks 导出
|
||||||
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
export { useBackgroundCanvas } from "./useBackgroundCanvas";
|
||||||
export { useCommentState } from "./useCommentState";
|
export { useSidePanelState } from "./useSidePanelState";
|
||||||
export { useImageCarousel } from "./useImageCarousel";
|
export { useImageCarousel } from "./useImageCarousel";
|
||||||
export { useNavigation } from "./useNavigation";
|
export { useNavigation } from "./useNavigation";
|
||||||
export { usePlayerState } from "./usePlayerState";
|
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 (fromIndex >= currentImages.length - 1) {
|
||||||
if (loopMode === "sequential" && neighbors?.next) {
|
if (loopMode === "sequential" && neighbors?.next) {
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
idxRef.current = 0;
|
idxRef.current = 0;
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function useNavigation({
|
|||||||
// 预取路由
|
// 预取路由
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!neighbors) return;
|
if (!neighbors) return;
|
||||||
if (neighbors.next) router.prefetch(`/aweme/${neighbors.next.aweme_id}`);
|
if (neighbors.next) router.prefetch(neighbors.next.href);
|
||||||
if (neighbors.prev) router.prefetch(`/aweme/${neighbors.prev.aweme_id}`);
|
if (neighbors.prev) router.prefetch(neighbors.prev.href);
|
||||||
}, [neighbors, router]);
|
}, [neighbors, router]);
|
||||||
|
|
||||||
// 鼠标滚轮切换
|
// 鼠标滚轮切换
|
||||||
@@ -72,10 +72,10 @@ export function useNavigation({
|
|||||||
|
|
||||||
if (dy > 0 && neighbors?.next) {
|
if (dy > 0 && neighbors?.next) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
} else if (dy < 0 && neighbors?.prev) {
|
} else if (dy < 0 && neighbors?.prev) {
|
||||||
wheelCooldownRef.current = now;
|
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);
|
return () => el.removeEventListener("wheel", onWheel);
|
||||||
}, [neighbors, mediaContainerRef, router]);
|
}, [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 循环)
|
// 键盘快捷键(支持向右箭头 / D 键区分短按与长按 2 倍速,以及视频 [ ] 设置 AB 循环)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const clearLongPressTimer = (): void => {
|
const clearLongPressTimer = (): void => {
|
||||||
@@ -124,7 +208,7 @@ export function useNavigation({
|
|||||||
if (now - wheelCooldownRef.current < 700) return;
|
if (now - wheelCooldownRef.current < 700) return;
|
||||||
if (neighbors?.prev) {
|
if (neighbors?.prev) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.prev.aweme_id}`);
|
router.push(neighbors.prev.href);
|
||||||
}
|
}
|
||||||
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
|
} else if (key === "arrowdown" || key === "s" || code === "KeyS") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -132,7 +216,7 @@ export function useNavigation({
|
|||||||
if (now - wheelCooldownRef.current < 700) return;
|
if (now - wheelCooldownRef.current < 700) return;
|
||||||
if (neighbors?.next) {
|
if (neighbors?.next) {
|
||||||
wheelCooldownRef.current = now;
|
wheelCooldownRef.current = now;
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
}
|
}
|
||||||
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
} else if (key === "arrowleft" || key === "a" || code === "KeyA") {
|
||||||
e.preventDefault();
|
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) {
|
if (loopMode === "sequential" && neighbors?.next) {
|
||||||
router.push(`/aweme/${neighbors.next.aweme_id}`);
|
router.push(neighbors.next.href);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { parsePlaybackContext } from "@/lib/playback-context";
|
||||||
|
import { resolvePlayback } from "@/lib/playback-context.server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import AwemeDetailClient from "./Client";
|
import AwemeDetailClient from "./Client";
|
||||||
import type { AwemeData, VideoTranscript } from "./types";
|
import type { AwemeData, VideoTranscript } from "./types";
|
||||||
@@ -45,8 +47,10 @@ export async function generateMetadata({
|
|||||||
|
|
||||||
export default async function AwemeDetail({
|
export default async function AwemeDetail({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ awemeId: string }>;
|
params: Promise<{ awemeId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const id = (await params).awemeId;
|
const id = (await params).awemeId;
|
||||||
|
|
||||||
@@ -71,12 +75,10 @@ export default async function AwemeDetail({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let data: AwemeData;
|
let data: AwemeData;
|
||||||
let currentCreatedAt: Date;
|
|
||||||
let transcript: VideoTranscript | null = null;
|
let transcript: VideoTranscript | null = null;
|
||||||
|
|
||||||
if (video) {
|
if (video) {
|
||||||
transcript = video.transcript ?? null;
|
transcript = video.transcript ?? null;
|
||||||
currentCreatedAt = video.created_at;
|
|
||||||
data = {
|
data = {
|
||||||
type: "video",
|
type: "video",
|
||||||
aweme_id: video.aweme_id,
|
aweme_id: video.aweme_id,
|
||||||
@@ -100,7 +102,6 @@ export default async function AwemeDetail({
|
|||||||
};
|
};
|
||||||
} else if (post) {
|
} else if (post) {
|
||||||
const coverImage = post.images[0];
|
const coverImage = post.images[0];
|
||||||
currentCreatedAt = post.created_at;
|
|
||||||
data = {
|
data = {
|
||||||
type: "image",
|
type: "image",
|
||||||
aweme_id: post.aweme_id,
|
aweme_id: post.aweme_id,
|
||||||
@@ -129,65 +130,16 @@ export default async function AwemeDetail({
|
|||||||
return <main className="p-8">找不到该作品</main>;
|
return <main className="p-8">找不到该作品</main>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute prev/next neighbors by created_at across videos and image posts
|
const authorId = video?.authorId ?? post?.authorId ?? null;
|
||||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
const [playback, authorWorksCount] = await Promise.all([
|
||||||
prisma.video.findFirst({
|
resolvePlayback(id, parsePlaybackContext(await searchParams)),
|
||||||
where: { created_at: { gt: currentCreatedAt } },
|
authorId
|
||||||
orderBy: { created_at: "asc" },
|
? Promise.all([
|
||||||
select: { aweme_id: true, created_at: true },
|
prisma.video.count({ where: { authorId } }),
|
||||||
}),
|
prisma.imagePost.count({ where: { authorId } }),
|
||||||
prisma.imagePost.findFirst({
|
]).then(([videos, posts]) => videos + posts)
|
||||||
where: { created_at: { gt: currentCreatedAt } },
|
: 0,
|
||||||
orderBy: { created_at: "asc" },
|
|
||||||
select: { aweme_id: true, created_at: true },
|
|
||||||
}),
|
|
||||||
prisma.video.findFirst({
|
|
||||||
where: { created_at: { lt: currentCreatedAt } },
|
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
select: { aweme_id: true, created_at: true },
|
|
||||||
}),
|
|
||||||
prisma.imagePost.findFirst({
|
|
||||||
where: { created_at: { lt: currentCreatedAt } },
|
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
select: { aweme_id: true, created_at: true },
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
const pickPrev = (() => {
|
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
|
||||||
if (newerVideo)
|
|
||||||
cands.push({
|
|
||||||
aweme_id: newerVideo.aweme_id,
|
|
||||||
created_at: newerVideo.created_at,
|
|
||||||
});
|
|
||||||
if (newerPost)
|
|
||||||
cands.push({
|
|
||||||
aweme_id: newerPost.aweme_id,
|
|
||||||
created_at: newerPost.created_at,
|
|
||||||
});
|
|
||||||
if (cands.length === 0) return null;
|
|
||||||
cands.sort((a, b) => +a.created_at - +b.created_at);
|
|
||||||
return { aweme_id: cands[0].aweme_id };
|
|
||||||
})();
|
|
||||||
const pickNext = (() => {
|
|
||||||
const cands: { aweme_id: string; created_at: Date }[] = [];
|
|
||||||
if (olderVideo)
|
|
||||||
cands.push({
|
|
||||||
aweme_id: olderVideo.aweme_id,
|
|
||||||
created_at: olderVideo.created_at,
|
|
||||||
});
|
|
||||||
if (olderPost)
|
|
||||||
cands.push({
|
|
||||||
aweme_id: olderPost.aweme_id,
|
|
||||||
created_at: olderPost.created_at,
|
|
||||||
});
|
|
||||||
if (cands.length === 0) return null;
|
|
||||||
cands.sort((a, b) => +b.created_at - +a.created_at);
|
|
||||||
return { aweme_id: cands[0].aweme_id };
|
|
||||||
})();
|
|
||||||
const neighbors: {
|
|
||||||
prev: { aweme_id: string } | null;
|
|
||||||
next: { aweme_id: string } | null;
|
|
||||||
} = { prev: pickPrev, next: pickNext };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen w-full">
|
<main className="min-h-screen w-full">
|
||||||
@@ -200,7 +152,10 @@ export default async function AwemeDetail({
|
|||||||
</div>
|
</div>
|
||||||
<AwemeDetailClient
|
<AwemeDetailClient
|
||||||
data={data}
|
data={data}
|
||||||
neighbors={neighbors}
|
neighbors={playback.neighbors}
|
||||||
|
playbackContext={playback.context}
|
||||||
|
contextQuery={playback.contextQuery}
|
||||||
|
authorWorksCount={authorWorksCount}
|
||||||
transcript={transcript}
|
transcript={transcript}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -58,9 +58,22 @@ export type VideoTranscript = {
|
|||||||
transcript: string[];
|
transcript: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NeighborLink = { aweme_id: string; href: string };
|
||||||
|
|
||||||
export type Neighbors = {
|
export type Neighbors = {
|
||||||
prev: { aweme_id: string } | null;
|
prev: NeighborLink | null;
|
||||||
next: { aweme_id: string } | null;
|
next: NeighborLink | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaybackContextInfo = {
|
||||||
|
kind: "search" | "tag" | "hashtag" | "author";
|
||||||
|
/** 顶部提示里的说明,如「搜索「百姓」」 */
|
||||||
|
label: string;
|
||||||
|
/** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */
|
||||||
|
listTitle: string;
|
||||||
|
/** 从 1 开始 */
|
||||||
|
position: number;
|
||||||
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LoopMode = "loop" | "single" | "sequential";
|
export type LoopMode = "loop" | "single" | "sequential";
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type Props = {
|
|||||||
initialItems: FeedItem[];
|
initialItems: FeedItem[];
|
||||||
initialCursor: string | null;
|
initialCursor: string | null;
|
||||||
fetchUrl?: string;
|
fetchUrl?: string;
|
||||||
|
/** 附加在作品链接上的播放上下文查询串(含 ?),决定播放页上下切换的列表 */
|
||||||
|
linkQuery?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TagUpdateResponse = {
|
type TagUpdateResponse = {
|
||||||
@@ -30,6 +32,7 @@ export default function FeedMasonry({
|
|||||||
initialItems,
|
initialItems,
|
||||||
initialCursor,
|
initialCursor,
|
||||||
fetchUrl = "/api/feed",
|
fetchUrl = "/api/feed",
|
||||||
|
linkQuery = "",
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// 哨兵与容器
|
// 哨兵与容器
|
||||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||||
@@ -264,7 +267,7 @@ export default function FeedMasonry({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/aweme/${item.aweme_id}`}
|
href={`/aweme/${item.aweme_id}${linkQuery}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="block relative w-full"
|
className="block relative w-full"
|
||||||
>
|
>
|
||||||
|
|||||||
+5
-1
@@ -1,8 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { playbackContextQuery } from "@/lib/playback-context";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
import FeedMasonry from "./components/FeedMasonry";
|
import FeedMasonry from "./components/FeedMasonry";
|
||||||
import SearchBox from "./components/SearchBox";
|
import SearchBox from "./components/SearchBox";
|
||||||
import type { FeedItem } from "./types/feed";
|
import type { FeedItem } from "./types/feed";
|
||||||
@@ -142,6 +143,9 @@ export default async function Home({
|
|||||||
key={selectedTag ?? "all"}
|
key={selectedTag ?? "all"}
|
||||||
initialItems={initial}
|
initialItems={initial}
|
||||||
initialCursor={cursor}
|
initialCursor={cursor}
|
||||||
|
linkQuery={playbackContextQuery(
|
||||||
|
selectedTag ? { kind: "tag", tag: selectedTag } : null,
|
||||||
|
)}
|
||||||
fetchUrl={
|
fetchUrl={
|
||||||
selectedTag
|
selectedTag
|
||||||
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
|
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
type FormEvent,
|
type FormEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { playbackContextQuery } from "@/lib/playback-context";
|
||||||
import type { SnippetPart } from "@/lib/search";
|
import type { SnippetPart } from "@/lib/search";
|
||||||
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
import { isVideoContentTag, VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
|
|
||||||
@@ -209,6 +211,15 @@ export default function SearchClient({
|
|||||||
router.push("/search");
|
router.push("/search");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 从搜索结果进入播放页时,上下切换在这次的搜索结果里移动(与当前 URL 上的搜索条件一致)
|
||||||
|
const resultLinkQuery = useMemo(() => {
|
||||||
|
const q = (searchParams.get("q") || "").trim();
|
||||||
|
const tagParam = searchParams.get("tag") || "";
|
||||||
|
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||||
|
if (!q && !tag) return "";
|
||||||
|
return playbackContextQuery({ kind: "search", q, tag });
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<div className="min-h-screen bg-zinc-950 text-white">
|
||||||
{/* 顶部搜索栏 */}
|
{/* 顶部搜索栏 */}
|
||||||
@@ -333,7 +344,7 @@ export default function SearchClient({
|
|||||||
<div className="flex gap-4 p-4">
|
<div className="flex gap-4 p-4">
|
||||||
{/* 左侧:封面预览 */}
|
{/* 左侧:封面预览 */}
|
||||||
<Link
|
<Link
|
||||||
href={`/aweme/${content.aweme_id}`}
|
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
||||||
>
|
>
|
||||||
@@ -388,7 +399,7 @@ export default function SearchClient({
|
|||||||
|
|
||||||
{/* 描述 */}
|
{/* 描述 */}
|
||||||
<Link
|
<Link
|
||||||
href={`/aweme/${content.aweme_id}`}
|
href={`/aweme/${content.aweme_id}${resultLinkQuery}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<p className="text-white/90 text-sm sm:text-base line-clamp-3 mb-3 hover:text-white transition-colors">
|
<p className="text-white/90 text-sm sm:text-base line-clamp-3 mb-3 hover:text-white transition-colors">
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// 从作品描述里解析抖音 hashtag(#xxx)。
|
||||||
|
// 前端渲染可点击的 hashtag 与后端按 hashtag 精确筛选作品共用这里的边界规则,
|
||||||
|
// 保证「点了 #丰川祥子 看到的列表」不会混进 #丰川祥子cos。
|
||||||
|
|
||||||
|
// hashtag 在遇到空白、下一个 #、或这些标点时结束
|
||||||
|
const STOP_CHARS =
|
||||||
|
"#,。!?、;:,.!?;:()()【】\\[\\]《》<>\"'“”‘’…~~|/\\\\@";
|
||||||
|
const MAX_HASHTAG_LENGTH = 50;
|
||||||
|
|
||||||
|
const HASHTAG_RE = new RegExp(`#([^\\s${STOP_CHARS}]+)`, "gu");
|
||||||
|
|
||||||
|
export type DescPart =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "hashtag"; text: string; name: string };
|
||||||
|
|
||||||
|
/** 把描述切成普通文字与 hashtag 片段。 */
|
||||||
|
export function parseDescHashtags(desc: string): DescPart[] {
|
||||||
|
const parts: DescPart[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
for (const m of desc.matchAll(HASHTAG_RE)) {
|
||||||
|
const start = m.index ?? 0;
|
||||||
|
if (start > cursor) {
|
||||||
|
parts.push({ type: "text", text: desc.slice(cursor, start) });
|
||||||
|
}
|
||||||
|
parts.push({ type: "hashtag", text: m[0], name: m[1] });
|
||||||
|
cursor = start + m[0].length;
|
||||||
|
}
|
||||||
|
if (cursor < desc.length) {
|
||||||
|
parts.push({ type: "text", text: desc.slice(cursor) });
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化 hashtag 名(去掉开头的 #、首尾空白,限制长度);不合法时返回 null。 */
|
||||||
|
export function normalizeHashtag(raw: string): string | null {
|
||||||
|
const name = raw.trim().replace(/^#+/, "").slice(0, MAX_HASHTAG_LENGTH);
|
||||||
|
if (!name) return null;
|
||||||
|
if (new RegExp(`[\\s${STOP_CHARS}]`, "u").test(name)) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配 lower() 后描述文本的 Postgres 正则(ARE):#名字 之后必须是结束符或文本末尾。
|
||||||
|
* 名字里的正则特殊字符会被转义。
|
||||||
|
*/
|
||||||
|
export function hashtagToPgPattern(name: string): string {
|
||||||
|
const escaped = name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
return `#${escaped}([\\s${STOP_CHARS}]|$)`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { hashtagToPgPattern } from "@/lib/hashtag";
|
||||||
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import {
|
||||||
|
awemeHref,
|
||||||
|
type PlaybackContext,
|
||||||
|
type PlaylistItem,
|
||||||
|
playbackContextQuery,
|
||||||
|
} from "@/lib/playback-context";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { parseSearchQuery } from "@/lib/search";
|
||||||
|
import { searchIds } from "@/lib/search-query";
|
||||||
|
|
||||||
|
export type NeighborLink = { aweme_id: string; href: string };
|
||||||
|
|
||||||
|
export type PlaybackNeighbors = {
|
||||||
|
prev: NeighborLink | null;
|
||||||
|
next: NeighborLink | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaybackContextInfo = {
|
||||||
|
kind: PlaybackContext["kind"];
|
||||||
|
/** 顶部提示里的说明,如「搜索「百姓」」 */
|
||||||
|
label: string;
|
||||||
|
/** 侧栏列表标签页的标题,如「搜索结果」「#丰川祥子」 */
|
||||||
|
listTitle: string;
|
||||||
|
/** 从 1 开始 */
|
||||||
|
position: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedPlayback = {
|
||||||
|
neighbors: PlaybackNeighbors;
|
||||||
|
/** 当前生效的上下文(URL 里的上下文不包含当前作品时为 null,退回全部作品) */
|
||||||
|
context: PlaybackContextInfo | null;
|
||||||
|
/** URL 里原本的上下文查询串,侧栏里跳转时原样带上 */
|
||||||
|
contextQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 侧栏一次最多列出的作品数(搜索词很宽泛时避免一次塞几千张封面) */
|
||||||
|
export const PLAYLIST_LIMIT = 500;
|
||||||
|
|
||||||
|
// 全部作品 / 某作者的作品 / 带某个 hashtag 的作品:视频与图文合并,按发布时间倒序(与首页、作者页一致)
|
||||||
|
async function timelineIds(filter: Prisma.Sql = Prisma.empty) {
|
||||||
|
const rows = await prisma.$queryRaw<{ id: string }[]>`
|
||||||
|
SELECT aweme_id AS id FROM (
|
||||||
|
SELECT aweme_id, created_at, "authorId", "desc" FROM "Video"
|
||||||
|
UNION ALL
|
||||||
|
SELECT aweme_id, created_at, "authorId", "desc" FROM "ImagePost"
|
||||||
|
) works
|
||||||
|
${filter}
|
||||||
|
ORDER BY created_at DESC, aweme_id DESC
|
||||||
|
`;
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据量小(全库约两千个作品),直接取完整的有序 ID 列表再定位当前作品,
|
||||||
|
// 比为每种上下文分别写游标查询简单得多。数据量大了再换成游标查询,前端不用动。
|
||||||
|
export async function contextIds(
|
||||||
|
ctx: PlaybackContext | null,
|
||||||
|
): Promise<string[]> {
|
||||||
|
if (!ctx) return timelineIds();
|
||||||
|
switch (ctx.kind) {
|
||||||
|
case "author":
|
||||||
|
return timelineIds(Prisma.sql`WHERE "authorId" = ${ctx.secUid}`);
|
||||||
|
case "hashtag":
|
||||||
|
return timelineIds(
|
||||||
|
Prisma.sql`WHERE lower("desc") ~ ${hashtagToPgPattern(ctx.name)}`,
|
||||||
|
);
|
||||||
|
case "tag":
|
||||||
|
return searchIds({ terms: [], excludes: [], contentTag: ctx.tag });
|
||||||
|
case "search": {
|
||||||
|
const { terms, excludes } = parseSearchQuery(ctx.q);
|
||||||
|
if (terms.length === 0 && !ctx.tag) return [];
|
||||||
|
return searchIds({ terms, excludes, contentTag: ctx.tag });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contextLabels(
|
||||||
|
ctx: PlaybackContext,
|
||||||
|
): Promise<{ label: string; listTitle: string }> {
|
||||||
|
switch (ctx.kind) {
|
||||||
|
case "search":
|
||||||
|
if (!ctx.q) return { label: `标签「${ctx.tag}」`, listTitle: "搜索结果" };
|
||||||
|
return {
|
||||||
|
label: ctx.tag ? `搜索「${ctx.q}」· ${ctx.tag}` : `搜索「${ctx.q}」`,
|
||||||
|
listTitle: "搜索结果",
|
||||||
|
};
|
||||||
|
case "tag":
|
||||||
|
return { label: `标签「${ctx.tag}」`, listTitle: ctx.tag };
|
||||||
|
case "hashtag":
|
||||||
|
return { label: `#${ctx.name}`, listTitle: `#${ctx.name}` };
|
||||||
|
case "author": {
|
||||||
|
const author = await prisma.author.findUnique({
|
||||||
|
where: { sec_uid: ctx.secUid },
|
||||||
|
select: { nickname: true },
|
||||||
|
});
|
||||||
|
const label = `${author?.nickname ?? "作者"} 的作品`;
|
||||||
|
return { label, listTitle: label };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function neighborsAt(
|
||||||
|
ids: string[],
|
||||||
|
index: number,
|
||||||
|
query: string,
|
||||||
|
): PlaybackNeighbors {
|
||||||
|
const link = (id: string | undefined) =>
|
||||||
|
id ? { aweme_id: id, href: awemeHref(id, query) } : null;
|
||||||
|
return { prev: link(ids[index - 1]), next: link(ids[index + 1]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePlayback(
|
||||||
|
awemeId: string,
|
||||||
|
ctx: PlaybackContext | null,
|
||||||
|
): Promise<ResolvedPlayback> {
|
||||||
|
const contextQuery = playbackContextQuery(ctx);
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
const [ids, labels] = await Promise.all([
|
||||||
|
contextIds(ctx),
|
||||||
|
contextLabels(ctx),
|
||||||
|
]);
|
||||||
|
const index = ids.indexOf(awemeId);
|
||||||
|
if (index !== -1) {
|
||||||
|
return {
|
||||||
|
neighbors: neighborsAt(ids, index, contextQuery),
|
||||||
|
context: {
|
||||||
|
kind: ctx.kind,
|
||||||
|
...labels,
|
||||||
|
position: index + 1,
|
||||||
|
total: ids.length,
|
||||||
|
},
|
||||||
|
contextQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 当前作品不在这个列表里(例如在作者模式里切到了不在搜索结果中的作品),
|
||||||
|
// 退回全部作品的顺序;继续往下切时不再带上原来的上下文。
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = await timelineIds();
|
||||||
|
const index = ids.indexOf(awemeId);
|
||||||
|
return {
|
||||||
|
neighbors:
|
||||||
|
index === -1 ? { prev: null, next: null } : neighborsAt(ids, index, ""),
|
||||||
|
context: null,
|
||||||
|
contextQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按上下文顺序列出作品(供侧栏网格使用),最多 PLAYLIST_LIMIT 个。 */
|
||||||
|
export async function contextItems(
|
||||||
|
ctx: PlaybackContext,
|
||||||
|
): Promise<{ items: PlaylistItem[]; total: number }> {
|
||||||
|
const allIds = await contextIds(ctx);
|
||||||
|
const ids = allIds.slice(0, PLAYLIST_LIMIT);
|
||||||
|
const [videos, posts] = await Promise.all([
|
||||||
|
prisma.video.findMany({
|
||||||
|
where: { aweme_id: { in: ids } },
|
||||||
|
select: { aweme_id: true, desc: true, cover_url: true, digg_count: true },
|
||||||
|
}),
|
||||||
|
prisma.imagePost.findMany({
|
||||||
|
where: { aweme_id: { in: ids } },
|
||||||
|
select: {
|
||||||
|
aweme_id: true,
|
||||||
|
desc: true,
|
||||||
|
digg_count: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1, select: { url: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const byId = new Map<string, PlaylistItem>();
|
||||||
|
for (const v of videos) {
|
||||||
|
byId.set(v.aweme_id, {
|
||||||
|
aweme_id: v.aweme_id,
|
||||||
|
type: "video",
|
||||||
|
desc: v.desc,
|
||||||
|
cover_url: v.cover_url ? getFileUrl(v.cover_url) : null,
|
||||||
|
likes: Number(v.digg_count),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const p of posts) {
|
||||||
|
byId.set(p.aweme_id, {
|
||||||
|
aweme_id: p.aweme_id,
|
||||||
|
type: "image",
|
||||||
|
desc: p.desc,
|
||||||
|
cover_url: p.images[0] ? getFileUrl(p.images[0].url) : null,
|
||||||
|
likes: Number(p.digg_count),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = ids
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter((item): item is PlaylistItem => Boolean(item));
|
||||||
|
return { items, total: allIds.length };
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// 搜索的 SQL 部分:搜索接口(分页取结果)和播放页(取完整的有序 ID 列表算上一条/下一条)
|
||||||
|
// 共用同一段匹配与排序逻辑,保证两边顺序完全一致。
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { termToPgPattern } from "@/lib/search";
|
||||||
|
|
||||||
|
export type SearchInput = {
|
||||||
|
/** 已转小写的关键词(parseSearchQuery 的结果) */
|
||||||
|
terms: string[];
|
||||||
|
excludes: string[];
|
||||||
|
contentTag: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchRow = {
|
||||||
|
awemeId: string;
|
||||||
|
type: "video" | "image";
|
||||||
|
score: number;
|
||||||
|
inDesc: boolean;
|
||||||
|
inTranscript: boolean;
|
||||||
|
rawDesc: string;
|
||||||
|
rawTranscript: string;
|
||||||
|
contentTags: string[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写),
|
||||||
|
// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。
|
||||||
|
// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。
|
||||||
|
// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。
|
||||||
|
function matchedCte({ terms, excludes, contentTag }: SearchInput) {
|
||||||
|
const patterns = terms.map(termToPgPattern);
|
||||||
|
const excludePatterns = excludes.map(termToPgPattern);
|
||||||
|
return Prisma.sql`
|
||||||
|
WITH params AS (
|
||||||
|
SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes
|
||||||
|
),
|
||||||
|
docs AS (
|
||||||
|
SELECT
|
||||||
|
v.aweme_id AS "awemeId",
|
||||||
|
'video'::text AS type,
|
||||||
|
v."desc" AS "rawDesc",
|
||||||
|
COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript",
|
||||||
|
v.created_at AS created,
|
||||||
|
v.manual_content_tags AS "contentTags"
|
||||||
|
FROM "Video" v
|
||||||
|
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||||
|
WHERE ${contentTag}::text IS NULL
|
||||||
|
OR v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
ip.aweme_id,
|
||||||
|
'image'::text,
|
||||||
|
ip."desc",
|
||||||
|
'',
|
||||||
|
ip.created_at,
|
||||||
|
ARRAY[]::text[]
|
||||||
|
FROM "ImagePost" ip
|
||||||
|
WHERE ${contentTag}::text IS NULL
|
||||||
|
),
|
||||||
|
lowered AS (
|
||||||
|
SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t
|
||||||
|
FROM docs
|
||||||
|
),
|
||||||
|
matched AS (
|
||||||
|
SELECT
|
||||||
|
l.*,
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(
|
||||||
|
4 * LEAST(regexp_count(l.d, term), 3)
|
||||||
|
+ LEAST(regexp_count(l.t, term), 10)
|
||||||
|
)
|
||||||
|
FROM unnest(p.terms) term
|
||||||
|
), 0)::float8 AS score,
|
||||||
|
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc",
|
||||||
|
EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript"
|
||||||
|
FROM lowered l, params p
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM unnest(p.terms) term
|
||||||
|
WHERE l.d !~ term AND l.t !~ term
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM unnest(p.excludes) x
|
||||||
|
WHERE l.d ~ x OR l.t ~ x
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_BY = Prisma.sql`ORDER BY score DESC, created DESC, "awemeId"`;
|
||||||
|
|
||||||
|
export function searchRows(input: SearchInput, offset: number, limit: number) {
|
||||||
|
return prisma.$queryRaw<SearchRow[]>`
|
||||||
|
${matchedCte(input)}
|
||||||
|
SELECT
|
||||||
|
"awemeId", type, score, "inDesc", "inTranscript",
|
||||||
|
"rawDesc", "rawTranscript", "contentTags",
|
||||||
|
(COUNT(*) OVER ())::int AS total
|
||||||
|
FROM matched
|
||||||
|
${ORDER_BY}
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${limit};
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按搜索结果顺序返回全部命中作品的 ID。 */
|
||||||
|
export async function searchIds(input: SearchInput): Promise<string[]> {
|
||||||
|
const rows = await prisma.$queryRaw<{ awemeId: string }[]>`
|
||||||
|
${matchedCte(input)}
|
||||||
|
SELECT "awemeId" FROM matched
|
||||||
|
${ORDER_BY};
|
||||||
|
`;
|
||||||
|
return rows.map((r) => r.awemeId);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user