diff --git a/assets/proto/song_details_cache_v1.proto b/assets/proto/song_details_cache_v1.proto index 495a3734..4786b665 100644 --- a/assets/proto/song_details_cache_v1.proto +++ b/assets/proto/song_details_cache_v1.proto @@ -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; } diff --git a/src/main/ipcs/bs-maps-ipcs.ts b/src/main/ipcs/bs-maps-ipcs.ts index b492d6f9..1aa1f8c4 100644 --- a/src/main/ipcs/bs-maps-ipcs.ts +++ b/src/main/ipcs/bs-maps-ipcs.ts @@ -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)); + +}) diff --git a/src/main/services/additional-content/maps/song-details-cache.service.ts b/src/main/services/additional-content/maps/song-details-cache.service.ts index 4a8d584e..bd04bd4d 100644 --- a/src/main/services/additional-content/maps/song-details-cache.service.ts +++ b/src/main/services/additional-content/maps/song-details-cache.service.ts @@ -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; diff --git a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx index 4be7629f..cf386373 100644 --- a/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps-playlists-panel.component.tsx @@ -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?.() }, diff --git a/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx index c091eef0..bcd669ac 100644 --- a/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/filter-panel.component.tsx @@ -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 ? ( - +
@@ -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; +}; diff --git a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx index 1d04f076..ce2dbc53 100644 --- a/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/local-maps-list-panel.component.tsx @@ -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(({ 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) { diff --git a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx index efa32a9e..9cf0c587 100644 --- a/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx +++ b/src/renderer/components/maps-playlists-panel/maps/map-item.component.tsx @@ -27,18 +27,18 @@ import { typedMemo } from "renderer/helpers/typed-memo"; export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number }; -export type MapItemProps = { +export type MapItemComponentProps = { hash: string; title: string; autor: string; - songAutor: string; - coverUrl: string; - songUrl: string; + songAutor?: string; + coverUrl?: string; + songUrl?: string; autorId: number; mapId: string; diffs: Map; ranked: boolean; - bpm: number; + bpm?: number; duration: number; likes: number; createdAt: number | CalendarDateTime; @@ -53,7 +53,7 @@ export type MapItemProps = { onDoubleClick?: (param: T) => void; }; -export function MapItemComponent ({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemProps) { +export function MapItemComponent ({ hash, title, autor, songAutor, coverUrl, songUrl, autorId, mapId, diffs, ranked, bpm, duration, likes, createdAt, selected, downloading, showOwned, callBackParam, onDelete, onDownload, onSelected, onCancelDownload, onDoubleClick }: MapItemComponentProps) { const linkOpener = useService(LinkOpenerService); const audioPlayer = useService(AudioPlayerService); @@ -115,7 +115,7 @@ export function MapItemComponent ({ 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 = () => { diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx index db15466a..952e837b 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlist-filter-panel.component.tsx @@ -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(); diff --git a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx index ed1e70aa..4a3d4ccb 100644 --- a/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx +++ b/src/renderer/components/maps-playlists-panel/playlists/local-playlists-list-panel.component.tsx @@ -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; syncPlaylists: () => Promise; deletePlaylists: () => Promise; exportPlaylists: () => Promise; @@ -87,6 +89,9 @@ export const LocalPlaylistsListPanel = forwardRef( } 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( 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; diff --git a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx index ee1cea4f..56cee3d8 100644 --- a/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/download-playlist-modal/download-playlist-modal.component.tsx @@ -139,7 +139,6 @@ export const DownloadPlaylistModal: ModalComponent items.map(item => item.playlist.playlistId).join("-")} /> ) })()} diff --git a/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx new file mode 100644 index 00000000..0b348794 --- /dev/null +++ b/src/renderer/components/modal/modal-types/playlist/edit-playlist-modal.component.tsx @@ -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; + playlist?: LocalBPList; +} + +export const EditPlaylistModal: ModalComponent = ({ 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(""); + const [availableMapsFilter, setAvailableMapsFilter] = useState({}); + const [playlistMapsSearch, setPlaylistMapsSearch] = useState(""); + const [playlistMapsFilter, setPlaylistMapsFilter] = useState({}); + + const [availableMapsSource, setAvailableMapsSource] = useState(0); + + const [playlistMaps, setPlaylistMaps] = useState>(); + 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); + + + (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 ( + + ); + }, []); + + 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 = (maps: T[], render: (item: T) => JSX.Element) => { + return ( + + ) + } + + return ( +
+ {(() => { + if(!playlistMaps || !maps){ + return
Loading...
+ } + else{ + return ( +
+
header
+
+
+
+ + setAvailableMapsSearch(() => e.target.value)} /> + + + +
+ {renderList(maps.filter(map => { + if(playlistMaps[map.hash]){ return false; } + return isLocalMapFitMapFilter({ map, filter: availableMapsFilter, search: availableMapsSearch }); + }), renderAvailableMapItem)} +
+
+
+
+ + +
+
+
+ setPlaylistMapsSearch(() => e.target.value)} /> + + + +
+ {renderList(displayablePlaylistMaps.filter(map => { + return isMapFitFilter({ map, filter: playlistMapsFilter, search: playlistMapsSearch }); + }), renderPlaylistMapItem)} +
+ {Object.keys(playlistMaps ?? {}).length} Maps +
+
+
+
footer
+
+ ) + } + })()} +
+ ) +} diff --git a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx index 9f08114a..4b1dd0f8 100644 --- a/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component.tsx @@ -84,15 +84,8 @@ export const LocalPlaylistDetailsModal: ModalComponent = ({resolver }, []); const renderMaps = () => { - if (!Array.isArray(installedMaps) && !isInQueue) { - return ( -
-
- ); - } - - if(installedMaps.length === 0 && !isInQueue) { + if(!installedMaps.length && !isInQueue) { return (
diff --git a/src/renderer/components/shared/bsm-dropdown-button.component.tsx b/src/renderer/components/shared/bsm-dropdown-button.component.tsx index 09cc55e0..d6da6415 100644 --- a/src/renderer/components/shared/bsm-dropdown-button.component.tsx +++ b/src/renderer/components/shared/bsm-dropdown-button.component.tsx @@ -62,10 +62,12 @@ export const BsmDropdownButton = forwardRef(({ className, items, align, withBar return "right-0 origin-top-right"; })(); + console.log("alignClass", alignClass); + return (
} className={className}> setExpanded(!expanded)} className={buttonClassName ?? defaultButtonClassName} icon={icon} active={expanded} textClassName={textClassName} onClickOutside={handleClickOutside} withBar={withBar} text={text} /> -
+
{items?.map( i => i && ( diff --git a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx index ce9c60cf..adcfb62b 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -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 = { className?: string; - classNames?: ClassNames; - minItemWidth: number; + classNames?: VirtualScrollClassNames; + minItemWidth?: number; maxColumns: number; minColumns?: number; itemHeight: number; @@ -40,13 +40,15 @@ export function VirtualScroll({ className, classNames, minItemWidth const [itemPerRow, setItemPerRow] = useState(1); const [itemsToRender, setItemsToRender] = useState([]); const listHeight$ = useConstant(() => new BehaviorSubject(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({ className, classNames, minItemWidth useOnUpdate(() => { const splitedItems = splitIntoChunk(items, itemPerRow); setItemsToRender(() => splitedItems); + }, [itemPerRow, items]) const handleScroll = (e: ListOnScrollProps) => { diff --git a/src/renderer/services/maps-manager.service.ts b/src/renderer/services/maps-manager.service.ts index 42a95543..58095d66 100644 --- a/src/renderer/services/maps-manager.service.ts +++ b/src/renderer/services/maps-manager.service.ts @@ -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 { + return this.ipcService.sendV2("get-maps-info-from-cache", hashs); + } + public async isDeepLinksEnabled(): Promise { return lastValueFrom(this.ipcService.sendV2("is-map-deep-links-enabled")); } diff --git a/src/shared/mappers/map/map-item-component-props.mapper.ts b/src/shared/mappers/map/map-item-component-props.mapper.ts new file mode 100644 index 00000000..ba5098d0 --- /dev/null +++ b/src/shared/mappers/map/map-item-component-props.mapper.ts @@ -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 { + const res = new Map(); + 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 { + 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 { + 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 { + 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 { + if ((mapDetails as BsmLocalMap).rawInfo) { + return MapItemComponentPropsMapper.fromBsmLocalMap(mapDetails as BsmLocalMap) as MapItemComponentProps; + } else if ((mapDetails as BsvMapDetail).metadata) { + return MapItemComponentPropsMapper.fromBsvMapDetail(mapDetails as BsvMapDetail) as MapItemComponentProps;; + } else { + return MapItemComponentPropsMapper.fromSongDetails(mapDetails as SongDetails) as MapItemComponentProps;; + } + } + +} diff --git a/src/shared/models/ipc/ipc-routes.ts b/src/shared/models/ipc/ipc-routes.ts index 984499ad..d6f8e55b 100644 --- a/src/shared/models/ipc/ipc-routes.ts +++ b/src/shared/models/ipc/ipc-routes.ts @@ -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 }; diff --git a/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts b/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts index 2748fb88..0b77a268 100644 --- a/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts +++ b/src/shared/models/maps/song-details-cache/raw-song-details-cache.model.ts @@ -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; diff --git a/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts b/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts index 5bd1263e..8fa8fb3e 100644 --- a/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts +++ b/src/shared/models/maps/song-details-cache/raw-song-details-deserializer.class.ts @@ -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, diff --git a/src/shared/models/maps/song-details-cache/song-details-cache.model.ts b/src/shared/models/maps/song-details-cache/song-details-cache.model.ts index 5dd77c41..e5ae12ad 100644 --- a/src/shared/models/maps/song-details-cache/song-details-cache.model.ts +++ b/src/shared/models/maps/song-details-cache/song-details-cache.model.ts @@ -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;