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 {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 overwrite?: boolean, 191) { 192 // 1. 分离默认歌单和其他歌单 193 const defaultSheetIndex = sheets.findIndex(it => it.id === defaultSheet.id); 194 195 let exportedDefaultSheet: IMusic.IMusicSheetItem | null = null; 196 197 if (defaultSheetIndex !== -1) { 198 exportedDefaultSheet = sheets.splice(defaultSheetIndex, 1)[0]; 199 } 200 201 // 逆序恢复,最新创建的在最上方 202 for (let i = sheets.length - 1; i >= 0; --i) { 203 const newSheetId = await addSheet(sheets[i].title || ''); 204 await addMusic(newSheetId, sheets[i].musicList || []); 205 } 206 207 if (overwrite) { 208 await addMusic(defaultSheet.id, exportedDefaultSheet?.musicList || []); 209 } else { 210 const newSheetId = await addSheet( 211 exportedDefaultSheet?.title || defaultSheet.title!, 212 ); 213 await addMusic(newSheetId, exportedDefaultSheet?.musicList || []); 214 } 215} 216 217function backupSheets() { 218 const allSheets = getDefaultStore().get(musicSheetsBaseAtom); 219 return allSheets.map(it => ({ 220 ...it, 221 musicList: musicListMap.get(it.id)?.musicList || [], 222 })) as IMusic.IMusicSheetItem[]; 223} 224 225/** 226 * 删除歌单 227 * @param sheetId 歌单id 228 */ 229async function removeSheet(sheetId: string) { 230 // 只能删除非默认歌单 231 if (sheetId === defaultSheet.id) { 232 return; 233 } 234 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 235 236 // 删除后的歌单 237 const newSheets = musicSheets.filter(item => item.id !== sheetId); 238 239 // 写入存储 240 storage.removeMusicList(sheetId); 241 await storage.setSheets(newSheets); 242 243 // 修改状态 244 getDefaultStore().set(musicSheetsBaseAtom, newSheets); 245 musicListMap.delete(sheetId); 246} 247 248/** 249 * 向歌单内添加音乐 250 * @param sheetId 歌单id 251 * @param musicItem 音乐 252 */ 253async function addMusic( 254 sheetId: string, 255 musicItem: IMusic.IMusicItem | Array<IMusic.IMusicItem>, 256) { 257 const now = Date.now(); 258 if (!Array.isArray(musicItem)) { 259 musicItem = [musicItem]; 260 } 261 const taggedMusicItems = musicItem.map((it, index) => ({ 262 ...it, 263 $timestamp: now, 264 $sortIndex: musicItem.length - index, 265 })); 266 267 let musicList = getSortedMusicListBySheetId(sheetId); 268 269 const addedCount = musicList.add(taggedMusicItems); 270 271 // Update 272 if (!addedCount) { 273 return; 274 } 275 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 276 if ( 277 !musicSheets 278 .find(_ => _.id === sheetId) 279 ?.coverImg?.startsWith('file://') 280 ) { 281 await updateMusicSheetBase(sheetId, { 282 coverImg: musicList.at(0)?.artwork, 283 }); 284 } 285 286 // 更新音乐数量 287 getDefaultStore().set( 288 musicSheetsBaseAtom, 289 produce(draft => { 290 const musicSheet = draft.find(it => it.id === sheetId); 291 if (musicSheet) { 292 musicSheet.worksNum = musicList.length; 293 } 294 }), 295 ); 296 297 await storage.setMusicList(sheetId, musicList.musicList); 298 ee.emit('UpdateMusicList', { 299 sheetId, 300 updateType: 'length', 301 }); 302} 303 304async function removeMusicByIndex(sheetId: string, indices: number | number[]) { 305 if (!Array.isArray(indices)) { 306 indices = [indices]; 307 } 308 309 const musicList = getSortedMusicListBySheetId(sheetId); 310 311 musicList.removeByIndex(indices); 312 313 // Update 314 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 315 if ( 316 !musicSheets 317 .find(_ => _.id === sheetId) 318 ?.coverImg?.startsWith('file://') 319 ) { 320 await updateMusicSheetBase(sheetId, { 321 coverImg: musicList.at(0)?.artwork, 322 }); 323 } 324 // 更新音乐数量 325 getDefaultStore().set( 326 musicSheetsBaseAtom, 327 produce(draft => { 328 const musicSheet = draft.find(it => it.id === sheetId); 329 if (musicSheet) { 330 musicSheet.worksNum = musicList.length; 331 } 332 }), 333 ); 334 await storage.setMusicList(sheetId, musicList.musicList); 335 ee.emit('UpdateMusicList', { 336 sheetId, 337 updateType: 'length', 338 }); 339} 340 341async function removeMusic( 342 sheetId: string, 343 musicItems: IMusic.IMusicItem | IMusic.IMusicItem[], 344) { 345 if (!Array.isArray(musicItems)) { 346 musicItems = [musicItems]; 347 } 348 349 const musicList = getSortedMusicListBySheetId(sheetId); 350 musicList.remove(musicItems); 351 352 // Update 353 const musicSheets = getDefaultStore().get(musicSheetsBaseAtom); 354 355 let patchData: Partial<IMusic.IMusicSheetItemBase> = {}; 356 if ( 357 !musicSheets 358 .find(_ => _.id === sheetId) 359 ?.coverImg?.startsWith('file://') 360 ) { 361 patchData.coverImg = musicList.at(0)?.artwork; 362 } 363 patchData.worksNum = musicList.length; 364 await updateMusicSheetBase(sheetId, { 365 coverImg: musicList.at(0)?.artwork, 366 }); 367 368 await storage.setMusicList(sheetId, musicList.musicList); 369 ee.emit('UpdateMusicList', { 370 sheetId, 371 updateType: 'length', 372 }); 373} 374 375async function setSortType(sheetId: string, sortType: SortType) { 376 const musicList = getSortedMusicListBySheetId(sheetId); 377 musicList.setSortType(sortType); 378 379 // update 380 await storage.setMusicList(sheetId, musicList.musicList); 381 storage.setSheetMeta(sheetId, 'sort', sortType); 382 ee.emit('UpdateMusicList', { 383 sheetId, 384 updateType: 'resort', 385 }); 386} 387 388async function manualSort( 389 sheetId: string, 390 musicListAfterSort: IMusic.IMusicItem[], 391) { 392 const musicList = getSortedMusicListBySheetId(sheetId); 393 musicList.manualSort(musicListAfterSort); 394 395 // update 396 await storage.setMusicList(sheetId, musicList.musicList); 397 storage.setSheetMeta(sheetId, 'sort', SortType.None); 398 399 ee.emit('UpdateMusicList', { 400 sheetId, 401 updateType: 'resort', 402 }); 403} 404 405function useSheetsBase() { 406 return useAtomValue(musicSheetsBaseAtom); 407} 408 409// sheetId should not change 410function useSheetItem(sheetId: string) { 411 const sheetsBase = useAtomValue(musicSheetsBaseAtom); 412 413 const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem>({ 414 ...(sheetsBase.find(it => it.id === sheetId) || 415 ({} as IMusic.IMusicSheetItemBase)), 416 musicList: musicListMap.get(sheetId)?.musicList || [], 417 }); 418 419 useEffect(() => { 420 const onUpdateMusicList = ({sheetId: updatedSheetId}) => { 421 if (updatedSheetId !== sheetId) { 422 return; 423 } 424 setSheetItem(prev => ({ 425 ...prev, 426 musicList: musicListMap.get(sheetId)?.musicList || [], 427 })); 428 }; 429 430 const onUpdateSheetBasic = ({sheetId: updatedSheetId}) => { 431 if (updatedSheetId !== sheetId) { 432 return; 433 } 434 setSheetItem(prev => ({ 435 ...prev, 436 ...(getDefaultStore() 437 .get(musicSheetsBaseAtom) 438 .find(it => it.id === sheetId) || {}), 439 })); 440 }; 441 ee.on('UpdateMusicList', onUpdateMusicList); 442 ee.on('UpdateSheetBasic', onUpdateSheetBasic); 443 444 return () => { 445 ee.off('UpdateMusicList', onUpdateMusicList); 446 ee.off('UpdateSheetBasic', onUpdateSheetBasic); 447 }; 448 }, []); 449 450 return sheetItem; 451} 452 453function useFavorite(musicItem: IMusic.IMusicItem | null) { 454 const [fav, setFav] = useState(false); 455 456 useEffect(() => { 457 const onUpdateMusicList = ({sheetId: updatedSheetId, updateType}) => { 458 if (updatedSheetId !== defaultSheet.id || updateType === 'resort') { 459 return; 460 } 461 setFav(musicListMap.get(defaultSheet.id)?.has(musicItem) || false); 462 }; 463 ee.on('UpdateMusicList', onUpdateMusicList); 464 465 setFav(musicListMap.get(defaultSheet.id)?.has(musicItem) || false); 466 return () => { 467 ee.off('UpdateMusicList', onUpdateMusicList); 468 }; 469 }, [musicItem]); 470 471 return fav; 472} 473 474async function setupStarredMusicSheets() { 475 const starredSheets: IMusic.IMusicSheetItem[] = 476 storage.getStarredSheets() || []; 477 getDefaultStore().set(starredMusicSheetsAtom, starredSheets); 478} 479 480async function starMusicSheet(musicSheet: IMusic.IMusicSheetItem) { 481 const store = getDefaultStore(); 482 const starredSheets: IMusic.IMusicSheetItem[] = store.get( 483 starredMusicSheetsAtom, 484 ); 485 486 const newVal = [musicSheet, ...starredSheets]; 487 488 store.set(starredMusicSheetsAtom, newVal); 489 await storage.setStarredSheets(newVal); 490} 491 492async function unstarMusicSheet(musicSheet: IMusic.IMusicSheetItemBase) { 493 const store = getDefaultStore(); 494 const starredSheets: IMusic.IMusicSheetItem[] = store.get( 495 starredMusicSheetsAtom, 496 ); 497 498 const newVal = starredSheets.filter( 499 it => 500 !isSameMediaItem( 501 it as ICommon.IMediaBase, 502 musicSheet as ICommon.IMediaBase, 503 ), 504 ); 505 store.set(starredMusicSheetsAtom, newVal); 506 await storage.setStarredSheets(newVal); 507} 508 509function useSheetIsStarred( 510 musicSheet: IMusic.IMusicSheetItem | null | undefined, 511) { 512 // TODO: 类型有问题 513 const musicSheets = useAtomValue(starredMusicSheetsAtom); 514 return useMemo(() => { 515 if (!musicSheet) { 516 return false; 517 } 518 return ( 519 musicSheets.findIndex(it => 520 isSameMediaItem( 521 it as ICommon.IMediaBase, 522 musicSheet as ICommon.IMediaBase, 523 ), 524 ) !== -1 525 ); 526 }, [musicSheet, musicSheets]); 527} 528 529function useStarredSheets() { 530 return useAtomValue(starredMusicSheetsAtom); 531} 532 533/********* MusicSheet Meta ****************/ 534 535const MusicSheet = { 536 setup, 537 addSheet, 538 defaultSheet, 539 addMusic, 540 removeSheet, 541 backupSheets, 542 resumeSheets, 543 removeMusicByIndex, 544 removeMusic, 545 starMusicSheet, 546 unstarMusicSheet, 547 useFavorite, 548 useSheetsBase, 549 useSheetItem, 550 setSortType, 551 useSheetIsStarred, 552 useStarredSheets, 553 updateMusicSheetBase, 554 manualSort, 555 getSheetMeta: storage.getSheetMeta, 556}; 557 558export default MusicSheet; 559