[feature-107] start modal to create and edit playlists

This commit is contained in:
MathieuG-P
2024-06-13 21:54:22 +02:00
parent 15ebe8e929
commit b1ee9f054d
20 changed files with 537 additions and 219 deletions
+18 -17
View File
@@ -17,22 +17,23 @@ message UploadersList {
message SongDetails {
uint32 idInt = 1;
string hash = 2;
uint32 duration = 3;
UploaderRef uploaderRef = 4;
uint32 uploadedAt = 5;
repeated MapTag tags = 6;
bool ranked = 7;
bool qualified = 8;
bool curated = 9;
bool rankedBL = 10;
bool nominatedBL = 11;
bool qualifiedBL = 12;
uint32 upVotes = 13;
uint32 downVotes = 14;
uint32 downloads = 15;
bool automapper = 16;
repeated Difficulty difficulties = 17;
repeated uint32 hashIndices = 2;
string name = 3;
uint32 duration = 4;
UploaderRef uploaderRef = 5;
uint32 uploadedAt = 6;
repeated MapTag tags = 7;
bool ranked = 8;
bool qualified = 9;
bool curated = 10;
bool rankedBL = 11;
bool nominatedBL = 12;
bool qualifiedBL = 13;
uint32 upVotes = 14;
uint32 downVotes = 15;
uint32 downloads = 16;
bool automapper = 17;
repeated Difficulty difficulties = 18;
}
message Difficulty {
@@ -54,7 +55,7 @@ message Difficulty {
}
message UploaderRef {
uint32 uploaderRefIndex = 1;
uint32 uploader_ref_index = 1;
bool verified = 2;
}
+20
View File
@@ -1,7 +1,10 @@
import { SongCacheService } from "main/services/additional-content/maps/song-cache.service";
import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service";
import { IpcService } from "../services/ipc.service";
import { from, of, throwError } from "rxjs";
import { tryit } from "shared/helpers/error.helpers";
import { SongDetailsCacheService } from "main/services/additional-content/maps/song-details-cache.service";
import { SongDetails } from "shared/models/maps";
const ipc = IpcService.getInstance();
@@ -62,3 +65,20 @@ ipc.on("is-map-deep-links-enabled", (_, reply) => {
reply(of(result));
});
ipc.on("get-maps-info-from-cache", (args, reply) => {
const songsCache = SongDetailsCacheService.getInstance();
const res = (args ?? []).reduce((acc, hash) => {
const songDetails = songsCache.getSongDetails(hash);
if(songDetails){
acc.push(songDetails);
}
return acc;
}, [] as SongDetails[]);
reply(of(res));
})
@@ -81,7 +81,8 @@ export class SongDetailsCacheService {
RawSongDetailsDeserializer.setDifficultyLabels(messageObj.difficultyLabels);
for(const rawSong of messageObj.songs){
res[rawSong.hash.toLocaleLowerCase()] = RawSongDetailsDeserializer.deserialize(rawSong);
const deserialized = RawSongDetailsDeserializer.deserialize(rawSong);
res[deserialized.hash.toLocaleLowerCase()] = deserialized;
}
return res;
@@ -108,6 +108,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
}
return [
{ icon: "add", text: "Créer une playlist", onClick: () => playlistsRef?.current?.createPlaylist?.() },
{ icon: "sync", text: "Synchroniser les playlists", onClick: () => playlistsRef?.current?.syncPlaylists?.() },
{ icon: "export", text: "Exporter les playlists", onClick: () => playlistsRef?.current?.exportPlaylists?.() },
{ icon: "trash", text: "Supprimer les playlists", onClick: () => playlistsRef?.current?.deletePlaylists?.() },
@@ -1,4 +1,4 @@
import { MapFilter, MapRequirement, MapSpecificity, MapStyle, MapTag, MapType } from "shared/models/maps/beat-saver.model";
import { BsvMapDetail, MapFilter, MapRequirement, MapSpecificity, MapStyle, MapTag, MapType } from "shared/models/maps/beat-saver.model";
import { motion } from "framer-motion";
import { MutableRefObject, useEffect, useRef, useState } from "react";
import { BsmCheckbox } from "../../shared/bsm-checkbox.component";
@@ -11,6 +11,8 @@ import { BsmButton } from "../../shared/bsm-button.component";
import equal from "fast-deep-equal/es6";
import clone from "rfdc";
import { GlowEffect } from "../../shared/glow-effect.component";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { SongDetails } from "shared/models/maps";
export type Props = {
className?: string;
@@ -158,7 +160,7 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat
};
return !playlist ? (
<motion.div ref={ref} className={`${className} bg-light-main-color-2 dark:bg-main-color-3`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<motion.div ref={ref} className={`${className} bg-light-main-color-2 dark:bg-main-color-3`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} draggable>
<div className="w-full h-6 grid grid-cols-2 gap-x-12 px-4 mb-6 pt-1">
<BsmRange min={MIN_NPS} max={MAX_NPS} values={npss} onChange={onNpssChange} renderLabel={renderNpsLabel} step={0.1} />
<BsmRange min={MIN_DURATION} max={MAX_DURATION} values={durations} onChange={onDurationsChange} renderLabel={renderDurationLabel} step={5} />
@@ -220,3 +222,159 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat
<></>
);
}
// Filter functions
function isFitEnabledTags(filter: MapFilter, tags: MapTag[]): boolean {
if(!Array.isArray(tags)) { return false; }
if (!filter?.enabledTags || filter.enabledTags.size === 0) {
return true;
}
return Array.from(filter.enabledTags.values()).every(tag => tags.some(mapTag => mapTag === tag));
}
function isFitExcludedTags(filter: MapFilter, tags: MapTag[]): boolean {
if(!Array.isArray(tags)) { return false; }
if (!filter?.excludedTags || filter.excludedTags.size === 0) {
return true;
}
return !tags.some(tag => filter.excludedTags.has(tag as MapTag));
}
function isFitMinNps(filter: MapFilter, nps: number): boolean {
if (!filter?.minNps) { return true; }
return nps > filter.minNps;
}
function isFitMaxNps(filter: MapFilter, nps: number): boolean {
if (!filter?.maxNps) { return true; }
return nps < filter.maxNps;
}
function isFitMinDuration(filter: MapFilter, duration: number): boolean {
if (!filter?.minDuration) { return true; }
return duration >= filter.minDuration;
}
function isFitMaxDuration(filter: MapFilter, duration: number): boolean {
if (!filter?.maxDuration) { return true; }
return duration <= filter.maxDuration;
}
function isFitNoodle(filter: MapFilter, noodle: boolean): boolean {
if (!filter?.noodle) { return true; }
return noodle;
}
function isFitMe(filter: MapFilter, me: boolean): boolean {
if (!filter?.me) { return true; }
return me;
}
function isFitCinema(filter: MapFilter, cinema: boolean): boolean {
if (!filter?.cinema) { return true; }
return cinema;
}
function isFitChroma(filter: MapFilter, chroma: boolean): boolean {
if (!filter?.chroma) { return true; }
return chroma;
}
function isFitFullSpread(filter: MapFilter, nbDiff: number): boolean {
if (!filter?.fullSpread) { return true; }
return nbDiff >= 5;
}
function isFitAutomapper(filter: MapFilter, automapper: boolean): boolean {
if (!filter?.automapper) { return true; }
return automapper;
}
function isFitRanked(filter: MapFilter, ranked: boolean): boolean {
if (!filter?.ranked) { return true; }
return ranked;
}
function isFitCurated(filter: MapFilter, curated: boolean): boolean {
if (!filter?.curated) { return true; }
return curated;
}
function isFitVerified(filter: MapFilter, verified: boolean): boolean {
if (!filter?.verified) { return true; }
return verified;
}
function isFitSearch(search: string, {songName, songAuthorName, levelAuthorName}: {songName: string, songAuthorName: string, levelAuthorName: string}): boolean {
if (!search) { return true; }
return songName?.toLowerCase().includes(search.toLowerCase()) || songAuthorName?.toLowerCase().includes(search.toLowerCase()) || levelAuthorName?.toLowerCase().includes(search.toLowerCase());
}
export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap, search: string }): boolean => {
if (!isFitEnabledTags(filter, map.songDetails?.tags)) { return false; }
if (!isFitExcludedTags(filter, map.songDetails?.tags)) { return false; }
if (map?.songDetails?.difficulties?.length && !map.songDetails?.difficulties.some(diff => isFitMinNps(filter, diff.nps))) { return false; }
if (map?.songDetails?.difficulties?.length && !map.songDetails?.difficulties.some(diff => isFitMaxNps(filter, diff.nps))) { return false; }
if (!isFitMinDuration(filter, map.songDetails?.duration)) { return false; }
if (!isFitMaxDuration(filter, map.songDetails?.duration)) { return false; }
if (!isFitNoodle(filter, map.songDetails?.difficulties.some(diff => !!diff.ne))) { return false; }
if (!isFitMe(filter, map.songDetails?.difficulties.some(diff => !!diff.me))) { return false; }
if (!isFitCinema(filter, map.songDetails?.difficulties.some(diff => !!diff.cinema))) { return false; }
if (!isFitChroma(filter, map.songDetails?.difficulties.some(diff => !!diff.chroma))) { return false; }
if (!isFitFullSpread(filter, map.songDetails?.difficulties.length)) { return false; }
if (!isFitAutomapper(filter, map.songDetails?.automapper)){ return false; }
if (!isFitRanked(filter, map.songDetails?.ranked)) { return false; }
if (!isFitCurated(filter, map.songDetails?.curated)) { return false; }
if (!isFitVerified(filter, map.songDetails?.uploader.verified)) { return false; }
if (!isFitSearch(search, {songName: map.rawInfo?._songName, songAuthorName: map.rawInfo?._songAuthorName, levelAuthorName: map.rawInfo?._levelAuthorName})) { return false; }
return true;
};
export const isBsvMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsvMapDetail, search: string }): boolean => {
if (!isFitEnabledTags(filter, map.tags)) { return false; }
if (!isFitExcludedTags(filter, map.tags)) { return false; }
if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMinNps(filter, diff.nps))) { return false; }
if (map.versions?.at(0)?.diffs && !map.versions.at(0).diffs.some(diff => isFitMaxNps(filter, diff.nps))) { return false; }
if (!isFitMinDuration(filter, map.metadata.duration)) { return false; }
if (!isFitMaxDuration(filter, map.metadata.duration)) { return false; }
if (!isFitNoodle(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.ne))) { return false; }
if (!isFitMe(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.me))) { return false; }
if (!isFitCinema(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.cinema))) { return false; }
if (!isFitChroma(filter, map.versions?.at(0)?.diffs.some(diff => !!diff.chroma))) { return false; }
if (!isFitFullSpread(filter, map.versions?.at(0)?.diffs.length)) { return false; }
if (!isFitAutomapper(filter, map.automapper)){ return false; }
if (!isFitRanked(filter, map.ranked)) { return false; }
if (!isFitCurated(filter, !!map.curator)) { return false; }
if (!isFitVerified(filter, !!map.curatedAt)) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { return false; }
return true;
};
export const isSongDetailsFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: SongDetails, search: string }): boolean => {
if (!isFitEnabledTags(filter, map.tags)) { return false; }
if (!isFitExcludedTags(filter, map.tags)) { return false; }
if (map.difficulties && !map.difficulties.some(diff => isFitMinNps(filter, diff.nps))) { return false; }
if (map.difficulties && !map.difficulties.some(diff => isFitMaxNps(filter, diff.nps))) { return false; }
if (!isFitMinDuration(filter, map.duration)) { return false; }
if (!isFitMaxDuration(filter, map.duration)) { return false; }
if (!isFitNoodle(filter, map.difficulties.some(diff => !!diff.ne))) { return false; }
if (!isFitMe(filter, map.difficulties.some(diff => !!diff.me))) { return false; }
if (!isFitCinema(filter, map.difficulties.some(diff => !!diff.cinema))) { return false; }
if (!isFitChroma(filter, map.difficulties.some(diff => !!diff.chroma))) { return false; }
if (!isFitFullSpread(filter, map.difficulties.length)) { return false; }
if (!isFitAutomapper(filter, map.automapper)){ return false; }
if (!isFitRanked(filter, map.ranked)) { return false; }
if (!isFitCurated(filter, map.curated)) { return false; }
if (!isFitVerified(filter, map.uploader.verified)) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelAuthorName: map.uploader.name})) { return false; }
return true;
}
export const isMapFitFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap | BsvMapDetail | SongDetails, search: string }): boolean => {
if ((map as BsmLocalMap)?.rawInfo) { return isLocalMapFitMapFilter({filter, map: (map as BsmLocalMap), search}); }
if ((map as BsvMapDetail)?.metadata) { return isBsvMapFitMapFilter({filter, map: (map as BsvMapDetail), search}); }
if ((map as SongDetails).hash) { return isSongDetailsFitMapFilter({filter, map: (map as SongDetails), search}); }
return false;
};
@@ -21,6 +21,7 @@ import { useObservable } from "renderer/hooks/use-observable.hook";
import equal from "fast-deep-equal";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
import { MapItem, extractMapDiffs } from "./map-item.component";
import { isLocalMapFitMapFilter } from "./filter-panel.component";
type Props = {
version: BSVersion;
@@ -175,178 +176,10 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
callBackParam={map}
/>
);
}, [version])
const isMapFitFilter = (map: BsmLocalMap): boolean => {
// Can be more clean and optimized i think
const fitEnabledTags = (() => {
if (!filter?.enabledTags || filter.enabledTags.size === 0) {
return true;
}
if (!map?.songDetails?.tags) {
return false;
}
return Array.from(filter.enabledTags.values()).every(tag => map.songDetails.tags.some(mapTag => mapTag === tag));
})();
if (!fitEnabledTags) {
return false;
}
const fitExcluedTags = (() => {
if (!filter?.excludedTags || filter.excludedTags.size === 0) {
return true;
}
if (!map?.songDetails?.tags) {
return true;
}
return !map.songDetails?.tags.some(tag => filter.excludedTags.has(tag as MapTag));
})();
if (!fitExcluedTags) {
return false;
}
const fitMinNps = (() => {
if (!filter?.minNps) {
return true;
}
return map.songDetails?.difficulties.some(diff => diff.nps > filter.minNps);
})();
if (!fitMinNps) {
return false;
}
const fitMaxNps = (() => {
if (!filter?.maxNps) {
return true;
}
return map.songDetails?.difficulties.some(diff => diff.nps < filter.maxNps);
})();
if (!fitMaxNps) {
return false;
}
const fitMinDuration = (() => {
if (!filter?.minDuration) {
return true;
}
if (!map?.songDetails?.duration) {
return false;
}
return map.songDetails?.duration >= filter.minDuration;
})();
if (!fitMinDuration) {
return false;
}
const fitMaxDuration = (() => {
if (!filter?.maxDuration) {
return true;
}
if (!map?.songDetails?.duration) {
return false;
}
return map.songDetails?.duration <= filter.maxDuration;
})();
if (!fitMaxDuration) {
return false;
}
const fitNoodle = (() => {
if (!filter?.noodle) {
return true;
}
return map.songDetails?.difficulties.some(diff => !!diff.ne);
})();
if (!fitNoodle) {
return false;
}
const fitMe = (() => {
if (!filter?.me) {
return true;
}
return map.songDetails?.difficulties.some(diff => !!diff.me);
})();
if (!fitMe) {
return false;
}
const fitCinema = (() => {
if (!filter?.cinema) {
return true;
}
return map.songDetails?.difficulties.some(diff => !!diff.cinema);
})();
if (!fitCinema) {
return false;
}
const fitChroma = (() => {
if (!filter?.chroma) {
return true;
}
return map.songDetails?.difficulties.some(diff => !!diff.chroma);
})();
if (!fitChroma) {
return false;
}
const fitFullSpread = (() => {
if (!filter?.fullSpread) {
return true;
}
return map.songDetails?.difficulties.length >= 5;
})();
if (!fitFullSpread) {
return false;
}
if (filter?.automapper && (map.songDetails && !map.songDetails?.automapper)) {
return false;
}
if (!(filter?.ranked ? map.songDetails?.ranked === filter.ranked : true)) {
return false;
}
if (!(filter?.curated ? !!map.songDetails?.curated === filter.curated : true)) {
return false;
}
if (!(filter?.verified ? !!map.songDetails?.uploader?.verified : true)) {
return false;
}
const searchCheck = (() => {
return (map.rawInfo?._songName || "")?.toLowerCase().includes(search.toLowerCase()) || (map.rawInfo?._songAuthorName || "")?.toLowerCase().includes(search.toLowerCase()) || (map.rawInfo?._levelAuthorName || "")?.toLowerCase().includes(search.toLowerCase());
})();
if (!searchCheck) {
return false;
}
return true;
};
}, [version]);
const preppedMaps: RenderableMap[] = (() => {
return renderableMaps?.filter(renderableMap => isMapFitFilter(renderableMap.map)) ?? [];
return renderableMaps?.filter(renderableMap => isLocalMapFitMapFilter({ map: renderableMap.map, filter, search })) ?? [];
})();
if (!maps) {
@@ -27,18 +27,18 @@ import { typedMemo } from "renderer/helpers/typed-memo";
export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number };
export type MapItemProps<T = unknown> = {
export type MapItemComponentProps<T = unknown> = {
hash: string;
title: string;
autor: string;
songAutor: string;
coverUrl: string;
songUrl: string;
songAutor?: string;
coverUrl?: string;
songUrl?: string;
autorId: number;
mapId: string;
diffs: Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>;
ranked: boolean;
bpm: number;
bpm?: number;
duration: number;
likes: number;
createdAt: number | CalendarDateTime;
@@ -53,7 +53,7 @@ export type MapItemProps<T = unknown> = {
onDoubleClick?: (param: T) => void;
};
export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemProps<T>) {
export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps<T>) {
const linkOpener = useService(LinkOpenerService);
const audioPlayer = useService(AudioPlayerService);
@@ -115,7 +115,7 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
if (!audioPlayer.playing && audioPlayer.src === songUrl) {
return audioPlayer.resume();
}
audioPlayer.play([{ src: songUrl, bpm }]);
audioPlayer.play([{ src: songUrl, bpm: bpm ?? 1 }]);
};
const bottomBarHoverStart = () => {
@@ -18,8 +18,6 @@ const [MIN_NB_MAPPER, MAX_NB_MAPPER] = [0, 1000];
const [MIN_DURATION, MAX_DURATION] = [0, hourToS(9)];
const [MIN_NPS, MAX_NPS] = [0, 17];
console.log(hourToS(9));
export function LocalPlaylistFilterPanel({ className, filter, onChange }: Props) {
const t = useTranslation();
@@ -37,6 +37,7 @@ import { ProgressionInterface } from "shared/models/progress-bar";
import { enumerate } from "shared/helpers/array.helpers";
import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component";
import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component";
import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal.component";
type Props = {
version: BSVersion;
@@ -48,6 +49,7 @@ type Props = {
};
export type LocalPlaylistsListRef = {
createPlaylist: () => Promise<void>;
syncPlaylists: () => Promise<void>;
deletePlaylists: () => Promise<void>;
exportPlaylists: () => Promise<void>;
@@ -87,6 +89,9 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
}
useImperativeHandle(forwardedRef, () => ({
createPlaylist: async () => {
const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { version, maps$ } });
},
syncPlaylists: async () => {
if(!isOnline){ return; }
const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value;
@@ -270,7 +275,6 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
isInQueue$={playlistDownloader.$isPlaylistInQueue(playlist.customData?.syncURL ?? playlist.path, version)}
selected$={selectedPlaylists$.pipe(map(selected => selected.some(s => s.path === playlist.path)), distinctUntilChanged(equal))}
onClick={() => {
console.log(selectedPlaylists$.value, playlist.path);
if(selectedPlaylists$.value.some(s => s.path === playlist.path)){
selectedPlaylists$.next(selectedPlaylists$.value.filter(s => s.path !== playlist.path));
return;
@@ -139,7 +139,6 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
margin: 120
}}
renderItem={renderPlaylist}
rowKey={items => items.map(item => item.playlist.playlistId).join("-")}
/>
)
})()}
@@ -0,0 +1,184 @@
import { useCallback, useMemo, useState } from "react";
import { FilterPanel, isLocalMapFitMapFilter, isMapFitFilter } from "renderer/components/maps-playlists-panel/maps/filter-panel.component";
import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { BsmDropdownButton } from "renderer/components/shared/bsm-dropdown-button.component";
import { BsmSelect } from "renderer/components/shared/bsm-select.component";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
import { ChevronTopIcon } from "renderer/components/svgs/icons/chevron-top-icon.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { ModalComponent } from "renderer/services/modale.service"
import { Observable, filter, lastValueFrom, take } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"
import { BsvMapDetail, SongDetails } from "shared/models/maps";
import { logRenderError } from "renderer";
import { useService } from "renderer/hooks/use-service.hook";
import { MapsManagerService } from "renderer/services/maps-manager.service";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { MapItemComponentPropsMapper } from "shared/mappers/map/map-item-component-props.mapper";
import { MapFilter } from "shared/models/maps/beat-saver.model";
type Props = {
version?: BSVersion;
maps$: Observable<BsmLocalMap[]>;
playlist?: LocalBPList;
}
export const EditPlaylistModal: ModalComponent<LocalBPListsDetails, Props> = ({ resolver, options: { data: { version, maps$, playlist } } }) => {
const t = useTranslation();
const color = useThemeColor("first-color");
const mapsService = useService(MapsManagerService);
const maps = useObservable(() => maps$.pipe(filter(Array.isArray), take(1)), undefined);
const [availableMapsSearch, setAvailableMapsSearch] = useState<string>("");
const [availableMapsFilter, setAvailableMapsFilter] = useState<MapFilter>({});
const [playlistMapsSearch, setPlaylistMapsSearch] = useState<string>("");
const [playlistMapsFilter, setPlaylistMapsFilter] = useState<MapFilter>({});
const [availableMapsSource, setAvailableMapsSource] = useState<number>(0);
const [playlistMaps, setPlaylistMaps] = useState<Record<string, (BsmLocalMap|BsvMapDetail|SongDetails)>>();
const displayablePlaylistMaps = useMemo(() => playlistMaps ? Object.values(playlistMaps).filter(Boolean) : [], [playlistMaps]);
const availableMaps = (() => {
})();
useOnUpdate(() => {
if(!maps){ return; }
const playlistMapsRes = (playlist?.songs ?? []).reduce((acc, song) => {
const map = maps.find(map => map.hash === song.hash);
if(map){
acc[map.hash] = map;
}
else {
acc[song.hash] = undefined;
}
return acc;
}, {} as Record<string, (BsmLocalMap|BsvMapDetail|SongDetails)>);
(async () => {
const notInstalledHashs = Object.keys(playlistMapsRes).filter(hash => !playlistMapsRes[hash]);
const songsDetails = await lastValueFrom(mapsService.getMapsInfoFromHashs(notInstalledHashs));
songsDetails.forEach(song => {
playlistMapsRes[song.hash] = song;
});
})()
.catch(logRenderError)
.finally(() => setPlaylistMaps(playlistMapsRes));
}, [maps]);
useOnUpdate(() => {
}, [maps, playlistMaps, playlist]);
const renderMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails), onClick: (map: (BsmLocalMap|BsvMapDetail|SongDetails)) => void) => {
return (
<MapItem
key={(map as BsmLocalMap | SongDetails).hash ?? (map as BsvMapDetail).versions?.[0]?.hash}
{ ...MapItemComponentPropsMapper.from(map) }
onSelected={onClick}
/>
);
}, []);
const renderAvailableMapItem = useCallback((map: BsmLocalMap) => {
return renderMapItem(map, () => setPlaylistMaps(prev => ({ ...prev, [map.hash]: map })));
}, []);
const renderPlaylistMapItem = useCallback((map: (BsmLocalMap|BsvMapDetail|SongDetails)) => {
return renderMapItem(map, () => setPlaylistMaps(prev => {
if(!map){ return prev; }
const hash = (map as BsmLocalMap | SongDetails).hash ? (map as BsmLocalMap|SongDetails).hash : (map as BsvMapDetail).versions?.[0]?.hash;
if(!hash){ return prev; }
const newPlaylistMaps = { ...prev };
delete newPlaylistMaps[(map as BsmLocalMap).hash];
return newPlaylistMaps;
}));
}, []);
const renderList = <T, >(maps: T[], render: (item: T) => JSX.Element) => {
return (
<VirtualScroll
classNames={{
mainDiv: "bg-theme-1 rounded-md size-full min-w-0 overflow-hidden",
rows: "my-2.5 px-2.5"
}}
items={maps}
itemHeight={110}
maxColumns={1}
renderItem={render}
/>
)
}
return (
<div className="w-screen h-screen max-h-[calc(100vh-2rem)] max-w-[55rem] lg:max-w-[66rem] xl:max-w-[77rem] bg-theme-3 p-4 rounded-md">
{(() => {
if(!playlistMaps || !maps){
return <div className="flex items-center justify-center w-full h-full">Loading...</div>
}
else{
return (
<div className="size-full flex flex-col justify-between gap-3">
<header>header</header>
<div className="grow flex flex-row min-h-0 gap-2.5">
<div className="flex flex-col grow basis-0">
<div className="h-8 flex flex-row gap-2 w-full mb-1.5">
<BsmSelect className="bg-theme-1 h-full rounded-full text-center" options={[{ text: "Local", value: 0 }, { text: "BeatSaver", value: 1 }]} onChange={setAvailableMapsSource}/>
<input className="h-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 grow pb-0.5" type="text" placeholder={t("pages.version-viewer.maps.search-bar.search-placeholder")} value={availableMapsSearch} onChange={e => setAvailableMapsSearch(() => e.target.value)} />
<BsmDropdownButton className="h-full relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" textClassName="whitespace-nowrap" withBar={false}>
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black -translate-x-1/4 lg:translate-x-0" filter={availableMapsFilter} onChange={setAvailableMapsFilter}/>
</BsmDropdownButton>
</div>
{renderList(maps.filter(map => {
if(playlistMaps[map.hash]){ return false; }
return isLocalMapFitMapFilter({ map, filter: availableMapsFilter, search: availableMapsSearch });
}), renderAvailableMapItem)}
<div className="w-full h-4 flex justify-end">
</div>
</div>
<div className="shrink-0 flex flex-col gap-2.5 pb-4 pt-10">
<button className="grow w-9 rounded-md cursor-pointer" style={{backgroundColor: color}} type="button"><ChevronTopIcon className="origin-center rotate-90"/></button>
<button className="grow w-9 rounded-md cursor-pointer" style={{backgroundColor: color}} type="button"><ChevronTopIcon className="origin-center -rotate-90"/></button>
</div>
<div className="flex flex-col grow basis-0">
<div className="h-8 flex flex-row gap-2 w-full mb-1.5">
<input className="h-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 grow pb-0.5" type="text" placeholder={t("pages.version-viewer.maps.search-bar.search-placeholder")} value={playlistMapsSearch} onChange={e => setPlaylistMapsSearch(() => e.target.value)} />
<BsmDropdownButton className="h-full relative z-[1] flex justify-center" buttonClassName="flex items-center justify-center h-full rounded-full px-2 py-1" icon="filter" text="pages.version-viewer.maps.search-bar.filters-btn" textClassName="whitespace-nowrap" withBar={false}>
<FilterPanel className="absolute top-[calc(100%+3px)] origin-top w-[500px] h-fit p-2 rounded-md shadow-md shadow-black -translate-x-[40%]" filter={playlistMapsFilter} onChange={setPlaylistMapsFilter}/>
</BsmDropdownButton>
</div>
{renderList(displayablePlaylistMaps.filter(map => {
return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch });
}), renderPlaylistMapItem)}
<div className="w-full h-4 flex justify-end">
<span className="text-xs italic leading-4">{Object.keys(playlistMaps ?? {}).length} Maps</span>
</div>
</div>
</div>
<footer>footer</footer>
</div>
)
}
})()}
</div>
)
}
@@ -84,15 +84,8 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
}, []);
const renderMaps = () => {
if (!Array.isArray(installedMaps) && !isInQueue) {
return (
<div className="grow bg-red-400">
</div>
);
}
if(installedMaps.length === 0 && !isInQueue) {
if(!installedMaps.length && !isInQueue) {
return (
<div className="grow flex justify-center items-center flex-col">
<BsmImage image={BeatConflict} className="size-28"/>
@@ -62,10 +62,12 @@ export const BsmDropdownButton = forwardRef(({ className, items, align, withBar
return "right-0 origin-top-right";
})();
console.log("alignClass", alignClass);
return (
<div ref={ref as unknown as React.LegacyRef<HTMLDivElement>} className={className}>
<BsmButton onClick={() => setExpanded(!expanded)} className={buttonClassName ?? defaultButtonClassName} icon={icon} active={expanded} textClassName={textClassName} onClickOutside={handleClickOutside} withBar={withBar} text={text} />
<div className={`py-1 w-fit absolute cursor-pointer top-[calc(100%-4px)] rounded-md bg-inherit text-sm text-gray-800 dark:text-gray-200 shadow-md shadow-black transition-[scale] ease-in-out ${alignClass}`} style={{ scale: expanded ? "1" : "0", translate: `0 ${menuTranslationY}` }}>
<div className={`py-1 w-fit absolute cursor-pointer top-[calc(100%-4px)] rounded-md bg-inherit text-sm text-gray-800 dark:text-gray-200 shadow-md shadow-black transition-[scale] duration-150 ease-in-out ${alignClass}`} style={{ scale: expanded ? "1" : "0", translate: `0 ${menuTranslationY}` }}>
{items?.map(
i =>
i && (
@@ -5,10 +5,10 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { VirtualRow } from "./virtual-row.component";
import { splitIntoChunk } from "shared/helpers/array.helpers";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { BehaviorSubject, debounceTime } from "rxjs";
import { BehaviorSubject, debounceTime, distinctUntilChanged } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
type ClassNames = {
export type VirtualScrollClassNames = {
mainDiv?: string;
variableList?: string;
rows?: string;
@@ -21,8 +21,8 @@ type ScrollEndHandler = {
type Props<T = unknown> = {
className?: string;
classNames?: ClassNames;
minItemWidth: number;
classNames?: VirtualScrollClassNames;
minItemWidth?: number;
maxColumns: number;
minColumns?: number;
itemHeight: number;
@@ -40,13 +40,15 @@ export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth
const [itemPerRow, setItemPerRow] = useState(1);
const [itemsToRender, setItemsToRender] = useState<T[][]>([]);
const listHeight$ = useConstant(() => new BehaviorSubject<number>(0));
const listHeight = useObservable(() => listHeight$.pipe(debounceTime(100)), 0);
const listHeight = useObservable(() => listHeight$.pipe(distinctUntilChanged(), debounceTime(100)), 0);
console.log(listHeight);
useLayoutEffect(() => {
const updateItemPerRow = (listWidth: number) => {
if (!listWidth) return;
const calculatedColumns = Math.floor(listWidth / minItemWidth);
const calculatedColumns = Math.floor(listWidth / (minItemWidth ?? 1));
const newColumns = Math.max((minColumns || 1), Math.min(maxColumns, calculatedColumns));
setItemPerRow(() => newColumns);
};
@@ -64,6 +66,7 @@ export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth
useOnUpdate(() => {
const splitedItems = splitIntoChunk(items, itemPerRow);
setItemsToRender(() => splitedItems);
}, [itemPerRow, items])
const handleScroll = (e: ListOnScrollProps) => {
@@ -12,6 +12,7 @@ import { ConfigurationService } from "./configuration.service";
import { map, last, catchError } from "rxjs/operators";
import { ProgressionInterface } from "shared/models/progress-bar";
import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service";
import { SongDetails } from "shared/models/maps";
export class MapsManagerService {
private static instance: MapsManagerService;
@@ -145,6 +146,10 @@ export class MapsManagerService {
});
}
public getMapsInfoFromHashs(hashs: string[]): Observable<SongDetails[]> {
return this.ipcService.sendV2("get-maps-info-from-cache", hashs);
}
public async isDeepLinksEnabled(): Promise<boolean> {
return lastValueFrom(this.ipcService.sendV2("is-map-deep-links-enabled"));
}
@@ -0,0 +1,106 @@
import { getLocalTimeZone, parseAbsolute, parseDateTime, toCalendarDateTime } from "@internationalized/date";
import { MapItemComponentProps, ParsedMapDiff } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetails } from "shared/models/maps";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
export abstract class MapItemComponentPropsMapper {
public static 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;
}
public static fromBsmLocalMap(map: BsmLocalMap): MapItemComponentProps<BsmLocalMap> {
return {
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?.duration,
diffs: MapItemComponentPropsMapper.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: map
}
}
public static fromBsvMapDetail(map: BsvMapDetail): MapItemComponentProps<BsvMapDetail> {
return {
autor: map.metadata.levelAuthorName,
autorId: map.uploader.id,
bpm: map.metadata.bpm,
coverUrl: map.versions.at(0).coverURL,
createdAt: map.createdAt && toCalendarDateTime(parseAbsolute(map.createdAt, getLocalTimeZone())),
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: MapItemComponentPropsMapper.extractMapDiffs({ bsvMap: map }),
songUrl: map.versions.at(0).previewURL,
callBackParam: map
}
}
public static fromSongDetails(song: SongDetails): MapItemComponentProps<SongDetails> {
return {
autor: song.uploader.name,
autorId: song.uploader.id,
createdAt: song.uploadedAt,
duration: song.duration,
hash: song.hash,
likes: song.upVotes,
mapId: song.id,
ranked: song.ranked,
title: song.name,
diffs: MapItemComponentPropsMapper.extractMapDiffs({ songDetails: song }),
callBackParam: song
}
}
public static from(mapDetails: BsmLocalMap|BsvMapDetail|SongDetails): MapItemComponentProps<BsmLocalMap|BsvMapDetail|SongDetails> {
if ((mapDetails as BsmLocalMap).rawInfo) {
return MapItemComponentPropsMapper.fromBsmLocalMap(mapDetails as BsmLocalMap) as MapItemComponentProps<BsmLocalMap|BsvMapDetail|SongDetails>;
} else if ((mapDetails as BsvMapDetail).metadata) {
return MapItemComponentPropsMapper.fromBsvMapDetail(mapDetails as BsvMapDetail) as MapItemComponentProps<BsmLocalMap|BsvMapDetail|SongDetails>;;
} else {
return MapItemComponentPropsMapper.fromSongDetails(mapDetails as SongDetails) as MapItemComponentProps<BsmLocalMap|BsvMapDetail|SongDetails>;;
}
}
}
+2 -1
View File
@@ -2,7 +2,7 @@ import { Progression } from "main/helpers/fs.helpers";
import { Observable } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BSLaunchEventData, LaunchOption } from "shared/models/bs-launch";
import { BsvMapDetail } from "shared/models/maps";
import { BsvMapDetail, SongDetails } from "shared/models/maps";
import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface";
import { BsvPlaylist, BsvPlaylistPage, PlaylistSearchParams, SearchParams } from "../maps/beat-saver.model";
import { ImportVersionOptions } from "main/services/bs-local-version.service";
@@ -58,6 +58,7 @@ export interface IpcChannelMapping {
"register-maps-deep-link": { request: void, response: boolean };
"unregister-maps-deep-link": { request: void, response: boolean };
"is-map-deep-links-enabled": { request: void, response: boolean };
"get-maps-info-from-cache": { request: string[], response: SongDetails[] }
/* ** bs-model-ipcs ** */
"one-click-install-model": { request: MSModel, response: void };
@@ -16,7 +16,8 @@ export interface UploadersList {
// SongDetails Message
export interface RawSongDetails {
idInt: number;
hash: string;
hashIndices: number[];
name: string;
duration: number;
uploaderRef: UploaderRef;
uploadedAt: number;
@@ -7,6 +7,8 @@ export abstract class RawSongDetailsDeserializer {
public static uploaderList: UploadersList = { names: [], ids: [] };
public static difficultyLabels: string[] = [];
private static readonly HASH_CHARS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"];
private constructor() {}
public static setUploadersList(uploadersList: UploadersList): void {
@@ -135,6 +137,10 @@ export abstract class RawSongDetailsDeserializer {
return rawMapId.toString(16);
}
private static deserializeHashIndices(hashIndices: number[]): string {
return hashIndices.map(index => this.HASH_CHARS[index]).join("");
}
/**
* Deserialize the raw song details to the song details model.
* @param {RawSongDetails} rawSongDetails the raw song details to deserialize (normally get from the proto message)
@@ -148,7 +154,8 @@ export abstract class RawSongDetailsDeserializer {
return {
id: this.deserializeMapId(rawSongDetails.idInt),
hash: rawSongDetails.hash,
hash: this.deserializeHashIndices(rawSongDetails.hashIndices),
name: rawSongDetails.name,
duration: rawSongDetails.duration,
uploader: this.deserializeRawUploader(rawSongDetails.uploaderRef),
uploadedAt: rawSongDetails.uploadedAt,
@@ -3,6 +3,7 @@ import { MapTag } from "../beat-saver.model";
export interface SongDetails {
id: string;
hash: string;
name: string;
duration: number;
uploader: SongUploader;
uploadedAt: number;