import { forwardRef, useCallback, useContext, useImperativeHandle, useMemo, useState } from "react"; import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component"; import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useService } from "renderer/hooks/use-service.hook"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { FolderLinkState } from "renderer/services/version-folder-linker.service"; import { BehaviorSubject, Observable, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; import { BSVersion } from "shared/bs-version.interface"; import { noop } from "shared/helpers/function.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { PlaylistItem } from "./playlist-item.component"; import { useStateMap } from "renderer/hooks/use-state-map.hook"; import { ModalExitCode, ModalService } from "renderer/services/modale.service"; import { LocalPlaylistDetailsModal } from "renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component"; import { InstalledMapsContext } from "../maps-playlists-panel.component"; import { IpcService } from "renderer/services/ipc.service"; import equal from "fast-deep-equal"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; import { NotificationService } from "renderer/services/notification.service"; import { DeletePlaylistModal } from "renderer/components/modal/modal-types/playlist/delete-playlist-modal.component"; import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; import { PlaylistItemComponentPropsMapper } from "shared/mappers/playlist/playlist-item-component-props.mapper"; import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; import { LocalPlaylistFilter } from "./local-playlist-filter-panel.component"; import { BsmImage } from "renderer/components/shared/bsm-image.component"; import { BsmButton } from "renderer/components/shared/bsm-button.component"; import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { DownloadPlaylistModal } from "renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component"; import { logRenderError } from "renderer"; import { tryit } from "shared/helpers/error.helpers"; import { ProgressBarService } from "renderer/services/progress-bar.service"; import { ProgressionInterface } from "shared/models/progress-bar"; import { enumerate } from "shared/helpers/array.helpers"; import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component"; import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component"; import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component"; import { NeedCloneEditPlaylistModal } from "renderer/components/modal/modal-types/playlist/need-clone-edit-playlist-modal.component"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; type Props = { version: BSVersion; className?: string; filter?: LocalPlaylistFilter; search?: string; linkedState?: FolderLinkState; isActive?: boolean; }; export type LocalPlaylistsListRef = { createPlaylist: () => Promise; syncPlaylists: () => Promise; deletePlaylists: () => Promise; exportPlaylists: () => Promise; } export const LocalPlaylistsListPanel = forwardRef(({ version, className, filter: playlistFiler, search, isActive, linkedState }, forwardedRef) => { const t = useTranslation(); const progess = useService(ProgressBarService); const playlistService = useService(PlaylistsManagerService); const playlistDownloader = useService(PlaylistDownloaderService); const modals = useService(ModalService); const ipc = useService(IpcService); const osDiagnostic = useService(OsDiagnosticService); const notification = useService(NotificationService); const isOnline = useObservable(() => osDiagnostic.isOnline$, false); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); const { maps$, playlists$, setPlaylists, setMaps } = useContext(InstalledMapsContext); const selectedPlaylists$ = useConstant(() => new BehaviorSubject([])); const playlists = useObservable(() => playlists$, []); const [playlistsLoading, setPlaylistsLoading] = useState(false); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); const installPlaylist = (playlist: LocalBPList) => { const ignoreSongsHashs = (maps$.value || []).map(m => m.hash.toLowerCase()); return playlistDownloader.downloadPlaylist({ downloadSource: playlist.customData?.syncURL ?? playlist.path, version, ignoreSongsHashs, dest: playlist.path }); } useImperativeHandle(forwardedRef, () => ({ createPlaylist: async () => { const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { maps$ } }); if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } const { error, result } = await tryit(() => lastValueFrom(playlistDownloader.installPlaylistFile(modalRes.data, version))); if(error){ logRenderError("Error occured while creating playlist", error); notification.notifyError({ title: "playlist.error-playlist-creation-title", desc: "playlist.error-playlist-creation-desc" }); return; } setPlaylists([result, ...playlists$.value]); const notifRes = await notification.notifySuccess({ title: "playlist.playlist-created-title", desc: "playlist.playlist-created-desc", duration: 8000, actions: [ { id: "sync", title: "playlist.synchronize-maps" } ]}); if(notifRes === "sync"){ await lastValueFrom(installPlaylist(result)); } }, syncPlaylists: async () => { if(!isOnline){ return; } const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; if(!toSync.length){ return; } const modalRes = await modals.openModal(SyncPlaylistModal, { data: toSync }); if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } const obs$ = combineLatest(toSync.map(playlist => installPlaylist(playlist))); const { error, result } = await tryit(() => lastValueFrom(obs$)); if(error){ logRenderError("Error occured while synchronizing playlists", error); notification.notifyError({ title: "playlist.error-playlists-synchronization-title", desc: "playlist.error-playlists-synchronization-desc" }); return; } if(result.every(res => res.current === res.total)){ notification.notifySuccess({ title: "playlist.playlists-synchronized-title", desc: "playlist.playlists-synchronized-desc", duration: 5000 }); } }, exportPlaylists: async () => { if(!progess.require()){ return; } const toExport = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; if(!toExport.length){ return; } const { exitCode, data: exportMaps } = await modals.openModal(ExportPlaylistModal, { data: toExport }); if(exitCode !== ModalExitCode.COMPLETED){ return; } const folderRes = await lastValueFrom(ipc.sendV2("choose-folder")); if(!folderRes || folderRes.canceled || !folderRes.filePaths?.length){ return; } const mapsToExport = exportMaps ? ( maps$?.value?.filter(m => toExport.some(p => p.songs.some(s => s.hash.toLowerCase() === m.hash.toLowerCase()))) ?? [] ) : []; const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), playlistsMaps: mapsToExport }); progess.show(obs$); const { error } = await tryit(() => lastValueFrom(obs$)); if(error){ logRenderError("Error occured while exporting playlists", error); notification.notifyError({ title: "playlist.playlists-export-error-title", desc: "playlist.playlists-export-error-desc" }); return; } notification.notifySuccess({ title: "playlist.playlists-exported-title", desc: exportMaps ? "playlist.playlists-exported-desc" : "playlist.playlists-with-maps-exported-desc", duration: 5000 }); progess.hide(); }, deletePlaylists: () => { const toDelete = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; if(!toDelete.length){ return Promise.resolve(); } return deletePlaylists(toDelete); } })) const loadLocalPlaylistsDetails = (): Promise => { setPlaylistsLoading(true); const obs = playlistService.getVersionPlaylistsDetails(version).pipe( tap({ next: load => loadPercent$.next((load.current / load.total) * 100)}), map(load => load.data), finalize(() => setPlaylistsLoading(false)) ); return lastValueFrom(obs); } useOnUpdate(() => selectedPlaylists$.next([]), [version]); useOnUpdate(() => { if(!isActiveOnce){ return noop(); } loadLocalPlaylistsDetails().then(loadedPlaylists => { setPlaylists(loadedPlaylists); }).catch(() => { setPlaylists([]); }).finally(() => { loadPercent$.next(0); }); const onPlaylistDownloadedCB = (downloaded: LocalBPListsDetails) => { const newPlaylist = (() => { const index = playlists$.value.findIndex(p => p.path === downloaded.path); if(index === -1){ return [...playlists$.value, downloaded]; } const newPlaylists = [...playlists$.value]; newPlaylists[index] = downloaded; return newPlaylists; })(); setPlaylists(newPlaylist); } const sub = playlistDownloader.currentDownload$.pipe(filter(download => download?.downloaded && equal(download?.info.version, version))).subscribe(download => onPlaylistDownloadedCB(download.downloaded)); return () => { sub.unsubscribe(); } }, [isActiveOnce, version, linked]); const openDownloadPlaylistModal = () => { modals.openModal(DownloadPlaylistModal, { data: { version, ownedPlaylists$: playlists$, ownedMaps$: maps$ } }); } const handleClickSync = (playlist: LocalBPList) => { const obs$ = installPlaylist(playlist); return lastValueFrom(obs$).then(res => { if(res.current === res.total){ notification.notifySuccess({ title: "playlist.playlists-synchronized-title", desc: "playlist.playlists-synchronized-desc", duration: 5000 }) } }); } const viewPlaylistFile = (path: string) => { return lastValueFrom(ipc.sendV2("view-path-in-explorer", path)); }; const deletePlaylists = async (bpLists: LocalBPList[]) => { if(!bpLists.length || !progess.require()){ return; } const { exitCode, data: deleteMaps } = await modals.openModal(DeletePlaylistModal, { data: bpLists }); if(exitCode !== ModalExitCode.COMPLETED){ return; } const progess$ = new BehaviorSubject({ progression: 0 }); progess.show(progess$) for(const [i, bpList] of enumerate(bpLists)){ const { error } = await tryit(() => lastValueFrom(playlistService.deletePlaylist({ version, bpList, deleteMaps }))) if(error){ logRenderError("Error occured while deleting playlist", error); notification.notifyError({ title: "playlist.playlist-delete-error-title", desc: "playlist.playlist-delete-error-desc" }); progess.hide(); return; } progess$.next({ progression: (i / bpLists.length) * 100, label: bpList.playlistTitle }); setPlaylists(playlists$.value.filter(p => p.path !== bpList.path)); if(deleteMaps){ setMaps(maps$.value.filter(m => !bpList.songs.some(s => s.hash.toLowerCase() === m.hash.toLowerCase()))); } } notification.notifySuccess({ title: "playlist.playlists-deleted-title", desc: "playlist.playlists-deleted-desc", duration: 5000 }); progess.hide(); }; const openPlaylistDetails = (playlistPath: string) => { const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistPath))); const installedMaps$: Observable = combineLatest([maps$, localPlaylist$]).pipe( filter(([maps, playlist]) => !!maps && !!playlist), map(([maps, playlist]) => { return playlist.songs.reduce((acc, playlistSong) => { if(!playlistSong){ return acc; } let map: BsmLocalMap; if(playlistSong.hash || playlistSong?.songDetails?.hash){ const hash = (playlistSong.hash || playlistSong.songDetails.hash).toLowerCase(); map = maps.find(m => m.hash.toLowerCase() === hash); } else if(playlistSong.key){ map = maps.find(m => m?.songDetails?.id === playlistSong.key); } if(map){ acc.push(map); } return acc; }, []); }), distinctUntilChanged(equal) ); modals.openModal(LocalPlaylistDetailsModal, { data: { version, localPlaylist$, installedMaps$ }, noStyle: true, }) }; const editPlaylist = async (playlist: LocalBPList) => { const needClone = playlist?.customData?.syncURL; const res = await (needClone ? modals.openModal(NeedCloneEditPlaylistModal) : Promise.resolve()); if(res && res.exitCode !== ModalExitCode.COMPLETED){ return; } const tmpPlaylist: LocalBPList = { ...playlist, playlistTitle: needClone ? `${playlist.playlistTitle} (${t("Clone")})` : playlist.playlistTitle }; const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { maps$, playlist: tmpPlaylist } }); if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } const { error, result } = await tryit(() => lastValueFrom(playlistDownloader.installPlaylistFile(modalRes.data, version, needClone ? undefined : playlist.path))); if(error){ logRenderError("Error occured while editing playlist", error); notification.notifyError({ title: "playlist.playlist-edit-error-title", desc: "playlist.playlist-edit-error-desc" }); return; } const newPlaylists = [...playlists$.value]; if(needClone){ newPlaylists.unshift(result); } else { const index = newPlaylists.findIndex(p => p.path === playlist.path); newPlaylists[index] = result; } setPlaylists(newPlaylists); const notifRes = await notification.notifySuccess({ title: "playlist.playlist-edited-title", desc: "playlist.playlist-edited-desc", duration: 8000, actions: [ { id: "sync", title: "playlist.synchronize-maps" } ]}); if(notifRes === "sync"){ await lastValueFrom(installPlaylist(result)); } } const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => { return ( selected.some(s => s.path === playlist.path)), distinctUntilChanged(equal))} onClick={() => { if(selectedPlaylists$.value.some(s => s.path === playlist.path)){ selectedPlaylists$.next(selectedPlaylists$.value.filter(s => s.path !== playlist.path)); return; } selectedPlaylists$.next([...selectedPlaylists$.value, playlist]); }} onClickOpen={() => openPlaylistDetails(playlist.path)} onClickDelete={() => deletePlaylists([playlist])} onClickSync={(playlist?.songs?.length && isOnline) && (() => handleClickSync(playlist))} onClickOpenFile={() => viewPlaylistFile(playlist.path)} onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.customData?.syncURL ?? playlist.path, version)} onClickEdit={() => editPlaylist(playlist)} /> ); }, [isOnline, version]); const filteredPlaylists = useMemo(() => { if(!playlists){ return []; } return playlists.filter(p => { if(!p.playlistTitle?.toLowerCase().includes(search.toLowerCase())){ return false; } if(!p.playlistAuthor?.toLowerCase().includes(search.toLowerCase())){ return false; } if(typeof p.nbMaps === "number" && (typeof playlistFiler?.minNbMaps === "number" || typeof playlistFiler?.maxNbMaps === "number")){ if(playlistFiler?.minNbMaps && p.nbMaps < playlistFiler.minNbMaps){ return false; } if(playlistFiler?.maxNbMaps && p.nbMaps > playlistFiler.maxNbMaps){ return false; } } if(typeof p.nbMappers === "number" && (typeof playlistFiler?.minNbMappers === "number" || typeof playlistFiler?.maxNbMappers === "number")){ if(playlistFiler?.minNbMappers && p.nbMappers < playlistFiler.minNbMappers){ return false; } if(playlistFiler?.maxNbMappers && p.nbMappers > playlistFiler.maxNbMappers){ return false; } } if(typeof p.duration === "number" && (typeof playlistFiler?.minDuration === "number" || typeof playlistFiler?.maxDuration === "number")){ if(playlistFiler?.minDuration && p.duration < playlistFiler.minDuration){ return false; } if(playlistFiler?.maxDuration && p.duration > playlistFiler.maxDuration){ return false; } } if(typeof p.minNps === "number" && typeof playlistFiler.minNps === "number" && p.minNps < playlistFiler.minNps){ return false; } if(typeof p.maxNps === "number" && typeof playlistFiler.maxNps === "number" && p.maxNps > playlistFiler.maxNps){ return false; } return true; }); }, [playlists, search, playlistFiler]); return (
{(() => { if(playlistsLoading){ return ( ) } if (filteredPlaylists?.length){ return ( ) } return (
{t("playlist.no-playlists")} { e.preventDefault(); openDownloadPlaylistModal(); }} />
); })()}
) });