xref: /MusicFree/src/core/musicSheet/index.ts (revision 7fb901109d23a379901d3a07c4fcb24021621c5d)
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