diff --git a/electron/main/ipc/comments.ts b/electron/main/ipc/comments.ts index e4dbdbf0..8915b036 100644 --- a/electron/main/ipc/comments.ts +++ b/electron/main/ipc/comments.ts @@ -1,7 +1,13 @@ import { ipcMain } from "electron"; import { getCommentSources, getMusicComments } from "@main/services/comments"; +import { getCreatorComments } from "@main/services/comments/creator"; import { coreLog } from "@main/utils/logger"; -import type { MusicCommentQuery, MusicCommentResponse } from "@shared/types/comment"; +import type { + MusicCommentCreatorQuery, + MusicCommentCreatorResponse, + MusicCommentQuery, + MusicCommentResponse, +} from "@shared/types/comment"; /** 注册评论 IPC */ export const registerCommentsIpc = (): void => { @@ -18,4 +24,16 @@ export const registerCommentsIpc = (): void => { } }, ); + + ipcMain.handle( + "comments:creator", + async (_evt, args: MusicCommentCreatorQuery): Promise => { + try { + return { ok: true, data: await getCreatorComments(args) }; + } catch (err) { + coreLog.warn("[comments] creator failed:", err); + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + }, + ); }; diff --git a/electron/main/services/comments/creator.ts b/electron/main/services/comments/creator.ts new file mode 100644 index 00000000..3d3426f4 --- /dev/null +++ b/electron/main/services/comments/creator.ts @@ -0,0 +1,145 @@ +import { callNetease } from "@main/apis/netease"; +import { coreLog } from "@main/utils/logger"; +import type { MusicCommentCreatorQuery, MusicCommentItem } from "@shared/types/comment"; +import { normalizeNeteaseCommentPage, scanCreatorComments } from "./data"; +import { findNeteaseSongMeta } from "./index"; + +const NETEASE_SOURCE_ID = "builtin:netease"; +const NETEASE_RESOURCE_TYPE = "R_SO_4_"; +const HOT_LIMIT = 20; +/** 首屏前两页未命中时,最多额外翻页数 */ +const MAX_EXTRA_PAGES = 3; +const ARTIST_ACCOUNT_CACHE_LIMIT = 200; +const CREATOR_COMMENTS_CACHE_LIMIT = 100; + +/** + * 简易有界 LRU Map + * Map 保持插入顺序,访问时移到末尾,超限时删除首项 + */ +class BoundedMap { + private readonly map = new Map(); + constructor(private readonly limit: number) {} + + has(key: K): boolean { + return this.map.has(key); + } + + get(key: K): V | undefined { + const v = this.map.get(key); + if (v !== undefined && this.map.size > 1) { + this.map.delete(key); + this.map.set(key, v); + } + return v; + } + + set(key: K, value: V): void { + if (this.map.has(key)) this.map.delete(key); + this.map.set(key, value); + while (this.map.size > this.limit) { + const first = this.map.keys().next().value; + if (first === undefined) break; + this.map.delete(first); + } + } +} + +/** artist id → 绑定的网易云用户 accountId(null 表示负缓存:未绑定) */ +const artistAccountIdCache = new BoundedMap(ARTIST_ACCOUNT_CACHE_LIMIT); +/** 网易云 songId → 主创评论列表(空数组表示负缓存:无主创评论) */ +const creatorCommentsCache = new BoundedMap( + CREATOR_COMMENTS_CACHE_LIMIT, +); + +/** 拉取一页热门评论(归一化后返回) */ +const fetchHotPage = async ( + songId: string, + page: number, +): Promise<{ items: MusicCommentItem[]; hasMore: boolean }> => { + const { body } = await callNetease("comment_hot", { + id: songId, + type: NETEASE_RESOURCE_TYPE, + limit: HOT_LIMIT, + offset: (page - 1) * HOT_LIMIT, + }); + const normalized = normalizeNeteaseCommentPage(body, "hot", page, HOT_LIMIT); + const hasMore = Boolean(body?.hasMore ?? body?.data?.hasMore ?? false); + return { items: normalized.list, hasMore }; +}; + +/** + * 解析歌手列表对应的网易云用户 accountId 集合 + * 多歌手多账号匹配并去重;accountId 为 0 或不存在视为未绑定(负缓存) + */ +const resolveArtistAccountIds = async (artistIds: string[]): Promise> => { + const accountIds = new Set(); + for (const artistId of artistIds) { + if (!artistId) continue; + const cached = artistAccountIdCache.get(artistId); + if (cached !== undefined) { + if (cached) accountIds.add(cached); + continue; + } + try { + const { body } = await callNetease("artists", { id: artistId }); + const raw = body?.artist?.accountId; + const normalized = + typeof raw === "number" && raw > 0 + ? String(raw) + : typeof raw === "string" && raw + ? raw + : null; + artistAccountIdCache.set(artistId, normalized); + if (normalized) accountIds.add(normalized); + } catch (err) { + coreLog.warn(`[comments] resolve artist ${artistId} accountId failed:`, err); + artistAccountIdCache.set(artistId, null); + } + } + return accountIds; +}; + +/** + * 获取「主创说」评论:歌曲关联歌手在网易云绑定的账号,在该歌曲热门评论中的发言 + * 仅网易云内建源启用;前两页必拉,未命中且 hasMore 时额外翻页最多 MAX_EXTRA_PAGES 页 + */ +export const getCreatorComments = async ( + args: MusicCommentCreatorQuery, +): Promise => { + if (args.sourceId !== NETEASE_SOURCE_ID) return []; + + const meta = await findNeteaseSongMeta(args.track); + if (!meta) return []; + const { songId, artistIds } = meta; + + const cached = creatorCommentsCache.get(songId); + if (cached) return cached; + + const accountIds = await resolveArtistAccountIds(artistIds); + if (accountIds.size === 0) { + creatorCommentsCache.set(songId, []); + return []; + } + + const result: MusicCommentItem[] = []; + let hasMore = true; + // 前两页必拉,收集全部命中 + for (let page = 1; page <= 2 && hasMore; page++) { + const { items, hasMore: hm } = await fetchHotPage(songId, page); + result.push(...scanCreatorComments(items, accountIds)); + hasMore = hm; + } + // 前两页无命中 + 还有更多 → 额外翻页,命中即停 + if (result.length === 0 && hasMore) { + for (let extra = 1; extra <= MAX_EXTRA_PAGES && hasMore; extra++) { + const { items, hasMore: hm } = await fetchHotPage(songId, 2 + extra); + const hits = scanCreatorComments(items, accountIds); + result.push(...hits); + hasMore = hm; + if (hits.length > 0) break; + } + } + + creatorCommentsCache.set(songId, result); + return result; +}; diff --git a/electron/main/services/comments/data.ts b/electron/main/services/comments/data.ts index 7a9828bf..62356ebc 100644 --- a/electron/main/services/comments/data.ts +++ b/electron/main/services/comments/data.ts @@ -104,6 +104,15 @@ export const normalizeNeteaseCommentPage = ( }; }; +/** 纯函数:从已归一化的评论中筛选主创评论(userId 命中账号集合) */ +export const scanCreatorComments = ( + items: MusicCommentItem[], + accountIds: Set, +): MusicCommentItem[] => { + if (accountIds.size === 0) return []; + return items.filter((item) => item.userId != null && accountIds.has(item.userId)); +}; + /** 构建可用评论源 */ export const buildCommentSources = ( plugins: Array< diff --git a/electron/main/services/comments/index.ts b/electron/main/services/comments/index.ts index 0bbe8eef..eba3a5ca 100644 --- a/electron/main/services/comments/index.ts +++ b/electron/main/services/comments/index.ts @@ -68,8 +68,19 @@ const findPluginMatch = async ( return pickBestCandidate(candidates, track)?.extra ?? null; }; -const findNeteaseId = async (track: Track): Promise => { - if (track.source === "netease" && track.id) return track.id; +/** + * 解析 Track 对应的网易云歌曲元信息:songId + 歌手 id 列表 + * 一次 search 同时取两者,避免主创说二次搜索 + */ +export const findNeteaseSongMeta = async ( + track: Track, +): Promise<{ songId: string; artistIds: string[] } | null> => { + if (track.source === "netease" && track.id) { + return { + songId: track.id, + artistIds: track.artists.map((a) => a.id).filter((id): id is string => !!id), + }; + } const keyword = toKeyword(track); if (!keyword) return null; const { status, body } = await callNetease("search", { @@ -79,11 +90,11 @@ const findNeteaseId = async (track: Track): Promise => { }); if (status !== 200) return null; const songs = body.result?.songs ?? []; - const candidates: LyricCandidate<{ id: string }>[] = songs.map( + const candidates: LyricCandidate<{ id: string; artistIds: string[] }>[] = songs.map( (song: { id: string | number; name?: string; - artists?: { name: string }[]; + artists?: { id?: string | number; name: string }[]; album?: { name?: string }; duration?: number; }) => ({ @@ -91,10 +102,21 @@ const findNeteaseId = async (track: Track): Promise => { artist: (song.artists ?? []).map((artist) => artist.name).join(" / "), album: song.album?.name, duration: song.duration, - extra: { id: String(song.id) }, + extra: { + id: String(song.id), + artistIds: (song.artists ?? []).map((a) => (a.id ? String(a.id) : "")).filter(Boolean), + }, }), ); - return pickBestCandidate(candidates, track)?.extra.id ?? null; + const best = pickBestCandidate(candidates, track)?.extra; + if (!best) return null; + return { songId: best.id, artistIds: best.artistIds }; +}; + +/** 取网易云歌曲 id(薄包装,供普通评论拉取复用) */ +const findNeteaseId = async (track: Track): Promise => { + const meta = await findNeteaseSongMeta(track); + return meta?.songId ?? null; }; const getNeteaseComments = async (args: MusicCommentQuery): Promise => { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index bd2db81d..61e4875b 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -15,7 +15,7 @@ import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; import type { TagEditRequest } from "@shared/types/tagEditor"; import type { UpdateEvent } from "@shared/types/update"; import type { CloudUploadProgress } from "@shared/types/cloudUpload"; -import type { MusicCommentQuery } from "@shared/types/comment"; +import type { MusicCommentCreatorQuery, MusicCommentQuery } from "@shared/types/comment"; import type { AiModelSaveInput } from "@shared/types/ai"; /** 订阅主进程推送的事件 */ @@ -369,6 +369,7 @@ const api = { comments: { sources: () => ipcRenderer.invoke("comments:sources"), get: (args: MusicCommentQuery) => ipcRenderer.invoke("comments:get", args), + creator: (args: MusicCommentCreatorQuery) => ipcRenderer.invoke("comments:creator", args), }, download: { // 入队下载 diff --git a/shared/types/comment.ts b/shared/types/comment.ts index c8317f2e..4e38738c 100644 --- a/shared/types/comment.ts +++ b/shared/types/comment.ts @@ -54,8 +54,20 @@ export type MusicCommentResponse = | { ok: true; data: MusicCommentPage } | { ok: false; error: string }; +/** 主创说查询参数 */ +export interface MusicCommentCreatorQuery { + sourceId: string; + track: Track; +} + +/** 主创说 IPC 响应 */ +export type MusicCommentCreatorResponse = + | { ok: true; data: MusicCommentItem[] } + | { ok: false; error: string }; + /** 渲染端评论 API */ export interface CommentsApi { sources: () => Promise; get: (args: MusicCommentQuery) => Promise; + creator: (args: MusicCommentCreatorQuery) => Promise; } diff --git a/src/components/comments/CommentCard.vue b/src/components/comments/CommentCard.vue new file mode 100644 index 00000000..bf4d0044 --- /dev/null +++ b/src/components/comments/CommentCard.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/components/comments/CommentList.vue b/src/components/comments/CommentList.vue new file mode 100644 index 00000000..32c1ec4d --- /dev/null +++ b/src/components/comments/CommentList.vue @@ -0,0 +1,124 @@ + + + diff --git a/src/components/modals/MusicCommentsDialog.vue b/src/components/modals/MusicCommentsDialog.vue index 8f6fe7a4..b728c912 100644 --- a/src/components/modals/MusicCommentsDialog.vue +++ b/src/components/modals/MusicCommentsDialog.vue @@ -1,4 +1,8 @@ + + diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index 03d8a43c..d21817d8 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -13,6 +13,7 @@ import { useTimeFormat } from "@/composables/useTimeFormat"; import Lyrics from "@/components/player/Lyrics/index.vue"; import AMLLLyrics from "@/components/player/Lyrics/AMLLLyrics.vue"; import PlaylistPickerDialog from "@/components/modals/PlaylistPickerDialog.vue"; +import FullPlayerComments from "./FullPlayerComments.vue"; import { useWindowControls } from "@/composables/useWindowControls"; import * as player from "@/core/player"; import { openExternal } from "@/utils/url"; @@ -105,10 +106,16 @@ watch( const fullscreenCover = computed(() => settings.player.coverLayout === "fullscreen"); const coverCentered = computed(() => { - if (fullscreenCover.value || status.fullQueueOpen) return false; + if (fullscreenCover.value || status.fullQueueOpen || status.fullCommentsOpen) return false; return !showLyric.value || (settings.player.autoCenterCover && !hasLyric.value); }); +/** 全屏内嵌评论布局模式:off 关闭 / half 左半屏 / full 全屏 */ +const fullCommentMode = computed<"off" | "half" | "full">(() => { + if (!status.fullCommentsOpen) return "off"; + return hasLyric.value && showLyric.value ? "half" : "full"; +}); + const handleLyricSeek = async (timeMs: number): Promise => { await player.seek(timeMs); if (!isPlaying.value) await player.play(); @@ -173,9 +180,17 @@ const toggleLyric = (): void => { } }; -const showComments = (): void => { - if (media.track) status.showComments(media.track); +const toggleFullComments = (): void => { + if (status.fullQueueOpen) status.fullQueueOpen = false; + status.fullCommentsOpen = !status.fullCommentsOpen; }; + +watch( + () => status.fullQueueOpen, + (open) => { + if (open && status.fullCommentsOpen) status.fullCommentsOpen = false; + }, +);