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