diff --git a/components.d.ts b/components.d.ts index eb040d8..ca19716 100644 --- a/components.d.ts +++ b/components.d.ts @@ -36,6 +36,7 @@ declare module 'vue' { ContextMenuSubContent: typeof import('reka-ui')['ContextMenuSubContent'] ContextMenuSubTrigger: typeof import('reka-ui')['ContextMenuSubTrigger'] ContextMenuTrigger: typeof import('reka-ui')['ContextMenuTrigger'] + CoverCard: typeof import('./src/components/list/CoverCard.vue')['default'] CoverList: typeof import('./src/components/list/CoverList.vue')['default'] DbCacheManager: typeof import('./src/components/settings/custom/DbCacheManager.vue')['default'] DeviceSelector: typeof import('./src/components/settings/custom/DeviceSelector.vue')['default'] @@ -63,8 +64,10 @@ declare module 'vue' { FullPlayer: typeof import('./src/components/player/FullPlayer/index.vue')['default'] HotkeyConfig: typeof import('./src/components/settings/custom/HotkeyConfig.vue')['default'] IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] + IconLucideArrowRightToLine: typeof import('~icons/lucide/arrow-right-to-line')['default'] IconLucideArrowUp: typeof import('~icons/lucide/arrow-up')['default'] IconLucideArrowUpCircle: typeof import('~icons/lucide/arrow-up-circle')['default'] + IconLucideCalendarDays: typeof import('~icons/lucide/calendar-days')['default'] IconLucideCheck: typeof import('~icons/lucide/check')['default'] IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default'] @@ -83,15 +86,23 @@ declare module 'vue' { IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default'] IconLucideGithub: typeof import('~icons/lucide/github')['default'] IconLucideHardDrive: typeof import('~icons/lucide/hard-drive')['default'] + IconLucideHeadphones: typeof import('~icons/lucide/headphones')['default'] + IconLucideHeart: typeof import('~icons/lucide/heart')['default'] + IconLucideHeartOff: typeof import('~icons/lucide/heart-off')['default'] IconLucideHistory: typeof import('~icons/lucide/history')['default'] + IconLucideInfinity: typeof import('~icons/lucide/infinity')['default'] IconLucideInfo: typeof import('~icons/lucide/info')['default'] IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default'] IconLucideLink: typeof import('~icons/lucide/link')['default'] + IconLucideList: typeof import('~icons/lucide/list')['default'] + IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListMusic: typeof import('~icons/lucide/list-music')['default'] + IconLucideListOrdered: typeof import('~icons/lucide/list-ordered')['default'] IconLucideLocate: typeof import('~icons/lucide/locate')['default'] IconLucideLock: typeof import('~icons/lucide/lock')['default'] IconLucideLogOut: typeof import('~icons/lucide/log-out')['default'] IconLucideMaximize: typeof import('~icons/lucide/maximize')['default'] + IconLucideMenu: typeof import('~icons/lucide/menu')['default'] IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default'] IconLucideMic: typeof import('~icons/lucide/mic')['default'] IconLucideMicVocal: typeof import('~icons/lucide/mic-vocal')['default'] @@ -125,7 +136,12 @@ declare module 'vue' { IconLucideX: typeof import('~icons/lucide/x')['default'] IconLucideZap: typeof import('~icons/lucide/zap')['default'] IconMaterialSymbolsFavoriteOutlineRounded: typeof import('~icons/material-symbols/favorite-outline-rounded')['default'] + IconMaterialSymbolsPlaylistPlayRounded: typeof import('~icons/material-symbols/playlist-play-rounded')['default'] + IconMaterialSymbolsShuffleRounded: typeof import('~icons/material-symbols/shuffle-rounded')['default'] + IconSpHeartMode: typeof import('~icons/sp/heart-mode')['default'] IconSpLossless: typeof import('~icons/sp/lossless')['default'] + IconSpPlayOrder: typeof import('~icons/sp/play-order')['default'] + IconSpRepeatOff: typeof import('~icons/sp/repeat-off')['default'] LoginCookieDialog: typeof import('./src/components/modals/LoginCookieDialog.vue')['default'] LoginDialog: typeof import('./src/components/modals/LoginDialog.vue')['default'] LyricFormatOrderConfig: typeof import('./src/components/settings/custom/LyricFormatOrderConfig.vue')['default'] diff --git a/electron/main/apis/netease/index.ts b/electron/main/apis/netease/index.ts index 840da81..10c580c 100644 --- a/electron/main/apis/netease/index.ts +++ b/electron/main/apis/netease/index.ts @@ -47,6 +47,10 @@ const NON_CACHEABLE: ReadonlySet = new Set([ "user_cloud", "user_cloud_del", "album_sub", + "playmode_intelligence", + "personal_fm", + "fm_trash", + "recommend_songs", ]); /** 内存缓存 */ diff --git a/electron/main/apis/netease/modules/album_new.ts b/electron/main/apis/netease/modules/album_new.ts new file mode 100644 index 0000000..3538780 --- /dev/null +++ b/electron/main/apis/netease/modules/album_new.ts @@ -0,0 +1,15 @@ +/** + * 新碟上架(无需登录) + * + * 响应:`{ code, albums: NeteaseAlbum[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const albumNew: NeteaseModule = (query, request) => { + const data = { area: "ALL", offset: 0, total: true, limit: query.limit ?? 30 }; + return request("/api/album/new", data, createOption(query, "weapi")); +}; + +export default albumNew; diff --git a/electron/main/apis/netease/modules/fm_trash.ts b/electron/main/apis/netease/modules/fm_trash.ts new file mode 100644 index 0000000..39bbca3 --- /dev/null +++ b/electron/main/apis/netease/modules/fm_trash.ts @@ -0,0 +1,22 @@ +/** + * 私人 FM 减少推荐(不喜欢,影响后续推荐) + * + * params: + * - id 歌曲 id + * - time 已播放秒数,默认 25 + * - alg 推荐算法标识,默认 "RT" + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const fmTrash: NeteaseModule = (query, request) => { + const data = { + songId: query.id, + alg: query.alg ?? "RT", + time: query.time ?? 25, + }; + return request("/api/radio/trash/add", data, createOption(query, "weapi")); +}; + +export default fmTrash; diff --git a/electron/main/apis/netease/modules/index.ts b/electron/main/apis/netease/modules/index.ts index cfad087..4ca8ce0 100644 --- a/electron/main/apis/netease/modules/index.ts +++ b/electron/main/apis/netease/modules/index.ts @@ -52,6 +52,16 @@ import cloud_lyric_get from "./cloud_lyric_get"; // 播放 import song_detail from "./song_detail"; import song_url from "./song_url"; +import playmode_intelligence from "./playmode_intelligence"; +import personal_fm from "./personal_fm"; +import fm_trash from "./fm_trash"; + +// 每日推荐 / 发现 +import recommend_songs from "./recommend_songs"; +import personalized from "./personalized"; +import recommend_resource from "./recommend_resource"; +import top_artists from "./top_artists"; +import album_new from "./album_new"; // 歌单 / 喜欢 import playlist_detail from "./playlist_detail"; @@ -119,6 +129,15 @@ export const modules: Record = { song_detail, song_url, + playmode_intelligence, + personal_fm, + fm_trash, + + recommend_songs, + personalized, + recommend_resource, + top_artists, + album_new, playlist_detail, playlist_create, diff --git a/electron/main/apis/netease/modules/personal_fm.ts b/electron/main/apis/netease/modules/personal_fm.ts new file mode 100644 index 0000000..ace037c --- /dev/null +++ b/electron/main/apis/netease/modules/personal_fm.ts @@ -0,0 +1,15 @@ +/** + * 私人 FM(需登录) + * 服务端按用户偏好返回一批推荐曲目,通常 3 首 + * + * 响应:`{ code, data: NeteaseSong[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const personalFm: NeteaseModule = (query, request) => { + return request("/api/v1/radio/get", {}, createOption(query, "weapi")); +}; + +export default personalFm; diff --git a/electron/main/apis/netease/modules/personalized.ts b/electron/main/apis/netease/modules/personalized.ts new file mode 100644 index 0000000..33aae34 --- /dev/null +++ b/electron/main/apis/netease/modules/personalized.ts @@ -0,0 +1,15 @@ +/** + * 推荐歌单(无需登录) + * + * 响应:`{ code, result: NeteasePlaylist[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const personalized: NeteaseModule = (query, request) => { + const data = { limit: query.limit ?? 30, total: true, n: 1000 }; + return request("/api/personalized/playlist", data, createOption(query, "weapi")); +}; + +export default personalized; diff --git a/electron/main/apis/netease/modules/playmode_intelligence.ts b/electron/main/apis/netease/modules/playmode_intelligence.ts new file mode 100644 index 0000000..d5f0c4f --- /dev/null +++ b/electron/main/apis/netease/modules/playmode_intelligence.ts @@ -0,0 +1,28 @@ +/** + * 心动模式 / 智能播放列表 + * + * params: + * - id 种子歌曲 id + * - pid 歌单 id(通常为「我喜欢的音乐」) + * - sid 起始歌曲 id,缺省取 id + * + * count 字段服务端必传,缺省取 1,否则返回 500。 + * + * 响应:`{ code, data: [{ songInfo: NeteaseSong, recommended }] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const playmodeIntelligence: NeteaseModule = (query, request) => { + const data = { + songId: query.id, + type: "fromPlayOne", + playlistId: query.pid, + startMusicId: query.sid ?? query.id, + count: query.count ?? 1, + }; + return request("/api/playmode/intelligence/list", data, createOption(query, "weapi")); +}; + +export default playmodeIntelligence; diff --git a/electron/main/apis/netease/modules/recommend_resource.ts b/electron/main/apis/netease/modules/recommend_resource.ts new file mode 100644 index 0000000..62f5963 --- /dev/null +++ b/electron/main/apis/netease/modules/recommend_resource.ts @@ -0,0 +1,14 @@ +/** + * 每日推荐歌单 / 专属歌单(需登录) + * + * 响应:`{ code, recommend: NeteasePlaylist[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const recommendResource: NeteaseModule = (query, request) => { + return request("/api/v1/discovery/recommend/resource", {}, createOption(query, "weapi")); +}; + +export default recommendResource; diff --git a/electron/main/apis/netease/modules/recommend_songs.ts b/electron/main/apis/netease/modules/recommend_songs.ts new file mode 100644 index 0000000..efcadf0 --- /dev/null +++ b/electron/main/apis/netease/modules/recommend_songs.ts @@ -0,0 +1,17 @@ +/** + * 每日推荐歌曲(每日 30 首,需登录) + * + * 响应:`{ code, data: { dailySongs: NeteaseSong[], recommendReasons } }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const recommendSongs: NeteaseModule = (query, request) => { + if (query.cookie && typeof query.cookie === "object") { + (query.cookie as Record).os = "ios"; + } + return request("/api/v3/discovery/recommend/songs", {}, createOption(query, "weapi")); +}; + +export default recommendSongs; diff --git a/electron/main/apis/netease/modules/top_artists.ts b/electron/main/apis/netease/modules/top_artists.ts new file mode 100644 index 0000000..0003840 --- /dev/null +++ b/electron/main/apis/netease/modules/top_artists.ts @@ -0,0 +1,15 @@ +/** + * 热门歌手(无需登录) + * + * 响应:`{ code, artists: NeteaseArtist[] }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const topArtists: NeteaseModule = (query, request) => { + const data = { offset: 0, total: true, limit: query.limit ?? 50 }; + return request("/api/artist/top", data, createOption(query, "weapi")); +}; + +export default topArtists; diff --git a/electron/main/database/index.ts b/electron/main/database/index.ts index 9d7d8dd..ed0d93d 100644 --- a/electron/main/database/index.ts +++ b/electron/main/database/index.ts @@ -17,6 +17,9 @@ export const getDb = (): Database.Database => { return db; }; +/** 数据库是否已打开 */ +export const isDbOpen = (): boolean => db !== null; + /** 初始化数据库:打开连接、启用 WAL、建表建索引、执行迁移 */ export const initDatabase = (): void => { fs.mkdirSync(dbDir, { recursive: true }); @@ -93,6 +96,27 @@ export const initDatabase = (): void => { ); CREATE INDEX IF NOT EXISTS idx_song_cache_last_used ON song_cache(last_used_at); CREATE INDEX IF NOT EXISTS idx_song_cache_source ON song_cache(source); + + CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id TEXT NOT NULL, + source TEXT NOT NULL, + started_at INTEGER NOT NULL, + listened_ms INTEGER NOT NULL, + track_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_play_history_started ON play_history(started_at); + CREATE INDEX IF NOT EXISTS idx_play_history_track ON play_history(source, track_id); + + CREATE TABLE IF NOT EXISTS favorite_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + track_id TEXT NOT NULL, + source TEXT NOT NULL, + action TEXT NOT NULL, + at INTEGER NOT NULL, + track_json TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_favorite_history_at ON favorite_history(at); `); migrate(db); libraryLog.info(`数据库已初始化: ${dbPath}`); @@ -120,6 +144,8 @@ export { getAlbumTracks, getArtistTracks, getTracksByIds, + getRandomTrack, + getRandomTracks, } from "./queries"; export type { FileRecord, UpsertTrack } from "./queries"; diff --git a/electron/main/database/playStats.ts b/electron/main/database/playStats.ts new file mode 100644 index 0000000..d621692 --- /dev/null +++ b/electron/main/database/playStats.ts @@ -0,0 +1,177 @@ +/** + * 播放统计采集 + */ + +import { getDb, isDbOpen } from "./index"; +import { libraryLog } from "@main/utils/logger"; +import type { Track } from "@shared/types/player"; +import type { + PlayEventInput, + FavoriteEventInput, + PlayStatsSummary, + TopTrack, +} from "@shared/types/stats"; + +/** 写入一条播放记录 */ +export const insertPlayEvent = (event: PlayEventInput): void => { + if (!isDbOpen()) return; + try { + getDb() + .prepare( + `INSERT INTO play_history (track_id, source, started_at, listened_ms, track_json) + VALUES (?, ?, ?, ?, ?)`, + ) + .run( + event.track.id, + event.track.source, + event.startedAt, + event.listenedMs, + JSON.stringify(event.track), + ); + } catch (error) { + libraryLog.error("写入播放记录失败:", error); + } +}; + +/** 写入一条收藏变更记录 */ +export const insertFavoriteEvent = (event: FavoriteEventInput): void => { + if (!isDbOpen()) return; + try { + getDb() + .prepare( + `INSERT INTO favorite_history (track_id, source, action, at, track_json) + VALUES (?, ?, ?, ?, ?)`, + ) + .run( + event.track.id, + event.track.source, + event.action, + Date.now(), + JSON.stringify(event.track), + ); + } catch (error) { + libraryLog.error("写入收藏记录失败:", error); + } +}; + +/** 今日 00:00 的 unix ms(本地时区) */ +const dayStartMs = (now: number): number => { + const date = new Date(now); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +}; + +/** 本周一 00:00 的 unix ms(本地时区) */ +const weekStartMs = (now: number): number => { + const date = new Date(now); + const daysFromMonday = (date.getDay() + 6) % 7; + const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysFromMonday); + return monday.getTime(); +}; + +/** 本地日期 key:YYYY-MM-DD */ +const dayKey = (date: Date): string => { + const pad = (value: number): string => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + +/** 从倒序的有播放日期列表算连续天数 */ +const computeStreak = (descDays: string[]): number => { + if (descDays.length === 0) return 0; + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + if (descDays[0] !== dayKey(today) && descDays[0] !== dayKey(yesterday)) return 0; + const present = new Set(descDays); + const cursor = new Date(today); + // 今天还没听则从昨天起算 + if (descDays[0] !== dayKey(today)) cursor.setDate(cursor.getDate() - 1); + let streak = 0; + while (present.has(dayKey(cursor))) { + streak += 1; + cursor.setDate(cursor.getDate() - 1); + } + return streak; +}; + +/** 读盘失败时的兜底空统计 */ +const EMPTY_SUMMARY: PlayStatsSummary = { + todayListenedMs: 0, + weekListenedMs: 0, + lastWeekListenedMs: 0, + totalListenedMs: 0, + weekPlayCount: 0, + totalPlayCount: 0, + weekFavoriteAdds: 0, + streakDays: 0, +}; + +/** + * 取播放统计汇总 + * 读盘失败时返回全 0 + */ +export const getStatsSummary = (): PlayStatsSummary => { + try { + const db = getDb(); + const now = Date.now(); + const dayStart = dayStartMs(now); + const weekStart = weekStartMs(now); + const lastWeekStart = weekStart - 7 * 24 * 60 * 60 * 1000; + + /** 取单值聚合结果 */ + const scalar = (sql: string, ...params: number[]): number => + (db.prepare(sql).get(...params) as { value: number }).value; + + const listenedSince = + "SELECT COALESCE(SUM(listened_ms), 0) AS value FROM play_history WHERE started_at >= ?"; + const playCountSince = "SELECT COUNT(*) AS value FROM play_history WHERE started_at >= ?"; + + const dayRows = db + .prepare( + "SELECT DISTINCT date(started_at / 1000, 'unixepoch', 'localtime') AS day FROM play_history ORDER BY day DESC", + ) + .all() as { day: string }[]; + + return { + todayListenedMs: scalar(listenedSince, dayStart), + weekListenedMs: scalar(listenedSince, weekStart), + lastWeekListenedMs: scalar( + "SELECT COALESCE(SUM(listened_ms), 0) AS value FROM play_history WHERE started_at >= ? AND started_at < ?", + lastWeekStart, + weekStart, + ), + totalListenedMs: scalar(listenedSince, 0), + weekPlayCount: scalar(playCountSince, weekStart), + totalPlayCount: scalar(playCountSince, 0), + weekFavoriteAdds: scalar( + "SELECT COUNT(*) AS value FROM favorite_history WHERE action = 'add' AND at >= ?", + weekStart, + ), + streakDays: computeStreak(dayRows.map((row) => row.day)), + }; + } catch (error) { + libraryLog.error("读取播放统计失败:", error); + return EMPTY_SUMMARY; + } +}; + +/** 取最常播放的曲目(含播放次数),按次数倒序;读盘失败返回空 */ +export const getTopTracks = (limit: number): TopTrack[] => { + try { + const rows = getDb() + .prepare( + `SELECT track_json, COUNT(*) AS plays + FROM play_history + GROUP BY source, track_id + ORDER BY plays DESC, MAX(started_at) DESC + LIMIT ?`, + ) + .all(limit) as { track_json: string; plays: number }[]; + return rows.map((row) => ({ + track: JSON.parse(row.track_json) as Track, + playCount: row.plays, + })); + } catch (error) { + libraryLog.error("读取最常播放失败:", error); + return []; + } +}; diff --git a/electron/main/database/queries.ts b/electron/main/database/queries.ts index 6397abf..3c6c601 100644 --- a/electron/main/database/queries.ts +++ b/electron/main/database/queries.ts @@ -64,6 +64,24 @@ export const getTrackCount = (): number => { return row.count; }; +/** 随机取一首曲目,库为空时返回 null */ +export const getRandomTrack = (): Track | null => { + const row = getDb().prepare("SELECT * FROM tracks ORDER BY RANDOM() LIMIT 1").get() as + | TrackRow + | undefined; + return row ? rowToTrack(row) : null; +}; + +/** 随机取多首曲目 */ +export const getRandomTracks = (limit: number): Track[] => { + const safe = Math.max(0, Math.min(limit | 0, 500)); + if (safe === 0) return []; + const rows = getDb() + .prepare("SELECT * FROM tracks ORDER BY RANDOM() LIMIT ?") + .all(safe) as TrackRow[]; + return rows.map(rowToTrack); +}; + /** 用于增量扫描比对的文件记录 */ export interface FileRecord { path: string; diff --git a/electron/main/ipc/index.ts b/electron/main/ipc/index.ts index 57cdc48..a278cb5 100644 --- a/electron/main/ipc/index.ts +++ b/electron/main/ipc/index.ts @@ -12,6 +12,7 @@ import { registerThemeIpc } from "./theme"; import { registerStreamingIpc } from "./streaming"; import { registerCacheIpc } from "./cache"; import { registerExternalApiIpc } from "./externalApi"; +import { registerStatsIpc } from "./stats"; /** 注册所有 IPC 处理 */ export const registerIpcHandlers = (): void => { @@ -29,4 +30,5 @@ export const registerIpcHandlers = (): void => { registerStreamingIpc(); registerCacheIpc(); registerExternalApiIpc(); + registerStatsIpc(); }; diff --git a/electron/main/ipc/library.ts b/electron/main/ipc/library.ts index 09ef659..dd16d7a 100644 --- a/electron/main/ipc/library.ts +++ b/electron/main/ipc/library.ts @@ -12,6 +12,8 @@ import { getAlbumTracks, getArtistTracks, getTracksByIds, + getRandomTrack, + getRandomTracks, } from "@main/database"; import { startScan, cancelScan, isScanning } from "@main/services/scanner"; import { fetchArtistAvatar, prefetchArtistAvatars } from "@main/apis/musicbrainz"; @@ -113,6 +115,24 @@ export const registerLibraryIpc = (): void => { } }); + // 随机取一首曲目 + ipcMain.handle("library:getRandomTrack", () => { + try { + return { success: true, data: getRandomTrack() }; + } catch (_error) { + return { success: false, error: ErrorCode.UNKNOWN }; + } + }); + + // 随机取多首曲目 + ipcMain.handle("library:getRandomTracks", (_event, limit: number) => { + try { + return { success: true, data: getRandomTracks(limit) }; + } catch (_error) { + return { success: false, error: ErrorCode.UNKNOWN }; + } + }); + // 获取扫描状态 ipcMain.handle("library:isScanning", () => { return { success: true, data: isScanning() }; diff --git a/electron/main/ipc/player.ts b/electron/main/ipc/player.ts index 8de5aed..859b03d 100644 --- a/electron/main/ipc/player.ts +++ b/electron/main/ipc/player.ts @@ -151,6 +151,12 @@ export const registerPlayerIpc = (): void => { }; sendToMain("player:event", loadingEvent); wsBroadcast(loadingEvent); + // 在线封面原图 URL + const remoteCover = + authoritative && authoritative.source !== "local" + ? (authoritative.coverOriginal ?? authoritative.cover) + : undefined; + const coverUrl = remoteCover && /^https?:\/\//i.test(remoteCover) ? remoteCover : undefined; // 写一次 SMTC/托盘/标题 const applyDisplay = ( title: string, @@ -160,7 +166,7 @@ export const registerPlayerIpc = (): void => { durationMs: number, ): void => { const header = artist ? `${title} - ${artist}` : title || appName; - mediaService.setMetadata({ title, artist, album, coverData, durationMs }); + mediaService.setMetadata({ title, artist, album, coverData, coverUrl, durationMs }); mediaService.setPlayState({ status: autoPlay ? "Playing" : "Paused" }); getMainWindow()?.setTitle(header); setTraySongName(header); @@ -188,9 +194,8 @@ export const registerPlayerIpc = (): void => { const localCover = isRemote ? null : (inst.getCoverRaw() ?? null); applyDisplay(displayTitle, displayArtist, displayAlbum, localCover ?? undefined, durationMs); // 远端高清封面 - const remoteCover = isRemote ? (authoritative?.coverOriginal ?? authoritative?.cover) : null; - if (remoteCover && /^https?:\/\//i.test(remoteCover)) { - void fetchBytes(remoteCover).then((buf) => { + if (coverUrl) { + void fetchBytes(coverUrl).then((buf) => { if (!buf) return; if (seq !== loadSeq) return; mediaService.setMetadata({ @@ -198,6 +203,7 @@ export const registerPlayerIpc = (): void => { artist: displayArtist, album: displayAlbum, coverData: buf, + coverUrl, durationMs, }); }); diff --git a/electron/main/ipc/stats.ts b/electron/main/ipc/stats.ts new file mode 100644 index 0000000..91d730d --- /dev/null +++ b/electron/main/ipc/stats.ts @@ -0,0 +1,20 @@ +import { ipcMain } from "electron"; +import { + insertPlayEvent, + insertFavoriteEvent, + getStatsSummary, + getTopTracks, +} from "@main/database/playStats"; +import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; + +/** 播放统计 IPC */ +export const registerStatsIpc = (): void => { + ipcMain.on("stats:recordPlay", (_event, payload: PlayEventInput) => { + insertPlayEvent(payload); + }); + ipcMain.on("stats:recordFavorite", (_event, payload: FavoriteEventInput) => { + insertFavoriteEvent(payload); + }); + ipcMain.handle("stats:getStatsSummary", () => getStatsSummary()); + ipcMain.handle("stats:getTopTracks", (_event, limit: number) => getTopTracks(limit)); +}; diff --git a/electron/main/services/thumbar.ts b/electron/main/services/thumbar.ts index bb7028d..7304e79 100644 --- a/electron/main/services/thumbar.ts +++ b/electron/main/services/thumbar.ts @@ -23,6 +23,7 @@ class ThumbarImpl implements Thumbar { private pause: ThumbarButton; private isPlaying: boolean = false; private onThemeUpdated: () => void; + private onWindowShown: () => void; constructor(win: BrowserWindow) { this.win = win; @@ -57,9 +58,13 @@ class ThumbarImpl implements Thumbar { this.updateThumbar(this.isPlaying); }; nativeTheme.on("updated", this.onThemeUpdated); + // 窗口从托盘恢复显示后系统会清空任务栏按钮,需重新下发一次 + this.onWindowShown = () => this.updateThumbar(this.isPlaying); + win.on("show", this.onWindowShown); // 窗口销毁时移除监听 win.on("closed", () => { nativeTheme.removeListener("updated", this.onThemeUpdated); + win.removeListener("show", this.onWindowShown); }); } diff --git a/electron/main/window/main.ts b/electron/main/window/main.ts index 3191dd6..7f8debf 100644 --- a/electron/main/window/main.ts +++ b/electron/main/window/main.ts @@ -43,7 +43,7 @@ export const createMainWindow = (): BrowserWindow => { initTray(); // 缩略图工具栏 - mainWindow.once("ready-to-show", () => { + mainWindow.webContents.once("did-finish-load", () => { initThumbar(mainWindow!); }); diff --git a/electron/preload/index.d.ts b/electron/preload/index.d.ts index f8b4741..55ba11e 100644 --- a/electron/preload/index.d.ts +++ b/electron/preload/index.d.ts @@ -15,6 +15,7 @@ import { import { HotkeyApi } from "@shared/types/hotkey"; import { StreamingApi } from "@shared/types/streaming"; import { IpcResponse } from "@shared/types/player"; +import { StatsApi } from "@shared/types/stats"; declare global { interface Window { @@ -65,6 +66,7 @@ declare global { cancel: (cacheKey: string) => Promise; }; }; + stats: StatsApi; hotkey: HotkeyApi; streaming: StreamingApi; externalApi: { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 143b5a6..7305c82 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -5,6 +5,7 @@ import type { PluginInfo, PluginResolveUrlArgs } from "@shared/types/plugin"; import type { HotkeyActionId, HotkeyBinding, HotkeyConflict } from "@shared/types/hotkey"; import type { LoadOptions, TrackSource } from "@shared/types/player"; import type { StreamingServerConfig } from "@shared/types/streaming"; +import type { PlayEventInput, FavoriteEventInput } from "@shared/types/stats"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -139,6 +140,10 @@ const api = { searchTracks: (query: string) => ipcRenderer.invoke("library:searchTracks", query), // 获取曲目总数 getTrackCount: () => ipcRenderer.invoke("library:getTrackCount"), + // 随机取一首曲目 + getRandomTrack: () => ipcRenderer.invoke("library:getRandomTrack"), + // 随机取多首曲目 + getRandomTracks: (limit: number) => ipcRenderer.invoke("library:getRandomTracks", limit), // 获取扫描状态 isScanning: () => ipcRenderer.invoke("library:isScanning"), // 弹出目录选择器,添加扫描目录 @@ -369,6 +374,16 @@ const api = { // 查询当前运行状态 getStatus: () => ipcRenderer.invoke("externalApi:getStatus"), }, + stats: { + // 记录一次播放 + recordPlay: (event: PlayEventInput) => ipcRenderer.send("stats:recordPlay", event), + // 记录一次收藏变更 + recordFavorite: (event: FavoriteEventInput) => ipcRenderer.send("stats:recordFavorite", event), + // 取播放统计汇总 + getStatsSummary: () => ipcRenderer.invoke("stats:getStatsSummary"), + // 取最常播放的曲目 + getTopTracks: (limit: number) => ipcRenderer.invoke("stats:getTopTracks", limit), + }, hotkey: { getAll: () => ipcRenderer.invoke("hotkey:getAll"), set: (id: HotkeyActionId, binding: HotkeyBinding) => diff --git a/index.html b/index.html index 8f1125b..3cb7d4e 100644 --- a/index.html +++ b/index.html @@ -104,8 +104,8 @@ text-align: center; font-size: 12px; font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", - "Microsoft YaHei", sans-serif; + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", + sans-serif; animation: splash-rise-footer 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.36s both; } @keyframes splash-rise { diff --git a/shared/defaults/hotkeys.ts b/shared/defaults/hotkeys.ts index e454056..a8082a0 100644 --- a/shared/defaults/hotkeys.ts +++ b/shared/defaults/hotkeys.ts @@ -127,6 +127,12 @@ export const HOTKEY_ACTIONS: HotkeyActionMeta[] = [ defaultBinding: { inApp: "CommandOrControl+P", global: null }, allowGlobal: false, }, + { + id: "view.openSearch", + labelKey: "settings.hotkeys.actions.openSearch", + defaultBinding: { inApp: "CommandOrControl+F", global: null }, + allowGlobal: false, + }, ]; /** 默认绑定表(HotkeyBindingsMap) */ diff --git a/shared/types/hotkey.ts b/shared/types/hotkey.ts index 6aaade6..14aa70e 100644 --- a/shared/types/hotkey.ts +++ b/shared/types/hotkey.ts @@ -18,7 +18,8 @@ export type HotkeyActionId = | "window.toggleTaskbarLyric" | "view.openPlayer" | "view.closePlayer" - | "view.togglePlaylist"; + | "view.togglePlaylist" + | "view.openSearch"; /** 单个动作的两条作用域绑定 */ export interface HotkeyBinding { diff --git a/shared/types/library.ts b/shared/types/library.ts index 3422f73..7172588 100644 --- a/shared/types/library.ts +++ b/shared/types/library.ts @@ -54,6 +54,10 @@ export interface LibraryApi { searchTracks: (query: string) => Promise>; /** 获取曲目总数 */ getTrackCount: () => Promise>; + /** 随机取一首曲目 */ + getRandomTrack: () => Promise>; + /** 随机取多首曲目 */ + getRandomTracks: (limit: number) => Promise>; /** 获取扫描状态 */ isScanning: () => Promise>; /** 弹出目录选择器,添加扫描目录 */ diff --git a/shared/types/stats.ts b/shared/types/stats.ts new file mode 100644 index 0000000..f2291ba --- /dev/null +++ b/shared/types/stats.ts @@ -0,0 +1,65 @@ +/** + * 播放统计采集类型 + * + * 渲染端构造、主进程写入 SQLite。直接带完整 Track,行即自包含、可还原重播。 + */ + +import type { Track } from "./player"; + +/** 一次播放的统计事件,写入 play_history */ +export interface PlayEventInput { + /** 完整曲目(播放当时的快照) */ + track: Track; + /** 本次播放开始 unix ms */ + startedAt: number; + /** 实际收听墙钟毫秒(扣掉暂停) */ + listenedMs: number; +} + +/** 一次收藏变更事件,写入 favorite_history */ +export interface FavoriteEventInput { + /** 完整曲目(操作当时的快照) */ + track: Track; + /** 收藏 / 取消收藏 */ + action: "add" | "remove"; +} + +/** 播放统计汇总 */ +export interface PlayStatsSummary { + /** 今日收听时长(毫秒) */ + todayListenedMs: number; + /** 本周收听时长(毫秒) */ + weekListenedMs: number; + /** 上周收听时长(毫秒,用于环比) */ + lastWeekListenedMs: number; + /** 累计收听时长(毫秒) */ + totalListenedMs: number; + /** 本周播放次数 */ + weekPlayCount: number; + /** 累计播放次数 */ + totalPlayCount: number; + /** 本周新增收藏数 */ + weekFavoriteAdds: number; + /** 连续收听天数 */ + streakDays: number; +} + +/** 一首高频曲目及其播放次数 */ +export interface TopTrack { + /** 曲目(播放当时的快照) */ + track: Track; + /** 累计播放次数 */ + playCount: number; +} + +/** preload 暴露的统计 API */ +export interface StatsApi { + /** 记录一次播放 */ + recordPlay: (event: PlayEventInput) => void; + /** 记录一次收藏变更 */ + recordFavorite: (event: FavoriteEventInput) => void; + /** 取播放统计汇总 */ + getStatsSummary: () => Promise; + /** 取最常播放的曲目(按次数倒序) */ + getTopTracks: (limit: number) => Promise; +} diff --git a/src/apis/recommend/netease.ts b/src/apis/recommend/netease.ts new file mode 100644 index 0000000..20adce7 --- /dev/null +++ b/src/apis/recommend/netease.ts @@ -0,0 +1,135 @@ +import type { Track } from "@shared/types/player"; +import type { CoverItem } from "@/types/artist"; +import type { NeteaseSong } from "@/types/netease"; +import { netease as neteaseApi } from "@/apis/netease"; +import { songsToTracks, withPicSize, toPlaylist, toArtist, toAlbum } from "@/utils/format/netease"; +import { playlistToCoverItem, artistToCoverItem, albumToCoverItem } from "@/utils/format/coverItem"; + +/** 每日推荐歌曲(每日 30 首,需登录) */ +export const fetchDailySongs = async (): Promise => { + const body = await neteaseApi.recommend_songs({ timestamp: Date.now() }); + return songsToTracks(body?.data?.dailySongs); +}; + +/** + * 心动模式智能播放列表 + * @param seedId - 种子歌曲 id + * @param playlistId - 歌单 id(「我喜欢的音乐」) + * @returns 智能推荐的 Track 列表 + */ +export const fetchHeartModeList = async (seedId: string, playlistId: string): Promise => { + const body = await neteaseApi.playmode_intelligence<{ data?: { songInfo: NeteaseSong }[] }>({ + id: seedId, + pid: playlistId, + }); + return songsToTracks((body?.data ?? []).map((item) => item.songInfo)); +}; + +/** 取一批私人 FM 推荐 */ +export const fetchPersonalFm = async (): Promise => { + const body = await neteaseApi.personal_fm<{ data?: NeteaseSong[] }>(); + return songsToTracks(body?.data ?? []); +}; + +/** + * 私人 FM 减少推荐 + * @param songId - 歌曲 id + * @param playedSec - 实际播放秒数,作为算法反馈 + */ +export const submitFmTrash = async (songId: string, playedSec?: number): Promise => { + await neteaseApi.fm_trash({ + id: songId, + ...(playedSec !== undefined ? { time: playedSec } : {}), + }); +}; + +/** 首页推荐区块展示数 */ +const HOME_GRID_LIMIT = 12; +/** personalized 拉取数 */ +const PERSONALIZED_FETCH_LIMIT = 20; + +interface RawRecommendPlaylist { + id: number | string; + name: string; + picUrl?: string; + copywriter?: string; + trackCount?: number; +} + +/** 推荐歌单原始结构 → 封面卡片 */ +const playlistToCover = (raw: RawRecommendPlaylist): CoverItem => ({ + id: String(raw.id), + title: raw.name, + cover: withPicSize(raw.picUrl), + subtitle: raw.copywriter || undefined, + trackCount: raw.trackCount ?? 0, +}); + +/** + * 推荐歌单 + * 已登录取每日专属歌单(recommend/resource),未登录取通用推荐(personalized); + * 过滤掉「私人雷达」类个性化歌单 + * @param loggedIn - 是否已登录 + * @returns 歌单封面卡片列表 + */ +export const fetchRecommendPlaylists = async (loggedIn: boolean): Promise => { + const list = loggedIn + ? ((await neteaseApi.recommend_resource<{ recommend?: RawRecommendPlaylist[] }>())?.recommend ?? + []) + : (( + await neteaseApi.personalized<{ result?: RawRecommendPlaylist[] }>({ + limit: PERSONALIZED_FETCH_LIMIT, + }) + )?.result ?? []); + return list + .filter((raw) => !raw.name.includes("雷达")) + .slice(0, HOME_GRID_LIMIT) + .map(playlistToCover); +}; + +/** 雷达歌单固定 id(私人 / 会员 / 时光 / 乐迷 / 宝藏 / 新歌 / 神秘) */ +const RADAR_PLAYLIST_IDS = [ + "3136952023", + "8402996200", + "5320167908", + "5327906368", + "5362359247", + "5300458264", + "5341776086", +]; + +/** + * 雷达歌单 + * 按固定 id 逐个取歌单详情组装成封面卡片,个别失败不影响整体 + * @returns 雷达歌单封面卡片列表 + */ +export const fetchRadarPlaylists = async (): Promise => { + const results = await Promise.allSettled( + RADAR_PLAYLIST_IDS.map((id) => neteaseApi.playlist_detail({ id })), + ); + const covers: CoverItem[] = []; + for (const result of results) { + if (result.status === "fulfilled" && result.value?.playlist) { + covers.push(playlistToCoverItem(toPlaylist(result.value.playlist))); + } + } + return covers; +}; + +/** + * 热门歌手 + * @returns 歌手封面卡片列表 + */ +export const fetchArtists = async (): Promise => { + const body = await neteaseApi.top_artists<{ artists?: unknown[] }>({ limit: HOME_GRID_LIMIT }); + return (body?.artists ?? []).map((raw) => artistToCoverItem(toArtist(raw))); +}; + +/** + * 新碟上架 + * @returns 专辑封面卡片列表 + */ +export const fetchNewAlbums = async (): Promise => { + const body = await neteaseApi.album_new<{ albums?: unknown[] }>({ limit: HOME_GRID_LIMIT }); + return (body?.albums ?? []).map((raw) => albumToCoverItem(toAlbum(raw))); +}; diff --git a/src/assets/icons/heart-mode.svg b/src/assets/icons/heart-mode.svg new file mode 100644 index 0000000..73b4071 --- /dev/null +++ b/src/assets/icons/heart-mode.svg @@ -0,0 +1,9 @@ + + + diff --git a/src/assets/icons/play-order.svg b/src/assets/icons/play-order.svg new file mode 100644 index 0000000..f919469 --- /dev/null +++ b/src/assets/icons/play-order.svg @@ -0,0 +1,14 @@ + + + + + + diff --git a/src/components/layout/NavSearch.vue b/src/components/layout/NavSearch.vue index 576e4b5..705e0cd 100644 --- a/src/components/layout/NavSearch.vue +++ b/src/components/layout/NavSearch.vue @@ -1,5 +1,6 @@ + + diff --git a/src/components/list/CoverList.vue b/src/components/list/CoverList.vue index afc5f5c..bc3f400 100644 --- a/src/components/list/CoverList.vue +++ b/src/components/list/CoverList.vue @@ -1,13 +1,15 @@ + +
+ +
diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index c78989f..48e6ebd 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -7,6 +7,7 @@ import { useSettingsStore } from "@/stores/settings"; import { useTrackMenu } from "@/composables/useTrackMenu"; import { useMultiSelect } from "@/composables/useMultiSelect"; import { useFavorite } from "@/composables/useFavorite"; +import { PLAYER_BAR_GAP } from "@/composables/useFloatingPlayerBar"; import PlaylistPickerDialog from "@/components/modals/PlaylistPickerDialog.vue"; import { formatTime } from "@/utils/time"; import { formatFileSize } from "@/utils/format"; @@ -279,7 +280,7 @@ defineExpose({ ref="virtualListRef" :items="sortedItems" :item-height="88" - :padding-bottom="80" + :padding-bottom="isFloatingPlayerBar ? PLAYER_BAR_GAP : 80" :get-item-key="(item: Track) => item.id" item-fixed height="100%" diff --git a/src/components/player/FullPlayer/PlayerBackground.vue b/src/components/player/FullPlayer/PlayerBackground.vue index 545cf77..4f9db7e 100644 --- a/src/components/player/FullPlayer/PlayerBackground.vue +++ b/src/components/player/FullPlayer/PlayerBackground.vue @@ -112,7 +112,6 @@ onBeforeUnmount(() => { position: absolute; inset: 0; background-color: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(100px) saturate(1.2); } /* 模糊模式 */ @@ -128,6 +127,7 @@ onBeforeUnmount(() => { height: 100%; object-fit: cover; transform: scale(1.5); + filter: blur(45px) saturate(1.2); opacity: 0; transition: opacity 0.5s ease-in-out; } diff --git a/src/components/player/FullPlayer/index.vue b/src/components/player/FullPlayer/index.vue index f252437..dadb7d9 100644 --- a/src/components/player/FullPlayer/index.vue +++ b/src/components/player/FullPlayer/index.vue @@ -18,8 +18,18 @@ const media = useMediaStore(); const settings = useSettingsStore(); const settingsDialog = useSettingsDialog(); const fav = useFavorite(); -const { isPlaying, isLoading, position, duration, isExpanded, repeatMode, shuffleMode, showLyric } = - storeToRefs(status); +const { + isPlaying, + isLoading, + position, + duration, + isExpanded, + repeatMode, + shuffleMode, + heartMode, + fmMode, + showLyric, +} = storeToRefs(status); /** 歌词组件引用 */ const lyricRef = ref>(); @@ -405,16 +415,26 @@ const resetLyricOffset = (): void => writeOffset(0); type="cover" variant="ghost" circle - :class="shuffleMode === 'on' ? 'opacity-100' : 'opacity-40'" - @click="player.toggleShuffleMode()" + @click=" + fmMode + ? player.dislikeFmTrack() + : heartMode + ? player.exitHeartMode() + : player.toggleShuffleMode() + " > - + @@ -446,11 +466,13 @@ const resetLyricOffset = (): void => writeOffset(0); type="cover" variant="ghost" circle - :class="repeatMode === 'off' ? 'opacity-40' : 'opacity-100'" + :disabled="fmMode" + :class="fmMode || repeatMode === 'off' ? 'opacity-40' : 'opacity-100'" @click="player.cycleRepeatMode()" > diff --git a/src/components/player/Lyrics/engine/index.ts b/src/components/player/Lyrics/engine/index.ts index c69b584..cfbfabb 100644 --- a/src/components/player/Lyrics/engine/index.ts +++ b/src/components/player/Lyrics/engine/index.ts @@ -903,7 +903,10 @@ export class LyricRenderer { const blurKey = blurCurrent.toFixed(2); if (this.cachedBlurKeys[i] !== blurKey) { this.cachedBlurKeys[i] = blurKey; - this.lineElements[i].style.setProperty("--blur", blurKey); + // 仅在确有模糊时挂 filter,归零时移除,避免非模糊行常驻 filter 合成层 + const lineStyle = this.lineElements[i].style; + if (blurCurrent > 0.01) lineStyle.filter = `blur(${(blurCurrent * 1.5).toFixed(2)}px)`; + else lineStyle.removeProperty("filter"); } } } @@ -1014,6 +1017,10 @@ export class LyricRenderer { // 所有动画完成后触发清理 anim.onfinish = tryCleanup; } + // 已结束或暂停的动画不会再触发 onfinish,主动计入,避免该行动画永不被清理 + for (const anim of anims) { + if (anim.playState === "finished" || anim.playState === "paused") tryCleanup(); + } }; /** 清理所有非激活行的残留动画,释放合成资源 */ diff --git a/src/components/player/Lyrics/index.vue b/src/components/player/Lyrics/index.vue index 09d8458..b38107f 100644 --- a/src/components/player/Lyrics/index.vue +++ b/src/components/player/Lyrics/index.vue @@ -157,7 +157,6 @@ const handleLineClick = (timeMs: number) => { }; onMounted(() => { - console.log("[LyricPlayer] onMounted"); if (!containerRef.value) return; const { lyricLines: _lyricLines, ...config } = props; renderer = new LyricRenderer(containerRef.value, { @@ -171,7 +170,6 @@ onMounted(() => { }); onUnmounted(() => { - console.log("[LyricPlayer] onUnmounted"); renderer?.dispose(); renderer = null; }); diff --git a/src/components/player/Lyrics/renderer.css b/src/components/player/Lyrics/renderer.css index 1c624a7..751d7bb 100644 --- a/src/components/player/Lyrics/renderer.css +++ b/src/components/player/Lyrics/renderer.css @@ -33,14 +33,11 @@ transform-origin: left; opacity: var(--lp-inactive-opacity, 0.3); contain: content; - will-change: transform, filter; - backface-visibility: hidden; transition: opacity 0.3s ease; + will-change: transform, filter; pointer-events: none; --ba: 0.2; --da: 0.2; - --blur: 0; - filter: blur(calc(var(--blur) * 1.5px)); &.active { opacity: var(--lp-active-opacity, 1); @@ -117,7 +114,6 @@ display: inline-block; white-space: pre-wrap; vertical-align: bottom; - backface-visibility: hidden; } /* 强调单词包裹层(逐字符拆分的容器) */ @@ -125,14 +121,12 @@ display: inline-block; white-space: pre; vertical-align: bottom; - backface-visibility: hidden; /* 为缩放/辉光溢出预留空间,负 margin 抵消布局影响 */ padding: 1em; margin: -1em; & > span { display: inline-block; - backface-visibility: hidden; padding: 1em; margin: -1em; } diff --git a/src/components/player/PlayerControls.vue b/src/components/player/PlayerControls.vue index cec363f..2e805a0 100644 --- a/src/components/player/PlayerControls.vue +++ b/src/components/player/PlayerControls.vue @@ -13,7 +13,7 @@ withDefaults( const status = useStatusStore(); const media = useMediaStore(); -const { isPlaying, isLoading, repeatMode, shuffleMode } = storeToRefs(status); +const { isPlaying, isLoading, repeatMode, shuffleMode, heartMode, fmMode } = storeToRefs(status); const hasTrack = computed(() => !!media.track); @@ -21,14 +21,25 @@ const hasTrack = computed(() => !!media.track);