diff --git a/src/main/helpers/fs.helpers.ts b/src/main/helpers/fs.helpers.ts index 2ad48bb6..a6ebe662 100644 --- a/src/main/helpers/fs.helpers.ts +++ b/src/main/helpers/fs.helpers.ts @@ -1,4 +1,4 @@ -import { CopyOptions, copy, createReadStream, ensureDir, move, realpath, stat, symlink } from "fs-extra"; +import { CopyOptions, copy, createReadStream, ensureDir, move, pathExists, pathExistsSync, realpath, stat, symlink } from "fs-extra"; import { access, mkdir, rm, readdir, unlink, lstat, readlink } from "fs/promises"; import path from "path"; import { Observable, concatMap, from } from "rxjs"; @@ -217,13 +217,27 @@ export function rxCopy(src: string, dest: string, option?: CopyOptions): Observa export async function ensurePathNotAlreadyExist(path: string): Promise { let destPath = path; - let folderExist = await pathExist(destPath); + let folderExist = await pathExists(destPath); let i = 0; while (folderExist) { i++; destPath = `${path} (${i})`; - folderExist = await pathExist(destPath); + folderExist = await pathExists(destPath); + } + + return destPath; +} + +export function ensurePathNotAlreadyExistSync(path: string): string { + let destPath = path; + let folderExist = pathExistsSync(destPath); + let i = 0; + + while (folderExist) { + i++; + destPath = `${path} (${i})`; + folderExist = pathExistsSync(destPath); } return destPath; diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 26aebb03..01701dbb 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -56,9 +56,13 @@ ipc.on("delete-playlist", (args, reply) => { const maps = LocalMapsManagerService.getInstance(); reply(playlists.deletePlaylistFile(args.bpList).pipe(mergeMap(() => { if(args.deleteMaps){ - console.log("ALALALZELALZELAZELA"); return maps.deleteMapsFromHashs(args.version, args.bpList.songs.map(s => s.hash)); } return of({ current: 0, total: 0 } as Progression); }))); }); + +ipc.on("export-playlists", (args, reply) => { + const playlists = LocalPlaylistsManagerService.getInstance(); + reply(playlists.exportPlaylists(args)); +}); diff --git a/src/main/models/archive.class.ts b/src/main/models/archive.class.ts index d44fff8b..98459224 100644 --- a/src/main/models/archive.class.ts +++ b/src/main/models/archive.class.ts @@ -36,8 +36,7 @@ export class Archive { public addDirectory(path: string, destPath?: string | false): void { this.directories.push(path); - destPath = destPath === false ? false : _path.basename(path); - this.archive.directory(path, destPath); + this.archive.directory(path, destPath ?? _path.basename(path)); } public addFile(path: string, destPath?: string): void { @@ -46,13 +45,14 @@ export class Archive { this.archive.file(path, { name: destPath }); } - public finalize(): Observable { - const progress: Progression = { + public finalize(): Observable> { + const progress: Progression = { total: 0, current: 0, + data: this.output, }; - return new Observable(observer => { + return new Observable>(observer => { (async () => { progress.total = await this.loadTotalFiles(); diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index 2628ae91..2302456a 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -11,7 +11,7 @@ import { BPList, DownloadPlaylistProgressionData, PlaylistSong } from "shared/mo import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFile, writeFileSync } from "fs-extra"; -import { Progression, pathExist, unlinkPath } from "../../helpers/fs.helpers"; +import { Progression, ensurePathNotAlreadyExist, ensurePathNotAlreadyExistSync, pathExist, unlinkPath } from "../../helpers/fs.helpers"; import { FileAssociationService } from "../file-association.service"; import { SongDetailsCacheService } from "./maps/song-details-cache.service"; import { sToMs } from "shared/helpers/time.helpers"; @@ -21,6 +21,8 @@ import { InstallationLocationService } from "../installation-location.service"; import sanitize from "sanitize-filename"; import { isValidUrl } from "shared/helpers/url.helpers"; import { allSettled } from "shared/helpers/promise.helpers"; +import { Archive } from "main/models/archive.class"; +import { CustomError } from "shared/models/exceptions/custom-error.class"; export class LocalPlaylistsManagerService { private static instance: LocalPlaylistsManagerService; @@ -289,6 +291,76 @@ export class LocalPlaylistsManagerService { return from(unlinkPath(bpList.path)); } + public exportPlaylists(opt: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable> { + + if(!pathExistsSync(opt.dest)) { + throw new CustomError(`Destination folder not found ${opt.dest}`, "DEST_ENOENT"); + } + + if(opt.bpLists?.length === 0) { + throw new CustomError("No playlists to export", "NO_PLAYLISTS"); + } + + const versionName = opt.version ? opt.version.name ?? opt.version.BSVersion : "Shared"; + const destName = opt.version ? `${versionName} Playlists` : "Playlists"; + const zipDest = path.join(opt.dest, `${destName}.zip`); + + const archive = new Archive(zipDest) + + for(const bpList of opt.bpLists) { + + if(!pathExistsSync(bpList.path)) { + throw new CustomError(`Playlist file not found ${bpList.path}`, "PLAYLIST_ENOENT"); + } + + archive.addFile(bpList.path, path.join(this.PLAYLISTS_FOLDER, path.basename(bpList.path))); + } + + if(!opt.exportMaps) { + return archive.finalize(); + } + + const mapsHashsToExport = Array.from( + new Set(opt.bpLists.reduce((acc, bpList) => acc.concat((bpList.songs ?? []).map(s => s.hash)), [])).values() + ); + + const zipMaps$ = new Observable>(obs => { + (async () => { + const progress: Progression = { total: mapsHashsToExport.length, current: 0, data: zipDest }; + + for(const hash of mapsHashsToExport) { + const mapInfo = await this.maps.getMapInfoFromHash(hash, opt.version); + + if(!mapInfo || !pathExistsSync(mapInfo.path)) { continue; } + + archive.addDirectory( + mapInfo.path, + path.join("Maps", path.basename(mapInfo.path)) // Dont't know why, but "CustomLevels" not work + ); + progress.current += 1; + + obs.next(progress); + } + + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }); + + return new Observable>(obs => { + (async () => { + const maps$ = zipMaps$.pipe(tap({ next: p => obs.next(p) })); + const archive$ = archive.finalize().pipe(tap({ next: p => obs.next(p) })); + + await lastValueFrom(maps$); + await lastValueFrom(archive$); + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }) + + } + public oneClickInstallPlaylist(bpListUrl: string): Observable> { return new Observable>(obs => { diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index 374e64c6..ca55f084 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -283,7 +283,7 @@ export class LocalMapsManagerService { }); } - public async getMapInfoFromHash(hash: string, version: BSVersion): Promise { + public async getMapInfoFromHash(hash: string, version?: BSVersion): Promise { const versionMapsPath = await this.getMapsFolderPath(version); const mapInfo = this.songCache.getMapInfoFromHash(hash); diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 2f8fc49b..4be7629f 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -15,12 +15,13 @@ import { MapIcon } from "../svgs/icons/map-icon.component"; import { PlaylistIcon } from "../svgs/icons/playlist-icon.component"; import { useObservable } from "renderer/hooks/use-observable.hook"; import { BehaviorSubject, of } from "rxjs"; -import { LocalPlaylistsListPanel } from "./playlists/local-playlists-list-panel.component"; +import { LocalPlaylistsListPanel, LocalPlaylistsListRef } from "./playlists/local-playlists-list-panel.component"; import { PlaylistsManagerService } from "renderer/services/playlists-manager.service"; import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { useConstant } from "renderer/hooks/use-constant.hook"; import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"; +import { LocalPlaylistFilter, LocalPlaylistFilterPanel } from "./playlists/local-playlist-filter-panel.component"; type Props = { version?: BSVersion; @@ -28,17 +29,17 @@ type Props = { }; export const InstalledMapsContext = createContext<{ - maps$?: BehaviorSubject; + maps$: BehaviorSubject; setMaps: (maps: BsmLocalMap[]) => void; - playlists$?: BehaviorSubject; + playlists$: BehaviorSubject; setPlaylists: (playlist: LocalBPListsDetails[]) => void; }>(null); export function MapsPlaylistsPanel({ version, isActive }: Props) { - const mapsService = useService(MapsManagerService); + const mapsManager = useService(MapsManagerService); const mapsDownloader = useService(MapsDownloaderService); - const playlistsService = useService(PlaylistsManagerService); + const playlistsManager = useService(PlaylistsManagerService); const playlistsDownloader = useService(PlaylistDownloaderService); const t = useTranslation(); @@ -46,30 +47,31 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const maps$ = useConstant(() => new BehaviorSubject(undefined)); const playlists$ = useConstant(() => new BehaviorSubject(undefined)); - const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$), playlists$: playlists$, setPlaylists: playlists$.next.bind(playlists$)})); + + const mapsContextValue = useConstant(() => ({ + maps$, + setMaps: maps$.next.bind(maps$), + playlists$, + setPlaylists: playlists$.next.bind(playlists$), + })); const mapsRef = useRef(); + const playlistsRef = useRef(); + const [mapFilter, setMapFilter] = useState({}); - const [mapSearch, setMapSearch] = useState(""); + const [playlistFilter, setPlaylistFilter] = useState({}); + + const [search, setSearch] = useState(""); const mapsLinkedState = useObservable(() => { if(!version) return of(FolderLinkState.Unlinked); - return mapsService.$mapsFolderLinkState(version); + return mapsManager.$mapsFolderLinkState(version); }, FolderLinkState.Unlinked, [version]); - const [playlistSearch, setPlaylistSearch] = useState(""); const playlistLinkedState = useObservable(() => { if(!version) return of(FolderLinkState.Unlinked); - return playlistsService.$playlistsFolderLinkState(version); + return playlistsManager.$playlistsFolderLinkState(version); }, FolderLinkState.Unlinked, [version]); - - const handleSearch = (value: string) => { - if (tabIndex === 0) { - return setMapSearch(() => value); - } - return setPlaylistSearch(() => value); - }; - const handleAddClick = () => { switch (tabIndex) { case 0: return mapsDownloader.openDownloadMapModal(version, maps$.value); @@ -81,29 +83,34 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { if(mapsLinkedState === FolderLinkState.Pending || mapsLinkedState === FolderLinkState.Processing){ return Promise.resolve(false); } if (mapsLinkedState === FolderLinkState.Unlinked) { - return mapsService.linkVersion(version); + return mapsManager.linkVersion(version); } - return mapsService.unlinkVersion(version); + return mapsManager.unlinkVersion(version); }; const handlePlaylistLinkClick = () => { if(playlistLinkedState === FolderLinkState.Pending || playlistLinkedState === FolderLinkState.Processing){ return Promise.resolve(false); } if (playlistLinkedState === FolderLinkState.Unlinked) { - return playlistsService.linkVersion(version); + return playlistsManager.linkVersion(version); } - return playlistsService.unlinkVersion(version); + return playlistsManager.unlinkVersion(version); } const dropDownItems = ((): DropDownItem[] => { - if (tabIndex === 1) { - return []; + if (tabIndex === 0) { + return [ + { icon: "export", text: "pages.version-viewer.maps.search-bar.dropdown.export-maps", onClick: () => mapsRef.current.exportMaps?.() }, + { icon: "trash", text: "pages.version-viewer.maps.search-bar.dropdown.delete-maps", onClick: () => mapsRef.current.deleteMaps?.() }, + ]; } + return [ - { icon: "export", text: "pages.version-viewer.maps.search-bar.dropdown.export-maps", onClick: () => mapsRef.current.exportMaps?.() }, - { icon: "trash", text: "pages.version-viewer.maps.search-bar.dropdown.delete-maps", onClick: () => mapsRef.current.deleteMaps?.() }, + { icon: "sync", text: "Synchroniser les playlists", onClick: () => playlistsRef?.current?.syncPlaylists?.() }, + { icon: "export", text: "Exporter les playlists", onClick: () => playlistsRef?.current?.exportPlaylists?.() }, + { icon: "trash", text: "Supprimer les playlists", onClick: () => playlistsRef?.current?.deletePlaylists?.() }, ]; })(); @@ -119,10 +126,23 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { onClick={handleAddClick} />
- handleSearch(e.target.value)} tabIndex={-1} /> + setSearch(() => e.target.value)} + tabIndex={-1} + />
- + {( + tabIndex === 0 ? ( + + ) : ( + + ) + )} @@ -151,8 +171,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { ]} > - - + + diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx new file mode 100644 index 00000000..db15466a --- /dev/null +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx @@ -0,0 +1,123 @@ +import { motion } from "framer-motion" +import { Dispatch, SetStateAction, useState } from "react"; +import { BsmRange } from "renderer/components/shared/bsm-range.component"; +import { cn } from "renderer/helpers/css-class.helpers" +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import dateFormat from "dateformat"; +import { hourToS, sToMs } from "shared/helpers/time.helpers"; +import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; + +type Props = { + className?: string; + filter?: LocalPlaylistFilter; + onChange?: (filter: LocalPlaylistFilter) => void; +} + +const [MIN_NB_MAPS, MAX_NB_MAPS] = [0, 1000]; +const [MIN_NB_MAPPER, MAX_NB_MAPPER] = [0, 1000]; +const [MIN_DURATION, MAX_DURATION] = [0, hourToS(9)]; +const [MIN_NPS, MAX_NPS] = [0, 17]; + +console.log(hourToS(9)); + +export function LocalPlaylistFilterPanel({ className, filter, onChange }: Props) { + + const t = useTranslation(); + + const [minNps, setMinNps] = useState(filter?.minNps ?? MIN_NPS); + const [maxNps, setMaxNps] = useState(filter?.maxNps ?? MAX_NPS); + + const [minNbMaps, setMinNbMaps] = useState(filter?.minNbMaps ?? MIN_NB_MAPS); + const [maxNbMaps, setMaxNbMaps] = useState(filter?.maxNbMaps ?? MAX_NB_MAPS); + + const [minNbMappers, setMinNbMappers] = useState(filter?.minNbMappers ?? MIN_NB_MAPPER); + const [maxNbMappers, setMaxNbMappers] = useState(filter?.minNbMappers ?? MAX_NB_MAPPER); + + const [minDuration, setMinDuration] = useState(filter?.minDuration ?? MIN_DURATION); + const [maxDuration, setMaxDuration] = useState(filter?.maxDuration ?? MAX_DURATION); + + useOnUpdate(() => { + + if(!onChange){ return; } + + const filter: LocalPlaylistFilter = { + minNps: minNps <= MIN_NPS ? undefined : minNps, + maxNps: maxNps >= MAX_NPS ? undefined : maxNps, + minNbMaps: minNbMaps <= MIN_NB_MAPS ? undefined : minNbMaps, + maxNbMaps: maxNbMaps >= MAX_NB_MAPS ? undefined : maxNbMaps, + minNbMappers: minNbMappers <= MIN_NB_MAPPER ? undefined : minNbMappers, + maxNbMappers: maxNbMappers >= MAX_NB_MAPPER ? undefined : maxNbMappers, + minDuration: minDuration <= MIN_DURATION ? undefined : minDuration, + maxDuration: maxDuration >= MAX_DURATION ? undefined : maxDuration, + }; + + onChange(filter); + + }, [minNps, maxNps, minNbMaps, maxNbMaps, minNbMappers, maxNbMappers, minDuration, maxDuration]); + + const handleRangeChange = ([minSetter, maxSetter]: Dispatch>[], [min, max]: number[], absoluteMin: number, absoluteMax: number) => { + minSetter(() => min <= absoluteMin ? undefined : min); + maxSetter(() => max >= absoluteMax ? undefined : max); + }; + + const handleOnNpsChange = (minMax: number[]) => handleRangeChange([setMinNps, setMaxNps], minMax, MIN_NPS, MAX_NPS); + const handleOnNbMapsChange = (minMax: number[]) => handleRangeChange([setMinNbMaps, setMaxNbMaps], minMax, MIN_NB_MAPS, MAX_NB_MAPS); + const handleOnNbMapperChange = (minMax: number[]) => handleRangeChange([setMinNbMappers, setMaxNbMappers], minMax, MIN_NB_MAPPER, MAX_NB_MAPPER); + const handleOnDurationChange = (minMax: number[]) => handleRangeChange([setMinDuration, setMaxDuration], minMax, MIN_DURATION, MAX_DURATION); + + const renderLabel = (text: string | number, isMax: boolean): JSX.Element => { + return {text}; + }; + + const renderSimpleMinMaxLabel = (value: number, max: number) => { + const label = value >= max ? `∞` : value; + return renderLabel(label, label === "∞"); + } + + const renderDurationLabel = (sec: number): JSX.Element => { + const textValue = (() => { + if (sec === MIN_DURATION) { + return MIN_DURATION; + } + if (sec === MAX_DURATION) { + return "∞"; + } + + return sec > 3600 ? dateFormat(sToMs(sec), "h:MM:ss") : dateFormat(sToMs(sec), "MM:ss"); + })(); + + return renderLabel(textValue, sec === MAX_DURATION); + }; + + return ( + +
+ renderSimpleMinMaxLabel(v, MAX_NB_MAPS)}/> + Nombre de maps +
+
+ renderSimpleMinMaxLabel(v, MAX_NB_MAPPER)}/> + Nombre de mappeurs +
+
+ + Durée +
+
+ renderSimpleMinMaxLabel(v, MAX_NPS)}/> + Notes par secondes +
+
+ ) +} + +export type LocalPlaylistFilter = Partial<{ + minNps: number; + maxNps: number; + minNbMaps: number; + maxNbMaps: number; + minNbMappers: number; + maxNbMappers: number; + minDuration: number; + maxDuration: number; +}> diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index 6e175781..b4340c57 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -1,4 +1,4 @@ -import { forwardRef, useCallback, useContext, useState } from "react"; +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"; @@ -6,7 +6,7 @@ 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, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs"; +import { BehaviorSubject, combineAll, 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"; @@ -19,43 +19,130 @@ 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 { ProgressBarService } from "renderer/services/progress-bar.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"; type Props = { version: BSVersion; className?: string; + filter?: LocalPlaylistFilter; + search?: string; linkedState?: FolderLinkState; isActive?: boolean; }; -// TODO : Translate +export type LocalPlaylistsListRef = { + syncPlaylists: () => Promise; + deletePlaylists: () => Promise; + exportPlaylists: () => Promise; +} -export const LocalPlaylistsListPanel = forwardRef(({ version, className, isActive, linkedState }, forwardedRef) => { +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 progress = useService(ProgressBarService); const osDiagnostic = useService(OsDiagnosticService); const notification = useService(NotificationService); const isOnline = useObservable(() => osDiagnostic.isOnline$, false); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); - const { maps$, playlists$, setPlaylists } = useContext(InstalledMapsContext); + const { maps$, playlists$, setPlaylists, setMaps } = useContext(InstalledMapsContext); + const selectedPlaylists$ = useConstant(() => new BehaviorSubject([])); const playlists = useObservable(() => playlists$, []); + console.log(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.toLocaleLowerCase()); + return playlistDownloader.downloadPlaylist({ downloadSource: playlist.customData?.syncURL ?? playlist.path, version, ignoreSongsHashs, dest: playlist.path }); + } + + useImperativeHandle(forwardedRef, () => ({ + 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: "Erreur lors de la synchronisation des playlists", desc: "Une erreur est survenue lors de la synchronisation des playlists." }); + return; + } + + if(result.every(res => res.current === res.total)){ + notification.notifySuccess({ title: "Playlists synchronisées !", desc: "Les playlists et leurs maps ont été téléchargées.", duration: 5000 }); + } + }, + exportPlaylists: async () => { + if(!progess.require()){ return; } + const toExport = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value; + + if(!toExport.length){ return; } + + const modalRes = await modals.openModal(ExportPlaylistModal, { data: toExport }); + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const folderRes = await lastValueFrom(ipc.sendV2("choose-folder")); + if(!folderRes || folderRes.canceled || !folderRes.filePaths?.length){ return; } + + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), exportMaps: modalRes.data }); + + progess.show(obs$, true); + + const { error } = await tryit(() => lastValueFrom(obs$)); + + if(error){ + logRenderError("Error occured while exporting playlists", error); + notification.notifyError({ title: "Erreur lors de l'exportation des playlists", desc: "Une erreur est survenue lors de l'exportation des playlists." }); + return; + } + + notification.notifySuccess({ title: "Playlists exportées !", desc: "Les playlists et leurs maps ont été exportées.", duration: 5000 }); + + progess.hide(true); + }, + 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( @@ -101,11 +188,13 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }, [isActiveOnce, version, linked]); - const installPlaylist = (playlist: LocalBPList) => { + const openDownloadPlaylistModal = () => { + modals.openModal(DownloadPlaylistModal, { data: { version, ownedPlaylists$: playlists$, ownedMaps$: maps$ } }); + } - const ignoreSongsHashs = (maps$.value || []).map(m => m.hash.toLocaleLowerCase()); + const handleClickSync = (playlist: LocalBPList) => { - const obs$ = playlistDownloader.downloadPlaylist({ downloadSource: playlist.customData?.syncURL ?? playlist.path, version, ignoreSongsHashs, dest: playlist.path }); + const obs$ = installPlaylist(playlist); return lastValueFrom(obs$).then(res => { if(res.current === res.total){ @@ -118,14 +207,37 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl return lastValueFrom(ipc.sendV2("view-path-in-explorer", path)); }; - const deletePlaylist = async (bpList: LocalBPList) => { - const { exitCode, data: deleteMaps } = await modals.openModal(DeletePlaylistModal, { data: bpList }); + 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; } - lastValueFrom(playlistService.deletePlaylist({ version, bpList, deleteMaps })).then(() => { + const progess$ = new BehaviorSubject({ progression: 0 }); + progess.show(progess$, true) + + 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: "Erreur lors de la suppression de la playlist", desc: "Une erreur est survenue lors de la suppression de la playlist." }); + progess.hide(true); + 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.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))); + } + } + + progess.hide(true); }; const openPlaylistDetails = (playlistPath: string) => { @@ -143,31 +255,72 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }; const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => { + + return ( selected.some(s => s.path === playlist.path)), distinctUntilChanged(equal))} + onClick={() => { + console.log(selectedPlaylists$.value, playlist.path); + 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={() => deletePlaylist(playlist)} - onClickSync={isOnline && (() => installPlaylist(playlist))} + onClickDelete={() => deletePlaylists([playlist])} + onClickSync={isOnline && (() => handleClickSync(playlist))} onClickOpenFile={() => viewPlaylistFile(playlist.path)} onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.customData?.syncURL ?? playlist.path, version)} /> ); }, [isOnline, version]); + const filteredPlaylists = useMemo(() => { + if(!playlists){ return []; } + + return playlists.filter(p => { + if(!p.playlistTitle.toLocaleLowerCase().includes(search.toLocaleLowerCase())){ return false; } + if(!p.playlistAuthor.toLocaleLowerCase().includes(search.toLocaleLowerCase())){ 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 (playlists?.length){ + if (filteredPlaylists?.length){ return ( (({ version, cl itemHeight={120} maxColumns={4} minItemWidth={390} - items={playlists} + items={filteredPlaylists} renderItem={renderPlaylist} - rowKey={rowPlaylists => rowPlaylists.map(p => p.path).join("-")} /> ) } - return TODO; + return ( +
+ + Aucune playlist + { + e.preventDefault(); + openDownloadPlaylistModal(); + }} + /> +
+ ); })()}
) diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index 19a58fc5..01b66b57 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -25,9 +25,10 @@ export type PlaylistItemComponentProps = { duration?: number; minNps?: number; maxNps?: number; - selected?: boolean; + selected$?: Observable; isDownloading$?: Observable; isInQueue$?: Observable; + onClick?: () => void; onClickOpen?: () => void; onClickOpenFile?: () => void; onClickDelete?: () => void; @@ -45,9 +46,10 @@ export function PlaylistItem({ title, nbMappers, minNps, maxNps, - selected, + selected$, isDownloading$, isInQueue$, + onClick, onClickOpen, onClickOpenFile, onClickSync, @@ -59,6 +61,7 @@ export function PlaylistItem({ title, const color = useThemeColor("first-color"); const [hovered, setHovered] = useState(false); + const selected = useObservable(() => selected$ ?? of(false), false, [selected$]); const isDownloading = useObservable(() => isDownloading$ ?? of(), false, [isDownloading$]); const isInQueue = useObservable(() => isInQueue$ ?? of(), false, [isInQueue$]); @@ -80,9 +83,9 @@ export function PlaylistItem({ title, // TODO : Translate return ( - setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > + setHovered(() => true)} onHoverEnd={() => setHovered(() => false)}> -
+
{e.stopPropagation(); onClick?.()}}>
@@ -91,7 +94,7 @@ export function PlaylistItem({ title, {e.stopPropagation(); onClickOpen?.()}} />
@@ -106,7 +109,7 @@ export function PlaylistItem({ title,
- + e.stopPropagation()}> diff --git a/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx index 6dc6ee26..eeb34eed 100644 --- a/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/delete-playlist-modal.component.tsx @@ -8,20 +8,32 @@ import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.pn import { BPList } from "shared/models/playlists/playlist.interface"; import Tippy from "@tippyjs/react"; -export const DeletePlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { +// TODO : Translate + +export const DeletePlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { const t = useTranslation(); const [deleteMaps, setDeleteMaps] = useState(false); + const isMultiple = data.length > 1; return (
-

Supprimer la playlist ?

+ {!isMultiple ? ( +

Supprimer la playlist ?

+ ) : ( +

Supprimer les playlists ?

+ )} -

{`Est-tu sûr de vouloir supprimer la playlist "${data.playlistTitle}" ?`}

+ {!isMultiple ? ( +

{`Est-tu sûr de vouloir supprimer la playlist "${data.at(0)?.playlistTitle}" ?`}

+ ) : ( +

{`Est-tu sûr de vouloir supprimer les ${data.length} playlists ?`}

+ )} +
setDeleteMaps(() => val)} /> - + Supprimer les maps
diff --git a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx index ed837b67..be65b51c 100644 --- a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-filter-panel.component.tsx @@ -111,7 +111,7 @@ export function DownloadPlaylistFilterPanel({ className, params, onChange, onSub }; return ( - +

{t("maps.map-filter-panel.specificities")}

diff --git a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx index 6b0bbc4f..71cdf4b0 100644 --- a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal-header.component.tsx @@ -54,9 +54,9 @@ export function DownloadPlaylistModalHeader({ className, value, onSubmit }: Prop - setQuery(e.target.value)} /> - submit(searchParams)} /> - + setQuery(e.target.value)} /> + submit(searchParams)} /> + ) } diff --git a/src/renderer/components/modal/modal-types/playlist/export-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/export-playlist-modal.component.tsx new file mode 100644 index 00000000..dad1b4fa --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/export-playlist-modal.component.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; +import { BPList } from "shared/models/playlists/playlist.interface"; +import Tippy from "@tippyjs/react"; + +// TODO : Translate + +export const ExportPlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { + + const t = useTranslation(); + + const [exportMaps, setExportMaps] = useState(false); + const isMultiple = data.length > 1; + + return ( +
+ {!isMultiple ? ( +

Exporter la playlist ?

+ ) : ( +

Exporter les playlists ?

+ )} + + {!isMultiple ? ( +

{`Est-tu sûr de vouloir exporter la playlist "${data.at(0)?.playlistTitle}" ?`}

+ ) : ( +

{`Est-tu sûr de vouloir exporter les ${data.length} playlists ?`}

+ )} + +
+ setExportMaps(() => val)} /> + + Exporter les maps + +
+
+ resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED, data: exportMaps })} withBar={false} text="Exporter" /> +
+ + ); +}; diff --git a/src/renderer/components/modal/modal-types/playlist/sync-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/sync-playlist-modal.component.tsx new file mode 100644 index 00000000..f466c6e1 --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/sync-playlist-modal.component.tsx @@ -0,0 +1,37 @@ +import { BsmButton } from "renderer/components/shared/bsm-button.component"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { useTranslation } from "renderer/hooks/use-translation.hook"; +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"; +import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; +import { BPList } from "shared/models/playlists/playlist.interface"; + +// TODO : Translate + +export const SyncPlaylistModal: ModalComponent = ({ resolver, options: { data }}) => { + + const t = useTranslation(); + + return ( +
+ {data.length === 1 ? ( +

Synchroniser la playlist ?

+ ) : ( +

Synchroniser les playlists ?

+ )} + + + {data.length === 1 ? ( +

{`Est-tu sûr de vouloir synchroniser la playlist "${data.at(0)?.playlistTitle}" ?`}

+ ) : ( +

{`Est-tu sûr de vouloir synchroniser les ${data.length} playlists ?`}

+ )} + +

Cette action met à jour les playlists et télécharge les maps manquantes; cela peut durer plusieurs minutes.

+ +
+ resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="Synchroniser" /> +
+ + ); +}; diff --git a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx index 30e5c23d..ce9c60cf 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -28,7 +28,7 @@ type Props = { itemHeight: number; items: T[]; renderItem: (item: T) => JSX.Element; - rowKey: (rowItems: T[]) => Key; + rowKey?: (rowItems: T[]) => Key; scrollEnd?: ScrollEndHandler; } diff --git a/src/renderer/index.css b/src/renderer/index.css index 0c5cb0b4..ca0ee076 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -103,12 +103,12 @@ @apply border-r-neutral-900; } -.theme-color-1 { @apply bg-light-main-color-1 dark:bg-main-color-1; } -.theme-color-2 { @apply bg-light-main-color-2 dark:bg-main-color-2; } -.theme-color-3 { @apply bg-light-main-color-3 dark:bg-main-color-3; } -.\!theme-color-1 { @apply !bg-light-main-color-1 dark:!bg-main-color-1; } -.\!theme-color-2 { @apply !bg-light-main-color-2 dark:!bg-main-color-2; } -.\!theme-color-3 { @apply !bg-light-main-color-3 dark:!bg-main-color-3; } +.bg-theme-1 { @apply bg-light-main-color-1 dark:bg-main-color-1; } +.bg-theme-2 { @apply bg-light-main-color-2 dark:bg-main-color-2; } +.bg-theme-3 { @apply bg-light-main-color-3 dark:bg-main-color-3; } +.\!bg-theme-1 { @apply !bg-light-main-color-1 dark:!bg-main-color-1; } +.\!bg-theme-2 { @apply !bg-light-main-color-2 dark:!bg-main-color-2; } +.\!bg-theme-3 { @apply !bg-light-main-color-3 dark:!bg-main-color-3; } @keyframes glowing { 0% { diff --git a/src/renderer/services/playlists-manager.service.ts b/src/renderer/services/playlists-manager.service.ts index f6acf712..244b002e 100644 --- a/src/renderer/services/playlists-manager.service.ts +++ b/src/renderer/services/playlists-manager.service.ts @@ -1,6 +1,6 @@ import { BSVersion } from "shared/bs-version.interface"; import { IpcService } from "./ipc.service"; -import { Observable, lastValueFrom } from "rxjs"; +import { Observable, lastValueFrom, of, switchMap } from "rxjs"; import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service"; import { Progression } from "main/helpers/fs.helpers"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; @@ -38,6 +38,15 @@ export class PlaylistsManagerService { return this.ipc.sendV2("delete-playlist", opt); } + public exportPlaylists(opt: {version: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable> { + return this.ipc.sendV2("export-playlists", { + version: opt.version, + bpLists: opt.bpLists, + dest: opt.dest, + exportMaps: opt.exportMaps + }); + } + public async linkVersion(version: BSVersion): Promise { const modalRes = await this.modal.openModal(LinkPlaylistModal); diff --git a/src/shared/helpers/array.helpers.ts b/src/shared/helpers/array.helpers.ts index 58927f0b..a7e0a024 100644 --- a/src/shared/helpers/array.helpers.ts +++ b/src/shared/helpers/array.helpers.ts @@ -23,3 +23,9 @@ export function removeIndex(index: number, arr: T[]): T[] { arr.splice(index, 1); return arr; } + +export function* enumerate(arr: T[]): Generator<[number, T]> { + for (let i = 0; i < arr.length; i++) { + yield [i, arr[i]]; + } +} diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 55dec036..9f00e169 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -84,6 +84,7 @@ export interface IpcChannelMapping { "download-playlist": {request: {downloadSource: string, dest?: string, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression}; "get-version-playlists-details": {request: BSVersion, response: Progression}; "delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression}; + "export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}, response: Progression}; /* ** bs-uninstall-ipcs ** */ "bs.uninstall": { request: BSVersion, response: boolean };