1/** 2 * 歌单管理 3 */ 4import { Immer } from "immer"; 5import { useEffect, useMemo, useState } from "react"; 6import { nanoid } from "nanoid"; 7import { isSameMediaItem } from "@/utils/mediaItem"; 8import storage from "@/core/musicSheet/storage.ts"; 9import migrate, { migrateV2 } from "@/core/musicSheet/migrate.ts"; 10import { getDefaultStore, useAtomValue } from "jotai"; 11import { musicListMap, musicSheetsBaseAtom, starredMusicSheetsAtom } from "@/core/musicSheet/atoms.ts"; 12import { ResumeMode, SortType } from "@/constants/commonConst.ts"; 13import SortedMusicList from "@/core/musicSheet/sortedMusicList.ts"; 14import ee from "@/core/musicSheet/ee.ts"; 15import Config from "@/core/config.ts"; 16 17const produce = new Immer({ 18 autoFreeze: false, 19}).produce; 20 21const defaultSheet: IMusic.IMusicSheetItemBase = { 22 id: 'favorite', 23 coverImg: undefined, 24 title: '我喜欢', 25 worksNum: 0, 26}; 27 28async function setup() { 29 // 升级逻辑 - 从 AsyncStorage 升级到 MMKV 30 await migrate(); 31 try { 32 const allSheets: IMusic.IMusicSheetItemBase[] = storage.getSheets(); 33 34 if (!Array.isArray(allSheets)) { 35 throw new Error('not exist'); 36 } 37 38 let needRestore = false; 39 if (!allSheets.length) { 40 allSheets.push({ 41 ...defaultSheet, 42 }); 43 needRestore = true; 44 } 45 if (allSheets[0].id !== defaultSheet.id) { 46 const defaultSheetIndex = allSheets.findIndex( 47 it => it.id === defaultSheet.id, 48 ); 49 50 if (defaultSheetIndex === -1) { 51 allSheets.unshift({ 52 ...defaultSheet, 53 }); 54 } else { 55 const firstSheet = allSheets.splice(defaultSheetIndex, 1); 56 allSheets.unshift(firstSheet[0]); 57 } 58 needRestore = true; 59 } 60 61 if (needRestore) { 62 await storage.setSheets(allSheets); 63 } 64 65 for (let sheet of allSheets) { 66 const musicList = storage.getMusicList(sheet.id); 67 const sortType = storage.getSheetMeta(sheet.id, 'sort') as SortType; 68 sheet.worksNum = musicList.length; 69 migrateV2.migrate(sheet.id, musicList); 70 musicListMap.set( 71 sheet.id, 72 new SortedMusicList(musicList, sortType, true), 73 ); 74 sheet.worksNum = musicList.length; 75 ee.emit('UpdateMusicList', { 76 sheetId: sheet.id, 77 updateType: 'length', 78 }); 79 } 80 migrateV2.done(); 81 getDefaultStore().set(musicSheetsBaseAtom, allSheets); 82 setupStarredMusicSheets(); 83 } catch (e: any) { 84 if (e.message === 'not exist') { 85 await storage.setSheets([defaultSheet]); 86 await storage.setMusicList(defaultSheet.id, []); 87 getDefaultStore().set(musicSheetsBaseAtom, [defaultSheet]); 88 musicListMap.set( 89 defaultSheet.id, 90 new SortedMusicList([], SortType.None, true), 91 ); 92 } 93 } 94} 95 96// 获取音乐 97function getSortedMusicListBySheetId(sheetId: string) { 98 let musicList: SortedMusicList; 99 if (!musicListMap.has(sheetId)) { 100 musicList = new SortedMusicList([], SortType.None, true); 101 musicListMap.set(sheetId, musicList); 102 } else { 103 musicList = musicListMap.get(sheetId)!; 104 } 105 106 return musicList; 107} 108 109/** 110 * 更新基本信息 111 * @param sheetId 歌单ID 112 * @param data 歌单数据 113 */ 114async function updateMusicSheetBase( 115 sheetId: string, 116 data: Partial<IMusic.IMusicSheetItemBase>, 117) { 118 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 119 const targetSheetIndex = musicSheets.findIndex(it => it.id === sheetId); 120 121 if (targetSheetIndex === -1) { 122 return; 123 } 124 125 const newMusicSheets = produce(musicSheets, draft => { 126 draft[targetSheetIndex] = { 127 ...draft[targetSheetIndex], 128 ...data, 129 id: sheetId, 130 }; 131 return draft; 132 }); 133 await storage.setSheets(newMusicSheets); 134 getDefaultStore().set(musicSheetsBaseAtom, newMusicSheets); 135 ee.emit('UpdateSheetBasic', { 136 sheetId, 137 }); 138} 139 140/** 141 * 新建歌单 142 * @param title 歌单名称 143 */ 144async function addSheet(title: string) { 145 const newId = nanoid(); 146 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 147 148 const newSheets: IMusic.IMusicSheetItemBase[] = [ 149 musicSheets[0], 150 { 151 title, 152 id: newId, 153 coverImg: undefined, 154 worksNum: 0, 155 createAt: Date.now(), 156 }, 157 ...musicSheets.slice(1), 158 ]; 159 // 写入存储 160 await storage.setSheets(newSheets); 161 await storage.setMusicList(newId, []); 162 163 // 更新状态 164 getDefaultStore().set(musicSheetsBaseAtom, newSheets); 165 let defaultSortType = Config.getConfig('basic.musicOrderInLocalSheet'); 166 if ( 167 defaultSortType && 168 [ 169 SortType.Newest, 170 SortType.Artist, 171 SortType.Album, 172 SortType.Oldest, 173 SortType.Title, 174 ].includes(defaultSortType) 175 ) { 176 storage.setSheetMeta(newId, 'sort', defaultSortType); 177 } else { 178 defaultSortType = SortType.None; 179 } 180 musicListMap.set(newId, new SortedMusicList([], defaultSortType, true)); 181 return newId; 182} 183 184async function resumeSheets( 185 sheets: IMusic.IMusicSheetItem[], 186 resumeMode: ResumeMode, 187) { 188 if (resumeMode === ResumeMode.Append) { 189 // 逆序恢复,最新创建的在最上方 190 for (let i = sheets.length - 1; i >= 0; --i) { 191 const newSheetId = await addSheet(sheets[i].title || ''); 192 await addMusic(newSheetId, sheets[i].musicList || []); 193 } 194 return; 195 } 196 // 1. 分离默认歌单和其他歌单 197 const defaultSheetIndex = sheets.findIndex(it => it.id === defaultSheet.id); 198 199 let exportedDefaultSheet: IMusic.IMusicSheetItem | null = null; 200 201 if (defaultSheetIndex !== -1) { 202 exportedDefaultSheet = sheets.splice(defaultSheetIndex, 1)[0]; 203 } 204 205 // 2. 合并默认歌单 206 await addMusic(defaultSheet.id, exportedDefaultSheet?.musicList || []); 207 208 // 3. 合并其他歌单 209 if (resumeMode === ResumeMode.OverwriteDefault) { 210 // 逆序恢复,最新创建的在最上方 211 for (let i = sheets.length - 1; i >= 0; --i) { 212 const newSheetId = await addSheet(sheets[i].title || ''); 213 await addMusic(newSheetId, sheets[i].musicList || []); 214 } 215 } else { 216 // 合并同名 217 const existsSheetIdMap: Record<string, string> = {}; 218 const allSheets = getDefaultStore().get(musicSheetsBaseAtom); 219 allSheets.forEach(it => { 220 existsSheetIdMap[it.title!] = it.id; 221 }); 222 for (let i = sheets.length - 1; i >= 0; --i) { 223 let newSheetId = existsSheetIdMap[sheets[i].title || '']; 224 if (!newSheetId) { 225 newSheetId = await addSheet(sheets[i].title || ''); 226 } 227 await addMusic(newSheetId, sheets[i].musicList || []); 228 } 229 } 230} 231 232function backupSheets() { 233 const allSheets = getDefaultStore().get(musicSheetsBaseAtom); 234 return allSheets.map(it => ({ 235 ...it, 236 musicList: musicListMap.get(it.id)?.musicList || [], 237 })) as IMusic.IMusicSheetItem[]; 238} 239 240/** 241 * 删除歌单 242 * @param sheetId 歌单id 243 */ 244async function removeSheet(sheetId: string) { 245 // 只能删除非默认歌单 246 if (sheetId === defaultSheet.id) { 247 return; 248 } 249 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 250 251 // 删除后的歌单 252 const newSheets = musicSheets.filter(item => item.id !== sheetId); 253 254 // 写入存储 255 storage.removeMusicList(sheetId); 256 await storage.setSheets(newSheets); 257 258 // 修改状态 259 getDefaultStore().set(musicSheetsBaseAtom, newSheets); 260 musicListMap.delete(sheetId); 261} 262 263/** 264 * 向歌单内添加音乐 265 * @param sheetId 歌单id 266 * @param musicItem 音乐 267 */ 268async function addMusic( 269 sheetId: string, 270 musicItem: IMusic.IMusicItem | Array<IMusic.IMusicItem>, 271) { 272 const now = Date.now(); 273 if (!Array.isArray(musicItem)) { 274 musicItem = [musicItem]; 275 } 276 const taggedMusicItems = musicItem.map((it, index) => ({ 277 ...it, 278 $timestamp: now, 279 $sortIndex: musicItem.length - index, 280 })); 281 282 let musicList = getSortedMusicListBySheetId(sheetId); 283 284 const addedCount = musicList.add(taggedMusicItems); 285 286 // Update 287 if (!addedCount) { 288 return; 289 } 290 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 291 if ( 292 !musicSheets 293 .find(_ => _.id === sheetId) 294 ?.coverImg?.startsWith('file://') 295 ) { 296 await updateMusicSheetBase(sheetId, { 297 coverImg: musicList.at(0)?.artwork, 298 }); 299 } 300 301 // 更新音乐数量 302 getDefaultStore().set( 303 musicSheetsBaseAtom, 304 produce(draft => { 305 const musicSheet = draft.find(it => it.id === sheetId); 306 if (musicSheet) { 307 musicSheet.worksNum = musicList.length; 308 } 309 }), 310 ); 311 312 await storage.setMusicList(sheetId, musicList.musicList); 313 ee.emit('UpdateMusicList', { 314 sheetId, 315 updateType: 'length', 316 }); 317} 318 319async function removeMusicByIndex(sheetId: string, indices: number | number[]) { 320 if (!Array.isArray(indices)) { 321 indices = [indices]; 322 } 323 324 const musicList = getSortedMusicListBySheetId(sheetId); 325 326 musicList.removeByIndex(indices); 327 328 // Update 329 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 330 if ( 331 !musicSheets 332 .find(_ => _.id === sheetId) 333 ?.coverImg?.startsWith('file://') 334 ) { 335 await updateMusicSheetBase(sheetId, { 336 coverImg: musicList.at(0)?.artwork, 337 }); 338 } 339 // 更新音乐数量 340 getDefaultStore().set( 341 musicSheetsBaseAtom, 342 produce(draft => { 343 const musicSheet = draft.find(it => it.id === sheetId); 344 if (musicSheet) { 345 musicSheet.worksNum = musicList.length; 346 } 347 }), 348 ); 349 await storage.setMusicList(sheetId, musicList.musicList); 350 ee.emit('UpdateMusicList', { 351 sheetId, 352 updateType: 'length', 353 }); 354} 355 356async function removeMusic( 357 sheetId: string, 358 musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], 359) { 360 if (!Array.isArray(musicItems)) { 361 musicItems = [musicItems]; 362 } 363 364 const musicList = getSortedMusicListBySheetId(sheetId); 365 musicList.remove(musicItems); 366 367 // Update 368 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 369 370 let patchData: Partial<IMusic.IMusicSheetItemBase> = {}; 371 if ( 372 !musicSheets 373 .find(_ => _.id === sheetId) 374 ?.coverImg?.startsWith('file://') 375 ) { 376 patchData.coverImg = musicList.at(0)?.artwork; 377 } 378 patchData.worksNum = musicList.length; 379 await updateMusicSheetBase(sheetId, { 380 coverImg: musicList.at(0)?.artwork, 381 }); 382 383 await storage.setMusicList(sheetId, musicList.musicList); 384 ee.emit('UpdateMusicList', { 385 sheetId, 386 updateType: 'length', 387 }); 388} 389 390async function setSortType(sheetId: string, sortType: SortType) { 391 const musicList = getSortedMusicListBySheetId(sheetId); 392 musicList.setSortType(sortType); 393 394 // update 395 await storage.setMusicList(sheetId, musicList.musicList); 396 storage.setSheetMeta(sheetId, 'sort', sortType); 397 ee.emit('UpdateMusicList', { 398 sheetId, 399 updateType: 'resort', 400 }); 401} 402 403async function manualSort( 404 sheetId: string, 405 musicListAfterSort: IMusic.IMusicItem[], 406) { 407 const musicList = getSortedMusicListBySheetId(sheetId); 408 musicList.manualSort(musicListAfterSort); 409 410 // update 411 await storage.setMusicList(sheetId, musicList.musicList); 412 storage.setSheetMeta(sheetId, 'sort', SortType.None); 413 414 ee.emit('UpdateMusicList', { 415 sheetId, 416 updateType: 'resort', 417 }); 418} 419 420function useSheetsBase() { 421 return useAtomValue(musicSheetsBaseAtom); 422} 423 424// sheetId should not change 425function useSheetItem(sheetId: string) { 426 const sheetsBase = useAtomValue(musicSheetsBaseAtom); 427 428 const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem>({ 429 ...(sheetsBase.find(it => it.id === sheetId) || 430 ({} as IMusic.IMusicSheetItemBase)), 431 musicList: musicListMap.get(sheetId)?.musicList || [], 432 }); 433 434 useEffect(() => { 435 const onUpdateMusicList = ({sheetId: updatedSheetId}) => { 436 if (updatedSheetId !== sheetId) { 437 return; 438 } 439 setSheetItem(prev => ({ 440 ...prev, 441 musicList: musicListMap.get(sheetId)?.musicList || [], 442 })); 443 }; 444 445 const onUpdateSheetBasic = ({sheetId: updatedSheetId}) => { 446 if (updatedSheetId !== sheetId) { 447 return; 448 } 449 setSheetItem(prev => ({ 450 ...prev, 451 ...(getDefaultStore() 452 .get(musicSheetsBaseAtom) 453 .find(it => it.id === sheetId) || {}), 454 })); 455 }; 456 ee.on('UpdateMusicList', onUpdateMusicList); 457 ee.on('UpdateSheetBasic', onUpdateSheetBasic); 458 459 return () => { 460 ee.off('UpdateMusicList', onUpdateMusicList); 461 ee.off('UpdateSheetBasic', onUpdateSheetBasic); 462 }; 463 }, []); 464 465 return sheetItem; 466} 467 468function useFavorite(musicItem: IMusic.IMusicItem | null) { 469 const [fav, setFav] = useState(false); 470 471 useEffect(() => { 472 const onUpdateMusicList = ({sheetId: updatedSheetId, updateType}) => { 473 if (updatedSheetId !== defaultSheet.id || updateType === 'resort') { 474 return; 475 } 476 setFav(musicListMap.get(defaultSheet.id)?.has(musicItem) || false); 477 }; 478 ee.on('UpdateMusicList', onUpdateMusicList); 479 480 setFav(musicListMap.get(defaultSheet.id)?.has(musicItem) || false); 481 return () => { 482 ee.off('UpdateMusicList', onUpdateMusicList); 483 }; 484 }, [musicItem]); 485 486 return fav; 487} 488 489async function setupStarredMusicSheets() { 490 const starredSheets: IMusic.IMusicSheetItem[] = 491 storage.getStarredSheets() || []; 492 getDefaultStore().set(starredMusicSheetsAtom, starredSheets); 493} 494 495async function starMusicSheet(musicSheet: IMusic.IMusicSheetItem) { 496 const store = getDefaultStore(); 497 const starredSheets: IMusic.IMusicSheetItem[] = store.get( 498 starredMusicSheetsAtom, 499 ); 500 501 const newVal = [musicSheet, ...starredSheets]; 502 503 store.set(starredMusicSheetsAtom, newVal); 504 await storage.setStarredSheets(newVal); 505} 506 507async function unstarMusicSheet(musicSheet: IMusic.IMusicSheetItemBase) { 508 const store = getDefaultStore(); 509 const starredSheets: IMusic.IMusicSheetItem[] = store.get( 510 starredMusicSheetsAtom, 511 ); 512 513 const newVal = starredSheets.filter( 514 it => 515 !isSameMediaItem( 516 it as ICommon.IMediaBase, 517 musicSheet as ICommon.IMediaBase, 518 ), 519 ); 520 store.set(starredMusicSheetsAtom, newVal); 521 await storage.setStarredSheets(newVal); 522} 523 524function useSheetIsStarred( 525 musicSheet: IMusic.IMusicSheetItem | null | undefined, 526) { 527 // TODO: 类型有问题 528 const musicSheets = useAtomValue(starredMusicSheetsAtom); 529 return useMemo(() => { 530 if (!musicSheet) { 531 return false; 532 } 533 return ( 534 musicSheets.findIndex(it => 535 isSameMediaItem( 536 it as ICommon.IMediaBase, 537 musicSheet as ICommon.IMediaBase, 538 ), 539 ) !== -1 540 ); 541 }, [musicSheet, musicSheets]); 542} 543 544function useStarredSheets() { 545 return useAtomValue(starredMusicSheetsAtom); 546} 547 548/********* MusicSheet Meta ****************/ 549 550const MusicSheet = { 551 setup, 552 addSheet, 553 defaultSheet, 554 addMusic, 555 removeSheet, 556 backupSheets, 557 resumeSheets, 558 removeMusicByIndex, 559 removeMusic, 560 starMusicSheet, 561 unstarMusicSheet, 562 useFavorite, 563 useSheetsBase, 564 useSheetItem, 565 setSortType, 566 useSheetIsStarred, 567 useStarredSheets, 568 updateMusicSheetBase, 569 manualSort, 570 getSheetMeta: storage.getSheetMeta, 571}; 572 573export default MusicSheet; 574