mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-18 19:47:29 +08:00
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 变更会丢——这些状态丢一次无大碍,可接受
This commit is contained in:
@@ -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,
|
||||
// 使用 debouncedLocalStorage:volume 拖动 / 静音切换会高频触发 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
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,74 +23,6 @@ const getMessage = () => {
|
||||
return _message;
|
||||
};
|
||||
|
||||
/**
|
||||
* 精简 SongResult 对象,只保留持久化必要字段
|
||||
* 排除大体积字段:lyric, song, backgroundColor, primaryColor
|
||||
*
|
||||
* picUrl/al.picUrl 若为 base64 Data URL 一律剥离:localStorage 仅 5MB 配额,
|
||||
* 单张 base64 封面动辄几百 KB,几首就能撑爆导致整个 playList 写入失败。
|
||||
* 剥离后恢复时展示默认封面图,picUrl 仍是 http(s):// 或 local:// 短引用时原样保留。
|
||||
*
|
||||
* 仅 local:// 的 playMusicUrl(永不过期)会被持久化,让本地音乐恢复后免重新解析;
|
||||
* expiredAt 不持久化——本地音乐每次走 toSongResult 会重新生成,远程歌曲恢复后重新拉详情即可。
|
||||
*/
|
||||
const stripDataUrl = (url: string | undefined): string =>
|
||||
!url || url.startsWith('data:') ? '' : url;
|
||||
|
||||
const minifySong = (s: SongResult) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
picUrl: stripDataUrl(s.picUrl),
|
||||
ar: s.ar?.map((a) => ({ id: a.id, name: a.name })),
|
||||
al: s.al && { id: s.al.id, name: s.al.name, picUrl: stripDataUrl(s.al.picUrl) },
|
||||
source: s.source,
|
||||
dt: s.dt,
|
||||
// 仅 local:// 永不过期,保留给本地音乐恢复后免重新解析;其他 URL 会过期,丢掉让恢复时重新拉
|
||||
// JSON.stringify 自动丢 undefined,无需条件 spread
|
||||
playMusicUrl: s.playMusicUrl?.startsWith('local://') ? s.playMusicUrl : undefined
|
||||
});
|
||||
|
||||
const minifySongList = (list: SongResult[] | undefined) => list?.map(minifySong) ?? [];
|
||||
|
||||
/**
|
||||
* 防抖 localStorage 包装,降低写入频率
|
||||
* 通过 pendingWrites 跟踪未写入数据,beforeunload 时刷新
|
||||
*/
|
||||
const pendingWrites = new Map<string, string>();
|
||||
|
||||
const safeSetItem = (key: string, value: string) => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch (error) {
|
||||
console.error('[playlist] localStorage 写入失败(可能超出配额):', error);
|
||||
}
|
||||
};
|
||||
|
||||
const flushPendingWrites = () => {
|
||||
pendingWrites.forEach((value, key) => {
|
||||
safeSetItem(key, value);
|
||||
});
|
||||
pendingWrites.clear();
|
||||
};
|
||||
|
||||
const debouncedSetItem = debounce((key: string, value: string) => {
|
||||
safeSetItem(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
|
||||
* 负责:播放列表、索引、播放模式、预加载、上/下一首
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/program:playbackController.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) ?? [];
|
||||
Reference in New Issue
Block a user