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