mirror of
https://github.com/Zagrios/bs-manager.git
synced 2026-07-03 14:08:25 +02:00
[chore-521] Move create playlist action
This commit is contained in:
@@ -8,6 +8,7 @@ import crypto from "crypto";
|
||||
import { execSync } from "child_process";
|
||||
import { tryit } from "../../shared/helpers/error.helpers";
|
||||
import { CustomError } from "shared/models/exceptions/custom-error.class";
|
||||
import { ErrorObject } from "serialize-error";
|
||||
|
||||
export async function pathExist(path: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -279,4 +280,5 @@ export interface Progression<T = unknown, D = unknown> {
|
||||
diff?: number;
|
||||
data?: T;
|
||||
extra?: D;
|
||||
lastError?: ErrorObject;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,11 @@ ipc.on("export-playlists", (args, reply) => {
|
||||
reply(playlists.exportPlaylists(args));
|
||||
});
|
||||
|
||||
ipc.on("import-playlists", (args, reply) => {
|
||||
const playlists = LocalPlaylistsManagerService.getInstance();
|
||||
reply(playlists.importPlaylists(args));
|
||||
});
|
||||
|
||||
ipc.on("install-playlist-file", (args, reply) => {
|
||||
const playlists = LocalPlaylistsManagerService.getInstance();
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ ipc.on("new-window", (args, reply) => {
|
||||
reply(from(shell.openExternal(args)));
|
||||
});
|
||||
|
||||
ipc.on("open-dialog", (args, reply) => {
|
||||
// Use this ipc instead of "choose-folder" or "choose-file" to have more control over the dialog
|
||||
reply(from(dialog.showOpenDialog(args)));
|
||||
})
|
||||
|
||||
ipc.on("choose-folder", (args, reply) => {
|
||||
reply(from(dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: args ?? "" })));
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { tryit } from "shared/helpers/error.helpers";
|
||||
import recursiveReadDir from "recursive-readdir";
|
||||
import { BsvMapDetail, SongDetails } from "shared/models/maps";
|
||||
import { findHashInString } from "shared/helpers/string.helpers";
|
||||
import { serializeError } from "serialize-error";
|
||||
|
||||
export class LocalPlaylistsManagerService {
|
||||
private static instance: LocalPlaylistsManagerService;
|
||||
@@ -97,7 +98,7 @@ export class LocalPlaylistsManagerService {
|
||||
const dest = await (async () => {
|
||||
if(opt.dest && path.isAbsolute(opt.dest) && this.acceptPlaylistFiletype(opt.dest)) { return opt.dest; }
|
||||
const playlistFolder = await this.getPlaylistsFolder(opt.version);
|
||||
const playlistPath = path.join(playlistFolder, `${sanitize(opt.bpList.playlistTitle)}${path.extname(opt.dest)}`);
|
||||
const playlistPath = path.join(playlistFolder, `${sanitize(opt.bpList.playlistTitle)}.bplist`);
|
||||
return getUniqueFileNamePath(playlistPath);
|
||||
})();
|
||||
|
||||
@@ -122,7 +123,7 @@ export class LocalPlaylistsManagerService {
|
||||
const dest = await (async () => {
|
||||
if(opt.dest && path.isAbsolute(opt.dest) && this.acceptPlaylistFiletype(opt.dest)) { return opt.dest; }
|
||||
const playlistFolder = await this.getPlaylistsFolder(opt.version);
|
||||
return path.join(playlistFolder, `${sanitize(bplist.playlistTitle)}${path.extname(opt.dest)}`);
|
||||
return path.join(playlistFolder, `${sanitize(bplist.playlistTitle)}.bplist`);
|
||||
})();
|
||||
|
||||
writeFileSync(dest, JSON.stringify(bplist, null, 2));
|
||||
@@ -137,13 +138,22 @@ export class LocalPlaylistsManagerService {
|
||||
const isLocalFile = await pathExists(source).catch(e => { log.error(e); return false; });
|
||||
|
||||
if(!isLocalFile && !isValidUrl(source)) {
|
||||
throw new Error(`Invalid source ${source}`);
|
||||
throw new CustomError(`Invalid source (${source})`, "INVALID_SOURCE");
|
||||
}
|
||||
|
||||
const bpList: BPList = isLocalFile ? JSON.parse(readFileSync(source).toString()) : await this.request.getJSON<BPList>(source);
|
||||
const bpList: BPList = await (async () => {
|
||||
if(isLocalFile){
|
||||
const res = await tryit(async () => JSON.parse(readFileSync(source).toString()));
|
||||
if(res.error) {
|
||||
throw CustomError.fromError(res.error, "CANNOT_PARSE_PLAYLIST");
|
||||
}
|
||||
return res.result;
|
||||
}
|
||||
return this.request.getJSON<BPList>(source);
|
||||
})();
|
||||
|
||||
if(!bpList?.playlistTitle) {
|
||||
throw new Error(`Invalid playlist file ${source}`);
|
||||
throw new CustomError(`Invalid playlist file (${source})`, "INVALID_PLAYLIST_FILE");
|
||||
}
|
||||
|
||||
bpList.songs = (bpList.songs ?? []).map(s => s.hash ? (
|
||||
@@ -430,6 +440,49 @@ export class LocalPlaylistsManagerService {
|
||||
return archive.finalize();
|
||||
}
|
||||
|
||||
public importPlaylists({ version, paths }: { version?: BSVersion, paths: string[] }): Observable<Progression<LocalBPListsDetails>> {
|
||||
return new Observable<Progression<LocalBPListsDetails>>(obs => {
|
||||
|
||||
let canceled = false;
|
||||
|
||||
(async () => {
|
||||
const bplistPaths = paths.filter(p => this.acceptPlaylistFiletype(p));
|
||||
const progress: Progression<LocalBPListsDetails> = { current: 0, total: bplistPaths.length, data: null };
|
||||
|
||||
obs.next(progress);
|
||||
|
||||
for(const playlistPath of bplistPaths) {
|
||||
if(canceled) {
|
||||
log.info("Playlist import canceled");
|
||||
return;
|
||||
}
|
||||
|
||||
const { result: localBPList, error } = await tryit(() => this.installBPListFile({ bplistSource: playlistPath, version }));
|
||||
|
||||
if(error) {
|
||||
progress.lastError = serializeError(CustomError.fromError(error));
|
||||
obs.next(progress);
|
||||
|
||||
log.error(error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const bpListDetails = this.getLocalBPListDetails(localBPList.localBPList);
|
||||
|
||||
progress.current += 1;
|
||||
progress.data = bpListDetails;
|
||||
obs.next(progress);
|
||||
}
|
||||
})()
|
||||
.catch(err => obs.error(err))
|
||||
.finally(() => obs.complete());
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public oneClickInstallPlaylist(bplistUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
|
||||
|
||||
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
|
||||
|
||||
@@ -348,14 +348,15 @@ export class LocalMapsManagerService {
|
||||
|
||||
const zip = new StreamZip.async({ file: zipPath });
|
||||
const { result: zipEntries, error } = await tryit(() => zip.entries());
|
||||
const zipEntriesValues = Object.values(zipEntries);
|
||||
|
||||
if(error) {
|
||||
log.error("Could not read zip entries", zipPath, error);
|
||||
await zip.close();
|
||||
const res = await tryit(() => zip.close());
|
||||
log.error("Could not read zip entries", zipPath, error, res?.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
const zipEntriesValues = Object.values(zipEntries);
|
||||
|
||||
const mapsFolders = zipEntriesValues.reduce((acc, entry) => {
|
||||
if(!/(^|\/)[Ii]nfo\.dat$/.test(entry.name)){ return acc; }
|
||||
acc.push(path.dirname(entry.name));
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
import { FolderLinkState } from "renderer/services/version-folder-linker.service";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { BsContentTabPanel } from "../shared/bs-content-tab-panel/bs-content-tab-panel.component";
|
||||
import { BsmButton } from "../shared/bsm-button.component";
|
||||
import { MapIcon } from "../svgs/icons/map-icon.component";
|
||||
import { PlaylistIcon } from "../svgs/icons/playlist-icon.component";
|
||||
import { useObservable } from "renderer/hooks/use-observable.hook";
|
||||
@@ -24,7 +23,6 @@ import { PlaylistDownloaderService } from "renderer/services/playlist-downloader
|
||||
import { LocalPlaylistFilter, LocalPlaylistFilterPanel } from "./playlists/local-playlist-filter-panel.component";
|
||||
import { noop } from "shared/helpers/function.helpers";
|
||||
import { Dropzone } from "../shared/dropzone.component";
|
||||
import { NotificationService } from "renderer/services/notification.service";
|
||||
import { logRenderError } from "renderer";
|
||||
|
||||
type Props = {
|
||||
@@ -45,11 +43,13 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
const mapsDownloader = useService(MapsDownloaderService);
|
||||
const playlistsManager = useService(PlaylistsManagerService);
|
||||
const playlistsDownloader = useService(PlaylistDownloaderService);
|
||||
const notifications = useService(NotificationService);
|
||||
|
||||
const t = useTranslation();
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
|
||||
const [mapsDropZoneOpen, setMapsDropZoneOpen] = useState(false);
|
||||
const [playlistsDropZoneOpen, setPlaylistsDropZoneOpen] = useState(false);
|
||||
|
||||
const maps$ = useConstant(() => new BehaviorSubject<BsmLocalMap[]>(undefined));
|
||||
const playlists$ = useConstant(() => new BehaviorSubject<LocalBPListsDetails[]>(undefined));
|
||||
|
||||
@@ -77,11 +77,16 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
return playlistsManager.$playlistsFolderLinkState(version);
|
||||
}, FolderLinkState.Unlinked, [version]);
|
||||
|
||||
const handleAddClick = () => {
|
||||
const handleBrowse = () => {
|
||||
switch (tabIndex) {
|
||||
case 0: return mapsDownloader.openDownloadMapModal(version, maps$.value);
|
||||
case 1: return playlistsDownloader.openDownloadPlaylistModal(version, playlists$, maps$);
|
||||
default: return noop();
|
||||
case 0:
|
||||
mapsDownloader.openDownloadMapModal(version, maps$.value);
|
||||
return;
|
||||
case 1:
|
||||
playlistsDownloader.openDownloadPlaylistModal(version, playlists$, maps$);
|
||||
return;
|
||||
default:
|
||||
return noop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,26 +110,30 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
return playlistsManager.unlinkVersion(version);
|
||||
}
|
||||
|
||||
const handleFileDrop = async (files: FileList) => {
|
||||
const zipMimeTypes = ["application/zip", "application/zip-compressed", "application/x-zip-compressed"];
|
||||
const paths: string[] = Array.from(files).reduce((acc, file) => {
|
||||
if (zipMimeTypes.includes(file.type)) {
|
||||
acc.push(window.electron.webUtils.getPathForFile(file));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const importMaps = async (paths: string[]) => {
|
||||
setMapsDropZoneOpen(() => false);
|
||||
return lastValueFrom(mapsManager.importMaps(paths, version)).catch(logRenderError);
|
||||
}
|
||||
|
||||
if (paths.length === 0) {
|
||||
notifications.notifyError({
|
||||
title: "notifications.maps.import-map.titles.error",
|
||||
desc: "notifications.maps.import-map.msgs.only-accept-zip"
|
||||
});
|
||||
return;
|
||||
const importPlaylists = async (paths: string[]) => {
|
||||
setPlaylistsDropZoneOpen(() => false);
|
||||
return lastValueFrom(playlistsManager.importPlaylists({ version, paths })).catch(logRenderError);
|
||||
}
|
||||
|
||||
const addDropDownItems = ((): DropDownItem[] => {
|
||||
if(tabIndex === 0){
|
||||
return [
|
||||
{ icon: "browse", text: "pages.version-viewer.maps.tabs.maps.actions.drop-down.browse-maps", onClick: handleBrowse },
|
||||
{ icon: "download", text: "pages.version-viewer.maps.tabs.maps.actions.drop-down.import-maps", onClick: () => setMapsDropZoneOpen(true) }
|
||||
];
|
||||
}
|
||||
|
||||
const import$ = mapsManager.importMaps(paths, version);
|
||||
return lastValueFrom(import$).catch(logRenderError);
|
||||
}
|
||||
return [
|
||||
{ icon: "browse", text: "pages.version-viewer.maps.tabs.playlists.drop-down.browse-playlists", onClick: handleBrowse },
|
||||
{ icon: "add-file", text: "pages.version-viewer.maps.tabs.playlists.drop-down.create-a-playlist", onClick: () => playlistsRef?.current?.createPlaylist?.() },
|
||||
{ icon: "download", text: "pages.version-viewer.maps.tabs.playlists.drop-down.import-playlists", onClick: () => setPlaylistsDropZoneOpen(true) }
|
||||
];
|
||||
})();
|
||||
|
||||
const dropDownItems = ((): DropDownItem[] => {
|
||||
if (tabIndex === 0) {
|
||||
@@ -136,7 +145,6 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
}
|
||||
|
||||
return [
|
||||
{ icon: "add", text: t("playlist.create-a-playlist"), onClick: () => playlistsRef?.current?.createPlaylist?.() },
|
||||
{ icon: "sync", text: t("playlist.synchronize-playlists"), onClick: () => playlistsRef?.current?.syncPlaylists?.() },
|
||||
{ icon: "export", text: t("playlist.export-playlists"), onClick: () => playlistsRef?.current?.exportPlaylists?.() },
|
||||
{ icon: "trash", text: t("playlist.delete-playlists"), onClick: () => playlistsRef?.current?.deletePlaylists?.() },
|
||||
@@ -146,13 +154,20 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
return (
|
||||
<>
|
||||
<nav className="w-full shrink-0 flex h-9 justify-center px-40 gap-2 text-main-color-1 dark:text-white mb-4">
|
||||
<BsmButton
|
||||
className="flex items-center justify-center w-fit rounded-full px-2 py-1 font-bold whitespace-nowrap"
|
||||
<BsmDropdownButton
|
||||
classNames={{
|
||||
mainContainer: "h-full relative z-[1] flex justify-center w-fit",
|
||||
button: "flex items-center justify-center h-full rounded-full px-2 py-1 font-bold whitespace-nowrap shadow-none",
|
||||
itemsContainer: "bg-theme-3",
|
||||
}}
|
||||
textClassName="whitespace-nowrap"
|
||||
icon="add"
|
||||
buttonColor="primary"
|
||||
text="misc.add"
|
||||
typeColor="primary"
|
||||
align="center"
|
||||
menuTranslationY="6px"
|
||||
withBar={false}
|
||||
onClick={handleAddClick}
|
||||
items={addDropDownItems}
|
||||
/>
|
||||
<div className="h-full rounded-full bg-light-main-color-2 dark:bg-main-color-2 grow p-[6px]">
|
||||
<input
|
||||
@@ -200,18 +215,37 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
|
||||
]}
|
||||
>
|
||||
<InstalledMapsContext.Provider value={mapsContextValue}>
|
||||
|
||||
<Dropzone
|
||||
className="w-full h-full shrink-0"
|
||||
onDrop={event => {
|
||||
handleFileDrop(event.dataTransfer.files);
|
||||
}}
|
||||
onFiles={importMaps}
|
||||
text={t("pages.version-viewer.maps.tabs.maps.drop-zone.text")}
|
||||
subtext={t("pages.version-viewer.maps.tabs.maps.drop-zone.subtext")}
|
||||
open={mapsDropZoneOpen}
|
||||
onClose={mapsDropZoneOpen ? () => setMapsDropZoneOpen(() => false) : undefined}
|
||||
filters={[{ name: ".zip", extensions: ["zip"] }]}
|
||||
dialogOptions={{ dialog: {
|
||||
properties: ["openFile", "multiSelections"],
|
||||
}}}
|
||||
>
|
||||
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={search} linkedState={mapsLinkedState} />
|
||||
</Dropzone>
|
||||
|
||||
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} ref={playlistsRef} version={version} linkedState={playlistLinkedState} filter={playlistFilter} search={search}/>
|
||||
<Dropzone
|
||||
className="w-full h-full shrink-0"
|
||||
onFiles={importPlaylists}
|
||||
text={t("pages.version-viewer.maps.tabs.playlists.drop-zone.text")}
|
||||
subtext={t("pages.version-viewer.maps.tabs.playlists.drop-zone.subtext")}
|
||||
open={playlistsDropZoneOpen}
|
||||
onClose={playlistsDropZoneOpen ? () => setPlaylistsDropZoneOpen(() => false) : undefined}
|
||||
filters={[{ name: ".bplist, .json", extensions: ["bplist", "json"] }]}
|
||||
dialogOptions={{ dialog: {
|
||||
properties: ["openFile", "multiSelections"],
|
||||
}}}
|
||||
>
|
||||
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} ref={playlistsRef} version={version} linkedState={playlistLinkedState} filter={playlistFilter} search={search}/>
|
||||
</Dropzone>
|
||||
|
||||
</InstalledMapsContext.Provider>
|
||||
</BsContentTabPanel>
|
||||
</>
|
||||
|
||||
+18
-6
@@ -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, Observable, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs";
|
||||
import { BehaviorSubject, Observable, Subscription, bufferTime, 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";
|
||||
@@ -52,7 +52,7 @@ type Props = {
|
||||
|
||||
export type LocalPlaylistsListRef = {
|
||||
createPlaylist: () => Promise<void>;
|
||||
syncPlaylists: () => Promise<void>;
|
||||
syncPlaylists: (playlists?: LocalBPList[]) => Promise<void>;
|
||||
deletePlaylists: () => Promise<void>;
|
||||
exportPlaylists: () => Promise<void>;
|
||||
}
|
||||
@@ -111,9 +111,9 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
|
||||
}
|
||||
|
||||
},
|
||||
syncPlaylists: async () => {
|
||||
syncPlaylists: async (playlists?: LocalBPList[]) => {
|
||||
if(!isOnline){ return; }
|
||||
const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value;
|
||||
const toSync = playlists ?? (selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value);
|
||||
if(!toSync.length){ return; }
|
||||
|
||||
const modalRes = await modals.openModal(SyncPlaylistModal, { data: toSync });
|
||||
@@ -211,10 +211,22 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
|
||||
setPlaylists(newPlaylist);
|
||||
}
|
||||
|
||||
const sub = playlistDownloader.currentDownload$.pipe(filter(download => download?.downloaded && equal(download?.info.version, version))).subscribe(download => onPlaylistDownloadedCB(download.downloaded));
|
||||
const subs: Subscription[] = [];
|
||||
|
||||
subs.push(playlistDownloader.currentDownload$.pipe(
|
||||
filter(download => download?.downloaded && equal(download?.info.version, version))
|
||||
).subscribe(download => onPlaylistDownloadedCB(download.downloaded)));
|
||||
|
||||
subs.push(playlistService.$onPlaylistImported(version).pipe(bufferTime(500)).subscribe({
|
||||
next: newPlaylists => {
|
||||
if(!newPlaylists.length){ return; }
|
||||
const playlists = (playlists$.value ?? []).filter(p => !newPlaylists.some(np => np.path === p.path));
|
||||
setPlaylists([...newPlaylists, ...playlists]);
|
||||
}
|
||||
}));
|
||||
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
subs.forEach(s => s.unsubscribe());
|
||||
}
|
||||
|
||||
}, [isActiveOnce, version, linked]);
|
||||
|
||||
@@ -1,38 +1,96 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { DragEvent, ReactNode, useCallback, useState } from "react";
|
||||
import { DragEvent, ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import { BsmImage } from "./bsm-image.component";
|
||||
import BeatImpatient from "../../../../assets/images/apngs/beat-impatient.png";
|
||||
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
|
||||
import Color from "color";
|
||||
import { cn } from "renderer/helpers/css-class.helpers";
|
||||
import { CloseIcon } from "../svgs/icons/close-icon.component";
|
||||
import { OpenDialogOptions } from "electron";
|
||||
import { useTranslation } from "renderer/hooks/use-translation.hook";
|
||||
import { useService } from "renderer/hooks/use-service.hook";
|
||||
import { IpcService } from "renderer/services/ipc.service";
|
||||
import { NotificationService } from "renderer/services/notification.service";
|
||||
import { lastValueFrom } from "rxjs";
|
||||
|
||||
type Props = Readonly<{
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
open?: boolean;
|
||||
text?: string;
|
||||
subtext?: string;
|
||||
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
|
||||
onFiles?: (paths: string[]) => void;
|
||||
onClose?: () => void;
|
||||
filters?: OpenDialogOptions["filters"];
|
||||
dialogOptions?: {
|
||||
text?: string;
|
||||
dialog?: Exclude<OpenDialogOptions, "filters">;
|
||||
}
|
||||
}>;
|
||||
|
||||
// Supports drop and drop functionality for files.
|
||||
export function Dropzone({
|
||||
children,
|
||||
className,
|
||||
open,
|
||||
text,
|
||||
subtext,
|
||||
onDrop,
|
||||
filters,
|
||||
dialogOptions,
|
||||
onFiles,
|
||||
onClose
|
||||
}: Props) {
|
||||
|
||||
const ipc = useService(IpcService);
|
||||
const notification = useService(NotificationService);
|
||||
|
||||
const t = useTranslation();
|
||||
|
||||
const [dragCount, setDragCount] = useState(0);
|
||||
const [dropZoneDragging, setDropZoneDragging] = useState(false);
|
||||
const themeColor = useThemeColor("first-color");
|
||||
const color = new Color(themeColor).lighten(.25).saturate(.8).hex();
|
||||
|
||||
const supportedExtensions = useMemo(() => {
|
||||
if(!filters?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filters.flatMap(filter => filter.extensions ?? []);
|
||||
}, [filters]);
|
||||
|
||||
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragCount(0);
|
||||
if (onDrop) onDrop(event);
|
||||
}, []);
|
||||
|
||||
setDragCount(() => 0);
|
||||
setDropZoneDragging(() => false);
|
||||
|
||||
if (!onFiles || !event.dataTransfer?.files?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = Array.from(event.dataTransfer.files).reduce<string[]>((acc, file) => {
|
||||
const path = window.electron.webUtils.getPathForFile(file);
|
||||
|
||||
if(supportedExtensions.some(ext => path.endsWith(ext))) {
|
||||
acc.push(path);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if(!paths.length) {
|
||||
notification.notifyError({
|
||||
title: t("notifications.shared.errors.titles.file-not-supported"),
|
||||
desc: t("notifications.shared.errors.msg.file-not-supported", { types: supportedExtensions.join(", ") }),
|
||||
duration: 8000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onFiles(paths);
|
||||
}, [supportedExtensions]);
|
||||
|
||||
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -51,14 +109,33 @@ export function Dropzone({
|
||||
setDragCount(prev => prev - 1);
|
||||
}, []);
|
||||
|
||||
const openDialog = useCallback(async () => {
|
||||
if(!dialogOptions?.dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await lastValueFrom(ipc.sendV2("open-dialog", { ...dialogOptions.dialog, filters }));
|
||||
|
||||
if(res?.canceled || !res?.filePaths?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
onFiles?.(res.filePaths);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={className} onDragEnter={onDragEnter} onDragLeave={onDragLeave}>
|
||||
<div className={cn("relative", className)} onDragEnter={onDragEnter} onDragLeave={onDragLeave}>
|
||||
<AnimatePresence>
|
||||
{dragCount ? (
|
||||
{(dragCount || open) ? (
|
||||
<div onDragEnter={onDragEnter} onDragLeave={onDragLeave} onDrop={onDragLeave} className="absolute size-full top-0 right-0 z-10">
|
||||
<motion.div className="absolute size-full top-0 right-0 bg-black" initial={{ opacity: 0 }} animate={{ opacity: .6 }} exit={{ opacity: 0 }}/>
|
||||
<motion.div className="absolute size-full top-0 right-0 flex justify-center items-center p-5" initial={{ y: "100%" }} animate={{ y: "0" }} exit={{ y: "100%" }}>
|
||||
<div className="h-72 max-h-full w-full max-w-lg sm:h-96 sm:max-w-2xl bg-theme-3 rounded-md shadow-black shadow-lg p-7">
|
||||
<div className="relative h-72 max-h-full w-full max-w-lg sm:h-96 sm:max-w-2xl bg-theme-3 rounded-md shadow-black shadow-lg p-7">
|
||||
{onClose && (
|
||||
<button className="absolute top-0.5 right-0.5 size-7" onClick={onClose}>
|
||||
<CloseIcon className="size-full"/>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
onDragOver={onDragOver}
|
||||
onDrop={handleDrop}
|
||||
@@ -77,6 +154,9 @@ export function Dropzone({
|
||||
<BsmImage className="size-28 pointer-events-none" image={BeatImpatient}/>
|
||||
<span className="text-2xl font-bold pointer-events-none">{text}</span>
|
||||
<span className="pointer-events-none">{subtext}</span>
|
||||
{dialogOptions?.dialog && (
|
||||
<button className="underline hover:no-underline" onClick={openDialog}>{dialogOptions?.text ?? t("drop-zone.or-browse-files")}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -62,9 +62,11 @@ import { ShortcutIcon } from "./icons/shortcut-icon.component";
|
||||
import { BackupRestoreIcon } from "./icons/backup-restore-icon.component";
|
||||
import { SongDetailDiffCharactertistic } from "shared/models/maps/song-details-cache/song-details-cache.model";
|
||||
import { CleanIcon } from "./icons/clean-icon.component";
|
||||
import { BrowseIcon } from "./icons/browse-icon.component";
|
||||
import { AddFileIcon } from "./icons/add-file-icon.component";
|
||||
|
||||
|
||||
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
|
||||
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "browse" | "add-file" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
|
||||
|
||||
export const BsmIcon = memo(({ className, icon, style }: { className?: string; icon: BsmIconType; style?: CSSProperties }) => {
|
||||
// TODO : Very ugly very messy, need to find a better way to do this
|
||||
@@ -262,6 +264,14 @@ export const BsmIcon = memo(({ className, icon, style }: { className?: string; i
|
||||
return <CleanIcon className={className} style={style} />;
|
||||
}
|
||||
|
||||
if (icon === "browse") {
|
||||
return <BrowseIcon className={className} style={style} />;
|
||||
}
|
||||
|
||||
if(icon === "add-file") {
|
||||
return <AddFileIcon className={className} style={style} />;
|
||||
}
|
||||
|
||||
return <TrashIcon className={className} style={style} />;
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createSvgIcon } from "../svg-icon.type";
|
||||
|
||||
export const AddFileIcon = createSvgIcon(( props, ref ) => {
|
||||
return (
|
||||
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path d="M440-357.37v80q0 17 11.5 28.5t28.5 11.5q17 0 28.5-11.5t11.5-28.5v-80h80q17 0 28.5-11.5t11.5-28.5q0-17-11.5-28.5t-28.5-11.5h-80v-80q0-17-11.5-28.5t-28.5-11.5q-17 0-28.5 11.5t-11.5 28.5v80h-80q-17 0-28.5 11.5t-11.5 28.5q0 17 11.5 28.5t28.5 11.5h80ZM242.87-71.87q-37.78 0-64.39-26.61t-26.61-64.39v-634.26q0-37.78 26.61-64.39t64.39-26.61H525.8q18.22 0 34.72 6.84 16.5 6.83 29.18 19.51L781.78-669.7q12.68 12.68 19.51 29.18 6.84 16.5 6.84 34.72v442.93q0 37.78-26.61 64.39t-64.39 26.61H242.87Zm274.26-570.76v-154.5H242.87v634.26h474.26v-434.26h-154.5q-19.15 0-32.33-13.17-13.17-13.18-13.17-32.33Zm-274.26-154.5V-597.13v-200 634.26-634.26Z"/>
|
||||
</svg>
|
||||
);
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createSvgIcon } from "../svg-icon.type";
|
||||
|
||||
export const BrowseIcon = createSvgIcon(( props, ref ) => {
|
||||
|
||||
return (
|
||||
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
|
||||
<path d="M73.38-480q0-84.36 32.02-158.55 32.02-74.2 87.06-129.15 55.03-54.96 129.23-87.06 74.19-32.1 158.55-32.1 134.3 0 238.05 76.51t145.17 196.84q5.8 15.77-1.12 30.78-6.93 15.01-22.85 19.75-14.93 4.2-28.19-4.11-13.25-8.3-18.95-22.99-22.75-67.42-71.58-119.41-48.83-52-116.66-79.69v17.01q0 34.1-23.83 59.02-23.84 24.93-57.93 24.93h-83.87v84.11q0 16.64-12.84 27.87-12.83 11.23-29.75 11.23h-81.28v84.53h60.85q16 0 26.94 11.02 10.93 11.02 10.93 27.02v84.42h-57.2L157.96-556.35q-4.33 19.26-6.5 38.13-2.16 18.87-2.16 37.74 0 122.43 77.84 214.51 77.85 92.08 197.26 111.19 14.87 2.74 23.8 14.97 8.92 12.22 7.57 27.5-1.6 15.83-13.52 25.68-11.92 9.84-27.64 6.72-145.92-23.35-243.57-135.47Q73.38-327.5 73.38-480Zm749.37 355.88L717.04-229.17q-20.76 13.44-44.66 21.04-23.89 7.6-49.14 7.6-73.46 0-125.14-51.53-51.67-51.54-51.67-125.07 0-73.37 51.61-124.79 51.62-51.41 124.99-51.41 73.53 0 124.94 51.55 51.42 51.56 51.42 125.02 0 25.4-7.93 49.26-7.94 23.86-20.71 45.28l105.13 105.05q11.26 11.03 11.47 26.53.22 15.5-10.81 26.52-11.1 11.26-26.89 11.26-15.79 0-26.9-11.26ZM622.86-276.28q42.13 0 71.37-29.27 29.25-29.28 29.25-71.41 0-42.13-29.15-71.37-29.16-29.25-71.29-29.25-42.13 0-71.41 29.15-29.29 29.16-29.29 71.29 0 42.13 29.19 71.49 29.2 29.37 71.33 29.37Z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
});
|
||||
@@ -242,7 +242,7 @@ export class MapsManagerService {
|
||||
title: "notifications.maps.import-map.titles.error",
|
||||
desc: ["invalid-zip"].includes(err?.code)
|
||||
? `notifications.maps.import-map.msgs.${err.code}`
|
||||
: "misc.unknown"
|
||||
: "notifications.maps.import-map.msgs.unknown"
|
||||
});
|
||||
obs.error(err);
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BSVersion } from "shared/bs-version.interface";
|
||||
import { IpcService } from "./ipc.service";
|
||||
import { Observable, lastValueFrom } from "rxjs";
|
||||
import { Observable, Subject, Subscription, catchError, distinctUntilChanged, filter, lastValueFrom, map, of } 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";
|
||||
@@ -8,6 +8,11 @@ import { ModalExitCode, ModalService } from "./modale.service";
|
||||
import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component";
|
||||
import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component";
|
||||
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
|
||||
import { ProgressBarService } from "./progress-bar.service";
|
||||
import equal from "fast-deep-equal";
|
||||
import { noop } from "shared/helpers/function.helpers";
|
||||
import { NotificationService } from "./notification.service";
|
||||
import { CustomError } from "shared/models/exceptions/custom-error.class";
|
||||
|
||||
export class PlaylistsManagerService {
|
||||
private static instance: PlaylistsManagerService;
|
||||
@@ -21,14 +26,20 @@ export class PlaylistsManagerService {
|
||||
|
||||
public static readonly RELATIVE_PLAYLISTS_FOLDER = "Playlists";
|
||||
|
||||
private readonly lastPlaylistImported$ = new Subject<{ version: BSVersion, playlist: LocalBPListsDetails }>();
|
||||
|
||||
private readonly ipc: IpcService;
|
||||
private readonly linker: VersionFolderLinkerService;
|
||||
private readonly modal: ModalService;
|
||||
private readonly progressBar: ProgressBarService;
|
||||
private readonly notification: NotificationService;
|
||||
|
||||
private constructor() {
|
||||
this.ipc = IpcService.getInstance();
|
||||
this.linker = VersionFolderLinkerService.getInstance();
|
||||
this.modal = ModalService.getInstance();
|
||||
this.progressBar = ProgressBarService.getInstance();
|
||||
this.notification = NotificationService.getInstance();
|
||||
}
|
||||
|
||||
public getVersionPlaylistsDetails(version: BSVersion): Observable<Progression<LocalBPListsDetails[]>> {
|
||||
@@ -48,6 +59,69 @@ export class PlaylistsManagerService {
|
||||
});
|
||||
}
|
||||
|
||||
public importPlaylists(opt: { version?: BSVersion, paths: string[] }): Observable<Progression<LocalBPListsDetails>> {
|
||||
return new Observable<Progression<LocalBPListsDetails>>(obs => {
|
||||
if(!this.progressBar.require()){
|
||||
obs.error("A progression is already running");
|
||||
return noop;
|
||||
}
|
||||
|
||||
const subs: Subscription[] = [];
|
||||
|
||||
const import$ = this.ipc.sendV2("import-playlists", opt);
|
||||
subs.push(import$.subscribe(obs));
|
||||
|
||||
subs.push(import$.pipe(catchError(() => of()), map(progress => progress?.data), filter(Boolean), distinctUntilChanged((a, b) => equal(a, b))).subscribe({
|
||||
next: playlist => {
|
||||
this.lastPlaylistImported$.next({ version: opt.version, playlist });
|
||||
}
|
||||
}));
|
||||
|
||||
this.progressBar.show(import$);
|
||||
|
||||
const knownErrors = ["INVALID_SOURCE", "INVALID_PLAYLIST_FILE", "CANNOT_PARSE_PLAYLIST"];
|
||||
|
||||
lastValueFrom(import$).then(res => {
|
||||
if(res.lastError && !res.current){
|
||||
throw res.lastError;
|
||||
}
|
||||
|
||||
if(!res.total){
|
||||
return this.notification.notifyWarning({
|
||||
title: "playlist.no-playlist-found",
|
||||
desc: "playlist.no-playlist-found-in-selected-files"
|
||||
});
|
||||
}
|
||||
|
||||
if(res.current < res.total){
|
||||
return this.notification.notifyWarning({
|
||||
title: "playlist.some-playlists-not-imported",
|
||||
desc: knownErrors.includes(res.lastError?.code) ? `playlist.some-playlists-have-been-imported.${res.lastError.code}` : "playlist.some-playlists-have-been-imported.unknown"
|
||||
});
|
||||
}
|
||||
|
||||
return this.notification.notifySuccess({
|
||||
title: "playlist.playlists-imported",
|
||||
desc: "playlist.all-playlists-have-been-successfully-imported",
|
||||
});
|
||||
}).catch((err: CustomError) => {
|
||||
this.notification.notifyError({
|
||||
title: "playlist.no-playlists-imported",
|
||||
desc: knownErrors.includes(err.code) ? `playlist.no-playlists-imported-errors.${err.code}` : "playlist.no-playlists-imported-errors.unknown"
|
||||
});
|
||||
}).finally(() => {
|
||||
obs.complete();
|
||||
})
|
||||
|
||||
|
||||
return () => {
|
||||
subs.forEach(s => s.unsubscribe());
|
||||
this.progressBar.hide();
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
public async linkVersion(version: BSVersion): Promise<boolean> {
|
||||
const modalRes = await this.modal.openModal(LinkPlaylistModal);
|
||||
|
||||
@@ -91,4 +165,11 @@ export class PlaylistsManagerService {
|
||||
public $playlistsFolderLinkState(version: BSVersion): Observable<FolderLinkState> {
|
||||
return this.linker.$folderLinkedState(version, PlaylistsManagerService.RELATIVE_PLAYLISTS_FOLDER);
|
||||
}
|
||||
|
||||
public $onPlaylistImported(version?: BSVersion): Observable<LocalBPListsDetails> {
|
||||
return this.lastPlaylistImported$.pipe(
|
||||
filter((val) => val?.playlist && equal(val.version, version)),
|
||||
map((val) => val.playlist)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,10 @@ export default function App() {
|
||||
const appVersion = await lastValueFrom(autoUpdater.getAppVersion());
|
||||
const lastAppVersion = autoUpdater.getLastAppVersion();
|
||||
|
||||
if(!lastAppVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastAppVersion.toLowerCase().includes("alpha")) {
|
||||
autoUpdater.setLastAppVersion("0"); // Reset last app version if it's an alpha version for users with alpha versions in the var stored.
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
export class CustomError extends Error {
|
||||
|
||||
private readonly _code: string;
|
||||
private readonly _data?: unknown;
|
||||
public readonly code: string;
|
||||
public readonly data?: unknown;
|
||||
|
||||
constructor(message: string, code: string, data?: unknown){
|
||||
super(message);
|
||||
this._code = code;
|
||||
this._data = data;
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public get code(): string { return this._code; }
|
||||
public get data(): unknown { return this._data; }
|
||||
|
||||
public static fromError(error: Error, code?: string, data?: unknown): CustomError{
|
||||
|
||||
if(error instanceof CustomError){
|
||||
@@ -33,4 +30,4 @@ export class CustomError extends Error {
|
||||
|
||||
export enum CustomErrorCodes {
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BsmLocalModel } from "../models/bsm-local-model.interface";
|
||||
import { Mod } from "../mods";
|
||||
import { BPList, DownloadPlaylistProgressionData } from "../playlists/playlist.interface";
|
||||
import { VersionLinkerAction } from "renderer/services/version-folder-linker.service";
|
||||
import { FileFilter, OpenDialogReturnValue } from "electron";
|
||||
import { FileFilter, OpenDialogOptions, OpenDialogReturnValue } from "electron";
|
||||
import { SystemNotificationOptions } from "../notification/system-notification.model";
|
||||
import { Supporter } from "../supporters";
|
||||
import { AppWindow } from "../window-manager/app-window.model";
|
||||
@@ -93,6 +93,7 @@ export interface IpcChannelMapping {
|
||||
"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, playlistsMaps?: BsmLocalMap[]}, response: Progression<string>};
|
||||
"import-playlists": {request: {version?: BSVersion, paths: string[]}, response: Progression<LocalBPListsDetails>};
|
||||
"install-playlist-file": {request: {bplist: BPList, version?: BSVersion, dest?: string}, response: LocalBPListsDetails};
|
||||
|
||||
/* ** bs-uninstall-ipcs ** */
|
||||
@@ -122,6 +123,7 @@ export interface IpcChannelMapping {
|
||||
|
||||
/* ** os-controls-ipcs ** */
|
||||
"new-window": { request: string, response: void };
|
||||
"open-dialog": { request: OpenDialogOptions, response: OpenDialogReturnValue };
|
||||
"choose-folder": { request: string, response: OpenDialogReturnValue };
|
||||
"choose-file": { request: string, response: OpenDialogReturnValue }
|
||||
"choose-image": { request: { multiple?: boolean, base64?: boolean }, response: string[] }
|
||||
|
||||
Reference in New Issue
Block a user