Compare commits

...
20 Commits
Author SHA1 Message Date
alger 837f0268d5 fix(player): 合并外部贡献后的集成修复
- PlayBar 下载按钮补 isElectron 守卫,Web 端不再显示无效按钮(#708 评审意见)
- preloadNextSongs 恢复 800ms 短去抖,避免快速切歌时音源请求风暴(#712 评审意见)
- 清理合并后未使用的导入
2026-07-05 14:00:07 +08:00
alger 8ea13e04f0 Merge pull request #712 from daili115/trae/agent-m1V9o5
feat: 优化音乐播放性能,实现异步加载和即时预加载
2026-07-05 13:55:34 +08:00
alger 91a3259e27 Merge pull request #708 from jiang-LJ/main
feat(player): 在播放栏添加下载当前歌曲按钮
2026-07-05 13:55:34 +08:00
alger ecb85f0146 Merge pull request #676 from chengww5217/refactor/persisted-storage
refactor(persist): localStorage 配额防护与历史持久化重写
2026-07-05 13:55:20 +08:00
alger 6bea735aef Merge pull request #675 from chengww5217/fix/local-music-playback
fix(local-music): 修复本地音乐播放失败、进度异常与切歌失效
2026-07-05 13:55:20 +08:00
alger d9b102879f Merge pull request #677 from chengww5217/chore/fix-existing-lint
ci: 修复 PR Code Quality job 失败(存量 lint + typecheck 拿不到 unplugin d.ts)
2026-07-05 13:55:09 +08:00
daili115andtraeagent c5035b0583 feat: 修复软件切歌慢问题
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
2026-06-25 07:41:15 +00:00
jiang-LJ 7792eeac2e feat(player): 在播放栏添加下载当前歌曲按钮 2026-06-17 21:57:01 +08:00
chengww 1a8b5f4977 ci: 加 setup-bun 让 lint:i18n 步骤能跑
package.json 里 lint:i18n = "bun scripts/check_i18n.ts",但 pr-check.yml
只装了 Node 没装 bun,I18n check 步骤报 sh: bun: not found。

之前这个错误被前两步(lint / typecheck)的失败遮住,前两步修了之后才暴露。
用官方 oven-sh/setup-bun 一步搞定,放在 setup-node 之后、install 之前。
2026-05-18 00:07:05 +08:00
chengww 95694057ec ci: typecheck 前先跑 build 让 unplugin 生成自动 import d.ts
tsconfig.web.json 的 compilerOptions.types 显式 require
src/renderer/{auto-imports,components}.d.ts,这两个文件由
unplugin-auto-import / unplugin-vue-components 在 vite 启动时生成,
且被 .gitignore 排除(58922dc 维护者主动设置)。

CI 直接跑 typecheck 拿不到 d.ts 会报 TS2688,导致所有 PR 都过不了
Code Quality job。在 Type check 步骤前插入一次 npm run build 触发
unplugin 生成 d.ts,同时顺带验证 build 链路(一次到位,不增加额外
专用脚本,CI 多 30-60s 但能拦下 build 阶段的回归)。

本地验证:删除两个 d.ts 后跑 npm run build 重新生成,typecheck 通过。
2026-05-18 00:01:19 +08:00
chengww 938c497ca3 chore: 修复 main 上的存量 lint 错误
pr-check.yml 只在 PR 触发,main push 不跑 lint,导致存量错误一直未被发现,
PR #675 / #676 都被 Code Quality job 拦下。

- 9 处 prettier/prettier 格式错误:StickyTabPage.vue / RadioCard.vue /
  list/index.vue / mv/index.vue / podcast/index.vue 的属性换行与 import
  排序,全部 eslint --fix 自动修复
- 1 处 no-undef ScrollToOptions(StickyTabPage.vue:79):在 eslint.config.mjs
  的 .ts 与 .vue 配置块同时补 ScrollToOptions 到 globals,与既有的
  ScrollBehavior 同级;TypeScript DOM lib 类型不在 globals.browser 里,
  需手动声明
2026-05-17 23:54:28 +08:00
chengww a078e37e2c fix(build): renderer 单 chunk 打包,规避 chunk 间循环依赖 TDZ 白屏
store/index.ts 用 `export *` 聚合所有 store + 抽出的共享工具(如
debouncedStorage)被多 store 引用,Vite 6 默认拆分会让 utils 合并到
entry chunk,再与 store chunk 形成 index ↔ store 循环;当循环 chunk
顶层同步访问对方导出的 const(如 `persist: { storage: debouncedLocalStorage }`),
生产构建会触发 TDZ 报错 `Cannot access 'debouncedLocalStorage' before
initialization`,表现为打包后白屏(dev 不分包不暴露)。

Electron 桌面端无 CDN/首屏体积顾虑,单 chunk 是最简洁的兜底。
2026-05-17 23:38:33 +08:00
chengww 405b144e66 refactor(playHistory): v1 旧 localStorage key 清理与调用方对齐
老用户升级后清掉 v1 时代独立 key 释放配额,调用方切到 store API。

- player.ts initializePlayState 调用从 migrateFromLocalStorage 切到
  cleanupLegacyPlayHistoryStorage:不再做数据迁移(历史是低关键性
  衍生数据,老用户重新听几次即可),仅清掉旧 musicHistory/podcastHistory/
  playlistHistory/albumHistory/podcastRadioHistory/playHistory-migrated/
  playMode 这 7 个旧 key,避免和新 play-history-store key 双倍占用
- SystemTab.vue 清缓存的 case 'history' 改走 usePlayHistoryStore().
  clearMusicHistory(),与 v1 时代行为对齐(只清音乐历史,不动 podcast/
  playlist/album/podcastRadio);移除 case 'settings' 里的
  removeItem('playMode')——已并入 player-core-store
2026-05-17 23:08:52 +08:00
chengww 761884f23a refactor(playHistory): 持久化重写,统一防抖落盘与序列化兜底
把 playHistory 接入 utils/debouncedStorage 与 utils/persistedSong,
配合 add* 方法重构与 clearAll 同步落盘,闭合 localStorage 配额防护。

- musicHistory 类型从 SongResult 收敛到 MusicHistoryItem(精简子集),
  导出 MinifiedDjProgram、stripBase64Covers,给 podcast/playlist/album/
  podcastRadio 历史也做顶层 picUrl/coverImgUrl/coverUrl 的 base64 兜底
- serializePlayHistoryState 提取为模块级函数,给 persistedstate.serializer
  与 clearAll 同步落盘共用,避免格式漂移;isPodcast/program 字段必须
  保留——playbackController.playTrack 用 isPodcast 决定写哪条历史
- 5 个 add* 全部重写成单步 ref 重赋值,避免 splice/pop/unshift 多次
  触发 watch 与持久化;命中已有条目时累加 count + 刷新 lastPlayTime,
  picUrl/al 用新数据覆盖(封面可能换了短引用)
- clearAll 增加 flushDebouncedStorage + 同步 setItem 空状态,防止
  kill -9 落在 2s 防抖窗口里导致旧历史残留
- heatmap/index.vue 类型切到 MusicHistoryItem,移除 music.artists
  兜底(minifySong 已合并 ar/artists,只剩 ar)
2026-05-17 23:08:22 +08:00
chengww 537e280fdd refactor(persist): 抽公共防抖 storage 与 minifySong 工具,playlist/playerCore 接入
把 9caaec3 在 playlist 内联的 minifySong + debouncedLocalStorage 抽到
utils 共用,playerCore 同步接入,为后续推广到 playHistory 做准备。

- 新增 utils/debouncedStorage.ts:高频状态变更(volume 拖动、isPlay
  切换)2s 防抖落盘,beforeunload 钩子兜底刷盘;多 store 共用同一
  pendingWrites map,flush 时一次写完所有 pending key
- 新增 utils/persistedSong.ts:minifySong 剥离 base64 picUrl、仅保留
  local:// 永不过期的 playMusicUrl;ar.length 守卫避免空数组吞掉
  s.artists 兜底;al.id 守卫避免序列化出无 id 空壳
- playlist.ts 删除内联实现改用 import,行为不变
- playerCore.ts 切到防抖落盘并 minify playMusic;id 守卫避免空
  playMusic 走 minify 后失去 Object.keys().length===0 判空能力,
  下次启动会误恢复一首无 id 的空歌

Trade-off:极端非正常退出(kill -9 / 断电)下最近 2s 的 volume /
isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受
2026-05-17 23:07:58 +08:00
chengww 15258f28fd fix(local-music): 封面落盘 + URL 编码统一,修复持久化配额与编码边界
- 新增 src/shared/localUrl.ts 共用 local:// 编码:按路径段 encodeURIComponent,
  避免整体编码把 / 转成 %2F 引发 Chromium 解析边界差异,同时正确处理
  空格/中文/# 等特殊字符(封面落到含空格目录时 Image loader 会加载失败)
- 封面从内嵌 base64 Data URL 改为 userData/AudioCovers/<sha256>.<ext> 落盘,
  MAX_COVER_BYTES 1MB→8MB;老条目(无 coverPath 字段)扫描时一次性自愈
- playlist minify 剥离 base64 picUrl 并仅持久化 local:// 永不过期的 playMusicUrl,
  防止单张 base64 封面撑爆 localStorage 5MB 配额导致整个 playList 写入失败;
  localStorage 写入加 try/catch 兜底,避免配额超限时直接抛异常
2026-05-17 23:07:17 +08:00
chengww fa818a020f fix(player): 双击歌曲时同步设置播放列表上下文
BaseSongItem 双击和右键菜单"播放"只调了 setPlay,未 emit 'play',
导致父组件 setPlayList 不会执行,上下一首切换失效。抽出统一入口
先 emit 再触发播放,与点击播放按钮行为对齐。
2026-05-17 23:07:01 +08:00
chengww c15a10c098 fix(local-music): 主进程实现 Range/206 让本地音乐 seek/快进恢复
切到 protocol.handle 后,audio 元素发的 Range 请求会带 HTTP 头到达
主进程;net.fetch(file://) 既不识别 Range 也不带 Accept-Ranges,导致
浏览器认为 local:// 不支持随机访问,seek 时只能从头加载,表现为
快进失效、退出后恢复播放从 0 开始。

新增 buildLocalFileResponse:
- 无 Range:200 + Accept-Ranges: bytes,让 audio 知道支持随机访问
- 有 Range:206 + Content-Range,用 fs.createReadStream 切片返回
- 非法 Range:416 + Content-Range: bytes */total

顺手把 fs.existsSync 换成 stat,一次拿文件大小且过滤掉目录。
2026-05-17 23:07:01 +08:00
chengww 51f1aaba55 chore: 忽略 *.tsbuildinfo 编译缓存 2026-05-17 23:07:01 +08:00
chengww 0c0189bcef fix(local-music): 注册 local 协议为特权 scheme 修复本地音乐 CORS 报错
- main/index.ts 在 app.whenReady 之前调用 registerSchemesAsPrivileged,
  把 local 标记为 standard/secure/supportFetchAPI/stream/corsEnabled/bypassCSP,
  让 http 页面(dev server / 生产)可跨协议加载本地音频
- fileManager.ts 用 Electron 25 起推荐的 protocol.handle 替代已弃用的
  registerFileProtocol,内部转发到 file:// 自动支持 Range / 流式播放
- 修复 macOS/Linux 上去掉前导斜杠后丢失绝对路径前缀的问题
2026-05-17 23:07:01 +08:00
36 changed files with 774 additions and 290 deletions
+12
View File
@@ -58,12 +58,24 @@ jobs:
with:
node-version: 24
# lint:i18n 脚本用 bun 跑(package.json: "bun scripts/check_i18n.ts"),
# CI 默认环境没有 bun 会报 sh: bun: not found
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: npm install
- name: Lint
run: npx eslint --max-warnings 0 "src/**/*.{ts,tsx,vue,js}"
# tsconfig.web.json 显式 require src/renderer/{auto-imports,components}.d.ts
# 这两个文件由 unplugin-auto-import / unplugin-vue-components 在 vite 启动时
# 生成,且被 .gitignore 排除(58922dc 维护者主动设置)。CI 直接跑 typecheck
# 拿不到 d.ts 会报 TS2688,先跑一次 build 触发 unplugin 生成
- name: Build (generates auto-import / components d.ts)
run: npm run build
- name: Type check
run: npm run typecheck
+3
View File
@@ -46,5 +46,8 @@ AGENTS.md
.auto-imports.d.ts
.components.d.ts
# TypeScript 增量编译缓存
*.tsbuildinfo
src/renderer/auto-imports.d.ts
src/renderer/components.d.ts
+11
View File
@@ -35,6 +35,17 @@ export default defineConfig({
})
],
publicDir: resolve('resources'),
build: {
rollupOptions: {
output: {
// 全部代码打到 entry chunk,避免 Vite 默认按共享依赖拆分时
// 与 store/index.ts 的 `export *` 形成 chunk 间循环引用,
// 触发生产构建里的 TDZ(dev 不分包不会暴露此问题)。
// Electron 桌面端本地加载,无 CDN/首屏体积顾虑,单 chunk 合算。
manualChunks: () => 'index'
}
}
},
server: {
host: '0.0.0.0',
port: 2389
+4 -2
View File
@@ -55,7 +55,8 @@ export default [
defineEmits: 'readonly',
// TypeScript 全局类型
NodeJS: 'readonly',
ScrollBehavior: 'readonly'
ScrollBehavior: 'readonly',
ScrollToOptions: 'readonly'
}
},
plugins: {
@@ -148,7 +149,8 @@ export default [
useMessage: 'readonly',
// TypeScript 全局类型
NodeJS: 'readonly',
ScrollBehavior: 'readonly'
ScrollBehavior: 'readonly',
ScrollToOptions: 'readonly'
}
},
plugins: {
+1
View File
@@ -59,6 +59,7 @@ export default {
eq: 'Equalizer',
playList: 'Play List',
reparse: 'Reparse',
download: 'Download',
miniPlayBar: 'Mini Play Bar',
playMode: {
sequence: 'Sequence',
+1
View File
@@ -59,6 +59,7 @@ export default {
eq: 'イコライザー',
playList: 'プレイリスト',
reparse: '再解析',
download: 'ダウンロード',
playMode: {
sequence: '順次再生',
loop: 'ループ再生',
+1
View File
@@ -59,6 +59,7 @@ export default {
eq: '이퀄라이저',
playList: '재생 목록',
reparse: '재분석',
download: '다운로드',
playMode: {
sequence: '순차 재생',
loop: '반복 재생',
+1
View File
@@ -58,6 +58,7 @@ export default {
eq: '均衡器',
playList: '播放列表',
reparse: '重新解析',
download: '下载',
playMode: {
sequence: '顺序播放',
loop: '循环播放',
+1
View File
@@ -58,6 +58,7 @@ export default {
eq: '等化器',
playList: '播放清單',
reparse: '重新解析',
download: '下載',
playMode: {
sequence: '順序播放',
loop: '循環播放',
+18 -1
View File
@@ -1,7 +1,24 @@
import { electronApp, optimizer } from '@electron-toolkit/utils';
import { app, ipcMain, nativeImage, session } from 'electron';
import { app, ipcMain, nativeImage, protocol, session } from 'electron';
import { join } from 'path';
// 必须在 app.whenReady() 之前注册自定义协议为特权协议,
// 否则 http(s) 页面(dev server、生产环境的 file://)无法把 local:// 当成
// 安全/可 fetch/可流式的资源加载,会触发 CORS 拦截或 net::ERR_UNKNOWN_URL_SCHEME
protocol.registerSchemesAsPrivileged([
{
scheme: 'local',
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
stream: true,
bypassCSP: true,
corsEnabled: true
}
}
]);
import type { Language } from '../i18n/main';
import i18n from '../i18n/main';
import { loadLyricWindow } from './lyric';
+2 -2
View File
@@ -6,6 +6,7 @@ import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
import { filePathToLocalUrl } from '../../shared/localUrl';
import { getStore } from './config';
type CacheCleanupPolicy = 'lru' | 'fifo';
@@ -535,8 +536,7 @@ class DiskCacheManager {
}
private toLocalUrl(filePath: string): string {
const normalized = path.normalize(filePath).replace(/\\/g, '/');
return `local:///${encodeURIComponent(normalized)}`;
return filePathToLocalUrl(path.normalize(filePath));
}
private isRemoteAudioUrl(url: string): boolean {
+59 -15
View File
@@ -2,6 +2,7 @@ import { app, dialog, ipcMain, protocol, shell } from 'electron';
import Store from 'electron-store';
import * as fs from 'fs';
import * as path from 'path';
import { Readable } from 'stream';
import { getStore } from './config';
@@ -23,36 +24,79 @@ function sanitizeFilename(filename: string): string {
.trim();
}
// Electron net.fetch(file://) 在当前版本不会回 206audio seek 需要主进程自己处理 Range
function buildLocalFileResponse(
filePath: string,
total: number,
rangeHeader: string | null
): Response {
const range416 = () =>
new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
let start = 0;
let end = total - 1;
let partial = false;
if (rangeHeader) {
const m = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!m || (!m[1] && !m[2])) return range416();
if (m[1]) {
start = parseInt(m[1], 10);
if (m[2]) end = Math.min(parseInt(m[2], 10), end);
} else {
start = Math.max(0, total - parseInt(m[2], 10));
}
if (start > end || start >= total) return range416();
partial = true;
}
return new Response(
Readable.toWeb(fs.createReadStream(filePath, { start, end })) as ReadableStream,
{
status: partial ? 206 : 200,
headers: {
'Content-Length': String(end - start + 1),
'Accept-Ranges': 'bytes',
...(partial && { 'Content-Range': `bytes ${start}-${end}/${total}` })
}
}
);
}
/**
* 初始化文件管理相关的IPC监听
*/
export function initializeFileManager() {
// 注册本地文件协议
protocol.registerFileProtocol('local', (request, callback) => {
// Electron 25+ 起 registerFileProtocol 已弃用,改用 protocol.handle,并配合 main/index.ts
// 中的 registerSchemesAsPrivileged,让 audio 元素能从 http(s) 页面跨协议加载本地文件
protocol.handle('local', async (request) => {
try {
const url = request.url;
// local://C:/Users/xxx.mp3
let filePath = decodeURIComponent(url.replace('local:///', ''));
// local:///<absolute-path>
let filePath = decodeURIComponent(request.url.replace(/^local:\/\/\/?/, ''));
// 兼容 local:///C:/Users/xxx.mp3 这种情况
// Windows: 协议解析后可能是 /C:/...,去掉前导斜杠
if (/^\/[a-zA-Z]:\//.test(filePath)) {
filePath = filePath.slice(1);
}
// 还原为系统路径格式
filePath = path.normalize(filePath);
// 检查文件是否存在
if (!fs.existsSync(filePath)) {
console.error('File not found:', filePath);
callback({ error: -6 }); // net::ERR_FILE_NOT_FOUND
return;
// macOS/Linux 上去掉前导斜杠后会丢失绝对路径标识,这里补回
if (process.platform !== 'win32' && !filePath.startsWith('/')) {
filePath = '/' + filePath;
}
callback({ path: filePath });
filePath = path.normalize(filePath);
const stat = await fs.promises.stat(filePath).catch(() => null);
if (!stat?.isFile()) {
console.error('File not found:', filePath);
return new Response(null, { status: 404 });
}
return buildLocalFileResponse(filePath, stat.size, request.headers.get('range'));
} catch (error) {
console.error('Error handling local protocol:', error);
callback({ error: -2 }); // net::FAILED
return new Response(null, { status: 500 });
}
});
+51 -13
View File
@@ -1,7 +1,8 @@
// 本地音乐扫描模块
// 负责文件系统递归扫描和音乐文件元数据提取,通过 IPC 暴露给渲染进程
import { ipcMain } from 'electron';
import * as crypto from 'crypto';
import { app, ipcMain } from 'electron';
import * as fs from 'fs';
import * as mm from 'music-metadata';
import * as os from 'os';
@@ -10,7 +11,30 @@ import * as path from 'path';
/** 支持的音频文件格式 */
const SUPPORTED_AUDIO_FORMATS = ['.mp3', '.flac', '.wav', '.ogg', '.m4a', '.aac'] as const;
const METADATA_PARSE_CONCURRENCY = Math.min(8, Math.max(2, os.cpus().length));
const MAX_COVER_BYTES = 1024 * 1024;
const MAX_COVER_BYTES = 8 * 1024 * 1024;
/** 封面缓存目录:userData/AudioCovers/<hash>.<ext> */
const COVER_DIR_NAME = 'AudioCovers';
let cachedCoverDir: string | null = null;
function getCoverDir(): string {
if (cachedCoverDir) return cachedCoverDir;
const dir = path.join(app.getPath('userData'), COVER_DIR_NAME);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (error) {
console.error('创建封面目录失败:', error);
}
cachedCoverDir = dir;
return dir;
}
/** 从 mime 类型推断文件扩展名 */
function extFromMime(mime: string | undefined): string {
const sub = mime?.split('/')[1]?.split(';')[0]?.trim().toLowerCase();
if (!sub) return 'bin';
return sub === 'jpeg' ? 'jpg' : sub;
}
/**
* 主进程返回的原始音乐元数据
@@ -27,8 +51,8 @@ type LocalMusicMeta = {
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** 封面图片缓存文件绝对路径,无封面时为 null */
coverPath: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
@@ -66,23 +90,37 @@ function extractTitleFromFilename(filePath: string): string {
}
/**
* 将封面图片数据转换为 base64 Data URL
* 将封面图片落盘到 userData/AudioCovers/,返回绝对路径
* 文件名按 sourceFilePath 的 sha256 + 推断扩展名拼成,幂等可覆盖
* @param picture music-metadata 解析出的封面图片对象
* @returns base64 Data URL 字符串,转换失败返回 null
* @param sourceFilePath 音乐源文件绝对路径,用于生成稳定的封面文件名
* @returns 封面文件绝对路径,无封面或写入失败返回 null
*/
function extractCoverAsDataUrl(picture: mm.IPicture | undefined): string | null {
async function extractCoverToFile(
picture: mm.IPicture | undefined,
sourceFilePath: string
): Promise<string | null> {
if (!picture) {
return null;
}
try {
if (picture.data.length > MAX_COVER_BYTES) {
console.warn(
`封面超过大小上限被跳过: ${sourceFilePath} (${picture.data.length} bytes > ${MAX_COVER_BYTES})`
);
return null;
}
const mime = picture.format ?? 'image/jpeg';
const base64 = Buffer.from(picture.data).toString('base64');
return `data:${mime};base64,${base64}`;
const ext = extFromMime(picture.format);
const hash = crypto.createHash('sha256').update(sourceFilePath).digest('hex');
const coverFile = path.join(getCoverDir(), `${hash}.${ext}`);
// 直接覆盖写:本函数只在文件 mtime 变更时被调用(见 scanFolders 的 parseTargets),
// 频率本就受守门;按 size 跳过会在"用户替换内嵌封面、新旧字节数恰好相等"时留旧图,
// 单张封面几十~几百 KB,覆盖代价可忽略。
await fs.promises.writeFile(coverFile, Buffer.from(picture.data));
return coverFile;
} catch (error) {
console.error('封面提取失败:', error);
console.error('封面落盘失败:', error);
return null;
}
}
@@ -234,7 +272,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
coverPath: null,
lyrics: null,
fileSize,
modifiedTime
@@ -250,7 +288,7 @@ async function parseMetadata(filePath: string): Promise<LocalMusicMeta> {
artist: common.artist || fallback.artist,
album: common.album || fallback.album,
duration: format.duration ? Math.round(format.duration * 1000) : 0,
cover: extractCoverAsDataUrl(common.picture?.[0]),
coverPath: await extractCoverToFile(common.picture?.[0], filePath),
lyrics: extractLyrics(common.lyrics),
fileSize,
modifiedTime
@@ -4,7 +4,9 @@
<div class="w-full pb-32">
<!-- Page Header (scrolls away) -->
<div ref="headerRef" class="page-padding pt-6 pb-2">
<h1 class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white">
<h1
class="mb-2 text-2xl font-bold tracking-tight text-neutral-900 md:text-3xl dark:text-white"
>
{{ title }}
</h1>
<p v-if="description" class="text-neutral-500 dark:text-neutral-400">
@@ -4,7 +4,7 @@
@contextmenu.prevent="handleContextMenu"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@dblclick.stop="playMusicEvent(item)"
@dblclick.stop="handlePlay(item)"
>
<slot name="index"></slot>
<slot name="select" v-if="selectable"></slot>
@@ -22,7 +22,7 @@
:is-dislike="isDislike"
:can-remove="canRemove"
@update:show="showDropdown = $event"
@play="playMusicEvent(item)"
@play="handlePlay(item)"
@play-next="handlePlayNext"
@download="downloadMusic(item)"
@download-lyric="downloadLyric(item)"
@@ -83,6 +83,12 @@ const imageLoad = async (event: Event) => {
await handleImageLoad(target);
};
// 双击和右键菜单"播放"统一入口:先通知父组件设置播放列表上下文,再触发播放
const handlePlay = (song: SongResult) => {
emits('play', song);
playMusicEvent(song);
};
// 切换选择状态
const toggleSelect = () => {
emits('select', props.item.id, !props.selected);
@@ -151,6 +151,16 @@
</template>
{{ t('player.playBar.reparse') }}
</n-tooltip>
<n-tooltip v-if="playMusic?.id && isElectron" trigger="hover" :z-index="9999999">
<template #trigger>
<i
class="iconfont ri-download-line"
:class="{ 'disabled-icon': isDownloading }"
@click="playMusic?.id && handleDownload()"
/>
</template>
{{ isDownloading ? t('songItem.message.downloading') : t('player.playBar.download') }}
</n-tooltip>
<!-- 高级控制菜单按钮整合了 EQ定时关闭播放速度 -->
<advanced-controls-popover />
@@ -189,6 +199,7 @@ import {
textColors
} from '@/hooks/MusicHook';
import { useArtist } from '@/hooks/useArtist';
import { useDownload } from '@/hooks/useDownload';
import { useFavorite } from '@/hooks/useFavorite';
import { usePlaybackControl } from '@/hooks/usePlaybackControl';
import { usePlayMode } from '@/hooks/usePlayMode';
@@ -217,6 +228,13 @@ const {
// 收藏
const { isFavorite, toggleFavorite } = useFavorite();
// 下载
const { downloadMusic, isDownloading } = useDownload();
const handleDownload = () => {
if (!playMusic.value || isDownloading.value) return;
downloadMusic(playMusic.value);
};
// 播放模式
const { playMode, playModeIcon, playModeText, togglePlayMode } = usePlayMode();
@@ -23,11 +23,7 @@ const goToDetail = () => {
</script>
<template>
<div
class="group cursor-pointer animate-item"
:style="{ animationDelay }"
@click="goToDetail"
>
<div class="group cursor-pointer animate-item" :style="{ animationDelay }" @click="goToDetail">
<!-- Cover -->
<div
class="relative aspect-square overflow-hidden rounded-2xl shadow-md group-hover:shadow-xl transition-all duration-500"
+4 -16
View File
@@ -6,8 +6,7 @@ import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { playbackRequestManager } from '@/services/playbackRequestManager';
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
import { getImgUrl, isElectron } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
import { isElectron } from '@/utils';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
const { message } = createDiscreteApi(['message']);
@@ -372,11 +371,9 @@ export const useLyrics = () => {
};
/**
* 获取歌曲详情
* 获取歌曲详情(优化版 - 只获取URL,背景色在播放后异步获取)
*/
export const useSongDetail = () => {
const { getSongUrl } = useSongUrl();
const getSongDetail = async (playMusic: SongResult, requestId?: string) => {
// 验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
@@ -405,19 +402,10 @@ export const useSongDetail = () => {
playMusic.createdAt = Date.now();
// 半小时后过期
playMusic.expiredAt = playMusic.createdAt + 1800000;
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
// 验证请求
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
console.log(`[getSongDetail] 背景色获取后请求已失效: ${requestId}`);
throw new Error('Request cancelled');
}
playMusic.playLoading = false;
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
// 返回歌曲信息,背景色和歌词将在播放后异步加载
return { ...playMusic, playMusicUrl } as SongResult;
} catch (error) {
if ((error as Error).message === 'Request cancelled') {
throw error;
+29 -32
View File
@@ -114,7 +114,7 @@ const loadAndPlayAudio = async (song: SongResult, shouldPlay: boolean): Promise<
};
/**
* 触发预加载下一首/下下首歌曲
* 触发预加载下一首/下下首歌曲(立即触发)
*/
const triggerPreload = async (song: SongResult): Promise<void> => {
try {
@@ -125,9 +125,8 @@ const triggerPreload = async (song: SongResult): Promise<void> => {
(item: SongResult) => item.id === song.id && item.source === song.source
);
if (idx !== -1) {
setTimeout(() => {
playlistStore.preloadNextSongs(idx);
}, 3000);
// 立即触发预加载,不等待
playlistStore.preloadNextSongs(idx);
}
}
} catch (e) {
@@ -152,7 +151,7 @@ const updateDocumentTitle = (music: SongResult): void => {
// ==================== 导出函数 ====================
/**
* 核心播放函数
* 核心播放函数(优化版)
*
* @param music 要播放的歌曲
* @param shouldPlay 是否立即播放(默认 true)
@@ -193,30 +192,12 @@ export const playTrack = async (
playerCore.isPlay = shouldPlay;
playerCore.userPlayIntent = shouldPlay;
// 4. 加载元数据(歌词 + 背景色
try {
const { lyrics, backgroundColor, primaryColor } = await loadMetadata(music);
// 检查 generation
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(加载元数据后),当前 gen=${generation}`);
return false;
}
music.lyric = lyrics;
music.backgroundColor = backgroundColor;
music.primaryColor = primaryColor;
} catch (error) {
if (gen !== generation) return false;
console.error('[playbackController] 加载元数据失败:', error);
// 元数据加载失败不阻塞播放,继续执行
}
// 5. 歌词已加载,现在设置 playMusic(触发 MusicHook 的歌词 watcher
// 4. 先设置基本歌曲信息(立即显示UI
music.playLoading = true;
playerCore.playMusic = music;
updateDocumentTitle(music);
// 保存原始歌曲数据
const originalMusic = { ...music };
// 5. 添加到播放历史
@@ -233,7 +214,7 @@ export const playTrack = async (
console.warn('[playbackController] 添加播放历史失败:', e);
}
// 6. 获取歌曲详情(解析 URL)
// 6. 获取歌曲详情(解析 URL)- 这是必须的,不能跳过
try {
const { getSongDetail } = useSongDetail();
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
@@ -244,7 +225,6 @@ export const playTrack = async (
return false;
}
updatedPlayMusic.lyric = music.lyric;
playerCore.playMusic = updatedPlayMusic;
playerCore.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
@@ -259,10 +239,7 @@ export const playTrack = async (
return false;
}
// 7. 触发预加载下一首(异步,不阻塞
triggerPreload(playerCore.playMusic);
// 8. 加载并播放音频
// 7. 加载并播放音频(优先播放
try {
const success = await loadAndPlayAudio(playerCore.playMusic, shouldPlay);
@@ -274,18 +251,38 @@ export const playTrack = async (
}
if (success) {
// 8. 播放成功后,异步加载元数据(不阻塞)
loadMetadata(playerCore.playMusic).then(({ lyrics, backgroundColor, primaryColor }) => {
// 检查 generation(确保还是当前歌曲)
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期,跳过元数据更新`);
return;
}
playerCore.playMusic.lyric = lyrics;
playerCore.playMusic.backgroundColor = backgroundColor;
playerCore.playMusic.primaryColor = primaryColor;
// 触发 watcher 更新
playerCore.playMusic = { ...playerCore.playMusic };
}).catch((error) => {
console.warn('[playbackController] 元数据加载失败:', error);
});
// 9. 播放成功
playerCore.playMusic.playLoading = false;
playerCore.playMusic.isFirstPlay = false;
playbackRequestManager.completeRequest(requestId);
console.log(`[playbackController] gen=${gen} 播放成功: ${music.name}`);
// 10. 触发预加载下一首(立即触发,不等待)
triggerPreload(playerCore.playMusic);
return true;
} else {
playbackRequestManager.failRequest(requestId);
return false;
}
} catch (error) {
// 10. 播放失败
// 11. 播放失败
if (gen !== generation) {
console.log(`[playbackController] gen=${gen} 已过期(播放异常),静默返回`);
return false;
+2 -2
View File
@@ -104,12 +104,12 @@ class PreloadService {
testAudio.src = url;
testAudio.load();
// 5秒超时
// 3秒超时(优化预加载速度)
setTimeout(() => {
cleanup();
// 超时不算失败,URL 可能是可用的只是服务器慢
resolve(url);
}, 5000);
}, 3000);
});
}
+6 -1
View File
@@ -150,10 +150,15 @@ export const useLocalMusicStore = defineStore(
}
// 2. 增量扫描:基于修改时间筛选需重新解析的文件
// 老条目(无 coverPath 字段)也视为需要重新解析,让数据自愈到统一格式
const parseTargets: string[] = [];
for (const file of files) {
const cached = cachedMap.get(file.path);
if (!cached || cached.modifiedTime !== file.modifiedTime) {
if (
!cached ||
cached.modifiedTime !== file.modifiedTime ||
!('coverPath' in cached)
) {
parseTargets.push(file.path);
}
}
+161 -104
View File
@@ -3,6 +3,37 @@ import { ref } from 'vue';
import type { SongResult } from '@/types/music';
import type { DjProgram } from '@/types/podcast';
import { debouncedLocalStorage, flushDebouncedStorage } from '@/utils/debouncedStorage';
import type { MusicHistoryItem } from '@/utils/persistedSong';
import {
minifyHistoryEntry,
minifyHistoryList,
stripBase64CoversList
} from '@/utils/persistedSong';
export type { MusicHistoryItem };
// 一次性清理 v1 时代遗留的 localStorage key。
// 旧版本以 5 个独立 key 单独存历史,新版本合并到 PERSIST_KEY 由 persistedstate 管。
// 不做数据迁移:历史是低关键性衍生数据,老用户升级后看到空"最近播放",重新听几次即可。
// 仅清掉旧 key 释放配额,避免和新 key 双倍占用
const LEGACY_KEYS = [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory',
// v1 迁移方案的 flag,已随 e53a035 发布到用户机器上
'playHistory-migrated',
// v1 时代独立持久化的播放模式,现已并入 player-core-store
'playMode'
];
export const cleanupLegacyPlayHistoryStorage = (): void => {
if (localStorage.getItem('playHistory-cleaned-v1')) return;
LEGACY_KEYS.forEach((key) => localStorage.removeItem(key));
localStorage.setItem('playHistory-cleaned-v1', '1');
};
// ==================== 类型定义 ====================
@@ -54,6 +85,43 @@ export type PodcastRadioHistoryItem = {
// 历史记录最大条数
const MAX_HISTORY_SIZE = 500;
// persistedstate 的 storage key
const PERSIST_KEY = 'play-history-store';
// pick 出来的持久化状态,给 persistedstate.serializer 与 clearAll 同步落盘共用
type PersistedPlayHistoryState = {
musicHistory: MusicHistoryItem[];
podcastHistory: DjProgram[];
playlistHistory: PlaylistHistoryItem[];
albumHistory: AlbumHistoryItem[];
podcastRadioHistory: PodcastRadioHistoryItem[];
};
// 序列化层兜底:哪怕有代码绕过 addMusic 直接 push 完整 SongResult,也能在 serialize 时
// 再过一遍 minifyHistoryList,确保 localStorage 里的 musicHistory 不混入 lyric/song/base64 封面
//
// podcast/playlist/album/podcastRadio 等历史也走 stripBase64CoversList 兜底:
// 它们的图片字段(picUrl / coverImgUrl / coverUrl)若被注入 base64 Data URL
// 同样会撑爆 5MB 配额;保持四类历史的防御对称,避免某一处漏掉变成隐患
//
// 入参用 any 是为了同时兼容 persistedstate 的 StateTree 与 clearAll 手工构造的 PersistedPlayHistoryState
const serializePlayHistoryState = (state: any): string => {
const s = state as PersistedPlayHistoryState;
return JSON.stringify({
...state,
musicHistory: minifyHistoryList(
s.musicHistory as unknown as (SongResult & {
count?: number;
lastPlayTime?: number;
})[]
),
podcastHistory: stripBase64CoversList(s.podcastHistory),
playlistHistory: stripBase64CoversList(s.playlistHistory),
albumHistory: stripBase64CoversList(s.albumHistory),
podcastRadioHistory: stripBase64CoversList(s.podcastRadioHistory)
});
};
/**
* 播放记录统一管理 Store
* 使用 Pinia 单例模式,解决多实例不同步问题
@@ -63,7 +131,9 @@ export const usePlayHistoryStore = defineStore(
'playHistory',
() => {
// ==================== 状态 ====================
const musicHistory = ref<SongResult[]>([]);
// musicHistory 用 MusicHistoryItem 而非完整 SongResultlyric/song/expiredAt 等
// 派生字段不进 localStorage,避免 5MB 配额被撑爆。详见 utils/persistedSong.ts
const musicHistory = ref<MusicHistoryItem[]>([]);
const podcastHistory = ref<DjProgram[]>([]);
const playlistHistory = ref<PlaylistHistoryItem[]>([]);
const albumHistory = ref<AlbumHistoryItem[]>([]);
@@ -71,20 +141,36 @@ export const usePlayHistoryStore = defineStore(
// ==================== 音乐记录 ====================
// lastPlayTime 语义:每次播放都刷新为当前时间("最近一次播放"),不是"首次添加"。
// 与 playlistHistory/albumHistory 等其他历史保持一致;count 仍为累计播放次数
const addMusic = (music: SongResult): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
// 单步 ref 重赋值,避免 splice/pop + unshift 多次触发 watch 与持久化
let next: MusicHistoryItem[];
if (index !== -1) {
musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
// 命中已有条目:累加计数 + 刷新时间戳,picUrl/al 等用新数据覆盖(封面可能换了短引用)
const existing = musicHistory.value[index];
const refreshed: MusicHistoryItem = {
...minifyHistoryEntry(music),
count: (existing.count || 0) + 1,
lastPlayTime: Date.now()
};
next = [
refreshed,
...musicHistory.value.slice(0, index),
...musicHistory.value.slice(index + 1)
];
} else {
musicHistory.value.unshift({ ...music, count: 1 });
}
if (musicHistory.value.length > MAX_HISTORY_SIZE) {
musicHistory.value.pop();
next = [
minifyHistoryEntry({ ...music, count: 1, lastPlayTime: Date.now() }),
...musicHistory.value
];
}
musicHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delMusic = (music: SongResult): void => {
// 删除入参允许 SongResult 或 MusicHistoryItem,仅按 id 匹配,类型放宽不影响逻辑
const delMusic = (music: { id: SongResult['id'] }): void => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value.splice(index, 1);
@@ -95,14 +181,19 @@ export const usePlayHistoryStore = defineStore(
const addPodcast = (program: DjProgram): void => {
const index = podcastHistory.value.findIndex((item) => item.id === program.id);
// 单步 ref 重赋值,避免 splice/unshift/pop 多次触发 watch 与持久化(与 addMusic 一致)
let next: DjProgram[];
if (index !== -1) {
podcastHistory.value.unshift(podcastHistory.value.splice(index, 1)[0]);
next = [
podcastHistory.value[index],
...podcastHistory.value.slice(0, index),
...podcastHistory.value.slice(index + 1)
];
} else {
podcastHistory.value.unshift(program);
}
if (podcastHistory.value.length > MAX_HISTORY_SIZE) {
podcastHistory.value.pop();
next = [program, ...podcastHistory.value];
}
podcastHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPodcast = (program: DjProgram): void => {
@@ -117,16 +208,19 @@ export const usePlayHistoryStore = defineStore(
const addPlaylist = (playlist: PlaylistHistoryItem): void => {
const index = playlistHistory.value.findIndex((item) => item.id === playlist.id);
const now = Date.now();
let next: PlaylistHistoryItem[];
if (index !== -1) {
playlistHistory.value[index].count = (playlistHistory.value[index].count || 0) + 1;
playlistHistory.value[index].lastPlayTime = now;
playlistHistory.value.unshift(playlistHistory.value.splice(index, 1)[0]);
const existing = playlistHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...playlistHistory.value.slice(0, index),
...playlistHistory.value.slice(index + 1)
];
} else {
playlistHistory.value.unshift({ ...playlist, count: 1, lastPlayTime: now });
}
if (playlistHistory.value.length > MAX_HISTORY_SIZE) {
playlistHistory.value.pop();
next = [{ ...playlist, count: 1, lastPlayTime: now }, ...playlistHistory.value];
}
playlistHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPlaylist = (playlist: PlaylistHistoryItem): void => {
@@ -141,16 +235,18 @@ export const usePlayHistoryStore = defineStore(
const addAlbum = (album: AlbumHistoryItem): void => {
const index = albumHistory.value.findIndex((item) => item.id === album.id);
const now = Date.now();
let next: AlbumHistoryItem[];
if (index !== -1) {
albumHistory.value[index].count = (albumHistory.value[index].count || 0) + 1;
albumHistory.value[index].lastPlayTime = now;
albumHistory.value.unshift(albumHistory.value.splice(index, 1)[0]);
const existing = albumHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...albumHistory.value.slice(0, index),
...albumHistory.value.slice(index + 1)
];
} else {
albumHistory.value.unshift({ ...album, count: 1, lastPlayTime: now });
}
if (albumHistory.value.length > MAX_HISTORY_SIZE) {
albumHistory.value.pop();
next = [{ ...album, count: 1, lastPlayTime: now }, ...albumHistory.value];
}
albumHistory.value = next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delAlbum = (album: AlbumHistoryItem): void => {
@@ -165,17 +261,19 @@ export const usePlayHistoryStore = defineStore(
const addPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id);
const now = Date.now();
let next: PodcastRadioHistoryItem[];
if (index !== -1) {
const existing = podcastRadioHistory.value.splice(index, 1)[0];
existing.count = (existing.count || 0) + 1;
existing.lastPlayTime = now;
podcastRadioHistory.value.unshift(existing);
const existing = podcastRadioHistory.value[index];
next = [
{ ...existing, count: (existing.count || 0) + 1, lastPlayTime: now },
...podcastRadioHistory.value.slice(0, index),
...podcastRadioHistory.value.slice(index + 1)
];
} else {
podcastRadioHistory.value.unshift({ ...radio, count: 1, lastPlayTime: now });
}
if (podcastRadioHistory.value.length > MAX_HISTORY_SIZE) {
podcastRadioHistory.value.pop();
next = [{ ...radio, count: 1, lastPlayTime: now }, ...podcastRadioHistory.value];
}
podcastRadioHistory.value =
next.length > MAX_HISTORY_SIZE ? next.slice(0, MAX_HISTORY_SIZE) : next;
};
const delPodcastRadio = (radio: PodcastRadioHistoryItem): void => {
@@ -213,72 +311,25 @@ export const usePlayHistoryStore = defineStore(
clearPlaylistHistory();
clearAlbumHistory();
clearPodcastRadioHistory();
};
// ==================== 数据迁移 ====================
/**
* 从旧的 localStorage 数据迁移到 Pinia store
* 只在首次启动时执行一次
*/
const migrateFromLocalStorage = (): void => {
const migrated = localStorage.getItem('playHistory-migrated');
if (migrated) return;
// 同步落盘空状态:persistedstate 的 watch 异步触发,clearAll 同步代码返回时
// watch 还没把空状态送进 storage;再叠 2s 防抖窗口 → 立刻 kill -9 会让 PERSIST_KEY
// 留在旧历史。手动 setItem 让"clearAll 返回 = 落盘完成"成立。
// 前面 flushDebouncedStorage 是顺手清掉别的 store 排队的写入与防抖定时器,
// 避免后续 watch 异步把同一份空状态再写一次(无害但多余 I/O)
flushDebouncedStorage();
try {
// 迁移音乐记录
const oldMusic = localStorage.getItem('musicHistory');
if (oldMusic) {
const parsed = JSON.parse(oldMusic);
if (Array.isArray(parsed) && parsed.length > 0 && musicHistory.value.length === 0) {
musicHistory.value = parsed;
}
}
// 迁移播客记录
const oldPodcast = localStorage.getItem('podcastHistory');
if (oldPodcast) {
const parsed = JSON.parse(oldPodcast);
if (Array.isArray(parsed) && parsed.length > 0 && podcastHistory.value.length === 0) {
podcastHistory.value = parsed;
}
}
// 迁移歌单记录
const oldPlaylist = localStorage.getItem('playlistHistory');
if (oldPlaylist) {
const parsed = JSON.parse(oldPlaylist);
if (Array.isArray(parsed) && parsed.length > 0 && playlistHistory.value.length === 0) {
playlistHistory.value = parsed;
}
}
// 迁移专辑记录
const oldAlbum = localStorage.getItem('albumHistory');
if (oldAlbum) {
const parsed = JSON.parse(oldAlbum);
if (Array.isArray(parsed) && parsed.length > 0 && albumHistory.value.length === 0) {
albumHistory.value = parsed;
}
}
// 迁移播客电台记录
const oldRadio = localStorage.getItem('podcastRadioHistory');
if (oldRadio) {
const parsed = JSON.parse(oldRadio);
if (
Array.isArray(parsed) &&
parsed.length > 0 &&
podcastRadioHistory.value.length === 0
) {
podcastRadioHistory.value = parsed;
}
}
localStorage.setItem('playHistory-migrated', '1');
console.log('[PlayHistory] 数据迁移完成');
localStorage.setItem(
PERSIST_KEY,
serializePlayHistoryState({
musicHistory: [],
podcastHistory: [],
playlistHistory: [],
albumHistory: [],
podcastRadioHistory: []
})
);
} catch (error) {
console.error('[PlayHistory] 数据迁移失败:', error);
console.error('[PlayHistory] 清空落盘失败:', error);
}
};
@@ -316,21 +367,27 @@ export const usePlayHistoryStore = defineStore(
clearPodcastRadioHistory,
// 通用
clearAll,
migrateFromLocalStorage
clearAll
};
},
{
persist: {
key: 'play-history-store',
storage: localStorage,
key: PERSIST_KEY,
// 共用防抖 storageaddMusic 在播放切换时会触发 mutation,避免每次都 stringify
// 整条 musicHistory(最多 500 条)走一遍 minifyHistoryList
storage: debouncedLocalStorage,
pick: [
'musicHistory',
'podcastHistory',
'playlistHistory',
'albumHistory',
'podcastRadioHistory'
]
],
// 与 clearAll 的同步落盘共用同一个序列化函数,避免格式漂移
serializer: {
serialize: serializePlayHistoryState,
deserialize: JSON.parse
}
}
}
);
+3 -4
View File
@@ -13,7 +13,7 @@ import { computed } from 'vue';
import { useFavoriteStore } from './favorite';
import { useIntelligenceModeStore } from './intelligenceMode';
import { usePlayerCoreStore } from './playerCore';
import { usePlayHistoryStore } from './playHistory';
import { cleanupLegacyPlayHistoryStorage } from './playHistory';
import { usePlaylistStore } from './playlist';
import { type SleepTimerInfo, SleepTimerType, useSleepTimerStore } from './sleepTimer';
@@ -72,9 +72,8 @@ export const usePlayerStore = defineStore('player', () => {
* 初始化播放状态(从 localStorage 恢复)
*/
const initializePlayState = async () => {
// 从旧的 localStorage 迁移播放记录到 Pinia store
const playHistoryStore = usePlayHistoryStore();
playHistoryStore.migrateFromLocalStorage();
// 一次性清理 v1 时代的旧 localStorage key,不做数据迁移(详见 playHistory.ts 注释)
cleanupLegacyPlayHistoryStorage();
const { initializePlayState: initPlayState } = await import('@/services/playbackController');
await initPlayState();
+24 -2
View File
@@ -4,6 +4,8 @@ import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import type { AudioOutputDevice } from '@/types/audio';
import type { SongResult } from '@/types/music';
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
import { minifySong } from '@/utils/persistedSong';
/**
* 核心播放控制 Store
@@ -220,7 +222,12 @@ export const usePlayerCoreStore = defineStore(
{
persist: {
key: 'player-core-store',
storage: localStorage,
// 使用 debouncedLocalStoragevolume 拖动 / 静音切换会高频触发 mutation,
// 直接写 localStorage 会导致每次都 stringify + minify 整个 state,浪费 I/O。
// 防抖 2s 写一次足够,beforeunload 钩子兜底刷盘。
// Trade-off:极端非正常退出(kill -9 / 断电 / 主进程崩溃)下 beforeunload 不触发,
// 最近 2s 的 volume / isPlay / playMusic 变更会丢——这些状态丢一次无大碍,可接受
storage: debouncedLocalStorage,
pick: [
'playMusic',
'playMusicUrl',
@@ -229,7 +236,22 @@ export const usePlayerCoreStore = defineStore(
'isMuted',
'isPlay',
'audioOutputDeviceId'
]
],
// playMusic 持久化前过 minifySong:剥离 base64 封面、lyric、song 等大字段。
// 单首歌的 lyric 持久化后没有用(重启后会重新加载),但 picUrl 若是 base64 会
// 拖累整个 player-core-store 写入失败(5MB 配额)。playMusicUrl 在 store 层级单独
// 持久化,不受 playMusic 内部精简影响——本地音乐 local:// URL 仍保持可恢复。
// id 守卫:空 playMusic 走 minifySong 会得到 {picUrl:'', ar:[]}stripDataUrl
// 把 undefined 转成空串、ar 缺省返回空数组),下次启动 playbackController 的
// Object.keys().length === 0 判空就会失效,误恢复一首无 id 的空歌
serializer: {
serialize: (state: any) =>
JSON.stringify({
...state,
playMusic: state.playMusic?.id ? minifySong(state.playMusic as SongResult) : {}
}),
deserialize: JSON.parse
}
}
}
);
+18 -52
View File
@@ -1,5 +1,4 @@
import { useThrottleFn } from '@vueuse/core';
import { debounce } from 'lodash';
import { createDiscreteApi } from 'naive-ui';
import { defineStore, storeToRefs } from 'pinia';
import { computed, ref, shallowRef, triggerRef } from 'vue';
@@ -9,6 +8,8 @@ import { useSongDetail } from '@/hooks/usePlayerHooks';
import { preloadService } from '@/services/preloadService';
import type { SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import { debouncedLocalStorage } from '@/utils/debouncedStorage';
import { minifySongList } from '@/utils/persistedSong';
import { performShuffle, preloadCoverImage } from '@/utils/playerUtils';
import { useIntelligenceModeStore } from './intelligenceMode';
@@ -22,53 +23,6 @@ const getMessage = () => {
return _message;
};
/**
* 精简 SongResult 对象,只保留持久化必要字段
* 排除大体积字段:lyric, song, playMusicUrl, backgroundColor, primaryColor
*/
const minifySong = (s: SongResult) => ({
id: s.id,
name: s.name,
picUrl: s.picUrl,
ar: s.ar?.map((a) => ({ id: a.id, name: a.name })),
al: s.al,
source: s.source,
dt: s.dt
});
const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) ?? [];
/**
* 防抖 localStorage 包装,降低写入频率
* 通过 pendingWrites 跟踪未写入数据,beforeunload 时刷新
*/
const pendingWrites = new Map<string, string>();
const flushPendingWrites = () => {
pendingWrites.forEach((value, key) => {
localStorage.setItem(key, value);
});
pendingWrites.clear();
};
const debouncedSetItem = debounce((key: string, value: string) => {
localStorage.setItem(key, value);
pendingWrites.delete(key);
}, 2000);
const debouncedLocalStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => {
pendingWrites.set(key, value);
debouncedSetItem(key, value);
}
};
// 正常关闭时刷新未写入的数据
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPendingWrites);
}
/**
* 播放列表管理 Store
* 负责:播放列表、索引、播放模式、预加载、上/下一首
@@ -154,8 +108,18 @@ export const usePlaylistStore = defineStore(
/**
* 智能预加载下一首歌曲
* 短去抖:快速连续切歌时只保留最后一次,避免对音源 API 的请求风暴
*/
let preloadDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const preloadNextSongs = (currentIndex: number) => {
if (preloadDebounceTimer) clearTimeout(preloadDebounceTimer);
preloadDebounceTimer = setTimeout(() => {
preloadDebounceTimer = null;
doPreloadNextSongs(currentIndex);
}, 800);
};
const doPreloadNextSongs = (currentIndex: number) => {
if (playList.value.length <= 1) return;
let nextIndex: number;
@@ -171,9 +135,11 @@ export const usePlaylistStore = defineStore(
nextIndex = (currentIndex + 1) % playList.value.length;
}
// 预加载下一首和下下首(最多2首)
const endIndex = Math.min(nextIndex + 2, playList.value.length);
if (nextIndex < playList.value.length) {
// 立即执行预加载
fetchSongs(nextIndex, endIndex);
// 循环模式且接近列表末尾,预加载列表开头
@@ -182,9 +148,8 @@ export const usePlaylistStore = defineStore(
nextIndex + 1 >= playList.value.length &&
playList.value.length > 2
) {
setTimeout(() => {
fetchSongs(0, 1);
}, 1000);
// 立即预加载,不等待
fetchSongs(0, 1);
}
}
};
@@ -682,7 +647,8 @@ export const usePlaylistStore = defineStore(
if (success) {
playerCore.isPlay = true;
if (songIndex !== -1) {
setTimeout(() => preloadNextSongs(playListIndex.value), 3000);
// 立即预加载,不等待
preloadNextSongs(playListIndex.value);
}
}
return success;
+2 -2
View File
@@ -24,8 +24,8 @@ export type LocalMusicMeta = {
album: string;
/** 时长(毫秒) */
duration: number;
/** base64 Data URL 格式的封面图片,无封面时为 null */
cover: string | null;
/** 封面图片缓存文件绝对路径(userData/AudioCovers/<hash>.<ext>,无封面时为 null */
coverPath: string | null;
/** LRC 格式歌词文本,无歌词时为 null */
lyrics: string | null;
/** 文件大小(字节) */
+68
View File
@@ -0,0 +1,68 @@
// 防抖 localStorage 包装:高频状态变更(volume 拖动、播放/暂停切换)
// 在 2s 内只落盘一次,正常关闭时通过 beforeunload 刷新未写入数据。
// pinia-plugin-persistedstate 默认每次 mutation 都同步写入;换成这个包装即可
// 降低 I/O 与 JSON.stringify/minify 的总开销。
//
// 多 store 共用同一实例:debounce 的回调不带 key 参数,触发时 flush 整个
// pendingWrites map。这样多个 key 在防抖窗口内交替写入也不会互相吞参数
// lodash debounce 只用最后一次的参数触发)。
import { debounce } from 'lodash';
const pendingWrites = new Map<string, string>();
const safeSetItem = (key: string, value: string) => {
try {
localStorage.setItem(key, value);
} catch (error) {
// 配额超限是预期失败:上层 store 已通过 minifySong 兜底,这里仅记录方便定位
console.error(`[debouncedStorage] localStorage 写入失败 key=${key}(可能超出配额):`, error);
}
};
/** 把 pendingWrites 里所有未落盘的 key 一次性写入并清空 */
const flushPendingWrites = () => {
pendingWrites.forEach((value, key) => {
safeSetItem(key, value);
});
pendingWrites.clear();
};
const debouncedFlush = debounce(flushPendingWrites, 2000);
/**
* 与 localStorage 接口兼容的防抖写入实例
* 多个 Pinia store 共用同一个,避免重复创建定时器/监听器
*
* 注意:极端非正常退出(kill -9 / 系统断电 / 主进程崩溃)下 beforeunload
* 不一定触发,最近 2s 的写入可能丢失。对 volume / isPlay / playMusic 等
* 「丢一次也无大碍」的状态可以接受;如果新增了不能容忍丢写的关键状态,
* 应直接用 localStorage 而非 debouncedLocalStorage。
*/
export const debouncedLocalStorage = {
getItem: (key: string) => localStorage.getItem(key),
setItem: (key: string, value: string) => {
pendingWrites.set(key, value);
debouncedFlush();
},
removeItem: (key: string) => {
// 同步清掉 pendingWrites,防止已排队的 flush 把旧值又写回去
pendingWrites.delete(key);
localStorage.removeItem(key);
}
};
/**
* 立即落盘所有 pending 写入并取消排队的 debounce
* 用于一次性的关键操作(如数据迁移完成后写 flag):先 flush 再写 flag
* 避免「flag 已写入、store 还在防抖窗口里」的不一致
*/
export const flushDebouncedStorage = () => {
debouncedFlush.cancel();
flushPendingWrites();
};
// 模块级注册一次:beforeunload 时立即 flush 所有未写入数据,并取消排队的 debounce
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushDebouncedStorage);
}
+13 -15
View File
@@ -6,6 +6,10 @@ import { SUPPORTED_AUDIO_FORMATS } from '@/types/localMusic';
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
import { filePathToLocalUrl } from '../../shared/localUrl';
export { filePathToLocalUrl };
/**
* 判断文件路径是否为支持的音频格式
* 通过提取文件扩展名(不区分大小写)与支持格式列表比对
@@ -47,7 +51,7 @@ export function buildFallbackMeta(filePath: string): LocalMusicMeta {
artist: '未知艺术家',
album: '未知专辑',
duration: 0,
cover: null,
coverPath: null,
lyrics: null,
fileSize: 0,
modifiedTime: 0
@@ -111,10 +115,15 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
// 解析内嵌歌词为 ILyric 对象
const lyric = parseLrcToILyric(entry.lyrics);
// 封面统一走落盘文件 + local:// 协议;缺 coverPath 时给空串,
// SongItem 模板用 v-if="item.picUrl" 自动跳过渲染。
// 用户重新扫描会让主进程落盘新封面(参见 scanFolders 的自愈条件)
const coverUrl = entry.coverPath ? filePathToLocalUrl(entry.coverPath) : '';
return {
id: entry.id,
name: entry.title,
picUrl: entry.cover || '/images/default_cover.png',
picUrl: coverUrl,
ar: [
{
name: entry.artist,
@@ -140,7 +149,7 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
blurPicUrl: '',
companyId: 0,
pic: 0,
picUrl: entry.cover || '',
picUrl: coverUrl,
publishTime: 0,
description: '',
tags: '',
@@ -176,7 +185,7 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
artists: [{ name: entry.artist }],
album: { name: entry.album }
},
playMusicUrl: `local:///${entry.filePath}`,
playMusicUrl: filePathToLocalUrl(entry.filePath),
duration: entry.duration,
dt: entry.duration,
source: 'netease' as const,
@@ -189,17 +198,6 @@ export function toSongResult(entry: LocalMusicEntry): SongResult {
};
}
/**
* 将封面图片 Buffer 转换为 base64 Data URL
* @param buffer 图片二进制数据
* @param mime MIME 类型(如 image/jpeg、image/png
* @returns base64 Data URL 字符串
*/
export function coverToDataUrl(buffer: Buffer, mime: string): string {
const base64 = buffer.toString('base64');
return `data:${mime};base64,${base64}`;
}
/**
* 按关键词搜索过滤本地音乐列表
* 不区分大小写,匹配歌曲标题或艺术家名称
+188
View File
@@ -0,0 +1,188 @@
// 持久化歌曲对象的精简工具
// 三处 store 共用:playlist、playerCore、playHistory
//
// 为什么需要:localStorage 仅 5MB 配额。SongResult 直接持久化时,picUrl 可能是
// 几百 KB 的 base64 Data URL(旧版本本地音乐扫描会注入),lyric/song/expiredAt 等
// 字段也是只在运行时有意义的派生数据。一旦撑爆配额,整个 store 写入失败 → 历史记录、
// 当前播放、播放列表全部丢失。
//
// 现在源头(src/main/modules/localMusicScanner.ts + filePathToLocalUrl)已经把
// 本地音乐封面落盘,picUrl 是 local:// 短引用;这个工具兜底两件事:
// 1. 老用户从 base64 版本升级上来时,剥离已写入 localStorage 的脏数据
// 2. 远程音乐的 lyric/expiredAt 等大字段从持久化路径切走(重启后 API 重拉即可)
//
// 仅 local:// 的 playMusicUrl 会保留——本地音乐 URL 永不过期,恢复后免重新解析。
// 其他 URL(云端、缓存代理)会过期,丢掉让恢复时重新拉。
import type { Artist, SongResult } from '@/types/music';
import type { DjProgram } from '@/types/podcast';
/**
* 播客 program 持久化保留的最小字段集合
*
* SongResult.program 在运行时是 any(来源接口透传),实际形态对齐 DjProgram。
* 这里只保留 addPodcast/podcast 列表 UI 真正消费的字段,避免接口塞进来的冗余
* 字段(评论列表、推荐位等)跟着持久化。仍要剥 coverUrl 的 base64 兜底。
*/
export type MinifiedDjProgram = Pick<
DjProgram,
| 'id'
| 'name'
| 'mainSong'
| 'radio'
| 'coverUrl'
| 'description'
| 'createTime'
| 'listenerCount'
| 'commentCount'
| 'liked'
| 'likedCount'
>;
/**
* 持久化保留的最小字段集合
*
* ar/al 类型用 SongResult['ar' | 'al'] 而非自定义 Pick
* Artist/Album 类型有几十个必填子字段,自定义 Pick 后无法回填成 Artist[]/Album,
* 调用 setPlay/SongItem 等期望 SongResult 的接口会全部报错。这里用类型断言把
* "只填了 id/name/picUrl 的对象"伪装成 Artist/Album——运行时下游用 optional chain
* 访问,缺字段不会炸;类型层省下一大批 cast。
*
* isPodcast/program 必须保留:playbackController.playTrack 用 music.isPodcast
* 决定写普通歌历史还是播客历史;缺失会让重启恢复后的播客被误记进 musicHistory。
*/
export type MinifiedSong = {
id: SongResult['id'];
name: SongResult['name'];
picUrl: SongResult['picUrl'];
ar: SongResult['ar'];
// SongResult.al 是非 optional,但 minifySong 在 s.al 缺失时返回 undefined。
// 用 NonNullable + ? 显式表达「可选且缺失即不存在」,下游 optional chain 访问更安全
al?: NonNullable<SongResult['al']>;
source?: SongResult['source'];
dt?: SongResult['dt'];
playMusicUrl?: SongResult['playMusicUrl'];
isPodcast?: SongResult['isPodcast'];
program?: MinifiedDjProgram;
};
/** 历史记录条目:精简歌曲 + 计数/时间戳 */
export type MusicHistoryItem = MinifiedSong & {
count: number;
lastPlayTime?: number;
};
/**
* 剥离 base64 Data URL,其余 URL 原样返回
* 旧版本注入的 data: 协议封面 → 空串(SongItem v-if 跳过渲染,展示默认封面)
*/
const stripDataUrl = (url: string | undefined): string =>
!url || url.startsWith('data:') ? '' : url;
/**
* 把 SongResult 精简到仅持久化必要字段
* - 剥离 base64 picUrl
* - 仅保留 local:// 协议的 playMusicUrl(本地音乐 URL 永不过期)
* - 不持久化 lyric/song/backgroundColor/primaryColor/expiredAt/createdAt 等派生数据
*
* JSON.stringify 自动丢 undefined,不需要条件 spread
*/
export const minifySong = (s: SongResult): MinifiedSong => {
// 兼容老数据:早期 SongResult 可能只填了 artists 没填 ar(接口字段历史回退路径),
// 取一次合并后再精简,避免 minify 后丢失歌手名导致 heatmap/SongItem 显示 'Unknown Artist'
// 用 length 守卫而不是 ???? 只在 null/undefined 回退,ar:[] 是 truthy 会原样保留空数组,
// 旧数据里同时存在 ar:[] 和 artists:[填值] 时会丢歌手名
const artistList = s.ar?.length ? s.ar : s.artists;
// program 透传可能塞接口冗余字段(评论列表、推荐位等),这里挑明保留 DjProgram 已知字段
// id 守卫:缺 id 视作无效 program,避免序列化出空壳
const minifyProgram = (p: any): MinifiedDjProgram | undefined => {
if (!p?.id) return undefined;
return {
id: p.id,
name: p.name,
mainSong: p.mainSong,
radio: p.radio,
coverUrl: stripDataUrl(p.coverUrl),
description: p.description,
createTime: p.createTime,
listenerCount: p.listenerCount,
commentCount: p.commentCount,
liked: p.liked,
likedCount: p.likedCount
};
};
return {
id: s.id,
name: s.name,
picUrl: stripDataUrl(s.picUrl),
// 类型断言:只回填 id/name 的 Artist 不满足全字段约束,但下游消费 ar 全是 optional chain
ar: (artistList?.map((a) => ({ id: a.id, name: a.name })) ?? []) as Artist[],
// 类型断言同 ar:Album 类型有几十个必填字段,这里只回填三个;下游用 optional chain 访问安全
// id 守卫:truthy 但字段全空的对象(如 `{}`)会被过滤为 undefined,避免序列化出
// `{name: undefined, picUrl: ''}` 这种无意义残骸(id undefined 被 JSON 丢弃,反而留下空串占位)
al: (s.al?.id
? {
id: s.al.id,
name: s.al.name,
picUrl: stripDataUrl(s.al.picUrl)
}
: undefined) as MinifiedSong['al'],
source: s.source,
dt: s.dt,
playMusicUrl: s.playMusicUrl?.startsWith('local://') ? s.playMusicUrl : undefined,
// 必须保留 isPodcast/programplaybackController.playTrack 用 music.isPodcast 决定写
// 普通歌历史还是播客历史;缺失会让重启恢复后的播客被误记进 musicHistory
isPodcast: s.isPodcast,
program: minifyProgram(s.program)
};
};
export const minifySongList = (list: SongResult[] | undefined): MinifiedSong[] =>
list?.map(minifySong) ?? [];
/** 历史记录条目精简:在 minifySong 基础上附带 count/lastPlayTime */
export const minifyHistoryEntry = (
s: SongResult & { count?: number; lastPlayTime?: number }
): MusicHistoryItem => ({
...minifySong(s),
// 能进历史 = 至少播过一次,缺省给 1 而非 0;history 列表直接 {{ count }} 显示,0 会渲染成「0 次播放」
count: s.count ?? 1,
lastPlayTime: s.lastPlayTime
});
export const minifyHistoryList = (
list: (SongResult & { count?: number; lastPlayTime?: number })[] | undefined
): MusicHistoryItem[] => list?.map(minifyHistoryEntry) ?? [];
/**
* 通用防御层:剥离任意对象上常见图片字段中的 base64 Data URL
*
* 给 podcast/playlist/album/podcastRadio 等历史记录做兜底:
* 即使源头注入了 base64 封面(可能来自爬虫、第三方接口、旧版本数据),
* 持久化时也会被洗成空串,避免单张几百 KB 的 Data URL 撑爆 5MB 配额。
*
* 覆盖三个常见字段名:picUrl(专辑/电台/单曲)、coverImgUrl(歌单)、coverUrl(电台节目)。
* 用 readonly tuple 而不是字符串数组:让 TS 把 key 推断成字面量类型集合,未来加字段需显式扩展。
*
* 已知盲区:仅扫顶层字段,嵌套结构(DjProgram.radio.picUrl、Playlist.creator.avatarUrl 等)
* 不在覆盖范围内。当前线上数据未观察到嵌套字段被注入 base64,所以暂不递归——避免无脑深度
* 遍历影响每次序列化的开销。后续如果发现新的嵌套注入路径,再针对该字段做点对点处理
* (参考 minifySong 对 al.picUrl 的专门洗法),不要把 stripBase64Covers 改成递归。
*/
const PIC_KEYS = ['picUrl', 'coverImgUrl', 'coverUrl'] as const;
export const stripBase64Covers = <T extends Record<string, any>>(item: T): T => {
// 浅拷贝 + 仅扫顶层:嵌套字段是已知盲区(见上方 jsdoc)
// minifySong 已专门处理嵌套 al.picUrl,此处不重复
const result: Record<string, any> = { ...item };
for (const key of PIC_KEYS) {
const value = result[key];
if (typeof value === 'string' && value.startsWith('data:')) {
result[key] = '';
}
}
return result as T;
};
export const stripBase64CoversList = <T extends Record<string, any>>(list: T[] | undefined): T[] =>
list?.map(stripBase64Covers) ?? [];
+2 -1
View File
@@ -557,6 +557,7 @@ import type { SongResult } from '@/types/music';
import { getImgUrl } from '@/utils';
import type { DownloadTask } from '../../../shared/download';
import { filePathToLocalUrl } from '../../../shared/localUrl';
const { t } = useI18n();
const playerStore = usePlayerStore();
@@ -656,7 +657,7 @@ const shortenPath = (path: string) => {
const getLocalFilePath = (path: string) => {
if (!path) return '';
return `local:///${encodeURIComponent(path)}`;
return filePathToLocalUrl(path);
};
const openDirectory = (path: string) => {
+9 -7
View File
@@ -152,6 +152,7 @@ import { usePlayerStore } from '@/store/modules/player';
import { usePlayHistoryStore } from '@/store/modules/playHistory';
import type { SongResult } from '@/types/music';
import { setAnimationClass } from '@/utils';
import type { MusicHistoryItem } from '@/utils/persistedSong';
const { t } = useI18n();
const playHistoryStore = usePlayHistoryStore();
@@ -220,7 +221,7 @@ const processHistoryData = () => {
const oneYearAgo = Date.now() - 365 * 24 * 60 * 60 * 1000;
// 遍历音乐历史记录
playHistoryStore.musicHistory.forEach((music: SongResult & { count?: number }) => {
playHistoryStore.musicHistory.forEach((music: MusicHistoryItem) => {
// 假设每次播放都记录在当前时间,我们根据 count 分散到最近的日期
const playCount = music.count || 1;
const now = Date.now();
@@ -251,7 +252,7 @@ const processHistoryData = () => {
dailyMap[dateKey].songs.set(songId, {
id: music.id,
name: music.name || 'Unknown',
artist: music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist',
artist: music.ar?.[0]?.name || 'Unknown Artist',
playCount: 1
});
}
@@ -307,11 +308,11 @@ const mostPlayedSong = computed<{
{ id: string | number; name: string; artist: string; playCount: number }
>();
playHistoryStore.musicHistory.forEach((music: SongResult & { count?: number }) => {
playHistoryStore.musicHistory.forEach((music: MusicHistoryItem) => {
const id = music.id;
const count = music.count || 1;
const name = music.name || 'Unknown';
const artist = music.ar?.[0]?.name || music.artists?.[0]?.name || 'Unknown Artist';
const artist = music.ar?.[0]?.name || 'Unknown Artist';
if (songPlayCounts.has(id)) {
songPlayCounts.get(id)!.playCount += count;
@@ -382,7 +383,7 @@ const latestNightSong = computed<{
return {
id: randomSong.id,
name: randomSong.name || 'Unknown',
artist: randomSong.ar?.[0]?.name || randomSong.artists?.[0]?.name || 'Unknown Artist',
artist: randomSong.ar?.[0]?.name || 'Unknown Artist',
time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}`
};
}
@@ -395,7 +396,7 @@ const latestNightSong = computed<{
return {
id: song.id,
name: song.name || 'Unknown',
artist: song.ar?.[0]?.name || song.artists?.[0]?.name || 'Unknown Artist',
artist: song.ar?.[0]?.name || 'Unknown Artist',
time: `凌晨 ${randomHour.toString().padStart(2, '0')}:${randomMinute.toString().padStart(2, '0')}`
};
}
@@ -407,7 +408,8 @@ const latestNightSong = computed<{
const handlePlaySong = async (songId: string | number) => {
const song = playHistoryStore.musicHistory.find((music) => music.id === songId);
if (song) {
await playerStore.setPlay(song);
// MusicHistoryItem 是 SongResult 的精简子集,setPlay 内部用 optional chain 访问字段
await playerStore.setPlay(song as SongResult);
playerStore.setPlayMusic(true);
}
};
+1 -1
View File
@@ -95,8 +95,8 @@ import { useRoute, useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import { getListByCat } from '@/api/list';
import StickyTabPage from '@/components/common/StickyTabPage.vue';
import { navigateToMusicList } from '@/components/common/MusicListNavigator';
import StickyTabPage from '@/components/common/StickyTabPage.vue';
import type { IPlayListSort } from '@/types/playlist';
import { calculateAnimationDelay, formatNumber, getImgUrl } from '@/utils';
+1 -3
View File
@@ -80,9 +80,7 @@
</span>
</div>
<div v-if="!hasMore && !initLoading" class="text-center">
<span
class="text-xs text-neutral-400 font-medium tracking-widest uppercase opacity-50"
>
<span class="text-xs text-neutral-400 font-medium tracking-widest uppercase opacity-50">
{{ t('comp.pages.mv.noMore') }}
</span>
</div>
+17 -5
View File
@@ -2,7 +2,9 @@
<sticky-tab-page
ref="pageRef"
:title="currentCategoryId === -1 ? t('podcast.podcast') : currentCategoryName"
:description="currentCategoryId === -1 ? t('podcast.discover') : t('podcast.exploreCategoryRadios')"
:description="
currentCategoryId === -1 ? t('podcast.discover') : t('podcast.exploreCategoryRadios')
"
:model-value="currentCategoryId"
:categories="categoryList"
label-key="name"
@@ -34,7 +36,9 @@
<section v-if="todayPerfered.length > 0">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white">
<h2
class="text-xl font-bold tracking-tight text-neutral-900 md:text-2xl dark:text-white"
>
{{ t('podcast.todayPerfered') }}
</h2>
<div class="h-1.5 w-1.5 rounded-full bg-primary" />
@@ -65,7 +69,9 @@
</div>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm md:text-base font-semibold text-neutral-900 dark:text-white truncate">
<h4
class="text-sm md:text-base font-semibold text-neutral-900 dark:text-white truncate"
>
{{ program.mainSong?.name || program.name }}
</h4>
<p class="text-xs md:text-sm text-neutral-500 dark:text-neutral-400 truncate mt-1">
@@ -125,7 +131,10 @@
</template>
</div>
<div v-if="!categoryLoading && categoryRadios.length === 0" class="flex flex-col items-center justify-center py-20 text-neutral-400">
<div
v-if="!categoryLoading && categoryRadios.length === 0"
class="flex flex-col items-center justify-center py-20 text-neutral-400"
>
<i class="ri-radio-line mb-4 text-5xl opacity-20" />
<p class="text-sm font-medium">{{ t('podcast.noCategoryRadios') }}</p>
</div>
@@ -134,7 +143,10 @@
<n-spin size="small" />
<span class="ml-2 text-neutral-500">{{ t('common.loading') }}</span>
</div>
<div v-if="!categoryHasMore && categoryRadios.length > 0" class="text-center py-8 text-neutral-500">
<div
v-if="!categoryHasMore && categoryRadios.length > 0"
class="text-center py-8 text-neutral-500"
>
{{ t('common.noMore') }}
</div>
</div>
+5 -2
View File
@@ -124,6 +124,7 @@ import { useI18n } from 'vue-i18n';
import localData from '@/../main/set.json';
import ClearCacheSettings from '@/components/settings/ClearCacheSettings.vue';
import { usePlayHistoryStore } from '@/store/modules/playHistory';
import { useUserStore } from '@/store/modules/user';
import { isElectron } from '@/utils';
import { openDirectory, selectDirectory } from '@/utils/fileOperation';
@@ -435,7 +436,10 @@ const clearCache = async (selectedCacheTypes: string[]) => {
const clearTasks = selectedCacheTypes.map(async (type) => {
switch (type) {
case 'history':
localStorage.removeItem('musicHistory');
// podcast/playlist/album/podcastRadio
// pinia storekey=play-history-store musicHistory key
// cleanupLegacyPlayHistoryStorage removeItem
usePlayHistoryStore().clearMusicHistory();
break;
case 'favorite':
localStorage.removeItem('favoriteList');
@@ -451,7 +455,6 @@ const clearCache = async (selectedCacheTypes: string[]) => {
localStorage.removeItem('theme');
localStorage.removeItem('lyricData');
localStorage.removeItem('lyricFontSize');
localStorage.removeItem('playMode');
break;
case 'downloads':
if (window.electron) {
+26
View File
@@ -0,0 +1,26 @@
// local:// 协议 URL 拼接工具
// 主进程与渲染进程共用,确保所有本地文件 URL 走同一套编码策略,
// 否则音频/封面/缓存/下载在 edge-case 上行为分裂。
/**
* local:// 协议 URL。
*
*
* 1. \\ -> /Windows
* 2. encodeURIComponent /
*
* encodeURIComponent / %2F standard
* local:// 协议在 Chromium 解析含 %2F 的 path 时存在边界差异。
* #?%
*
* Image loader crossOrigin='Anonymous'
* audio.src "Application Support"
*
* fileManager decodeURIComponent encodeURIComponent
* %XX
*/
export function filePathToLocalUrl(absPath: string): string {
const normalized = absPath.replace(/\\/g, '/');
const encoded = normalized.split('/').map(encodeURIComponent).join('/');
return `local:///${encoded}`;
}