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