diff --git a/src/main/helpers/fs.helpers.ts b/src/main/helpers/fs.helpers.ts index a6ebe662..68e921fd 100644 --- a/src/main/helpers/fs.helpers.ts +++ b/src/main/helpers/fs.helpers.ts @@ -258,6 +258,19 @@ export function resolveGUIDPath(guidPath: string): string { return path.join(driveLetter, path.relative(guidVolume, guidPath)); } +export function getUniqueFileNamePath(filePath: string): string { + const { dir, name, ext } = path.parse(filePath); + let i = 0; + let newFileName = `${name}${ext}`; + + while (pathExistsSync(path.join(dir, newFileName))) { + i++; + newFileName = `${name} (${i})${ext}`; + } + + return path.join(dir, newFileName); +} + export interface Progression { total: number; current: number; diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 01701dbb..f1edd62c 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -1,7 +1,7 @@ import { LocalBPList } from "shared/models/playlists/local-playlist.models"; import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service"; import { IpcService } from "../services/ipc.service"; -import { mergeMap, of } from "rxjs"; +import { from, lastValueFrom, mergeMap, of } from "rxjs"; import { BPList } from "shared/models/playlists/playlist.interface"; import path from "path"; import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service"; @@ -66,3 +66,14 @@ ipc.on("export-playlists", (args, reply) => { const playlists = LocalPlaylistsManagerService.getInstance(); reply(playlists.exportPlaylists(args)); }); + +ipc.on("install-playlist-file", (args, reply) => { + const playlists = LocalPlaylistsManagerService.getInstance(); + + const promise = async () => { + const playlist = await lastValueFrom(playlists.writeBPListFile({ bpList: args.bplist, version: args.version, dest: args.dest})); + return playlists.getLocalBPListDetails(playlist); + } + + reply(from(promise())); +}) diff --git a/src/main/services/additional-content/local-playlists-manager.service.ts b/src/main/services/additional-content/local-playlists-manager.service.ts index ea2710c8..48af8e08 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -11,7 +11,7 @@ import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copy, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFileSync } from "fs-extra"; -import { Progression, unlinkPath } from "../../helpers/fs.helpers"; +import { Progression, getUniqueFileNamePath, 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"; @@ -85,6 +85,27 @@ export class LocalPlaylistsManagerService { return fullPath; } + public writeBPListFile(opt: { bpList: BPList, version?: BSVersion, dest?: string }): Observable { + return new Observable(obs => { + (async () => { + const dest = await (async () => { + if(opt.dest && path.isAbsolute(opt.dest) && path.extname(opt.dest) === ".bplist") { return opt.dest; } + const playlistFolder = await this.getPlaylistsFolder(opt.version); + const playlistPath = path.join(playlistFolder, `${sanitize(opt.bpList.playlistTitle)}.bplist`); + return getUniqueFileNamePath(playlistPath); + })(); + + writeFileSync(dest, JSON.stringify(opt.bpList, null, 2)); + + const localBPList: LocalBPList = { ...opt.bpList, path: dest }; + + obs.next(localBPList); + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }); + } + private async installBPListFile(opt: { bslistSource: string, version?: BSVersion, diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index d10b1056..54a3ad0b 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -38,6 +38,7 @@ import { enumerate } from "shared/helpers/array.helpers"; import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component"; import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component"; import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component"; +import { NeedCloneEditPlaylistModal } from "renderer/components/modal/modal-types/playlist/need-clone-edit-playlist-modal.component"; type Props = { version: BSVersion; @@ -89,6 +90,27 @@ export const LocalPlaylistsListPanel = forwardRef( useImperativeHandle(forwardedRef, () => ({ createPlaylist: async () => { const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { version, maps$ } }); + + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const { error, result } = await tryit(() => lastValueFrom(playlistDownloader.installPlaylistFile(modalRes.data, version))); + + if(error){ + logRenderError("Error occured while creating playlist", error); + notification.notifyError({ title: "Erreur lors de la création de la playlist", desc: "Une erreur est survenue lors de la création de la playlist." }); + return; + } + + setPlaylists([result, ...playlists$.value]); + + const notifRes = await notification.notifySuccess({ title: "Playlist créée !", desc: "La playlist a été créée avec succès. Tu peut maintenant synchroniser ses maps !", duration: 8000, actions: [ + { id: "sync", title: "Synchroniser les maps" } + ]}); + + if(notifRes === "sync"){ + await lastValueFrom(installPlaylist(result)); + } + }, syncPlaylists: async () => { if(!isOnline){ return; } @@ -249,6 +271,7 @@ export const LocalPlaylistsListPanel = forwardRef( }; const openPlaylistDetails = (playlistPath: string) => { + const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistPath))); const installedMaps$ = combineLatest([maps$, localPlaylist$]).pipe( filter(([maps, playlist]) => !!maps && !!playlist), @@ -262,6 +285,48 @@ export const LocalPlaylistsListPanel = forwardRef( }) }; + const editPlaylist = async (playlist: LocalBPList) => { + const needClone = playlist?.customData?.syncURL; + const res = await (needClone ? modals.openModal(NeedCloneEditPlaylistModal) : Promise.resolve()); + + if(res && res.exitCode !== ModalExitCode.COMPLETED){ + return; + } + + const tmpPlaylist: LocalBPList = { ...playlist, playlistTitle: needClone ? `${playlist.playlistTitle} (${t("Clone")})` : playlist.playlistTitle }; + const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { version, maps$, playlist: tmpPlaylist } }); + + if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; } + + const { error, result } = await tryit(() => lastValueFrom(playlistDownloader.installPlaylistFile(modalRes.data, version, needClone ? undefined : playlist.path))); + + if(error){ + logRenderError("Error occured while editing playlist", error); + notification.notifyError({ title: "Erreur lors de la modification de la playlist", desc: "Une erreur est survenue lors de la modification de la playlist." }); + return; + } + + const newPlaylists = [...playlists$.value]; + + if(needClone){ + newPlaylists.unshift(result); + } + else { + const index = newPlaylists.findIndex(p => p.path === playlist.path); + newPlaylists[index] = result; + } + + setPlaylists(newPlaylists); + + const notifRes = await notification.notifySuccess({ title: "Playlist modifiée !", desc: "La playlist a été modifiée avec succès. Tu peut maintenant synchroniser ses maps !", duration: 8000, actions: [ + { id: "sync", title: "Synchroniser les maps" } + ]}); + + if(notifRes === "sync"){ + await lastValueFrom(installPlaylist(result)); + } + } + const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => { @@ -282,9 +347,10 @@ export const LocalPlaylistsListPanel = forwardRef( }} onClickOpen={() => openPlaylistDetails(playlist.path)} onClickDelete={() => deletePlaylists([playlist])} - onClickSync={isOnline && (() => handleClickSync(playlist))} + onClickSync={(playlist?.songs?.length && isOnline) && (() => handleClickSync(playlist))} onClickOpenFile={() => viewPlaylistFile(playlist.path)} onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.customData?.syncURL ?? playlist.path, version)} + onClickEdit={() => editPlaylist(playlist)} /> ); }, [isOnline, version]); diff --git a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx index e97ebe13..8ff7e826 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/playlist-item.component.tsx @@ -35,6 +35,7 @@ export type PlaylistItemComponentProps = { onClickSync?: () => void; onClickDownload?: () => void; onClickCancelDownload?: () => void; + onClickEdit?: () => void; } export function PlaylistItem({ title, @@ -55,7 +56,8 @@ export function PlaylistItem({ title, onClickSync, onClickDownload, onClickDelete, - onClickCancelDownload + onClickCancelDownload, + onClickEdit }: PlaylistItemComponentProps) { const color = useThemeColor("first-color"); @@ -112,7 +114,7 @@ export function PlaylistItem({ title, - {(isDownloading || isInQueue) && onClickCancelDownload && ( + {(isDownloading || isInQueue) && onClickCancelDownload ? ( - )} - {onClickSync && ( + ) : (<>)} + {onClickSync ? ( isDownloading ? ( ) : !isInQueue ? ( @@ -139,8 +141,8 @@ export function PlaylistItem({ title, ) : (<>) - )} - {onClickDownload && ( + ) : (<>)} + {onClickDownload ? ( isDownloading ? ( ) : !isInQueue ? ( @@ -156,8 +158,20 @@ export function PlaylistItem({ title, ) : (<>) - )} - {onClickOpenFile && + ) : (<>)} + {onClickEdit ? ( + + + + ) : (<>)} + {onClickOpenFile ? - } - {(onClickDelete && !isDownloading && !isInQueue) && + : (<>)} + {(onClickDelete && !isDownloading && !isInQueue) ? - } + : (<>)} diff --git a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx index 131c3021..583bb5cf 100644 --- a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-infos-modal.component.tsx @@ -8,18 +8,21 @@ import { IpcService } from "renderer/services/ipc.service"; import { lastValueFrom } from "rxjs"; import { logRenderError } from "renderer"; -type Props = { +type OutProps = { playlistTitle: string; playlistDescription: string; playlistAuthor: string; base64Image: string; } -export const EditPlaylistInfosModal: ModalComponent = ({ resolver, options: { data: { +type Props = OutProps & { isEdit: boolean }; + +export const EditPlaylistInfosModal: ModalComponent = ({ resolver, options: { data: { playlistTitle, playlistDescription, playlistAuthor, - base64Image + base64Image, + isEdit }}}) => { const steamDownloader = useService(SteamDownloaderService); @@ -49,7 +52,9 @@ export const EditPlaylistInfosModal: ModalComponent = ({ resolver, return (
-

Créer une playlist

+

+ {isEdit ? "Modifier la playlist" : "Créer une playlist"} +

{(() => { if(!playlistMaps || !localMaps){ return
Loading...
diff --git a/src/renderer/components/modal/modal-types/playlist/need-clone-edit-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/need-clone-edit-playlist-modal.component.tsx new file mode 100644 index 00000000..5e5dd787 --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/need-clone-edit-playlist-modal.component.tsx @@ -0,0 +1,23 @@ +import { ModalComponent, ModalExitCode } from "renderer/services/modale.service" +import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png"; +import { BsmImage } from "renderer/components/shared/bsm-image.component"; +import { BsmButton } from "renderer/components/shared/bsm-button.component"; + +// TODO : Translate + +export const NeedCloneEditPlaylistModal: ModalComponent = ({ resolver }) => { + + return ( +
+

Attention

+ +

Cette playlist a été téléchargée depuis un site externe et contient un lien de synchronisation.

+

Pour éviter de perdre vos modifications lors d'une synchronisation, la playlist va être dupliquée et son lien de synchronisation supprimé.

+

Vous pourrez ensuite, si vous le souhaitez, supprimer la playlist originale.

+
+ resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" /> + resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="J'ai compris" /> +
+ + ); +} diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx index 4b1dd0f8..1c789271 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx @@ -85,7 +85,26 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver const renderMaps = () => { - if(!installedMaps.length && !isInQueue) { + if (!Array.isArray(installedMaps) || !localPlaylist) { + return ( +
+ +
+ ); + } + + if(!localPlaylist.songs?.length){ + return ( +
+ +
+

La Playlist ne contient aucune map

+
+
+ ); + } + + if(!installedMaps.length && localPlaylist.songs?.length && !isInQueue) { return (
@@ -97,7 +116,7 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver ); } - if(installedMaps.length === 0 && isInQueue) { + if(!installedMaps.length && isInQueue) { return (
diff --git a/src/renderer/components/modal/modal.component.tsx b/src/renderer/components/modal/modal.component.tsx index 7734351b..b75298f9 100644 --- a/src/renderer/components/modal/modal.component.tsx +++ b/src/renderer/components/modal/modal.component.tsx @@ -4,12 +4,18 @@ import { useObservable } from "renderer/hooks/use-observable.hook"; import { useEffect } from "react"; import { BsmIcon } from "../svgs/bsm-icon.component"; import { ThemeColorGradientSpliter } from "../shared/theme-color-gradient-spliter.component"; +import { map } from "rxjs"; +import { useConstant } from "renderer/hooks/use-constant.hook"; export function Modal() { const modalSevice = ModalService.getInstance(); - const modals = useObservable(() => modalSevice.getModalToShow()); - const currentModal = modals?.at(-1); + const modals$ = useConstant(() => modalSevice.getModalToShow()); + + const modals = useObservable(() => modals$); + const currentModal = useObservable(() =>modals$.pipe(map(modals => modals?.at(-1)))); + + console.log(currentModal, modals, !!currentModal); useEffect(() => { const onEscape = (e: KeyboardEvent) => { @@ -56,9 +62,9 @@ export function Modal() { return ( - {currentModal && currentModal.resolver({ exitCode: ModalExitCode.NO_CHOICE })} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} />} - {modals?.map((modal, index) => ( - + {currentModal ? currentModal.resolver({ exitCode: ModalExitCode.NO_CHOICE })} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} /> : <>} + {modals?.map(modal => ( + {renderModal(modal)} ))} diff --git a/src/renderer/services/playlist-downloader.service.ts b/src/renderer/services/playlist-downloader.service.ts index 90af7c6d..e4061ee9 100644 --- a/src/renderer/services/playlist-downloader.service.ts +++ b/src/renderer/services/playlist-downloader.service.ts @@ -1,5 +1,5 @@ import { BehaviorSubject, Observable, Subject, Subscription, distinctUntilChanged, filter, lastValueFrom, map, shareReplay, take, takeUntil, takeWhile, tap } from "rxjs"; -import { DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; +import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface"; import { IpcService } from "./ipc.service"; import { ProgressBarService } from "./progress-bar.service"; import { Progression } from "main/helpers/fs.helpers"; @@ -126,6 +126,10 @@ export class PlaylistDownloaderService { public openDownloadPlaylistModal(version: BSVersion, ownedPlaylists$: Observable, ownedMaps$: Observable): Promise> { return this.modal.openModal(DownloadPlaylistModal, { data: { version, ownedPlaylists$, ownedMaps$ } }) } + + public installPlaylistFile(bplist: BPList, version?: BSVersion, dest?: string){ + return this.ipc.sendV2("install-playlist-file", { bplist, version, dest }); + } } export type PlaylistQueueInfo = { diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 3c80ecb4..a4fd3729 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -86,6 +86,7 @@ export interface IpcChannelMapping { "get-version-playlists-details": {request: BSVersion, response: Progression}; "delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression}; "export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}, response: Progression}; + "install-playlist-file": {request: {bplist: BPList, version?: BSVersion, dest?: string}, response: LocalBPListsDetails}; /* ** bs-uninstall-ipcs ** */ "bs.uninstall": { request: BSVersion, response: boolean };