mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[feature-107] local playlist filtering & can select playlist to perform actions : export, delete, sync
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
+123
@@ -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;
|
||||
}>
|
||||
+188
-21
@@ -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")' }} />
|
||||
|
||||
|
||||
+16
-4
@@ -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>
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+3
-3
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+50
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user