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