xref: /MusicFree/src/core/localMusicSheet.ts (revision cd669353b6a483aad2a61863c7df332c267907c6)
1import {internalSerializeKey, StorageKeys} from '@/constants/commonConst';
2import mp3Util, {IBasicMeta} from '@/native/mp3Util';
3import {errorLog} from '@/utils/log';
4import {
5    getInternalData,
6    InternalDataType,
7    isSameMediaItem,
8} from '@/utils/mediaItem';
9import StateMapper from '@/utils/stateMapper';
10import {getStorage, setStorage} from '@/utils/storage';
11import {useEffect, useState} from 'react';
12import {FileStat, FileSystem} from 'react-native-file-access';
13
14let localSheet: IMusic.IMusicItem[] = [];
15const localSheetStateMapper = new StateMapper(() => localSheet);
16
17export async function setup() {
18    const sheet = await getStorage(StorageKeys.LocalMusicSheet);
19    if (sheet) {
20        let validSheet = [];
21        for (let musicItem of sheet) {
22            const localPath = getInternalData<string>(
23                musicItem,
24                InternalDataType.LOCALPATH,
25            );
26            if (localPath && (await FileSystem.exists(localPath))) {
27                validSheet.push(musicItem);
28            }
29        }
30        if (validSheet.length !== sheet.length) {
31            await setStorage(StorageKeys.LocalMusicSheet, validSheet);
32        }
33        localSheet = validSheet;
34    } else {
35        await setStorage(StorageKeys.LocalMusicSheet, []);
36    }
37    localSheetStateMapper.notify();
38}
39
40export async function addMusic(
41    musicItem: IMusic.IMusicItem | IMusic.IMusicItem[],
42) {
43    if (!Array.isArray(musicItem)) {
44        musicItem = [musicItem];
45    }
46    let newSheet = [...localSheet];
47    musicItem.forEach(mi => {
48        if (localSheet.findIndex(_ => isSameMediaItem(mi, _)) === -1) {
49            newSheet.push(mi);
50        }
51    });
52    await setStorage(StorageKeys.LocalMusicSheet, newSheet);
53    localSheet = newSheet;
54    localSheetStateMapper.notify();
55}
56
57export async function removeMusic(
58    musicItem: IMusic.IMusicItem,
59    deleteOriginalFile = false,
60) {
61    const idx = localSheet.findIndex(_ => isSameMediaItem(_, musicItem));
62    let newSheet = [...localSheet];
63    if (idx !== -1) {
64        newSheet.splice(idx, 1);
65        if (deleteOriginalFile && musicItem[internalSerializeKey]?.localPath) {
66            await FileSystem.unlink(musicItem[internalSerializeKey].localPath);
67        }
68    }
69    localSheet = newSheet;
70    localSheetStateMapper.notify();
71}
72
73function parseFilename(fn: string): Partial<IMusic.IMusicItem> | null {
74    const data = fn.slice(0, fn.lastIndexOf('.')).split('@');
75    const [platform, id, title, artist] = data;
76    if (!platform || !id) {
77        return null;
78    }
79    return {
80        id,
81        platform,
82        title,
83        artist,
84    };
85}
86
87/** 从文件夹导入 */
88async function importFolder(folderPath: string) {
89    const dirFiles = await FileSystem.statDir(folderPath);
90    const musicFiles = dirFiles.filter(
91        // todo: flac播放没有声音
92        _ =>
93            _.type === 'file' &&
94            (_.filename.endsWith('.mp3') || _.filename.endsWith('.flac')),
95    );
96
97    const musicItems: IMusic.IMusicItem[] = await Promise.all(
98        musicFiles.map(async mf => {
99            let {platform, id, title, artist} =
100                parseFilename(mf.filename) ?? {};
101
102            let meta: IBasicMeta | null;
103            try {
104                meta = await mp3Util.getBasicMeta(mf.path);
105            } catch {
106                meta = null;
107            }
108            if (!platform || !id) {
109                platform = '本地';
110                id = await FileSystem.hash(mf.path, 'MD5');
111            }
112            return {
113                id,
114                platform,
115                title: title ?? meta?.title ?? mf.filename,
116                artist: artist ?? meta?.artist ?? '未知歌手',
117                duration: parseInt(meta?.duration ?? '0') / 1000,
118                album: meta?.album ?? '未知专辑',
119                artwork: '',
120                [internalSerializeKey]: {
121                    localPath: mf.path,
122                },
123            };
124        }),
125    );
126    addMusic(musicItems);
127}
128
129function localMediaFilter(_: FileStat) {
130    return (
131        _.filename.endsWith('.mp3') ||
132        _.filename.endsWith('.flac') ||
133        _.filename.endsWith('.wma')
134    );
135}
136
137// TODO: 需要支持中断&取消
138async function getMusicFiles(folderPath: string) {
139    try {
140        const dirFiles = await FileSystem.statDir(folderPath);
141        const musicFiles: FileStat[] = [];
142
143        await Promise.all(
144            dirFiles.map(async item => {
145                if (item.type === 'directory') {
146                    const res = await getMusicFiles(item.path);
147                    musicFiles.push(...res);
148                } else if (localMediaFilter(item)) {
149                    musicFiles.push(item);
150                }
151            }),
152        );
153        return musicFiles;
154    } catch (e: any) {
155        errorLog('获取本地文件失败', e?.message);
156        return [];
157    }
158}
159
160// 导入本地音乐
161async function importLocal(folderPaths: string[]) {
162    const musics = await Promise.all(folderPaths.map(_ => getMusicFiles(_)));
163    const musicList = musics.flat();
164    const metas = await mp3Util.getMediaMeta(musicList.map(_ => _.path));
165    console.log(metas);
166    const musicItems = await Promise.all(
167        musicList.map(async (musicStat, index) => {
168            let {platform, id, title, artist} =
169                parseFilename(musicStat.filename) ?? {};
170            const meta = metas[index];
171            if (!platform || !id) {
172                platform = '本地';
173                id = await FileSystem.hash(musicStat.path, 'MD5');
174            }
175            return {
176                id,
177                platform,
178                title: title ?? meta?.title ?? musicStat.filename,
179                artist: artist ?? meta?.artist ?? '未知歌手',
180                duration: parseInt(meta?.duration ?? '0') / 1000,
181                album: meta?.album ?? '未知专辑',
182                artwork: '',
183                [internalSerializeKey]: {
184                    localPath: musicStat.path,
185                },
186            };
187        }),
188    );
189    addMusic(musicItems);
190}
191
192/** 是否为本地音乐 */
193function isLocalMusic(
194    musicItem: ICommon.IMediaBase | null,
195): IMusic.IMusicItem | undefined {
196    return musicItem
197        ? localSheet.find(_ => isSameMediaItem(_, musicItem))
198        : undefined;
199}
200
201/** 状态-是否为本地音乐 */
202function useIsLocal(musicItem: IMusic.IMusicItem | null) {
203    const localMusicState = localSheetStateMapper.useMappedState();
204    const [isLocal, setIsLocal] = useState<boolean>(!!isLocalMusic(musicItem));
205    useEffect(() => {
206        if (!musicItem) {
207            setIsLocal(false);
208        } else {
209            setIsLocal(!!isLocalMusic(musicItem));
210        }
211    }, [localMusicState, musicItem]);
212    return isLocal;
213}
214
215function getMusicList() {
216    return localSheet;
217}
218
219const LocalMusicSheet = {
220    setup,
221    addMusic,
222    removeMusic,
223    importFolder,
224    importLocal,
225    isLocalMusic,
226    useIsLocal,
227    getMusicList,
228    useMusicList: localSheetStateMapper.useMappedState,
229};
230
231export default LocalMusicSheet;
232