[feature-107] local playlist filtering & can select playlist to perform actions : export, delete, sync

This commit is contained in:
MathieuG-P
2024-06-09 22:45:03 +02:00
parent ab07334cb8
commit 901137ada9
19 changed files with 602 additions and 84 deletions
+17 -3
View File
@@ -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<string> {
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;
+5 -1
View File
@@ -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));
});
+5 -5
View File
@@ -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<Progression> {
const progress: Progression = {
public finalize(): Observable<Progression<string>> {
const progress: Progression<string> = {
total: 0,
current: 0,
data: this.output,
};
return new Observable<Progression>(observer => {
return new Observable<Progression<string>>(observer => {
(async () => {
progress.total = await this.loadTotalFiles();
@@ -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<Progression<string>> {
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<string>(opt.bpLists.reduce((acc, bpList) => acc.concat((bpList.songs ?? []).map(s => s.hash)), [])).values()
);
const zipMaps$ = new Observable<Progression<string>>(obs => {
(async () => {
const progress: Progression<string> = { 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<Progression<string>>(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<Progression<DownloadPlaylistProgressionData>> {
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
@@ -283,7 +283,7 @@ export class LocalMapsManagerService {
});
}
public async getMapInfoFromHash(hash: string, version: BSVersion): Promise<BsmLocalMap> {
public async getMapInfoFromHash(hash: string, version?: BSVersion): Promise<BsmLocalMap> {
const versionMapsPath = await this.getMapsFolderPath(version);
const mapInfo = this.songCache.getMapInfoFromHash(hash);
@@ -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<BsmLocalMap[]>;
maps$: BehaviorSubject<BsmLocalMap[]>;
setMaps: (maps: BsmLocalMap[]) => void;
playlists$?: BehaviorSubject<LocalBPListsDetails[]>;
playlists$: BehaviorSubject<LocalBPListsDetails[]>;
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<BsmLocalMap[]>(undefined));
const playlists$ = useConstant(() => new BehaviorSubject<LocalBPListsDetails[]>(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<any>();
const playlistsRef = useRef<LocalPlaylistsListRef>();
const [mapFilter, setMapFilter] = useState<MapFilter>({});
const [mapSearch, setMapSearch] = useState("");
const [playlistFilter, setPlaylistFilter] = useState<LocalPlaylistFilter>({});
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}
/>
<div className="h-full rounded-full bg-light-main-color-2 dark:bg-main-color-2 grow p-[6px]">
<input type="text" className="h-full w-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2" placeholder={t("pages.version-viewer.maps.search-bar.search-placeholder")} value={tabIndex === 0 ? mapSearch : playlistSearch} onChange={e => handleSearch(e.target.value)} tabIndex={-1} />
<input
type="text"
className="h-full w-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2"
placeholder={tabIndex === 0 ? t("pages.version-viewer.maps.search-bar.search-placeholder") : "Rechercher une playlist"}
value={search}
onChange={e => setSearch(() => e.target.value)}
tabIndex={-1}
/>
</div>
<BsmDropdownButton className="h-full relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" textClassName="whitespace-nowrap" withBar={false}>
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black" filter={mapFilter} onChange={setMapFilter} />
{(
tabIndex === 0 ? (
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black" filter={mapFilter} onChange={setMapFilter} />
) : (
<LocalPlaylistFilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[300px] h-fit p-2 rounded-md shadow-md shadow-black" filter={playlistFilter} onChange={setPlaylistFilter} />
)
)}
</BsmDropdownButton>
<BsmDropdownButton className="h-full flex aspect-square relative rounded-full z-[1] bg-light-main-color-1 dark:bg-main-color-3" buttonClassName="rounded-full h-full w-full p-[6px]" icon="three-dots" withBar={false} items={dropDownItems} menuTranslationY="6px" align="center" />
</nav>
@@ -151,8 +171,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
]}
>
<InstalledMapsContext.Provider value={mapsContextValue}>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={mapSearch} linkedState={mapsLinkedState} />
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} version={version} linkedState={playlistLinkedState}/>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={search} linkedState={mapsLinkedState} />
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} ref={playlistsRef} version={version} linkedState={playlistLinkedState} filter={playlistFilter} search={search}/>
</InstalledMapsContext.Provider>
</BsContentTabPanel>
</div>
@@ -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<number>(filter?.minNps ?? MIN_NPS);
const [maxNps, setMaxNps] = useState<number>(filter?.maxNps ?? MAX_NPS);
const [minNbMaps, setMinNbMaps] = useState<number>(filter?.minNbMaps ?? MIN_NB_MAPS);
const [maxNbMaps, setMaxNbMaps] = useState<number>(filter?.maxNbMaps ?? MAX_NB_MAPS);
const [minNbMappers, setMinNbMappers] = useState<number>(filter?.minNbMappers ?? MIN_NB_MAPPER);
const [maxNbMappers, setMaxNbMappers] = useState<number>(filter?.minNbMappers ?? MAX_NB_MAPPER);
const [minDuration, setMinDuration] = useState<number>(filter?.minDuration ?? MIN_DURATION);
const [maxDuration, setMaxDuration] = useState<number>(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<SetStateAction<number>>[], [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 <span className={`bg-inherit absolute top-[calc(100%+4px)] whitespace-nowrap h-5 font-bold rounded-md shadow-center shadow-black px-1 flex items-center ${isMax ? "text-lg" : "text-sm"}`}>{text}</span>;
};
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 (
<motion.div className={cn("bg-theme-3 flex flex-col gap-1.5 p-2 absolute origin-top shadow-md shadow-black rounded-md", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{duration: .1}}>
<div className="w-full flex flex-col justify-center p-2">
<BsmRange values={[minNbMaps ?? MIN_NB_MAPS, maxNbMaps ?? MAX_NB_MAPS]} min={MIN_NB_MAPS} max={MAX_NB_MAPS} step={1} onChange={handleOnNbMapsChange} renderLabel={v => renderSimpleMinMaxLabel(v, MAX_NB_MAPS)}/>
<span className=" text-sm font-bold text-center mt-2.5">Nombre de maps</span>
</div>
<div className="w-full flex flex-col justify-center p-2">
<BsmRange values={[minNbMappers ?? MIN_NB_MAPPER, maxNbMappers ?? MAX_NB_MAPPER]} min={MIN_NB_MAPPER} max={MAX_NB_MAPPER} step={1} onChange={handleOnNbMapperChange} renderLabel={v => renderSimpleMinMaxLabel(v, MAX_NB_MAPPER)}/>
<span className=" text-sm font-bold text-center mt-2.5">Nombre de mappeurs</span>
</div>
<div className="w-full flex flex-col justify-center p-2">
<BsmRange values={[minDuration ?? MIN_DURATION, maxDuration ?? MAX_DURATION]} min={MIN_DURATION} max={MAX_DURATION} step={1} onChange={handleOnDurationChange} renderLabel={renderDurationLabel}/>
<span className=" text-sm font-bold text-center mt-2.5">Durée</span>
</div>
<div className="w-full flex flex-col justify-center p-2">
<BsmRange values={[minNps ?? MIN_NPS, maxNps ?? MAX_NPS]} min={MIN_NPS} max={MAX_NPS} step={0.1} onChange={handleOnNpsChange} renderLabel={v => renderSimpleMinMaxLabel(v, MAX_NPS)}/>
<span className=" text-sm font-bold text-center mt-2.5">Notes par secondes</span>
</div>
</motion.div>
)
}
export type LocalPlaylistFilter = Partial<{
minNps: number;
maxNps: number;
minNbMaps: number;
maxNbMaps: number;
minNbMappers: number;
maxNbMappers: number;
minDuration: number;
maxDuration: number;
}>
@@ -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<void>;
deletePlaylists: () => Promise<void>;
exportPlaylists: () => Promise<void>;
}
export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, className, isActive, linkedState }, forwardedRef) => {
export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(({ 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<LocalBPList[]>([]));
const playlists = useObservable(() => playlists$, []);
console.log(playlists);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const loadPercent$ = useConstant(() => new BehaviorSubject<number>(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<LocalBPListsDetails[]> => {
setPlaylistsLoading(true);
const obs = playlistService.getVersionPlaylistsDetails(version).pipe(
@@ -101,11 +188,13 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ 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<unknown, Props>(({ 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<ProgressionInterface>({ 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<unknown, Props>(({ version, cl
};
const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => {
return (
<PlaylistItem
key={playlist.path}
{...PlaylistItemComponentPropsMapper.fromLocalBPListDetails(playlist)}
isDownloading$={playlistDownloader.$isPlaylistDownloading(playlist.customData?.syncURL ?? playlist.path, version)}
isInQueue$={playlistDownloader.$isPlaylistInQueue(playlist.customData?.syncURL ?? playlist.path, version)}
selected$={selectedPlaylists$.pipe(map(selected => 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 (
<div className={className}>
{(() => {
if(playlistsLoading){
return (
<BsContentLoader className="w-full h-full flex justify-center flex-col items-center" value$={loadPercent$} text="aaaa"/>
<BsContentLoader className="w-full h-full flex justify-center flex-col items-center" value$={loadPercent$} text="Chargement des playlists"/>
)
}
if (playlists?.length){
if (filteredPlaylists?.length){
return (
<VirtualScroll
classNames={{
@@ -177,14 +330,28 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, cl
itemHeight={120}
maxColumns={4}
minItemWidth={390}
items={playlists}
items={filteredPlaylists}
renderItem={renderPlaylist}
rowKey={rowPlaylists => rowPlaylists.map(p => p.path).join("-")}
/>
)
}
return <span>TODO</span>;
return (
<div className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200">
<BsmImage className="h-32" image={BeatConflict} />
<span className="font-bold">Aucune playlist</span>
<BsmButton
className="font-bold rounded-md p-2"
text="Télécharger des playlists"
typeColor="primary"
withBar={false}
onClick={e => {
e.preventDefault();
openDownloadPlaylistModal();
}}
/>
</div>
);
})()}
</div>
)
@@ -25,9 +25,10 @@ export type PlaylistItemComponentProps = {
duration?: number;
minNps?: number;
maxNps?: number;
selected?: boolean;
selected$?: Observable<boolean>;
isDownloading$?: Observable<boolean>;
isInQueue$?: Observable<boolean>;
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 (
<motion.li className='relative flex-grow basis-0 min-w-80 h-28 cursor-pointer group' onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} >
<motion.li className='relative flex-grow basis-0 min-w-80 h-28 cursor-pointer group' onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)}>
<GlowEffect visible={selected || hovered}/>
<div className="size-full relative flex flex-row justify-start items-center overflow-hidden bg-black rounded-md *:z-[1]">
<div className="size-full relative flex flex-row justify-start items-center overflow-hidden bg-black rounded-md *:z-[1]" onClick={e => {e.stopPropagation(); onClick?.()}}>
<div className="absolute inset-0 flex justify-center items-center z-0">
<BsmImage className="size-full object-cover saturate-150 blur-lg" image={coverUrl} base64={coverBase64} />
<div className="absolute inset-0 bg-black opacity-20"/>
@@ -91,7 +94,7 @@ export function PlaylistItem({ title,
<BsmImage className="size-full flex-shrink-0 object-cover rounded-md shadow-center shadow-black bg-main-color-1" image={coverUrl} base64={coverBase64} style={{filter: hovered && "brightness(75%)"}} />
<SearchIcon
className="absolute size-full top-0 left-0 p-7 opacity-0 text-white hover:text-current group-hover:opacity-100 transition-opacity"
onClick={onClickOpen}
onClick={e => {e.stopPropagation(); onClickOpen?.()}}
/>
</div>
<div className="h-full py-2.5 text-white">
@@ -106,7 +109,7 @@ export function PlaylistItem({ title,
</div>
</div>
<motion.div className="absolute bg-light-main-color-3 dark:bg-main-color-3 top-0 h-full w-max left-full" animate={{x: (hovered || isDownloading ? "-100%" : "-0.625rem")}} transition={{duration: .1}}>
<motion.div className="absolute bg-theme-3 top-0 h-full w-max left-full" animate={{x: (hovered || isDownloading ? "-100%" : "-0.625rem")}} transition={{duration: .1}} onClick={e => e.stopPropagation()}>
<span className="absolute size-2.5 top-0 right-full bg-inherit translate-x-px" style={{ clipPath: 'path("M11 -1 L11 10 L10 10 A10 10 0 0 0 0 0 L0 -1 Z")' }} />
<span className="absolute size-2.5 bottom-0 right-full bg-inherit translate-x-px" style={{ clipPath: 'path("M11 11 L11 0 L10 0 A10 10 0 0 1 0 10 L 0 11 Z")' }} />
@@ -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<boolean, BPList> = ({ resolver, options: { data }}) => {
// TODO : Translate
export const DeletePlaylistModal: ModalComponent<boolean, BPList[]> = ({ resolver, options: { data }}) => {
const t = useTranslation();
const [deleteMaps, setDeleteMaps] = useState(false);
const isMultiple = data.length > 1;
return (
<form className="text-gray-800 dark:text-gray-200">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Supprimer la playlist ?</h1>
{!isMultiple ? (
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Supprimer la playlist ?</h1>
) : (
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Supprimer les playlists ?</h1>
)}
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p className="max-w-sm w-full">{`Est-tu sûr de vouloir supprimer la playlist "${data.playlistTitle}" ?`}</p>
{!isMultiple ? (
<p className="max-w-sm w-full">{`Est-tu sûr de vouloir supprimer la playlist "${data.at(0)?.playlistTitle}" ?`}</p>
) : (
<p className="max-w-sm w-full">{`Est-tu sûr de vouloir supprimer les ${data.length} playlists ?`}</p>
)}
<div className="flex items-center relative py-2 gap-1">
<BsmCheckbox className="h-5 relative z-[1]" checked={deleteMaps} onChange={val => setDeleteMaps(() => val)} />
<Tippy placement="top" content="Si activé, toutes les maps de la playlist seront supprimées" theme="default">
<Tippy placement="top" content={isMultiple ? "Si activé, toutes les maps de des playlists seront supprimées" : "Si activé, toutes les maps de la playlist seront supprimées"} theme="default">
<span className="italic cursor-help">Supprimer les maps</span>
</Tippy>
</div>
@@ -111,7 +111,7 @@ export function DownloadPlaylistFilterPanel({ className, params, onChange, onSub
};
return (
<motion.div className={cn("theme-color-3 flex flex-row gap-3 p-2 absolute origin-top shadow-md shadow-black rounded-md", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{duration: .1}}>
<motion.div className={cn("bg-theme-3 flex flex-row gap-3 p-2 absolute origin-top shadow-md shadow-black rounded-md", className)} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{duration: .1}}>
<div className="flex flex-col gap-1 shrink-0">
<h2 className="mb-0.5 uppercase text-sm">{t("maps.map-filter-panel.specificities")}</h2>
<div className="flex flex-row h-6 gap-1">
@@ -54,9 +54,9 @@ export function DownloadPlaylistModalHeader({ className, value, onSubmit }: Prop
<BsmDropdownButton ref={dropDownFilterRef} buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1 !bg-light-main-color-1 dark:!bg-main-color-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" withBar={false}>
<DownloadPlaylistFilterPanel className="z-10 translate-y-1" params={filter} onSubmit={handleFilterSubmit}/>
</BsmDropdownButton>
<input className="h-full theme-color-1 rounded-full px-2 grow pb-0.5" type="text" placeholder="Rechercher une playlist" value={query} onChange={e => setQuery(e.target.value)} />
<BsmButton className="shrink-0 rounded-full py-1 px-3 !theme-color-1 flex justify-center items-center capitalize" icon="search" text="modals.download-maps.search-btn" withBar={false} onClick={() => submit(searchParams)} />
<BsmSelect className="theme-color-1 rounded-full px-1 pb-0.5 text-center cursor-pointer" options={sortOptions} selected={order} onChange={handleOrderChange}/>
<input className="h-full bg-theme-1 rounded-full px-2 grow pb-0.5" type="text" placeholder="Rechercher une playlist" value={query} onChange={e => setQuery(e.target.value)} />
<BsmButton className="shrink-0 rounded-full py-1 px-3 !bg-theme-1 flex justify-center items-center capitalize" icon="search" text="modals.download-maps.search-btn" withBar={false} onClick={() => submit(searchParams)} />
<BsmSelect className="bg-theme-1 rounded-full px-1 pb-0.5 text-center cursor-pointer" options={sortOptions} selected={order} onChange={handleOrderChange}/>
</form>
)
}
@@ -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<boolean, BPList[]> = ({ resolver, options: { data }}) => {
const t = useTranslation();
const [exportMaps, setExportMaps] = useState(false);
const isMultiple = data.length > 1;
return (
<form className="max-w-sm text-gray-800 dark:text-gray-200">
{!isMultiple ? (
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Exporter la playlist ?</h1>
) : (
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Exporter les playlists ?</h1>
)}
<BsmImage className="mx-auto h-24" image={BeatConflict} />
{!isMultiple ? (
<p className="w-full">{`Est-tu sûr de vouloir exporter la playlist "${data.at(0)?.playlistTitle}" ?`}</p>
) : (
<p className="w-full">{`Est-tu sûr de vouloir exporter les ${data.length} playlists ?`}</p>
)}
<div className="flex items-center relative py-2 gap-1">
<BsmCheckbox className="h-5 relative z-[1]" checked={exportMaps} onChange={val => setExportMaps(() => val)} />
<Tippy
placement="top"
content={isMultiple ? "Si activé, toutes les maps des playlists seront également exportées" : "Si activé, toutes les maps de la playlist seront également exportées"}
theme="default"
>
<span className="italic cursor-help">Exporter les maps</span>
</Tippy>
</div>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-2">
<BsmButton typeColor="cancel" className="rounded-md text-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
<BsmButton typeColor="primary" className="rounded-md text-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.COMPLETED, data: exportMaps })} withBar={false} text="Exporter" />
</div>
</form>
);
};
@@ -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<boolean, BPList[]> = ({ resolver, options: { data }}) => {
const t = useTranslation();
return (
<form className="max-w-sm text-gray-800 dark:text-gray-200 overflow-hidden">
{data.length === 1 ? (
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Synchroniser la playlist ?</h1>
) : (
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Synchroniser les playlists ?</h1>
)}
<BsmImage className="mx-auto h-24" image={BeatConflict} />
{data.length === 1 ? (
<p className="w-full">{`Est-tu sûr de vouloir synchroniser la playlist "${data.at(0)?.playlistTitle}" ?`}</p>
) : (
<p className="w-full">{`Est-tu sûr de vouloir synchroniser les ${data.length} playlists ?`}</p>
)}
<p className="w-full py-2 italic text-sm">Cette action met à jour les playlists et télécharge les maps manquantes; cela peut durer plusieurs minutes.</p>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-2">
<BsmButton typeColor="cancel" className="rounded-md text-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
<BsmButton typeColor="primary" className="rounded-md text-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="Synchroniser" />
</div>
</form>
);
};
@@ -28,7 +28,7 @@ type Props<T = unknown> = {
itemHeight: number;
items: T[];
renderItem: (item: T) => JSX.Element;
rowKey: (rowItems: T[]) => Key;
rowKey?: (rowItems: T[]) => Key;
scrollEnd?: ScrollEndHandler;
}
+6 -6
View File
@@ -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% {
@@ -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<Progression<string>> {
return this.ipc.sendV2("export-playlists", {
version: opt.version,
bpLists: opt.bpLists,
dest: opt.dest,
exportMaps: opt.exportMaps
});
}
public async linkVersion(version: BSVersion): Promise<boolean> {
const modalRes = await this.modal.openModal(LinkPlaylistModal);
+6
View File
@@ -23,3 +23,9 @@ export function removeIndex<T = unknown>(index: number, arr: T[]): T[] {
arr.splice(index, 1);
return arr;
}
export function* enumerate<T = unknown>(arr: T[]): Generator<[number, T]> {
for (let i = 0; i < arr.length; i++) {
yield [i, arr[i]];
}
}
+1
View File
@@ -84,6 +84,7 @@ export interface IpcChannelMapping {
"download-playlist": {request: {downloadSource: string, dest?: string, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression<DownloadPlaylistProgressionData>};
"get-version-playlists-details": {request: BSVersion, response: Progression<LocalBPListsDetails[]>};
"delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression};
"export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}, response: Progression<string>};
/* ** bs-uninstall-ipcs ** */
"bs.uninstall": { request: BSVersion, response: boolean };