仰望星辰工作室

starmusic-pc

starmusic-pc/ frontend/src/common/playlist.js 9.1 KB · 287 行 原始文件
1/**
2 * 播放列表管理(迁移自 lx-music-mobile/src/plugins/player/playList.ts + core/list.ts)
3 * 使用 uni.setStorageSync 持久化
4 */
5import { STORAGE_KEYS, LIST_IDS, CUSTOM_LIST_KEY_PREFIX, CUSTOM_LIST_NAME_MAX } from './constants.js'
6import { getData, saveData, removeData } from './data.js'
7import { createMusicInfoId } from './utils.js'
8import event from './event.js'
9
10export { LIST_IDS }
11
12/** 内置列表:default(试听)、love(收藏) */
13let defaultList = []
14let loveList = []
15let tempPlayList = []
16
17/** 按 id 去重(保留首次出现的记录),用于清理历史遗留的重复歌曲 */
18const dedupeById = (list) => {
19 const seen = new Set()
20 const result = []
21 for (const item of list) {
22 if (!item || !item.id || seen.has(item.id)) continue
23 seen.add(item.id)
24 result.push(item)
25 }
26 return result
27}
28
29/** 加载列表 */
30export const initList = () => {
31 defaultList = dedupeById(getData(STORAGE_KEYS.DEFAULT_LIST, []))
32 loveList = dedupeById(getData(STORAGE_KEYS.LOVE_LIST, []))
33 tempPlayList = dedupeById(getData(STORAGE_KEYS.TEMP_LIST, []))
34 loadCustomLists()
35}
36
37/** 标准化音乐信息(补充 id 等) */
38export const normalizeMusicInfo = (musicInfo) => {
39 const info = { ...musicInfo }
40 if (!info.id) info.id = createMusicInfoId(info)
41 if (!info.meta) info.meta = {}
42 if (musicInfo.songmid) info.meta.songmid = musicInfo.songmid
43 if (!info.meta.picUrl && info.img) info.meta.picUrl = info.img
44 if (!info._types && info.types) {
45 info._types = {}
46 for (const t of info.types) info._types[t.type] = { size: t.size }
47 }
48 return info
49}
50
51// ============ 自定义歌单 ============
52
53/** 自定义歌单歌曲列表:id -> 歌曲数组(内存缓存,避免防抖落盘窗口内读到旧数据) */
54const customListMap = new Map()
55/** 自定义歌单元信息:[{ id, name, createTime }] */
56let customListInfos = []
57let customListLoaded = false
58
59/** 惰性加载自定义歌单(仅首次从 storage 载入) */
60const loadCustomLists = () => {
61 if (customListLoaded) return
62 customListLoaded = true
63 const infos = getData(STORAGE_KEYS.CUSTOM_LISTS, [])
64 customListInfos = Array.isArray(infos) ? infos : []
65 for (const info of customListInfos) {
66 customListMap.set(info.id, dedupeById(getData(`${CUSTOM_LIST_KEY_PREFIX}${info.id}`, [])))
67 }
68}
69
70/** 落盘歌单元信息 */
71const saveCustomListInfos = () => {
72 saveData(STORAGE_KEYS.CUSTOM_LISTS, customListInfos)
73}
74
75/** 校验并规范化歌单名 */
76const normalizeListName = (name) => {
77 const trimmed = String(name == null ? '' : name).trim()
78 if (!trimmed) throw new Error('歌单名不能为空')
79 if (trimmed.length > CUSTOM_LIST_NAME_MAX) throw new Error(`歌单名不能超过 ${CUSTOM_LIST_NAME_MAX} 个字`)
80 return trimmed
81}
82
83/** 获取自定义歌单元信息列表 */
84export const getCustomLists = () => {
85 loadCustomLists()
86 return customListInfos.map((info) => ({ ...info }))
87}
88
89/** 是否为自定义歌单 */
90export const isCustomList = (listId) => {
91 loadCustomLists()
92 return customListInfos.some((info) => info.id === listId)
93}
94
95/** 新建歌单 */
96export const createCustomList = (name) => {
97 loadCustomLists()
98 const info = {
99 id: `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`,
100 name: normalizeListName(name),
101 createTime: Date.now(),
102 }
103 customListInfos = [...customListInfos, info]
104 customListMap.set(info.id, [])
105 saveCustomListInfos()
106 event.emit('customListUpdated', getCustomLists())
107 return info
108}
109
110/** 重命名歌单 */
111export const renameCustomList = (listId, name) => {
112 loadCustomLists()
113 if (!customListInfos.some((info) => info.id === listId)) throw new Error('歌单不存在')
114 const info = { ...customListInfos.find((item) => item.id === listId), name: normalizeListName(name) }
115 customListInfos = customListInfos.map((item) => (item.id === listId ? info : item))
116 saveCustomListInfos()
117 event.emit('customListUpdated', getCustomLists())
118 return info
119}
120
121/** 删除歌单(连同歌单内歌曲) */
122export const removeCustomList = (listId) => {
123 loadCustomLists()
124 if (!customListInfos.some((info) => info.id === listId)) return false
125 customListInfos = customListInfos.filter((info) => info.id !== listId)
126 customListMap.delete(listId)
127 saveCustomListInfos()
128 removeData(`${CUSTOM_LIST_KEY_PREFIX}${listId}`)
129 event.emit('customListUpdated', getCustomLists())
130 event.emit('listUpdated', listId, [])
131 return true
132}
133
134/**
135 * 可收藏的目标歌单(我的收藏 + 自定义歌单)
136 * 供播放页收藏弹层遍历展示
137 */
138export const getCollectableLists = () => {
139 loadCustomLists()
140 return [
141 { id: LIST_IDS.LOVE, name: '我的收藏', builtin: true },
142 ...customListInfos.map((info) => ({ id: info.id, name: info.name, builtin: false })),
143 ]
144}
145
146/** 获取列表 */
147export const getList = (listId) => {
148 switch (listId) {
149 case LIST_IDS.DEFAULT:
150 return defaultList
151 case LIST_IDS.LOVE:
152 return loveList
153 default:
154 loadCustomLists()
155 return customListMap.get(listId) || []
156 }
157}
158
159/** 列表落盘防抖定时器 */
160let saveListTimer = null
161/** 待落盘的列表快照:listId -> list(防抖窗口内多个列表的修改互不覆盖) */
162const pendingListSaveMap = new Map()
163
164/** 将内存中的列表落盘到 storage */
165const persistList = (listId, list) => {
166 switch (listId) {
167 case LIST_IDS.DEFAULT:
168 saveData(STORAGE_KEYS.DEFAULT_LIST, list)
169 break
170 case LIST_IDS.LOVE:
171 saveData(STORAGE_KEYS.LOVE_LIST, list)
172 break
173 default:
174 saveData(`${CUSTOM_LIST_KEY_PREFIX}${listId}`, list)
175 }
176}
177
178/**
179 * 保存列表
180 * 内存与事件同步更新(页面即时响应),storage 落盘防抖合并——
181 * 点击播放等高频操作会反复触发 saveList,全量同步写盘会阻塞 UI 线程。
182 */
183const saveList = (listId, list) => {
184 switch (listId) {
185 case LIST_IDS.DEFAULT:
186 defaultList = list
187 break
188 case LIST_IDS.LOVE:
189 loveList = list
190 break
191 default:
192 // 自定义歌单:写入内存缓存,storage 由防抖统一落盘
193 loadCustomLists()
194 customListMap.set(listId, list)
195 break
196 }
197 event.emit('listUpdated', listId, list)
198
199 pendingListSaveMap.set(listId, list)
200 if (saveListTimer) clearTimeout(saveListTimer)
201 saveListTimer = setTimeout(() => {
202 saveListTimer = null
203 for (const [id, data] of pendingListSaveMap) persistList(id, data)
204 pendingListSaveMap.clear()
205 }, 500)
206}
207
208/**
209 * 向列表添加歌曲
210 * @param {string} listId
211 * @param {Array} musicInfos
212 * @param {string} location 'top' | 'bottom'
213 */
214/** 试听列表/稍后播放上限(与原版 LX Music 一致,防止无限膨胀导致写入卡顿) */
215const LIST_MAX_SIZE = 100
216
217export const addListMusics = (listId, musicInfos, location = 'top') => {
218 let list = getList(listId)
219 const infos = musicInfos.map((m) => normalizeMusicInfo(m))
220 // 去重:列表中已存在的同 id 歌曲先移除,再插入新记录。
221 // 否则同一首歌会反复记录,导致删除/播放/批量操作时多条联动。
222 const addIds = new Set()
223 for (const info of infos) addIds.add(info.id)
224 if (addIds.size) list = list.filter((m) => !addIds.has(m.id))
225 const newList = [...list]
226 if (location === 'top') {
227 newList.unshift(...infos)
228 } else {
229 newList.push(...infos)
230 }
231 // 仅对会随播放持续增长的列表(试听/稍后播放)做上限截断
232 if ((listId === LIST_IDS.DEFAULT || listId === LIST_IDS.TEMP) && newList.length > LIST_MAX_SIZE) {
233 newList.length = LIST_MAX_SIZE
234 }
235 saveList(listId, newList)
236 return newList
237}
238
239/** 移除列表中的歌曲 */
240export const removeListMusics = (listId, musicIds) => {
241 const list = getList(listId)
242 const idSet = new Set(musicIds)
243 const newList = list.filter((m) => !idSet.has(m.id))
244 saveList(listId, newList)
245 return newList
246}
247
248/** 清空列表 */
249export const clearListMusics = (listId) => {
250 saveList(listId, [])
251}
252
253/** 更新列表内歌曲 */
254export const updateListMusic = (listId, musicId, musicInfo) => {
255 const list = getList(listId)
256 const index = list.findIndex((m) => m.id === musicId)
257 if (index < 0) return
258 list[index] = { ...list[index], ...musicInfo }
259 saveList(listId, [...list])
260}
261
262/** 稍后播放列表 */
263export const getTempPlayList = () => [...tempPlayList]
264export const addTempPlayList = (musicInfos) => {
265 tempPlayList.push(...musicInfos.map((m) => normalizeMusicInfo(m)))
266 saveData(STORAGE_KEYS.TEMP_LIST, tempPlayList)
267 event.emit('tempPlayListUpdated', [...tempPlayList])
268}
269export const removeTempPlayList = (index) => {
270 tempPlayList.splice(index, 1)
271 saveData(STORAGE_KEYS.TEMP_LIST, tempPlayList)
272 event.emit('tempPlayListUpdated', [...tempPlayList])
273}
274export const clearTempPlayeList = () => {
275 tempPlayList = []
276 saveData(STORAGE_KEYS.TEMP_LIST, [])
277 event.emit('tempPlayListUpdated', [])
278}
279
280/** 检查歌曲是否在列表中 */
281export const checkListContainsMusic = (listId, musicId) => {
282 return getList(listId).some((m) => m.id === musicId)
283}
284
285/** 歌曲是否存在于任意可收藏歌单(我的收藏或自定义歌单) */
286export const isMusicInAnyList = (musicId) =>
287 getCollectableLists().some((item) => checkListContainsMusic(item.id, musicId))