* 歌单管理
*/
import { ResumeMode, SortType, localPluginPlatform } from "@/constants/commonConst.ts";
import { IAppConfig } from "@/types/core/config";
import { IInjectable } from "@/types/infra";
import { isSameMediaItem } from "@/utils/mediaUtils";
import EventEmitter from "eventemitter3";
import { Immer } from "immer";
import { atom, getDefaultStore, useAtomValue } from "jotai";
import { nanoid } from "nanoid";
import { useEffect, useMemo, useState } from "react";
import migrate, { migrateV2 } from "./migrate.ts";
import SortedMusicList from "./sortedMusicList.ts";
import storage from "./storage.ts";
const produce = new Immer({
autoFreeze: false,
}).produce;
const _defaultSheet: IMusic.IMusicSheetItemBase = {
id: "favorite",
platform: localPluginPlatform,
coverImg: undefined,
title: "我喜欢",
worksNum: 0,
};
const musicSheetsBaseAtom = atom<IMusic.IMusicSheetItemBase[]>([]);
const starredMusicSheetsAtom = atom<IMusic.IMusicSheetItem[]>([]);
const musicListMap = new Map<string, SortedMusicList>();
const ee = new EventEmitter<{
UpdateMusicList: (updateInfo: {
sheetId: string;
updateType: "length" | "resort"; // 更新类型
}) => void;
UpdateSheetBasic: (data: {
sheetId: string;
}) => void;
}>();
class MusicSheetClazz implements IInjectable {
private appConfig!: IAppConfig;
defaultSheet: IMusic.IMusicSheetItemBase = _defaultSheet;
injectDependencies(appConfigService: IAppConfig): void {
this.appConfig = appConfigService;
}
async setup() {
await migrate();
try {
const allSheets: IMusic.IMusicSheetItemBase[] = storage.getSheets();
if (!Array.isArray(allSheets)) {
throw new Error("not exist");
}
let needRestore = false;
if (!allSheets.length) {
allSheets.push({
..._defaultSheet,
});
needRestore = true;
}
if (allSheets[0].id !== _defaultSheet.id) {
const defaultSheetIndex = allSheets.findIndex(
it => it.id === _defaultSheet.id,
);
if (defaultSheetIndex === -1) {
allSheets.unshift({
..._defaultSheet,
});
} else {
const firstSheet = allSheets.splice(defaultSheetIndex, 1);
allSheets.unshift(firstSheet[0]);
}
needRestore = true;
}
if (needRestore) {
await storage.setSheets(allSheets);
}
for (let sheet of allSheets) {
const musicList = storage.getMusicList(sheet.id);
const sortType = storage.getSheetMeta(sheet.id, "sort") as SortType;
sheet.worksNum = musicList.length;
migrateV2.migrate(sheet.id, musicList);
musicListMap.set(
sheet.id,
new SortedMusicList(musicList, sortType, true),
);
sheet.worksNum = musicList.length;
ee.emit("UpdateMusicList", {
sheetId: sheet.id,
updateType: "length",
});
}
migrateV2.done();
getDefaultStore().set(musicSheetsBaseAtom, allSheets);
const starredSheets: IMusic.IMusicSheetItem[] =
storage.getStarredSheets() || [];
getDefaultStore().set(starredMusicSheetsAtom, starredSheets);
} catch (e: any) {
if (e.message === "not exist") {
await storage.setSheets([_defaultSheet]);
await storage.setMusicList(_defaultSheet.id, []);
getDefaultStore().set(musicSheetsBaseAtom, [_defaultSheet]);
musicListMap.set(
_defaultSheet.id,
new SortedMusicList([], SortType.None, true),
);
}
}
}
getSortedMusicListBySheetId(sheetId: string) {
let musicList: SortedMusicList;
if (!musicListMap.has(sheetId)) {
musicList = new SortedMusicList([], SortType.None, true);
musicListMap.set(sheetId, musicList);
} else {
musicList = musicListMap.get(sheetId)!;
}
return musicList;
}
* 更新基本信息
* @param sheetId 歌单ID
* @param data 歌单数据
*/
async updateMusicSheetBase(
sheetId: string,
data: Partial<IMusic.IMusicSheetItemBase>,
) {
const musicSheets = getDefaultStore().get(musicSheetsBaseAtom);
const targetSheetIndex = musicSheets.findIndex(it => it.id === sheetId);
if (targetSheetIndex === -1) {
return;
}
const newMusicSheets = produce(musicSheets, draft => {
draft[targetSheetIndex] = {
...draft[targetSheetIndex],
...data,
id: sheetId,
};
return draft;
});
await storage.setSheets(newMusicSheets);
getDefaultStore().set(musicSheetsBaseAtom, newMusicSheets);
ee.emit("UpdateSheetBasic", {
sheetId,
});
}
* 新建歌单
* @param title 歌单名称
*/
async addSheet(title: string) {
const newId = nanoid();
const musicSheets = getDefaultStore().get(musicSheetsBaseAtom);
const newSheets: IMusic.IMusicSheetItemBase[] = [
musicSheets[0],
{
title,
platform: localPluginPlatform,
id: newId,
coverImg: undefined,
worksNum: 0,
createAt: Date.now(),
},
...musicSheets.slice(1),
];
await storage.setSheets(newSheets);
await storage.setMusicList(newId, []);
getDefaultStore().set(musicSheetsBaseAtom, newSheets);
let defaultSortType = this.appConfig.getConfig("basic.musicOrderInLocalSheet");
if (
defaultSortType &&
[
SortType.Newest,
SortType.Artist,
SortType.Album,
SortType.Oldest,
SortType.Title,
].includes(defaultSortType)
) {
storage.setSheetMeta(newId, "sort", defaultSortType);
} else {
defaultSortType = SortType.None;
}
musicListMap.set(newId, new SortedMusicList([], defaultSortType, true));
return newId;
}
backupSheets() {
const allSheets = getDefaultStore().get(musicSheetsBaseAtom);
return allSheets.map(it => ({
...it,
musicList: musicListMap.get(it.id)?.musicList || [],
})) as IMusic.IMusicSheetItem[];
}
async resumeSheets(
sheets: IMusic.IMusicSheetItem[],
resumeMode: ResumeMode,
) {
if (resumeMode === ResumeMode.Append) {
for (let i = sheets.length - 1; i >= 0; --i) {
const newSheetId = await this.addSheet(sheets[i].title || "");
await this.addMusic(newSheetId, sheets[i].musicList || []);
}
return;
}
const defaultSheetIndex = sheets.findIndex(it => it.id === _defaultSheet.id);
let exportedDefaultSheet: IMusic.IMusicSheetItem | null = null;
if (defaultSheetIndex !== -1) {
exportedDefaultSheet = sheets.splice(defaultSheetIndex, 1)[0];
}
await this.addMusic(_defaultSheet.id, exportedDefaultSheet?.musicList || []);
if (resumeMode === ResumeMode.OverwriteDefault) {
for (let i = sheets.length - 1; i >= 0; --i) {
const newSheetId = await this.addSheet(sheets[i].title || "");
await this.addMusic(newSheetId, sheets[i].musicList || []);
}
} else {
const existsSheetIdMap: Record<string, string> = {};
const allSheets = getDefaultStore().get(musicSheetsBaseAtom);
allSheets.forEach(it => {
existsSheetIdMap[it.title!] = it.id;
});
for (let i = sheets.length - 1; i >= 0; --i) {
let newSheetId = existsSheetIdMap[sheets[i].title || ""];
if (!newSheetId) {
newSheetId = await this.addSheet(sheets[i].title || "");
}
await this.addMusic(newSheetId, sheets[i].musicList || []);
}
}
}
* 删除歌单
* @param sheetId 歌单id
*/
async removeSheet(sheetId: string) {
if (sheetId === _defaultSheet.id) {
return;
}
const musicSheets = getDefaultStore().get(musicSheetsBaseAtom);
const newSheets = musicSheets.filter(item => item.id !== sheetId);
storage.removeMusicList(sheetId);
await storage.setSheets(newSheets);
getDefaultStore().set(musicSheetsBaseAtom, newSheets);
musicListMap.delete(sheetId);
}
* 向歌单内添加音乐
* @param sheetId 歌单id
* @param musicItem 音乐
*/
async addMusic(
sheetId: string,
musicItem: IMusic.IMusicItem | Array<IMusic.IMusicItem>,
) {
const now = Date.now();
if (!Array.isArray(musicItem)) {
musicItem = [musicItem];
}
const taggedMusicItems = musicItem.map((it, index) => ({
...it,
$timestamp: now,
$sortIndex: musicItem.length - index,
}));
let musicList = this.getSortedMusicListBySheetId(sheetId);
const addedCount = musicList.add(taggedMusicItems);
if (!addedCount) {
return;
}
const musicSheets = getDefaultStore().get(musicSheetsBaseAtom);
if (
!musicSheets
.find(_ => _.id === sheetId)
?.coverImg?.startsWith?.("file://")
) {
await this.updateMusicSheetBase(sheetId, {
coverImg: musicList.at(0)?.artwork,
});
}
getDefaultStore().set(
musicSheetsBaseAtom,
produce(draft => {
const musicSheet = draft.find(it => it.id === sheetId);
if (musicSheet) {
musicSheet.worksNum = musicList.length;
}
}),
);
await storage.setMusicList(sheetId, musicList.musicList);
ee.emit("UpdateMusicList", {
sheetId,
updateType: "length",
});
}
async removeMusicByIndex(sheetId: string, indices: number | number[]) {
if (!Array.isArray(indices)) {
indices = [indices];
}
const musicList = this.getSortedMusicListBySheetId(sheetId);
musicList.removeByIndex(indices);
const musicSheets = getDefaultStore().get(musicSheetsBaseAtom);
if (
!musicSheets
.find(_ => _.id === sheetId)
?.coverImg?.startsWith("file://")
) {
await this.updateMusicSheetBase(sheetId, {
coverImg: musicList.at(0)?.artwork,
});
}
getDefaultStore().set(
musicSheetsBaseAtom,
produce(draft => {
const musicSheet = draft.find(it => it.id === sheetId);
if (musicSheet) {
musicSheet.worksNum = musicList.length;
}
}),
);
await storage.setMusicList(sheetId, musicList.musicList);
ee.emit("UpdateMusicList", {
sheetId,
updateType: "length",
});
}
async removeMusic(
sheetId: string,
musicItems: IMusic.IMusicItem | IMusic.IMusicItem[],
) {
if (!Array.isArray(musicItems)) {
musicItems = [musicItems];
}
const musicList = this.getSortedMusicListBySheetId(sheetId);
musicList.remove(musicItems);
const musicSheets = getDefaultStore().get(musicSheetsBaseAtom);
let patchData: Partial<IMusic.IMusicSheetItemBase> = {};
if (
!musicSheets
.find(_ => _.id === sheetId)
?.coverImg?.startsWith?.("file://")
) {
patchData.coverImg = musicList.at(0)?.artwork;
}
patchData.worksNum = musicList.length;
await this.updateMusicSheetBase(sheetId, {
coverImg: musicList.at(0)?.artwork,
});
await storage.setMusicList(sheetId, musicList.musicList);
ee.emit("UpdateMusicList", {
sheetId,
updateType: "length",
});
}
async setSortType(sheetId: string, sortType: SortType) {
const musicList = this.getSortedMusicListBySheetId(sheetId);
musicList.setSortType(sortType);
await storage.setMusicList(sheetId, musicList.musicList);
storage.setSheetMeta(sheetId, "sort", sortType);
ee.emit("UpdateMusicList", {
sheetId,
updateType: "resort",
});
}
async manualSort(
sheetId: string,
musicListAfterSort: IMusic.IMusicItem[],
) {
const musicList = this.getSortedMusicListBySheetId(sheetId);
musicList.manualSort(musicListAfterSort);
await storage.setMusicList(sheetId, musicList.musicList);
storage.setSheetMeta(sheetId, "sort", SortType.None);
ee.emit("UpdateMusicList", {
sheetId,
updateType: "resort",
});
}
getSheetMeta = storage.getSheetMeta;
async starMusicSheet(musicSheet: IMusic.IMusicSheetItem) {
const store = getDefaultStore();
const starredSheets: IMusic.IMusicSheetItem[] = store.get(
starredMusicSheetsAtom,
);
const newVal = [musicSheet, ...starredSheets];
store.set(starredMusicSheetsAtom, newVal);
await storage.setStarredSheets(newVal);
}
async unstarMusicSheet(musicSheet: IMusic.IMusicSheetItemBase) {
const store = getDefaultStore();
const starredSheets: IMusic.IMusicSheetItem[] = store.get(
starredMusicSheetsAtom,
);
const newVal = starredSheets.filter(
it =>
!isSameMediaItem(
it as ICommon.IMediaBase,
musicSheet as ICommon.IMediaBase,
),
);
store.set(starredMusicSheetsAtom, newVal);
await storage.setStarredSheets(newVal);
}
}
const MusicSheet = new MusicSheetClazz();
export default MusicSheet;
function useSheetsBase() {
return useAtomValue(musicSheetsBaseAtom);
}
function useSheetItem(sheetId: string) {
const sheetsBase = useAtomValue(musicSheetsBaseAtom);
const [sheetItem, setSheetItem] = useState<IMusic.IMusicSheetItem>({
...(sheetsBase.find(it => it.id === sheetId) ||
({} as IMusic.IMusicSheetItemBase)),
musicList: musicListMap.get(sheetId)?.musicList || [],
});
useEffect(() => {
const onUpdateMusicList = ({ sheetId: updatedSheetId }) => {
if (updatedSheetId !== sheetId) {
return;
}
setSheetItem(prev => ({
...prev,
musicList: musicListMap.get(sheetId)?.musicList || [],
}));
};
const onUpdateSheetBasic = ({ sheetId: updatedSheetId }) => {
if (updatedSheetId !== sheetId) {
return;
}
setSheetItem(prev => ({
...prev,
...(getDefaultStore()
.get(musicSheetsBaseAtom)
.find(it => it.id === sheetId) || {}),
}));
};
ee.on("UpdateMusicList", onUpdateMusicList);
ee.on("UpdateSheetBasic", onUpdateSheetBasic);
return () => {
ee.off("UpdateMusicList", onUpdateMusicList);
ee.off("UpdateSheetBasic", onUpdateSheetBasic);
};
}, []);
return sheetItem;
}
function useFavorite(musicItem: IMusic.IMusicItem | null) {
const [fav, setFav] = useState(false);
useEffect(() => {
const onUpdateMusicList = ({ sheetId: updatedSheetId, updateType }) => {
if (updatedSheetId !== _defaultSheet.id || updateType === "resort") {
return;
}
setFav(musicListMap.get(_defaultSheet.id)?.has(musicItem) || false);
};
ee.on("UpdateMusicList", onUpdateMusicList);
setFav(musicListMap.get(_defaultSheet.id)?.has(musicItem) || false);
return () => {
ee.off("UpdateMusicList", onUpdateMusicList);
};
}, [musicItem]);
return fav;
}
function useSheetIsStarred(
musicSheet?: IMusic.IMusicSheetItem | null,
) {
const musicSheets = useAtomValue(starredMusicSheetsAtom);
return useMemo(() => {
if (!musicSheet) {
return false;
}
return (
musicSheets.findIndex(it =>
isSameMediaItem(
it as ICommon.IMediaBase,
musicSheet as ICommon.IMediaBase,
),
) !== -1
);
}, [musicSheet, musicSheets]);
}
function useStarredSheets() {
return useAtomValue(starredMusicSheetsAtom);
}
export { useSheetIsStarred, useSheetsBase, useSheetItem, useStarredSheets, useFavorite };