[feature-107] can now list maps of playlist + lot of other things

This commit is contained in:
MathieuG-P
2024-03-02 17:50:08 +01:00
parent 08bdc03745
commit 1e6f051f59
44 changed files with 472 additions and 291 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ ipc.on("is-playlists-deep-links-enabled", (_, reply) => {
}
});
ipc.on<BSVersion>("get-version-playlists", (req, reply) => {
ipc.on<BSVersion>("get-version-playlists-details", (req, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
reply(playlists.getVersionPlaylists(req.args));
reply(playlists.getVersionPlaylistsDetails(req.args));
});
@@ -15,7 +15,7 @@ import { Progression, pathExist } 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, LocalBpListSong } from "shared/models/playlists/local-playlist.models";
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";
@@ -108,7 +108,7 @@ export class LocalPlaylistsManagerService {
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`);
}
private readLocalBPListsOfFolder(folerPath: string, version?: BSVersion): Observable<Progression<LocalBPList[]>> {
private getLocalBPListsOfFolder(folerPath: string): Observable<Progression<LocalBPList[]>> {
return new Observable<Progression<LocalBPList[]>>(obs => {
const progress: Progression<LocalBPList[]> = { current: 0, total: 0, data: [] };
@@ -116,13 +116,10 @@ export class LocalPlaylistsManagerService {
(async () => {
await this.songDetails.waitLoaded(sToMs(15));
if(!pathExistsSync(folerPath)) {
throw new Error(`Playlists folder not found ${folerPath}`);
}
const mapsFolder = version ? await this.maps.getMapsFolderPath(version) : null;
const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist");
progress.total = playlists.length;
@@ -130,18 +127,8 @@ export class LocalPlaylistsManagerService {
const playlistPath = path.join(folerPath, playlist);
const bpList = await this.readPlaylistFile(playlistPath);
const localBpListSongs = bpList.songs.map(song => {
const localInfos = mapsFolder ? this.songCache.getMapInfoFromHash(song.hash) : null;
const coverPath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._coverImageFilename) : null;
const songFilePath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._songFilename) : null;
return ({
song,
songDetails: this.songDetails.getSongDetails(song.hash),
coverUrl: (coverPath && pathExistsSync(coverPath)) ? pathToFileURL(coverPath).href : null,
songUrl: (songFilePath && pathExistsSync(songFilePath)) ? pathToFileURL(songFilePath).href : null,
} as LocalBpListSong)
});
const localBpList: LocalBPList = { ...bpList, path: playlistPath, songs: localBpListSongs };
const localBpList: LocalBPList = { ...bpList, path: playlistPath };
bpLists.push(localBpList);
progress.current += 1;
obs.next(progress);
@@ -153,18 +140,48 @@ export class LocalPlaylistsManagerService {
});
}
public getVersionPlaylists(version: BSVersion): Observable<Progression<LocalBPList[]>> {
public getVersionPlaylistsDetails(version: BSVersion): Observable<Progression<LocalBPListsDetails[]>> {
console.log("getVersionPlaylists", version);
return new Observable<Progression<LocalBPListsDetails[]>>(obs => {
(async () => {
return new Observable<Progression<LocalBPList[]>>(obs => {
this.getPlaylistsFolder(version)
.then(folder => this.readLocalBPListsOfFolder(folder, version))
.then(progress$ => lastValueFrom(progress$.pipe(
tap(progress => obs.next({...progress, data: []})),
)))
.then(res => obs.next(res))
.catch(err => obs.error(err))
const folder = await this.getPlaylistsFolder(version);
const progress$ = this.getLocalBPListsOfFolder(folder);
const localBPListsRes = (await lastValueFrom(progress$.pipe(tap({ next: progress => obs.next({...progress, data: []}) }))));
await this.songDetails.waitLoaded(sToMs(15));
const tryExtractPlaylistId = (url: string) => {
const regex = /\/id\/(\d+)\/download/;
const match = url.match(regex);
return match ? Number(match[1]) : undefined;
}
const bpListsDetails: LocalBPListsDetails[] = [];
for(const bpList of localBPListsRes.data){
const bpListDetails: LocalBPListsDetails = {
...bpList,
nbMaps: bpList.songs?.length ?? 0,
id: bpList.customData?.syncURL ? tryExtractPlaylistId(bpList.customData.syncURL) : undefined
}
const songsDetails = bpList.songs?.map(s => this.songDetails.getSongDetails(s.hash));
if(songsDetails){
bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0);
bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size;
bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0))));
bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0))));
}
bpListsDetails.push(bpListDetails);
}
obs.next({...localBPListsRes, data: bpListsDetails});
})().catch(err => obs.error(err))
.finally(() => obs.complete());
});
}
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { Dispatch, SetStateAction, createContext, useMemo, useRef, useState } from "react";
import { BSVersion } from "shared/bs-version.interface";
import { LocalMapsListPanel } from "./maps/local-maps-list-panel.component";
import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component";
@@ -14,15 +14,19 @@ 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";
import { of } from "rxjs";
import { BehaviorSubject, Observable, of } from "rxjs";
import { LocalPlaylistsListPanel } 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";
type Props = {
version?: BSVersion;
isActive?: boolean;
};
export const InstalledMapsContext = createContext<{ maps$?: BehaviorSubject<BsmLocalMap[]>; setMaps: (maps: BsmLocalMap[]) => void }>(null);
export function MapsPlaylistsPanel({ version, isActive }: Props) {
const mapsService = useService(MapsManagerService);
@@ -32,6 +36,9 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
const t = useTranslation();
const [tabIndex, setTabIndex] = useState(0);
const maps$ = useConstant(() => new BehaviorSubject<BsmLocalMap[]>(undefined));
const mapsContextValue = useConstant(() => ({ maps$: maps$, setMaps: maps$.next.bind(maps$) }));
const mapsRef = useRef<any>();
const [mapFilter, setMapFilter] = useState<MapFilter>({});
const [mapSearch, setMapSearch] = useState("");
@@ -55,7 +62,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
};
const handleMapsAddClick = () => {
mapsDownloader.openDownloadMapModal(version, mapsRef.current.getMaps?.());
mapsDownloader.openDownloadMapModal(version, maps$.value);
};
const handleMapsLinkClick = () => {
@@ -132,10 +139,10 @@ 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}/>
</>
</InstalledMapsContext.Provider>
</BsContentTabPanel>
</div>
);
@@ -1,6 +1,6 @@
import { MapsManagerService } from "renderer/services/maps-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from "react";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Subscription, BehaviorSubject } from "rxjs";
import { MapFilter, MapTag } from "shared/models/maps/beat-saver.model";
@@ -18,6 +18,8 @@ import { FolderLinkState } from "renderer/services/version-folder-linker.service
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component";
import { InstalledMapsContext } from "../maps-playlists-panel.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
type Props = {
version: BSVersion;
@@ -33,8 +35,11 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
const mapsDownloader = useService(MapsDownloaderService);
const t = useTranslation();
const ref = useRef(null);
const [maps, setMaps] = useState<BsmLocalMap[]>(null);
const {maps$, setMaps} = useContext(InstalledMapsContext);
const maps = useObservable(() => maps$, undefined);
const [subs] = useState<Subscription[]>([]);
const [selectedMaps$] = useState(new BehaviorSubject<BsmLocalMap[]>([]));
const [itemPerRow, setItemPerRow] = useState(2);
@@ -59,10 +64,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
},
exportMaps() {
mapsManager.exportMaps(version, selectedMaps$.value);
},
getMaps() {
return maps;
},
}
}),
[selectedMaps$.value, maps, version]
);
@@ -79,12 +81,12 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
if (targerVersion !== version) {
return;
}
setMaps(maps => (maps ? [map, ...maps] : [map]));
setMaps((maps ? [map, ...maps] : [map]));
});
}
return () => {
setMaps(() => null);
setMaps(null);
loadPercent$.next(0);
subs.forEach(s => s.unsubscribe());
mapsDownloader.removeOnMapDownloadedListener(loadMaps);
@@ -122,7 +124,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
}, [isActiveOnce, itemPerRow]);
const loadMaps = () => {
setMaps(() => null);
setMaps(null);
loadPercent$.next(0);
const loadMapsObs$ = mapsManager.getMaps(version);
@@ -134,7 +136,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
last()
)
.subscribe({
next: progress => setMaps(() => progress.maps),
next: progress => setMaps(progress.maps),
complete: () => loadPercent$.next(0),
})
);
@@ -142,7 +144,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
const removeMapsFromList = (mapsToRemove: BsmLocalMap[]) => {
const filtredMaps = maps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash));
setMaps(() => filtredMaps);
setMaps(filtredMaps);
const filtredSelectedMaps = selectedMaps$.value.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash));
selectedMaps$.next(filtredSelectedMaps);
@@ -155,7 +155,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
};
return (
<motion.li className="relative h-[100px] w-[400px] min-w-[370px] shrink-0 grow basis-0 text-white group cursor-pointer" onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)} style={{ zIndex: hovered && 5, transform: "translateZ(0) scale(1.0, 1.0)", backfaceVisibility: "hidden" }}>
<motion.li className="relative h-[100px] min-w-[370px] shrink-0 grow basis-0 text-white group cursor-pointer" onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)} style={{ zIndex: hovered && 5, transform: "translateZ(0) scale(1.0, 1.0)", backfaceVisibility: "hidden" }}>
<GlowEffect visible={hovered || (selected && !!onSelected)} />
<AnimatePresence>
{(diffsPanelHovered || bottomBarHovered) && (
@@ -5,7 +5,8 @@ import { BehaviorSubject } from "rxjs";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { ParsedMapDiff, MapItem } from "./map-item.component";
import equal from "fast-deep-equal/es6";
import { SongDetailDiffCharactertistic } from "shared/models/maps/song-details-cache.model";
import { SongDetailDiffCharactertistic, SongDetails } from "shared/models/maps/song-details-cache.model";
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
type Props = {
maps: BsmLocalMap[];
@@ -21,29 +22,6 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel
distinctUntilChanged(equal),
), []);
const extractMapDiffs = (map: BsmLocalMap): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> => {
const res = new Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>();
if (map.songDetails?.difficulties) {
map.songDetails?.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
const diffName = map.rawInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty;
arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
map.rawInfo._difficultyBeatmapSets.forEach(set => {
set._difficultyBeatmaps.forEach(diff => {
const arr = res.get(set._beatmapCharacteristicName) || [];
arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null });
res.set(set._beatmapCharacteristicName, arr);
});
});
return res;
};
const renderMapItem = (map: BsmLocalMap) => {
return <MapItem
@@ -57,7 +35,7 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.metadata.duration}
selected={selectedMaps.some(selected => selected.hash === map.hash)}
diffs={extractMapDiffs(map)}
diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
autorId={map.songDetails?.uploader.id}
@@ -75,3 +53,35 @@ export const MapsRow = memo(({ maps, style, selectedMaps$, onMapSelect, onMapDel
</ul>
);
}, equal);
export function extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> {
const res = new Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>();
if (bsvMap && bsvMap.versions?.at(0)?.diffs) {
bsvMap.versions.at(0).diffs.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
if (songDetails?.difficulties) {
songDetails?.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
const diffName = rawMapInfo._difficultyBeatmapSets.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty;
arr.push({ name: diffName, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
return res;
}
rawMapInfo._difficultyBeatmapSets.forEach(set => {
set._difficultyBeatmaps.forEach(diff => {
const arr = res.get(set._beatmapCharacteristicName) || [];
arr.push({ name: diff._customData?._difficultyLabel || diff._difficulty, type: diff._difficulty, stars: null });
res.set(set._beatmapCharacteristicName, arr);
});
});
return res;
}
@@ -1,4 +1,4 @@
import { forwardRef, useState } from "react";
import { forwardRef, useContext, 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,12 +6,15 @@ 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, finalize, lastValueFrom, map, tap } from "rxjs";
import { BehaviorSubject, finalize, lastValueFrom, map, of, tap } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { noop } from "shared/helpers/function.helpers";
import { LocalBPList } from "shared/models/playlists/local-playlist.models";
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { PlaylistItem } from "./playlist-item.component";
import { useStateMap } from "renderer/hooks/use-state-map.hook";
import { ModalService } from "renderer/services/modale.service";
import { LocalPlaylistDetailsModal } from "renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component";
import { InstalledMapsContext } from "../maps-playlists-panel.component";
type Props = {
version: BSVersion;
@@ -23,17 +26,19 @@ type Props = {
export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, className, isActive, linkedState }, forwardedRef) => {
const playlistService = useService(PlaylistsManagerService);
const modals = useService(ModalService);
const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true });
const { maps$ } = useContext(InstalledMapsContext);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const [playlists, setPlaylists] = useState<LocalBPList[]>([]);
const [playlists, setPlaylists] = useState<LocalBPListsDetails[]>([]);
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 loadPlaylists = (): Promise<LocalBPList[]> => {
const loadLocalPlaylistsDetails = (): Promise<LocalBPListsDetails[]> => {
setPlaylistsLoading(true);
const obs = playlistService.getVersionPlaylists(version).pipe(
const obs = playlistService.getVersionPlaylistsDetails(version).pipe(
tap({ next: load => loadPercent$.next((load.current / load.total) * 100)}),
map(load => load.data),
finalize(() => setPlaylistsLoading(false))
@@ -42,52 +47,45 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, cl
return lastValueFrom(obs);
}
const getNbMappersOfPlaylist = (playlist: LocalBPList) => {
return new Set(playlist.songs.map(s => s.songDetails?.uploader?.id ?? s.song.hash)).size;
};
const getDurationOfPlaylist = (playlist: LocalBPList) => {
return playlist.songs.reduce((acc, s) => acc + (s.songDetails?.duration ?? 0), 0);
}
const getMinNpsOfPlaylist = (playlist: LocalBPList) => {
let min = Infinity;
playlist.songs.forEach(s => {
s.songDetails?.difficulties.forEach(d => {
if(d.nps < min){
min = d.nps;
}
})
});
return min === Infinity ? null : min;
}
const getMaxNpsOfPlaylist = (playlist: LocalBPList) => {
let max = -Infinity;
playlist.songs.forEach(s => {
s.songDetails?.difficulties.forEach(d => {
if(d.nps > max){
max = d.nps;
}
})
});
return max === -Infinity ? null : max;
}
useOnUpdate(() => {
if(!isActiveOnce){ return noop(); }
loadPlaylists().then(loadedPlaylists => {
loadLocalPlaylistsDetails().then(loadedPlaylists => {
setPlaylists(() => loadedPlaylists);
}).catch(() => {
setPlaylists([])
setPlaylists([]);
}).finally(() => {
loadPercent$.next(0);
});
}, [isActiveOnce, version, linked]);
console.log(maps$.value);
const openPlaylistDetails = (playlistKey: string) => {
const playlist = playlists.find(p => p.path === playlistKey);
console.log(playlist.songs);
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())))),
},
noStyle: true,
})
};
const render = () => {
if(playlistsLoading){
return (
@@ -97,25 +95,22 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, cl
if (playlists.length){
return (
<>
<ul className="size-full flex flex-row flex-wrap justify-start content-start p-3 gap-3 overflow-y-scroll">
{playlists.map(p =>
<PlaylistItem
key={p.path}
id={p.path}
title={p.playlistTitle}
author={p.playlistAuthor}
coverBase64={p.image}
nbMaps={p.songs.length}
nbMappers={getNbMappersOfPlaylist(p)}
duration={getDurationOfPlaylist(p)}
maxNps={getMaxNpsOfPlaylist(p)}
minNps={getMinNpsOfPlaylist(p)}
/>
)}
</ul>
<br/>
</>
<ul className="relative size-full flex flex-row flex-wrap justify-start content-start p-3 gap-3">
{playlists.map(p =>
<PlaylistItem
key={p.path}
title={p.playlistTitle}
author={p.playlistAuthor}
coverBase64={p.image}
nbMaps={p.nbMaps}
nbMappers={p.nbMappers}
duration={p.duration}
maxNps={p.maxNps}
minNps={p.minNps}
onClickOpen={() => openPlaylistDetails(p.path)}
/>
)}
</ul>
)
}
@@ -8,11 +8,9 @@ import dateFormat from 'dateformat';
import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component';
import { GlowEffect } from 'renderer/components/shared/glow-effect.component';
import { useState } from 'react';
import { FolderOpenIcon } from 'renderer/components/svgs/icons/folder-open-icon.component';
import { SearchIcon } from 'renderer/components/svgs/icons/search-icon.component';
type Props = {
id: string;
className?: string;
title?: string;
author?: string;
coverBase64?: string;
@@ -23,9 +21,10 @@ type Props = {
minNps?: number;
maxNps?: number;
selected?: boolean;
onClickOpen?: () => void;
}
export function PlaylistItem({ id, className, title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected }: Props) {
export function PlaylistItem({ title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, selected, onClickOpen }: Props) {
const color = useThemeColor("first-color");
@@ -33,8 +32,9 @@ export function PlaylistItem({ id, className, title, author, coverUrl, coverBase
const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null;
const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null;
const minNpsText = minNps ? Math.round(minNps * 10) / 10 : null;
const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : null;
const minNpsText = minNps ? Math.round(minNps * 10) / 10 : 0;
const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : 0;
const showNps = minNps !== undefined && maxNps !== undefined;
const durationText = (() => {
if (!duration) {
@@ -45,27 +45,33 @@ export function PlaylistItem({ id, className, title, author, coverUrl, coverBase
return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss");
})();
// TODO : Translate
return (
<motion.li id={id} layoutId={id} className='relative flex-grow basis-0 min-w-80 h-28 cursor-pointer' onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} >
<motion.li className='relative flex-grow basis-0 min-w-80 h-28 cursor-pointer' onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} >
<GlowEffect visible={selected || hovered}/>
<div className={`size-full relative flex flex-row justify-start items-center overflow-hidden rounded-md ${className}`}>
<div className="size-full relative flex flex-row justify-start items-center overflow-hidden rounded-md">
<div className="absolute top-0 left-0 size-full flex justify-center items-center -z-[1]">
<BsmImage className="size-full object-cover scale-[2] saturate-150 blur-xl" image={coverUrl} base64={coverBase64} />
<BsmImage className="size-full object-cover scale-150 saturate-150 blur-lg" image={coverUrl} base64={coverBase64} />
<div className="absolute top-0 left-0 size-full bg-black opacity-15"/>
</div>
<div className="relative h-full aspect-square p-2.5" style={{ color }}>
<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%)"}} />
<FolderOpenIcon className="absolute size-1/2 top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 transition-opacity duration-150 text-white hover:text-current" style={{ opacity: hovered ? 1 : 0}}/>
<SearchIcon
className="absolute size-1/2 top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 transition-opacity duration-150 text-white hover:text-current"
style={{ opacity: hovered ? 1 : 0}}
onClick={onClickOpen}
/>
</div>
<div className="h-full py-2.5 text-white">
<h1 className="font-bold text-lg tracking-wide line-clamp-1 hover:underline w-fit">{title}</h1>
<h1 className="font-bold text-xl tracking-wide line-clamp-1 w-fit">{title}</h1>
<p className="text-xs font-bold">Créé par <span className="brightness-200" style={{color}}>{author}</span></p>
<div className="flex flex-row flex-wrap w-full gap-2 mt-1">
{ nbMapsText && <div className="flex items-center text-sm h-5 gap-0.5"> <MapIcon className='h-full aspect-square'/> <span className="mb-0.5">{nbMapsText}</span> </div> }
{ nbMappersText && <div className="flex items-center text-sm h-5 gap-0.5"> <PersonIcon className='h-full aspect-square'/> <span className="mb-0.5">{nbMappersText}</span> </div> }
{ durationText && <div className="flex items-center text-sm h-5 gap-0.5"> <ClockIcon className='h-full aspect-square'/> <span className="mb-0.5">{durationText}</span> </div> }
{ (minNps && maxNps) && <div className="flex items-center text-sm h-5 gap-0.5"> <NpsIcon className='h-full aspect-square scale-95'/> <span className="mb-0.5">{`${minNpsText} - ${maxNpsText}`}</span> </div> }
{ showNps && <div className="flex items-center text-sm h-5 gap-0.5"> <NpsIcon className='h-full aspect-square scale-95'/> <span className="mb-0.5">{`${minNpsText} - ${maxNpsText}`}</span> </div> }
</div>
</div>
@@ -13,8 +13,8 @@ import { BsmBasicSpinner } from "renderer/components/shared/bsm-basic-spinner/bs
export const LoginToSteamModal: ModalComponent<
{ username: string; password: string; stay: boolean, method: "form"|"qr" },
{ qrCode$: Observable<string>, logged$: Observable<string> }
> = ({ resolver, data }) => {
> = ({ resolver, options: {data} }) => {
const modal = useService(ModalService);
const [username, setUsername] = useState("");
@@ -82,10 +82,10 @@ export const LoginToSteamModal: ModalComponent<
</div>
</div>
<div className="flex flex-col max-w-[13rem]">
<span className="block font-bold cursor-pointer tracking-wide ">{t("modals.steam-login.inputs.qr.label")}</span>
<span className="block font-bold cursor-pointer tracking-wide ">{t("modals.steam-login.inputs.qr.label")}</span>
<div className="w-52 h-52 p-3 bg-light-main-color-1 dark:bg-white rounded-md max-w-xs mb-1 flex items-center justify-center">
{(qrCodeUrl ?
<QRCodeSVG className="w-full h-full text-light-main-color-1 dark:text-white" value={qrCodeUrl} bgColor="currentColor" level="H"/> :
{(qrCodeUrl ?
<QRCodeSVG className="w-full h-full text-light-main-color-1 dark:text-white" value={qrCodeUrl} bgColor="currentColor" level="H"/> :
<BsmBasicSpinner className="w-full h-full p-11 text-neutral-300" />
)}
</div>
@@ -6,8 +6,8 @@ import { useTranslation } from "renderer/hooks/use-translation.hook";
import { Observable } from "rxjs";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
export const SteamMobileApproveModal: ModalComponent<void, { logged$: Observable<unknown> }> = ({ resolver, data }) => {
export const SteamMobileApproveModal: ModalComponent<void, { logged$: Observable<unknown> }> = ({ resolver, options: {data} }) => {
const t = useTranslation();
useOnUpdate(() => {
@@ -36,4 +36,4 @@ export const SteamMobileApproveModal: ModalComponent<void, { logged$: Observable
<a className="underline text-sm mt-2.5" href="https://help.steampowered.com/wizard/HelpWithLoginInfo?lost=8&issueid=402" target="_blank">{t("modals.steam-auth-approve.not-access-to-steam-app")}</a>
</form>
);
};
};
@@ -7,7 +7,7 @@ import DOMPurify from 'dompurify';
import './changelog-modal.component.css';
import Tippy from "@tippyjs/react";
export const ChangelogModal: ModalComponent<void, ChangelogVersion> = ({ resolver, data: changelog }) => {
export const ChangelogModal: ModalComponent<void, ChangelogVersion> = ({ resolver, options: {data: changelog} }) => {
const linkOpener = useService(LinkOpenerService);
const openGithub = () => linkOpener.open("https://github.com/Zagrios/bs-manager");
@@ -12,7 +12,7 @@ import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component";
import Tippy from "@tippyjs/react";
export const CreateLaunchShortcutModal: ModalComponent<LaunchOption, BSVersion> = ({resolver, data}) => {
export const CreateLaunchShortcutModal: ModalComponent<LaunchOption, BSVersion> = ({resolver, options: {data}}) => {
const bsLauncher = useService(BSLauncherService);
@@ -76,10 +76,10 @@ export const CreateLaunchShortcutModal: ModalComponent<LaunchOption, BSVersion>
<div className={`grid grid-rows-[0fr] transition-[grid-template-rows] ${advanced ? "!grid-rows-[1fr]" : ""}`}>
<div className="overflow-hidden">
<div className="p-2">
<input
type="text"
className="w-full rounded-md text-center outline-none bg-light-main-color-3 dark:bg-main-color-3"
placeholder={t("pages.version-viewer.launch-mods.advanced-launch.placeholder")}
<input
type="text"
className="w-full rounded-md text-center outline-none bg-light-main-color-3 dark:bg-main-color-3"
placeholder={t("pages.version-viewer.launch-mods.advanced-launch.placeholder")}
value={additionalArgsString}
onChange={e => setAdditionalArgsString(e.target.value)}
/>
@@ -10,8 +10,8 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { useService } from "renderer/hooks/use-service.hook";
export const DeleteMapsModal: ModalComponent<void, { linked: boolean; maps: BsmLocalMap[] }> = ({ resolver, data: { linked, maps } }) => {
export const DeleteMapsModal: ModalComponent<void, { linked: boolean; maps: BsmLocalMap[] }> = ({ resolver, options: {data : { linked, maps }}}) => {
const config = useService(ConfigurationService);
const t = useTranslation();
@@ -20,8 +20,9 @@ import { useTranslation } from "renderer/hooks/use-translation.hook";
import { OsDiagnosticService } from "renderer/services/os-diagnostic.service";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { useService } from "renderer/hooks/use-service.hook";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; ownedMaps: BsmLocalMap[] }> = ({ data: { ownedMaps, version } }) => {
export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; ownedMaps: BsmLocalMap[] }> = ({ options: {data : { ownedMaps, version }} }) => {
const beatSaver = useService(BeatSaverService);
const mapsDownloader = useService(MapsDownloaderService);
const progressBar = useService(ProgressBarService);
@@ -84,24 +85,32 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
.finally(() => setLoading(() => false));
};
const extractMapDiffs = (map: BsvMapDetail): Map<BsvMapCharacteristic, ParsedMapDiff[]> => {
const res = new Map<BsvMapCharacteristic, ParsedMapDiff[]>();
if (map.versions.at(0).diffs) {
map.versions.at(0).diffs.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
arr.push({ name: diff.difficulty, type: diff.difficulty, stars: diff.stars });
res.set(diff.characteristic, arr);
});
}
return res;
};
const renderMap = (map: BsvMapDetail) => {
const isMapOwned = map.versions.some(version => ownedMapHashs.includes(version.hash));
const isDownloading = map.id === currentDownload?.map?.id;
const inQueue = mapsInQueue.some(toDownload => equal(toDownload.version, version) && toDownload.map.id === map.id);
return <MapItem autor={map.metadata.levelAuthorName} autorId={map.uploader.id} bpm={map.metadata.bpm} coverUrl={map.versions.at(0).coverURL} createdAt={map.createdAt} duration={map.metadata.duration} hash={map.versions.at(0).hash} likes={map.stats.upvotes} mapId={map.id} ranked={map.ranked} title={map.name} songAutor={map.metadata.songAuthorName} diffs={extractMapDiffs(map)} songUrl={map.versions.at(0).previewURL} key={map.id} onDownload={!isMapOwned && !inQueue && handleDownloadMap} onDoubleClick={!isMapOwned && !inQueue && handleDownloadMap} onCancelDownload={inQueue && !isDownloading && handleCancelDownload} downloading={isDownloading} showOwned={isMapOwned} callBackParam={map} />;
return <MapItem
autor={map.metadata.levelAuthorName}
autorId={map.uploader.id}
bpm={map.metadata.bpm}
coverUrl={map.versions.at(0).coverURL}
createdAt={map.createdAt}
duration={map.metadata.duration}
hash={map.versions.at(0).hash}
likes={map.stats.upvotes}
mapId={map.id} ranked={map.ranked}
title={map.name}
songAutor={map.metadata.songAuthorName}
diffs={extractMapDiffs({ bsvMap: map })}
songUrl={map.versions.at(0).previewURL}
key={map.id}
onDownload={!isMapOwned && !inQueue && handleDownloadMap}
onDoubleClick={!isMapOwned && !inQueue && handleDownloadMap}
onCancelDownload={inQueue && !isDownloading && handleCancelDownload}
downloading={isDownloading}
showOwned={isMapOwned}
callBackParam={map} />;
};
const handleDownloadMap = useCallback((map: BsvMapDetail) => {
@@ -9,7 +9,7 @@ import { ConfigurationService } from "renderer/services/configuration.service";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { BSVersion } from "shared/bs-version.interface";
export const EditVersionModal: ModalComponent<{ name: string; color: string }, { version: BSVersion; clone?: boolean }> = ({ resolver, data }) => {
export const EditVersionModal: ModalComponent<{ name: string; color: string }, { version: BSVersion; clone?: boolean }> = ({ resolver, options: {data} }) => {
const { version, clone } = data;
const configService = useService(ConfigurationService);
@@ -12,7 +12,7 @@ import { ConfigurationService } from "renderer/services/configuration.service";
import { ModelsManagerService } from "renderer/services/models-management/models-manager.service";
import { useService } from "renderer/hooks/use-service.hook";
export const DeleteModelsModal: ModalComponent<void, { models: BsmLocalModel[]; linked: boolean }> = ({ resolver, data }) => {
export const DeleteModelsModal: ModalComponent<void, { models: BsmLocalModel[]; linked: boolean }> = ({ resolver, options: {data} }) => {
const config = useService(ConfigurationService);
const t = useTranslation();
const [remember, setRemember] = useState(config.get<boolean>(ModelsManagerService.REMEMBER_CHOICE_DELETE_MODEL_KEY) || false);
@@ -23,7 +23,7 @@ import { catchError, of } from "rxjs";
import Tippy from "@tippyjs/react";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
export const DownloadModelsModal: ModalComponent<void, { version: BSVersion; type: MSModelType; owned: BsmLocalModel[] }> = ({ data: { version, type, owned } }) => {
export const DownloadModelsModal: ModalComponent<void, { version: BSVersion; type: MSModelType; owned: BsmLocalModel[] }> = ({ options: {data: { version, type, owned }} }) => {
const modelsDownloader = useService(ModelsDownloaderService);
const modelSaber = useService(ModelSaberService);
const os = useService(OsDiagnosticService);
@@ -7,7 +7,7 @@ import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"
import { MSModelType } from "shared/models/models/model-saber.model";
import BeatRunning from "../../../../../../assets/images/apngs/beat-running.png";
export const LinkModelsModal: ModalComponent<boolean, MSModelType> = ({ resolver, data }) => {
export const LinkModelsModal: ModalComponent<boolean, MSModelType> = ({ resolver, options: {data} }) => {
const t = useTranslation();
const [keepMaps, setKeepMaps] = useState(true);
@@ -7,7 +7,7 @@ import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"
import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png";
import { MSModelType } from "shared/models/models/model-saber.model";
export const UnlinkModelsModal: ModalComponent<boolean, MSModelType> = ({ resolver, data }) => {
export const UnlinkModelsModal: ModalComponent<boolean, MSModelType> = ({ resolver, options: {data} }) => {
const t = useTranslation();
const [keepMaps, setKeepMaps] = useState(true);
@@ -0,0 +1,57 @@
import { ModalComponent } from "renderer/services/modale.service"
import { PlaylistDetailsTemplate, PlaylistDetailsTemplateProps } from "./playlist-details-template.component"
import { Observable } from "rxjs"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
import { BSVersion } from "shared/bs-version.interface";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
interface Props extends Omit<PlaylistDetailsTemplateProps, "children"> {
version: BSVersion
installedMaps$: Observable<BsmLocalMap[]>;
}
export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver, options}) => {
const installedMaps = useObservable(() => options.data.installedMaps$, undefined);
console.log(installedMaps);
const renderMaps = () => {
if (!installedMaps) {
return null;
}
return (
<ul className="space-y-2 overflow-y-scroll pr-2 pl-2.5 pt-2 pb-5 scrollbar-thin scrollbar-thumb-rounded-full scrollbar-thumb-neutral-900">
{installedMaps.map(map => (
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.metadata.duration}
diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
callBackParam={null}
/>
))}
</ul>
)
}
return (
<PlaylistDetailsTemplate {...options.data}>
{renderMaps()}
</PlaylistDetailsTemplate>
)
}
@@ -0,0 +1,66 @@
import { ReactNode } from "react"
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import dateFormat from "dateformat";
import { MapIcon } from "renderer/components/svgs/icons/map-icon.component";
import { PersonIcon } from "renderer/components/svgs/icons/person-icon.component";
import { ClockIcon } from "renderer/components/svgs/icons/clock-icon.component";
import { NpsIcon } from "renderer/components/svgs/icons/nps-icon.component";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { ThemeColorGradientSpliter } from "renderer/components/shared/theme-color-gradient-spliter.component";
export type PlaylistDetailsTemplateProps = {
title: string;
image: string;
author: string;
description: string;
nbMaps: number;
duration: number;
nbMappers: number;
minNps: number;
maxNps: number;
children?: ReactNode;
}
export function PlaylistDetailsTemplate({title, image, author, description, nbMaps, duration, nbMappers, minNps, maxNps, children}: PlaylistDetailsTemplateProps) {
const color = useThemeColor("first-color");
const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null;
const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null;
const minNpsText = minNps ? Math.round(minNps * 10) / 10 : 0;
const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : 0;
const showNps = minNps !== undefined && maxNps !== undefined;
const durationText = (() => {
if (!duration) {
return null;
}
const date = new Date(0);
date.setSeconds(duration);
return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss");
})();
return (
<div className="flex flex-col w-screen max-w-2xl h-[calc(100vh-1.25rem)] translate-y-5 bg-main-color-1 rounded-t-lg overflow-hidden">
<header className="relative flex-shrink-0 h-36 overflow-hidden flex flex-row p-3">
<BsmImage className="absolute top-0 left-0 size-full object-cover blur-xl scale-150 brightness-75 saturate-200" base64={image}/>
<BsmImage className="h-full aspect-square object-cover z-[1] rounded-md shadow-black shadow-center" base64={image}/>
<div className="h-full px-3 text-white z-[1]">
<h1 className="font-bold text-xl tracking-wide line-clamp-1 w-fit">{title}</h1>
<p>{description}</p>
<p className="text-xs font-bold">Créé par <span className="brightness-200" style={{color}}>{author}</span></p>
<div className="flex flex-row flex-wrap w-full gap-2 mt-1">
{ nbMapsText && <div className="flex items-center text-sm h-5 gap-0.5"> <MapIcon className='h-full aspect-square'/> <span className="mb-0.5">{nbMapsText}</span> </div> }
{ nbMappersText && <div className="flex items-center text-sm h-5 gap-0.5"> <PersonIcon className='h-full aspect-square'/> <span className="mb-0.5">{nbMappersText}</span> </div> }
{ durationText && <div className="flex items-center text-sm h-5 gap-0.5"> <ClockIcon className='h-full aspect-square'/> <span className="mb-0.5">{durationText}</span> </div> }
{ showNps && <div className="flex items-center text-sm h-5 gap-0.5"> <NpsIcon className='h-full aspect-square scale-95'/> <span className="mb-0.5">{`${minNpsText} - ${maxNpsText}`}</span> </div> }
</div>
</div>
</header>
<ThemeColorGradientSpliter className="shrink-0 w-full h-0.5"/>
{children}
</div>
)
}
@@ -14,7 +14,7 @@ import { ModalComponent } from "renderer/services/modale.service";
import { FolderLinkState, VersionFolderLinkerService, VersionLinkerActionType } from "renderer/services/version-folder-linker.service";
import { BSVersion } from "shared/bs-version.interface";
export const ShareFoldersModal: ModalComponent<void, BSVersion> = ({ data }) => {
export const ShareFoldersModal: ModalComponent<void, BSVersion> = ({ options: {data} }) => {
const SHARED_FOLDERS_KEY = "default-shared-folders";
const config = useService(ConfigurationService);
@@ -5,7 +5,7 @@ import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { BSVersion } from "shared/bs-version.interface";
export const UninstallAllModsModal: ModalComponent<void, BSVersion> = ({ resolver, data }) => {
export const UninstallAllModsModal: ModalComponent<void, BSVersion> = ({ resolver, options: {data} }) => {
const version = data;
const t = useTranslation();
@@ -5,7 +5,7 @@ import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { Mod } from "shared/models/mods/mod.interface";
export const UninstallModModal: ModalComponent<void, Mod> = ({ resolver, data }) => {
export const UninstallModModal: ModalComponent<void, Mod> = ({ resolver, options: {data} }) => {
const mod = data;
const t = useTranslation();
@@ -5,7 +5,7 @@ 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";
export const UninstallModal: ModalComponent<void, BSVersion> = ({ resolver, data }) => {
export const UninstallModal: ModalComponent<void, BSVersion> = ({ resolver, options: {data} }) => {
const version = data;
const t = useTranslation();
@@ -1,17 +1,16 @@
import { ModalExitCode, ModalService } from "renderer/services/modale.service";
import { ModalExitCode, ModalObject, ModalService } from "renderer/services/modale.service";
import { AnimatePresence, motion } from "framer-motion";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { useEffect } from "react";
import { BsmIcon } from "../svgs/bsm-icon.component";
import { ThemeColorGradientSpliter } from "../shared/theme-color-gradient-spliter.component";
export function Modal() {
const modalSevice = ModalService.getInstance();
const ModalComponent = useObservable(() => modalSevice.getModalToShow());
const modalData = modalSevice.getModalData();
const resolver = modalSevice.getResolver();
const modals = useObservable(() => modalSevice.getModalToShow());
const currentModal = modals?.at(-1);
const { firstColor, secondColor } = useThemeColor();
@@ -20,10 +19,10 @@ export function Modal() {
if (e.key !== "Escape") {
return;
}
resolver?.(ModalExitCode.NO_CHOICE);
currentModal.resolver({ exitCode: ModalExitCode.CLOSED });
};
if (ModalComponent) {
if (currentModal) {
window.addEventListener("keyup", onEscape);
} else {
window.removeEventListener("keyup", onEscape);
@@ -32,32 +31,40 @@ export function Modal() {
return () => {
window.removeEventListener("keyup", onEscape);
};
}, [ModalComponent]);
}, [currentModal]);
const renderModal = (modal: ModalObject) => {
if (!modal?.modal) { return null; }
if(modal.options?.noStyle){
return <modal.modal resolver={modal.resolver} options={modal.options} />
}
return (
<div className="relative p-4 text-gray-800 dark:text-gray-200 rounded-md shadow-lg shadow-black bg-gradient-to-br from-light-main-color-3 to-light-main-color-2 dark:from-main-color-3 dark:to-main-color-2">
<ThemeColorGradientSpliter className="absolute top-0 w-full left-0 h-1 rounded-t-md overflow-hidden"/>
<div
className="w-2.5 h-2.5 absolute top-2.5 right-1.5 cursor-pointer"
onClick={e => {
e.stopPropagation();
modal.resolver({ exitCode: ModalExitCode.CLOSED });
}}
>
<BsmIcon className="w-full h-full" icon="cross" />
</div>
<modal.modal resolver={modal.resolver} options={modal.options} />
</div>
)
}
return (
<AnimatePresence>
{ModalComponent && resolver && (
<div className="top-0 absolute w-screen h-screen flex content-center items-center justify-center z-[90]">
<motion.span key="modal-overlay" onClick={() => modalSevice.resolve({ exitCode: ModalExitCode.NO_CHOICE })} className="absolute top-0 bottom-0 right-0 left-0 bg-black" initial={{ opacity: 0 }} animate={{ opacity: ModalComponent && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} />
<motion.div key="modal" initial={{ y: "100vh" }} animate={{ y: 0 }} exit={{ y: "100vh" }}>
<div className="relative p-4 text-gray-800 dark:text-gray-200 rounded-md shadow-lg shadow-black bg-gradient-to-br from-light-main-color-3 to-light-main-color-2 dark:from-main-color-3 dark:to-main-color-2">
<div className="absolute top-0 w-full left-0 h-1 rounded-t-md overflow-hidden">
<span className="block w-full h-full" style={{ backgroundImage: `linear-gradient(to right, ${firstColor}, ${secondColor})` }} />
</div>
<div
className="w-2.5 h-2.5 absolute top-2.5 right-1.5 cursor-pointer"
onClick={e => {
e.stopPropagation();
resolver(ModalExitCode.CLOSED);
}}
>
<BsmIcon className="w-full h-full" icon="cross" />
</div>
<ModalComponent resolver={resolver} data={modalData} />
</div>
<AnimatePresence>
{currentModal && <motion.span key="modal-overlay" onClick={() => 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) => (
<motion.div className="fixed z-[90] left-1/2 top-1/2" key={index} initial={{ y: "100vh", x: "-50%" }} animate={{y: "-50%", scale: modal === currentModal ? 1 : 0, opacity: modal === currentModal ? 1 : 0}} exit={{ y: "100vh" }}>
{renderModal(modal)}
</motion.div>
</div>
)}
</AnimatePresence>
))}
</AnimatePresence>
);
}
@@ -18,11 +18,11 @@ export function BsContentTabPanel({className, tabIndex, tabs, onTabChange, child
<div className={className ?? "w-full h-full flex flex-row bg-light-main-color-1 dark:bg-main-color-1 rounded-md shadow-black shadow-md overflow-hidden"}>
<nav className="h-full grid grid-flow-row !rounded-none shadow-sm flex-shrink-0">
{tabs.map((tab, i) => (
<BsContentTabItem
key={`${tab.text}${tab.icon}`}
<BsContentTabItem
key={`${tab.text}${tab.icon}`}
{...tab}
active={tab.active ?? tabIndex === i}
onClick={value => {onTabChange(i, tab); tab.onClick(value);}}
active={tab.active ?? tabIndex === i}
onClick={value => {onTabChange(i, tab); tab.onClick(value);}}
/>
))}
</nav>
@@ -0,0 +1,11 @@
import { ComponentProps } from "react";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
export function ThemeColorGradientSpliter(props: ComponentProps<"div">) {
const colors = useThemeColor();
return (
<div {...props} style={{ backgroundImage: `linear-gradient(to right, ${colors.firstColor}, ${colors.secondColor})`}}/>
)
}
@@ -1,9 +1,9 @@
import { CSSProperties } from "react";
import { createSvgIcon } from "../svg-icon.type";
export function SearchIcon(props: { className?: string; style?: CSSProperties }) {
export const SearchIcon = createSvgIcon((props, ref) => {
return (
<svg className={props.className} style={props.style} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" height="40" width="40">
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" height="40" width="40">
<path fill="currentColor" d="m31.833 34.167-9.708-9.75q-1.25 1.041-2.896 1.625-1.646.583-3.521.583-4.583 0-7.77-3.208-3.188-3.209-3.188-7.709 0-4.541 3.188-7.729 3.187-3.187 7.729-3.187 4.541 0 7.729 3.187 3.187 3.188 3.187 7.729 0 1.792-.583 3.438-.583 1.646-1.625 3.021l9.792 9.75q.458.458.458 1.104 0 .646-.5 1.146-.458.458-1.146.458-.687 0-1.146-.458ZM15.667 23.458q3.25 0 5.5-2.27 2.25-2.271 2.25-5.48 0-3.25-2.25-5.5t-5.5-2.25q-3.209 0-5.479 2.25-2.271 2.25-2.271 5.5 0 3.209 2.25 5.48 2.25 2.27 5.5 2.27Z" />
</svg>
);
}
});
@@ -46,7 +46,7 @@ export function VersionViewer() {
const verifyFiles = () => bsDownloader.verifyBsVersion(state);
const uninstall = async () => {
const modalCompleted = await modalService.openModal(UninstallModal, state);
const modalCompleted = await modalService.openModal(UninstallModal, { data: state });
if (modalCompleted.exitCode === ModalExitCode.COMPLETED) {
bsUninstallerService.uninstall(state).then(() => {
bsVersionManagerService.askInstalledVersions().then(versions => {
@@ -79,11 +79,11 @@ export function VersionViewer() {
};
const openShareFolderModal = () => {
modalService.openModal(ShareFoldersModal, state);
modalService.openModal(ShareFoldersModal, {data: state});
};
const createLaunchShortcut = async () => {
const { exitCode, data } = await modalService.openModal(CreateLaunchShortcutModal, state);
const { exitCode, data } = await modalService.openModal(CreateLaunchShortcutModal, {data: state});
if(exitCode !== ModalExitCode.COMPLETED){ return; }
lastValueFrom(bsLauncher.createLaunchShortcut(data)).then(() => {
@@ -109,6 +109,6 @@ export class AutoUpdaterService {
public async showChangelog(version:string): Promise<void>{
const changelog = await this.getChangelogVersion(version);
this.modal.openModal(ChangelogModal, changelog);
this.modal.openModal(ChangelogModal, {data: changelog});
}
}
@@ -90,7 +90,7 @@ export class BsModsManagerService {
return;
}
const modalRes = await this.modals.openModal(UninstallModModal, mod);
const modalRes = await this.modals.openModal(UninstallModModal, {data: mod});
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return;
@@ -117,7 +117,7 @@ export class BsModsManagerService {
return;
}
const modalRes = await this.modals.openModal(UninstallAllModsModal, version);
const modalRes = await this.modals.openModal(UninstallAllModsModal, {data: version});
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return;
@@ -15,7 +15,7 @@ import { DownloaderServiceInterface } from "./bs-store-downloader.interface";
import { AbstractBsDownloaderService } from "./abstract-bs-downloader.service";
export class SteamDownloaderService extends AbstractBsDownloaderService implements DownloaderServiceInterface{
private static instance: SteamDownloaderService;
public static getInstance(): SteamDownloaderService {
@@ -102,7 +102,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen
take(1),
).subscribe(async () => {
const logged$ = events$.pipe(filter(event => event.subType === DepotDownloaderInfoEvent.SteamID), take(1));
const res = await this.modalService.openModal(SteamMobileApproveModal, { logged$ });
const res = await this.modalService.openModal(SteamMobileApproveModal, {data: { logged$ }});
if(res.exitCode !== ModalExitCode.COMPLETED){
return this.stopDownload();
}
@@ -128,7 +128,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen
return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.verification-finished"});
}
return this.notificationService.notifySuccess({title: "notifications.bs-download.success.titles.download-success"});
}));
}));
return subs;
}
@@ -182,7 +182,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen
}),
share({connector: () => new ReplaySubject(1)})
);
}
private tryAutoDownloadBsVersion(downloadInfo: DownloadSteamInfo){
@@ -217,7 +217,7 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen
}
this.progressBarService.show(this.downloadProgress$, true);
const downloadPromise = (async () => {
const haveDotNet = await this.isDotNet6Installed().catch(() => false);
@@ -227,16 +227,16 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen
}
const downloadInfo: DownloadSteamInfo = {bsVersion, isVerification}
const autoDownload = await lastValueFrom(this.tryAutoDownloadBsVersion(downloadInfo)).then(() => true).catch(() => false);
if(autoDownload){ return Promise.resolve(); }
const qrCodeDownload$ = this.startQrCodeDownload(downloadInfo);
const qrCode$ = qrCodeDownload$.pipe(filter(event => event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.QRCode), map(event => event.data as string));
const logged$ = qrCodeDownload$.pipe(filter(event => event.type === DepotDownloaderEventType.Info && event.subType === DepotDownloaderInfoEvent.SteamID), map(event => event.data as string), take(1));
const loginRes = await this.modalService.openModal(LoginToSteamModal, { qrCode$, logged$ });
const loginRes = await this.modalService.openModal(LoginToSteamModal, {data: { qrCode$, logged$ }});
if(loginRes.exitCode !== ModalExitCode.COMPLETED){
return Promise.resolve();
@@ -66,7 +66,7 @@ export class BSVersionManagerService {
}
public async editVersion(version: BSVersion): Promise<BSVersion> {
const modalRes = await this.modalService.openModal(EditVersionModal, { version, clone: false });
const modalRes = await this.modalService.openModal(EditVersionModal, {data: { version, clone: false }});
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return null;
}
@@ -90,7 +90,7 @@ export class BSVersionManagerService {
if (!this.progressBar.require()) {
return null;
}
const modalRes = await this.modalService.openModal(EditVersionModal, { version, clone: true });
const modalRes = await this.modalService.openModal(EditVersionModal, {data: { version, clone: true }});
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return null;
}
@@ -128,7 +128,7 @@ export class BSVersionManagerService {
if(resModal.exitCode !== ModalExitCode.COMPLETED){
return;
}
const store = resModal.data;
const folderRes = await lastValueFrom(this.ipcService.sendV2<{ canceled: boolean; filePaths: string[] }>("choose-folder"));
@@ -78,7 +78,7 @@ export class MapsDownloaderService {
}
public async openDownloadMapModal(version?: BSVersion, ownedMaps: BsmLocalMap[] = []): Promise<ModalResponse<void>> {
const res = await this.modals.openModal(DownloadMapsModal, { version, ownedMaps });
const res = await this.modals.openModal(DownloadMapsModal, {data: { version, ownedMaps }});
this.progressBar.setStyle(null);
return res;
}
@@ -89,7 +89,7 @@ export class MapsManagerService {
const askModal = maps.length > 1 || !this.config.get<boolean>(MapsManagerService.REMEMBER_CHOICE_DELETE_MAP_KEY);
if (askModal) {
const modalRes = await this.modal.openModal(DeleteMapsModal, { linked: versionLinked, maps });
const modalRes = await this.modal.openModal(DeleteMapsModal, {data: { linked: versionLinked, maps }});
if (modalRes.exitCode !== ModalExitCode.COMPLETED) {
return false;
}
+15 -31
View File
@@ -1,12 +1,10 @@
import { Observable, BehaviorSubject } from "rxjs";
import { timeout } from "rxjs/operators";
export class ModalService {
private static instance: ModalService;
private _modalToShow$: BehaviorSubject<ModalComponent> = new BehaviorSubject(null);
private _modalToShow$: BehaviorSubject<ModalObject[]> = new BehaviorSubject([]);
private modalData: unknown = null;
private resolver: (value: ModalResponse | PromiseLike<ModalResponse>) => void = null;
private constructor() {}
@@ -17,47 +15,33 @@ export class ModalService {
return this.instance;
}
private close() {
if (this.resolver) {
this.resolve({ exitCode: ModalExitCode.NO_CHOICE });
}
this._modalToShow$.next(null);
}
public getModalData<Type>(): Type {
return this.modalData as Type;
}
public getResolver(): any {
return this.resolver;
}
public resolve(data: ModalResponse<unknown>): void {
this.resolver(data);
}
public async openModal<T, K>(modal: ModalComponent<T, K>, data?: K): Promise<ModalResponse<T>> {
this.close();
await timeout(100); // Must wait resolve
public async openModal<T, K>(modal: ModalComponent<T, K>, options?: ModalOptions<K>): Promise<ModalResponse<T>> {
let resolver: (value: ModalResponse | PromiseLike<ModalResponse>) => void;
const promise = new Promise<ModalResponse<T>>(resolve => {
this.resolver = resolve as (value: ModalResponse | PromiseLike<ModalResponse>) => void;
resolver = resolve as (value: ModalResponse | PromiseLike<ModalResponse>) => void;
});
this._modalToShow$.next(modal as ModalComponent);
promise.then(() => this.close());
if (data) {
this.modalData = data;
} else {
this.modalData = null;
}
const modalObj = {modal: modal as ModalComponent, resolver: resolver, options};
this._modalToShow$.next([...this._modalToShow$.getValue(), modalObj]);
promise.then(() => {
this._modalToShow$.next(this._modalToShow$.getValue().filter(m => m !== modalObj));
});
return promise;
}
public getModalToShow(): Observable<ModalComponent> {
public getModalToShow(): Observable<ModalObject[]> {
return this._modalToShow$.asObservable();
}
}
export type ModalComponent<Return = unknown, Receive = unknown> = ({ resolver, data }: { resolver: (x: ModalResponse<Return>) => void; data?: Receive }) => JSX.Element;
export type ModalOptions<T = unknown> = { data?: T, noStyle?: boolean }
export type ModalComponent<Return = unknown, Receive = unknown> = ({ resolver, options }: { resolver: (x: ModalResponse<Return>) => void; options?: ModalOptions<Receive> }) => JSX.Element;
export type ModalObject = {modal: ModalComponent, resolver: (value: ModalResponse | PromiseLike<ModalResponse>) => void, options: ModalOptions};
export const enum ModalExitCode {
NO_CHOICE = -1,
@@ -118,7 +118,7 @@ export class ModelsDownloaderService {
}
public openDownloadModelsModal(version: BSVersion, type?: MSModelType, owned?: BsmLocalModel[]): Promise<ModalResponse<void>> {
return this.modal.openModal(DownloadModelsModal, { version, type, owned });
return this.modal.openModal(DownloadModelsModal, {data: { version, type, owned }});
}
public async oneClickInstallModel(model: MSModel): Promise<boolean> {
@@ -73,7 +73,7 @@ export class ModelsManagerService {
}
public async linkModels(type: MSModelType, version?: BSVersion): Promise<boolean> {
const res = await this.modalService.openModal(LinkModelsModal, type);
const res = await this.modalService.openModal(LinkModelsModal, {data: type});
if (res.exitCode !== ModalExitCode.COMPLETED) {
return null;
@@ -82,13 +82,12 @@ export class ModelsManagerService {
return this.versionFolderLinked.linkVersionFolder({
version,
relativeFolder: MODEL_TYPE_FOLDERS[type],
type: VersionLinkerActionType.Link,
options: { keepContents: res.data !== false },
});
}
public async unlinkModels(type: MSModelType, version?: BSVersion): Promise<boolean> {
const res = await this.modalService.openModal(UnlinkModelsModal, type);
const res = await this.modalService.openModal(UnlinkModelsModal, {data: type});
if (res.exitCode !== ModalExitCode.COMPLETED) {
return null;
@@ -97,7 +96,6 @@ export class ModelsManagerService {
return this.versionFolderLinked.unlinkVersionFolder({
version,
relativeFolder: MODEL_TYPE_FOLDERS[type],
type: VersionLinkerActionType.Unlink,
options: { keepContents: res.data !== false },
});
}
@@ -165,7 +163,7 @@ export class ModelsManagerService {
return false;
})();
const res = await this.modalService.openModal(DeleteModelsModal, { models, linked });
const res = await this.modalService.openModal(DeleteModelsModal, {data: { models, linked }});
if (res.exitCode !== ModalExitCode.COMPLETED) {
return Promise.resolve([]);
}
@@ -3,9 +3,8 @@ import { IpcService } from "./ipc.service";
import { Observable, lastValueFrom } from "rxjs";
import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service";
import { Progression } from "main/helpers/fs.helpers";
import { LocalBPList } from "shared/models/playlists/local-playlist.models";
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { ModalExitCode, ModalService } from "./modale.service";
import { LinkMapsModal } from "renderer/components/modal/modal-types/link-maps-modal.component";
import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component";
import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component";
@@ -31,8 +30,8 @@ export class PlaylistsManagerService {
this.modal = ModalService.getInstance();
}
public getVersionPlaylists(version: BSVersion): Observable<Progression<LocalBPList[]>> {
return this.ipc.sendV2("get-version-playlists", { args: version });
public getVersionPlaylistsDetails(version: BSVersion): Observable<Progression<LocalBPListsDetails[]>> {
return this.ipc.sendV2("get-version-playlists-details", { args: version });
}
public async linkVersion(version: BSVersion): Promise<boolean> {
+4
View File
@@ -24,6 +24,8 @@ import { OsDiagnosticService } from "renderer/services/os-diagnostic.service";
import { useService } from "renderer/hooks/use-service.hook";
import { AutoUpdaterService } from "renderer/services/auto-updater.service";
import { gt } from "semver"
import { ModalService } from "renderer/services/modale.service";
import { ChooseStore } from "renderer/components/modal/modal-types/bs-downgrade/choose-store-modal.component";
export default function App() {
useService(OsDiagnosticService);
@@ -34,6 +36,7 @@ export default function App() {
const notification = useService(NotificationService);
const config = useService(ConfigurationService);
const autoUpdater = useService(AutoUpdaterService);
const modals = useService(ModalService);
const location = useLocation();
const navigate = useNavigate();
@@ -55,6 +58,7 @@ export default function App() {
};
const checkOneClicks = async () => {
if (config.get("not-remind-oneclick") === true) {
return;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache.model";
export interface BsvMapDetail {
automapper: boolean;
createdAt: string;
@@ -115,10 +117,10 @@ export interface BsvMapTestplay {
export interface BsvMapDifficulty {
bombs: number;
characteristic: BsvMapCharacteristic;
characteristic: SongDetailDiffCharactertistic;
chroma: boolean;
cinema: boolean;
difficulty: BsvMapDifficultyType;
difficulty: SongDiffName;
events: number;
length: number;
maxScore: number;
@@ -1,13 +1,16 @@
import { SongDetails } from "../maps";
import { BPList, PlaylistSong } from "./playlist.interface";
import { BPList } from "./playlist.interface";
export interface LocalBPList extends BPList<LocalBpListSong> {
export interface LocalBPList extends BPList {
path: string;
}
export interface LocalBpListSong {
song: PlaylistSong;
songDetails?: SongDetails;
songUrl?: string;
coverUrl?: string;
export interface LocalBPListsDetails extends LocalBPList {
nbMaps: number;
id?: number;
nbMappers?: number;
duration?: number;
minNps?: number;
maxNps?: number;
}
@@ -6,7 +6,7 @@ export interface BPList<SongType = PlaylistSong> {
playlistAuthor: string;
playlistDescription?: string;
image: string;
customData: unknown;
customData?: CustomDataBPList;
songs: SongType[];
}
@@ -24,3 +24,7 @@ export interface DownloadPlaylistProgressionData {
playlistInfos: BPList;
playlistPath: string;
}
export interface CustomDataBPList {
syncURL?: string;
}