diff --git a/src/main/ipcs/bs-playlist-ipcs.ts b/src/main/ipcs/bs-playlist-ipcs.ts index 3d76da3d..31adb7b8 100644 --- a/src/main/ipcs/bs-playlist-ipcs.ts +++ b/src/main/ipcs/bs-playlist-ipcs.ts @@ -44,3 +44,8 @@ ipc.on("get-version-playlists-details", (req, reply) => { const playlists = LocalPlaylistsManagerService.getInstance(); reply(playlists.getVersionPlaylistsDetails(req.args)); }); + +ipc.on<{path: string, deleteMaps?: boolean}>("delete-playlist", (req, reply) => { + const playlists = LocalPlaylistsManagerService.getInstance(); + reply(playlists.deletePlaylist(req.args)); +}); diff --git a/src/main/ipcs/os-controls-ipcs.ts b/src/main/ipcs/os-controls-ipcs.ts index a5b42525..d15eed28 100644 --- a/src/main/ipcs/os-controls-ipcs.ts +++ b/src/main/ipcs/os-controls-ipcs.ts @@ -11,14 +11,18 @@ import { from, of } from "rxjs"; const ipc = IpcService.getInstance(); -ipcMain.on("new-window", async (event, request: IpcRequest) => { +ipcMain.on("new-window", (event, request: IpcRequest) => { shell.openExternal(request.args); }); -ipc.on("choose-folder", async (req, reply) => { +ipc.on("choose-folder", (req, reply) => { reply(from(dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: req.args ?? "" }))); }); +ipc.on("view-path-in-explorer", (req, reply) => { + reply(of(shell.showItemInFolder(req.args))); +}); + ipcMain.on("window.progression", async (event, request: IpcRequest) => { BrowserWindow.fromWebContents(event.sender)?.setProgressBar(request.args / 100); }); 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 6cc43af0..243a1d35 100644 --- a/src/main/services/additional-content/local-playlists-manager.service.ts +++ b/src/main/services/additional-content/local-playlists-manager.service.ts @@ -11,13 +11,12 @@ import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists import { readFileSync } from "fs"; import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service"; import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra"; -import { Progression, pathExist } from "../../helpers/fs.helpers"; +import { Progression, 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"; import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; import { SongCacheService } from "./maps/song-cache.service"; -import { pathToFileURL } from "url"; import { InstallationLocationService } from "../installation-location.service"; export class LocalPlaylistsManagerService { @@ -229,6 +228,40 @@ export class LocalPlaylistsManagerService { } + public deletePlaylist(opt: {path: string, deleteMaps?: boolean}): Observable{ + + console.log("AAAAAA"); + + return new Observable(obs => { + (async () => { + + const bpList = await this.readPlaylistFile(opt.path); + + const progress: Progression = { current: 0, total: opt.deleteMaps ? bpList.songs.length + 1 : 1}; + + if(opt.deleteMaps){ + const mapsHashs = bpList.songs.map(s => ({ hash: s.hash })); + await lastValueFrom(this.maps.deleteMaps(mapsHashs).pipe( + tap({ + next: () => { + progress.current += 1 + obs.next(progress); + }, + error: err => obs.error(err), + complete: () => obs.next(progress), + }), + )); + } + + await unlinkPath(opt.path); + progress.current += 1; + obs.next(progress); + })() + .catch(err => obs.error(err)) + .finally(() => obs.complete()); + }); + } + public oneClickInstallPlaylist(bpListUrl: string): Observable> { return new Observable>(obs => { diff --git a/src/main/services/additional-content/maps/local-maps-manager.service.ts b/src/main/services/additional-content/maps/local-maps-manager.service.ts index 38e75f4a..071b5af9 100644 --- a/src/main/services/additional-content/maps/local-maps-manager.service.ts +++ b/src/main/services/additional-content/maps/local-maps-manager.service.ts @@ -7,7 +7,7 @@ import { InstallationLocationService } from "../../installation-location.service import { UtilsService } from "../../utils.service"; import crypto from "crypto"; import { lstatSync } from "fs"; -import { copy, createReadStream, ensureDir, pathExists, realpath, unlink } from "fs-extra"; +import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink } from "fs-extra"; import StreamZip from "node-stream-zip"; import { RequestService } from "../../request.service"; import sanitize from "sanitize-filename"; @@ -240,32 +240,33 @@ export class LocalMapsManagerService { return this.linker.unlinkFolder(versionMapsPath, { keepContents: keepMaps, intermediateFolder: LocalMapsManagerService.SHARED_MAPS_FOLDER }); } - public deleteMaps(maps: BsmLocalMap[]): Observable { - const mapsFolders = maps.map(map => map.path); - const mapsHashsToDelete = maps.map(map => map.hash); - + public deleteMaps(maps: Partial[]): Observable { return new Observable(observer => { + const progress: DeleteMapsProgress = { total: maps.length, deleted: 0 }; + (async () => { - const progress: DeleteMapsProgress = { total: maps.length, deleted: 0 }; - try { - for (const folder of mapsFolders) { - const detail = await this.loadMapInfoFromPath(folder); - if (!mapsHashsToDelete.includes(detail?.hash)) { - continue; - } - await deleteFolder(folder); - this.songCache.deleteMapInfoFromDirname(path.basename(folder)); + for (const map of maps) { + let mapPath = map.path; + + if (!mapPath) { + const mapInfo = map.hash ? this.songCache.getMapInfoFromHash(map.hash) : null; + mapPath = mapInfo?.path; + } + + if (mapPath && pathExistsSync(mapPath)) { + await deleteFolder(mapPath); + this.songCache.deleteMapInfoFromDirname(path.basename(mapPath)); progress.deleted++; observer.next(progress); } - } catch (e) { - observer.error(e); } - observer.complete(); - })(); + })() + .catch(e => observer.error(e)) + .finally(() => observer.complete()); }); } + public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise { if (!map.versions.at(0).hash) { throw new Error("Cannot download map, no hash found"); diff --git a/src/main/services/ipc.service.ts b/src/main/services/ipc.service.ts index 92604883..d503d7a1 100644 --- a/src/main/services/ipc.service.ts +++ b/src/main/services/ipc.service.ts @@ -54,10 +54,13 @@ export class IpcService { complete: () => this.send(this.getCompleteChannel(channel), window) }) - window.webContents.once("destroyed", () => sub.unsubscribe()); + const unsubscribeOnDestroy = () => sub.unsubscribe(); + + window.webContents.once("destroyed", unsubscribeOnDestroy); window.webContents.ipc.once(this.getTearDownChannel(channel), () => sub.unsubscribe()); sub.add(() => { + window.webContents.removeListener("destroyed", unsubscribeOnDestroy); window.webContents.ipc.removeAllListeners(this.getTearDownChannel(channel)); }); } diff --git a/src/renderer/components/available-versions/available-versions-slide.component.tsx b/src/renderer/components/available-versions/available-versions-slide.component.tsx index 89f9b3c4..353cd3bc 100644 --- a/src/renderer/components/available-versions/available-versions-slide.component.tsx +++ b/src/renderer/components/available-versions/available-versions-slide.component.tsx @@ -29,7 +29,7 @@ export function AvailableVersionsSlide({ versions }: Props) { } return ( -
    +
      {getVersions().map(version => ( setSelectedVersion(version)}/> ))} diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 8073f321..48083ec4 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -19,13 +19,19 @@ import { LocalPlaylistsListPanel } from "./playlists/local-playlists-list-panel. 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"; type Props = { version?: BSVersion; isActive?: boolean; }; -export const InstalledMapsContext = createContext<{ maps$?: BehaviorSubject; setMaps: (maps: BsmLocalMap[]) => void }>(null); +export const InstalledMapsContext = createContext<{ + maps$?: BehaviorSubject; + setMaps: (maps: BsmLocalMap[]) => void; + playlists$?: Observable; + setPlaylists: (playlist: LocalBPListsDetails[]) => void; +}>(null); export function MapsPlaylistsPanel({ version, isActive }: Props) { @@ -37,7 +43,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) { const [tabIndex, setTabIndex] = useState(0); const maps$ = useConstant(() => new BehaviorSubject(undefined)); - const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$) })); + const playlists$ = useConstant(() => new BehaviorSubject(undefined)); + const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$), playlists$: playlists$, setPlaylists: playlists$.next.bind(playlists$)})); const mapsRef = useRef(); const [mapFilter, setMapFilter] = useState({}); diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 567401d9..e35be457 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -402,7 +402,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa return (
      - 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}> + 108} itemCount={preppedMaps.length} itemData={preppedMaps} layout="vertical" style={{ scrollbarGutter: "stable both-edges" }} itemKey={(i, data) => data[i].map(map => map.hash).join()}> {props => }
      diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index 213157eb..c9528431 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -159,7 +159,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, {(diffsPanelHovered || bottomBarHovered) && ( - + {Array.from(diffs.entries()).map(([charac, diffSet]) => (
        {diffSet.map(({ type, name, stars }) => ( @@ -178,9 +178,9 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
        - + { e.stopPropagation(); @@ -188,12 +188,12 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, toogleMusic(); }} > - +
        - -
        + +

        {title} @@ -241,18 +241,16 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,

        -
        +
        -
        +
        {onDelete && !downloading && ( -
        { @@ -260,15 +258,13 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, onDelete(callBackParam); }} /> -
        )} {onDownload && !downloading && ( -
        -
        )} {onCancelDownload && !downloading && ( -
        -
        )} {downloading && @@ -306,10 +299,9 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, } {previewUrl && ( -
        -
        )} {mapId && ( -
        -
        )}
        diff --git a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx index 555b6452..19fbcfa0 100644 --- a/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/maps-row.component.tsx @@ -25,7 +25,7 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel const renderMapItem = (map: BsmLocalMap) => { return (({ version, cl const playlistService = useService(PlaylistsManagerService); const modals = useService(ModalService); + const ipc = useService(IpcService); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); - const { maps$ } = useContext(InstalledMapsContext); + const { maps$, playlists$, setPlaylists } = useContext(InstalledMapsContext); + + const playlists = useObservable(() => playlists$, []); + const [playlistsLoading, setPlaylistsLoading] = useState(false); - const [playlists, setPlaylists] = useState([]); const loadPercent$ = useConstant(() => new BehaviorSubject(0)); const linked = useStateMap(linkedState, (newState, precMapped) => (newState === FolderLinkState.Pending || newState === FolderLinkState.Processing) ? precMapped : newState === FolderLinkState.Linked, false); @@ -52,7 +58,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl if(!isActiveOnce){ return noop(); } loadLocalPlaylistsDetails().then(loadedPlaylists => { - setPlaylists(() => loadedPlaylists); + setPlaylists(loadedPlaylists); }).catch(() => { setPlaylists([]); }).finally(() => { @@ -61,27 +67,27 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }, [isActiveOnce, version, linked]); - console.log(maps$.value); + const viewPlaylistFile = (path: string) => { + return lastValueFrom(ipc.sendV2("view-path-in-explorer", { args: path })); + }; + + const deletePlaylist = (path: string) => { + // !! Need to call the modal to confirm the deletion and to ask if the maps should be deleted too + lastValueFrom(playlistService.deletePlaylist({ path, deleteMaps: false })).then(() => { + setPlaylists(playlists.filter(p => p.path !== path)); + }) + }; const openPlaylistDetails = (playlistKey: string) => { - const playlist = playlists.find(p => p.path === playlistKey); - - console.log(playlist.songs); + const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistKey))); + const installedMaps$ = combineLatest([maps$, localPlaylist$]).pipe( + filter(([maps, playlist]) => !!maps && !!playlist), + map(([maps, playlist]) => maps.filter(m => playlist.songs.some(song => song.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))), + distinctUntilChanged(equal) + ); modals.openModal(LocalPlaylistDetailsModal, { - data: { - version, - title: playlist.playlistTitle, - image: playlist.image, - author: playlist.playlistAuthor, - description: playlist.playlistDescription, - nbMaps: playlist.nbMaps, - duration: playlist.duration, - maxNps: playlist.maxNps, - minNps: playlist.minNps, - nbMappers: playlist.nbMappers, - installedMaps$: maps$.pipe(map(maps => maps.filter(m => playlist.songs.some(song => song.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase())))), - }, + data: { version, localPlaylist$, installedMaps$ }, noStyle: true, }) }; @@ -93,7 +99,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl ) } - if (playlists.length){ + if (playlists?.length){ return (
          {playlists.map(p => @@ -108,6 +114,9 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl maxNps={p.maxNps} minNps={p.minNps} onClickOpen={() => openPlaylistDetails(p.path)} + onClickDelete={() => deletePlaylist(p.path)} + onClickSync={() => console.log("sync")} + onClickOpenFile={() => viewPlaylistFile(p.path)} /> )}
        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 3d04855a..2c35127d 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 @@ -9,6 +9,8 @@ import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component'; import { GlowEffect } from 'renderer/components/shared/glow-effect.component'; import { useState } from 'react'; import { SearchIcon } from 'renderer/components/svgs/icons/search-icon.component'; +import { BsmButton } from 'renderer/components/shared/bsm-button.component'; +import Tippy from '@tippyjs/react'; type Props = { title?: string; @@ -21,10 +23,28 @@ type Props = { minNps?: number; maxNps?: number; selected?: boolean; + path?: string; onClickOpen?: () => void; + onClickOpenFile?: () => void; + onClickDelete?: () => void; + onClickSync?: () => void; } -export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected, onClickOpen }: Props) { +export function PlaylistItem({ title, + author, + coverUrl, + coverBase64, + duration, + nbMaps, + nbMappers, + minNps, + maxNps, + selected, + onClickOpen, + onClickOpenFile, + onClickSync, + onClickDelete +}: Props) { const color = useThemeColor("first-color"); @@ -48,18 +68,17 @@ export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, n // TODO : Translate return ( - setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} > + setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} >
        -
        - -
        +
        + +
        @@ -75,6 +94,43 @@ export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, n
        +
        + + + +
        + {onClickSync && + + } + {onClickOpenFile && + + } + {onClickDelete && + + } + +
        +
        diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index 3ed27f68..e6316753 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -206,7 +206,7 @@ export const DownloadMapsModal: ModalComponent handleSortChange(sort)} />
        -
          +
            {maps.length === 0 ? (
             diff --git a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx index 41a0033b..42982b8f 100644 --- a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx @@ -217,7 +217,7 @@ export const DownloadModelsModal: ModalComponent currentSort$.next(value)} />
            -
              +
                {msModels.length === 0 ? (
                 diff --git a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx index 87eed38d..342e0e6e 100644 --- a/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component.tsx @@ -8,9 +8,15 @@ import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item. import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component"; import { useService } from "renderer/hooks/use-service.hook"; import { AudioPlayerService } from "renderer/services/audio-player.service"; +import { AnimatePresence, motion } from "framer-motion"; +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 { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"; -interface Props extends Omit { - version: BSVersion +interface Props { + version: BSVersion; + localPlaylist$: Observable; installedMaps$: Observable; } @@ -18,7 +24,8 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver const audioPlayer = useService(AudioPlayerService); - const installedMaps = useObservable(() => options.data.installedMaps$, undefined); + const localPlaylist = useObservable(() => options.data.localPlaylist$, null); + const installedMaps = useObservable(() => options.data.installedMaps$, null); const playPlaylist = () => { if (!installedMaps) { @@ -29,37 +36,75 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver const renderMaps = () => { if (!installedMaps) { + // loading maps + return null; + } + + if(installedMaps.length === 0) { + // no maps return null; } return ( -
                  - {installedMaps.map(map => ( - - ))} -
                +
                + + {/* If nb installed maps not correspond to nb maps of the playlist */} + {installedMaps.length !== localPlaylist.nbMaps && ( + +
                + +
                +

                Certaines maps de cette playlist sont manquantes

                + +
                +
                +
                + )} +
                +
                  + {installedMaps.map(map => ( + + ))} +
                +
                ) } return ( - + {renderMaps()} ) diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx index 7046abe5..9f154204 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-template.component.tsx @@ -41,8 +41,8 @@ export function PlaylistDetailsTemplate({title, image, author, description, nbMa })(); return ( -
                -
                +
                +
                diff --git a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx index 7372bb8d..e098e3e0 100644 --- a/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/share-folders-modal.component.tsx @@ -73,7 +73,7 @@ export const ShareFoldersModal: ModalComponent = ({ options: {d

                {t("modals.shared-folders.title")}

                {t("modals.shared-folders.description")}

                -
                  +
                    {folders.map((folder, index) => ( (({ className, version, type } return ( -
                      +
                        {filtredModels().map(localModel => ( m.hash === localModel.hash)} onClick={() => handleModelClick(localModel)} onDelete={() => handleDelete(localModel)} /> ))} diff --git a/src/renderer/components/nav-bar/nav-bar.component.tsx b/src/renderer/components/nav-bar/nav-bar.component.tsx index f3fcc58b..295f2336 100644 --- a/src/renderer/components/nav-bar/nav-bar.component.tsx +++ b/src/renderer/components/nav-bar/nav-bar.component.tsx @@ -38,7 +38,7 @@ export function NavBar() { return (