From 0ea355d75c4b753030dbe0bd126be5eabfd88acf Mon Sep 17 00:00:00 2001 From: MathieuG-P <40181755+Zagrios@users.noreply.github.com> Date: Sat, 1 Jun 2024 20:25:50 +0200 Subject: [PATCH] [feature-107] multiple lists now uses virtual scroll --- .../maps/local-maps-list-panel.component.tsx | 139 +++++++++--------- .../maps/map-item.component.tsx | 7 +- .../local-playlists-list-panel.component.tsx | 55 ++++--- .../download-maps-modal.component.tsx | 104 ++++++++++--- .../download-models-modal.component.tsx | 81 +++++++--- .../download-playlist-modal.component.tsx | 46 ++++-- .../model-item.component.tsx | 5 +- .../models-grid.component.tsx | 93 ++++++++---- .../virtual-scroll/virtual-row.component.tsx | 5 +- .../virtual-scroll.component.tsx | 8 +- src/renderer/helpers/typed-memo.ts | 3 + .../services/maps-downloader.service.ts | 1 - 12 files changed, 366 insertions(+), 181 deletions(-) create mode 100644 src/renderer/helpers/typed-memo.ts 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 74977b0c..1d04f076 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 @@ -39,6 +39,7 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa const {maps$, setMaps} = useContext(InstalledMapsContext); const maps = useObservable(() => maps$, undefined); + const [renderableMaps, setRenderableMaps] = useState([]); const [subs] = useState([]); const [selectedMaps, setSelectedMaps] = useState([]); const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true }); @@ -46,25 +47,25 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa const loadPercent$ = useConstant(() => new BehaviorSubject(0)); - useImperativeHandle( - forwardRef, - () => ({ - deleteMaps() { - const mapsToDelete = selectedMaps.length === 0 ? maps : selectedMaps; - mapsManager.deleteMaps(mapsToDelete, version).then(res => { - if (!res) { - return; - } - removeMapsFromList(mapsToDelete); - setSelectedMaps([]); - }); - }, - exportMaps() { - mapsManager.exportMaps(version, selectedMaps); - } - }), - [selectedMaps, maps, version] - ); + useImperativeHandle(forwardRef, () => ({ + deleteMaps() { + const mapsToDelete = selectedMaps.length === 0 ? maps : selectedMaps; + mapsManager.deleteMaps(mapsToDelete, version).then(res => { + if (!res) { + return; + } + removeMapsFromList(mapsToDelete); + setSelectedMaps([]); + }); + }, + exportMaps() { + mapsManager.exportMaps(version, selectedMaps); + } + }),[selectedMaps, maps, version]); + + useOnUpdate(() => { + setRenderableMaps(() => maps?.map(map => ({ map, selected: selectedMaps.some(selectedMap => selectedMap.hash === map.hash) }) )); + }, [maps, selectedMaps]); useOnUpdate(() => { if(linkedState === FolderLinkState.Pending || linkedState === FolderLinkState.Processing) return () => {}; @@ -120,27 +121,24 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa }; const removeMapsFromList = (mapsToRemove: BsmLocalMap[]) => { - const filtredMaps = maps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash)); + const filtredMaps = maps$.value.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash)); setMaps(filtredMaps); - const filtredSelectedMaps = selectedMaps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash)); - setSelectedMaps(filtredSelectedMaps); + setSelectedMaps(selectedMaps => selectedMaps.filter(map => !mapsToRemove.some(toDeleteMaps => map.hash === toDeleteMaps.hash))); }; - const handleDelete = useCallback( - (map: BsmLocalMap) => { - mapsManager.deleteMaps([map], version).then(res => { - if (!res) { - return; - } - removeMapsFromList([map]); - }); - }, - [version, maps] - ); + const handleDelete = (map: BsmLocalMap) => { + mapsManager.deleteMaps([map], version).then(res => { + if (!res) { + return; + } + removeMapsFromList([map]); + }); + } - const onMapSelected = useCallback( - (map: BsmLocalMap) => { + const onMapSelected = (map: BsmLocalMap) => { + + setSelectedMaps(selectedMaps => { const mapsCopy = [...selectedMaps]; if (mapsCopy.some(selectedMap => selectedMap.hash === map.hash)) { const i = mapsCopy.findIndex(selectedMap => selectedMap.hash === map.hash); @@ -148,11 +146,36 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa } else { mapsCopy.push(map); } + return mapsCopy; + }); + } - setSelectedMaps(mapsCopy) - }, - [selectedMaps] - ); + const renderMap = useCallback((renderableMap: RenderableMap) => { + const map = renderableMap.map; + return ( + + ); + }, [version]) const isMapFitFilter = (map: BsmLocalMap): boolean => { // Can be more clean and optimized i think @@ -322,12 +345,8 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa return true; }; - const preppedMaps: BsmLocalMap[] = (() => { - if (!maps) { - return []; - } - - return maps.filter(isMapFitFilter); + const preppedMaps: RenderableMap[] = (() => { + return renderableMaps?.filter(renderableMap => isMapFitFilter(renderableMap.map)) ?? []; })(); if (!maps) { @@ -366,28 +385,12 @@ export const LocalMapsListPanel = forwardRef(({ version, classNa maxColumns={3} minItemWidth={400} items={preppedMaps} - renderItem={map => ( - selectedMap.hash === map.hash)} - 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} - onDelete={handleDelete} - onSelected={onMapSelected} - callBackParam={map} - /> - )}/> + rowKey={rowMaps => rowMaps.map(map => map.map.hash).join("")} + renderItem={renderMap}/> ); }); + +type RenderableMap = { + map: BsmLocalMap; + selected: boolean; +}; 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 8fd99d6f..efa32a9e 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 @@ -23,6 +23,7 @@ import Tippy from "@tippyjs/react"; import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetails, SongDiffName } from "shared/models/maps"; import { useConstant } from "renderer/hooks/use-constant.hook"; import { CalendarDateTime, getLocalTimeZone } from "@internationalized/date"; +import { typedMemo } from "renderer/helpers/typed-memo"; export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number }; @@ -52,7 +53,7 @@ export type MapItemProps = { onDoubleClick?: (param: T) => void; }; -export const MapItem = memo(({ 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 }: MapItemProps) { const linkOpener = useService(LinkOpenerService); const audioPlayer = useService(AudioPlayerService); @@ -337,7 +338,9 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl, ); -}, equal); +}; + +export const MapItem = typedMemo(MapItemComponent, equal) export function extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map { const res = new Map(); 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 10fe26d3..6e175781 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 @@ -1,4 +1,4 @@ -import { forwardRef, useContext, useState } from "react"; +import { forwardRef, useCallback, 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"; @@ -24,6 +24,7 @@ import { NotificationService } from "renderer/services/notification.service"; import { DeletePlaylistModal } from "renderer/components/modal/modal-types/playlist/delete-playlist-modal.component"; import { OsDiagnosticService } from "renderer/services/os-diagnostic.service"; import { PlaylistItemComponentPropsMapper } from "shared/mappers/playlist/playlist-item-component-props.mapper"; +import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; type Props = { version: BSVersion; @@ -79,9 +80,7 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }); const onPlaylistDownloadedCB = (downloaded: LocalBPListsDetails) => { - console.log("onPlaylistDownloadedCB", downloaded); const newPlaylist = (() => { - console.log(playlists); const index = playlists$.value.findIndex(p => p.path === downloaded.path); if(index === -1){ return [...playlists$.value, downloaded]; @@ -120,18 +119,17 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }; const deletePlaylist = async (bpList: LocalBPList) => { - // !! Need to call the modal to confirm the deletion and to ask if the maps should be deleted too const { exitCode, data: deleteMaps } = await modals.openModal(DeletePlaylistModal, { data: bpList }); if(exitCode !== ModalExitCode.COMPLETED){ return; } lastValueFrom(playlistService.deletePlaylist({ version, bpList, deleteMaps })).then(() => { - setPlaylists(playlists.filter(p => p.path !== bpList.path)); + setPlaylists(playlists$.value.filter(p => p.path !== bpList.path)); }) }; - const openPlaylistDetails = (playlistKey: string) => { - const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistKey))); + const openPlaylistDetails = (playlistPath: string) => { + const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistPath))); const installedMaps$ = combineLatest([maps$, localPlaylist$]).pipe( filter(([maps, playlist]) => !!maps && !!playlist), map(([maps, playlist]) => maps.filter(m => playlist.songs.some(song => song.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))), @@ -144,6 +142,22 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl }) }; + const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => { + return ( + openPlaylistDetails(playlist.path)} + onClickDelete={() => deletePlaylist(playlist)} + onClickSync={isOnline && (() => installPlaylist(playlist))} + onClickOpenFile={() => viewPlaylistFile(playlist.path)} + onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.customData?.syncURL ?? playlist.path, version)} + /> + ); + }, [isOnline, version]); + return (
{(() => { @@ -155,21 +169,18 @@ export const LocalPlaylistsListPanel = forwardRef(({ version, cl if (playlists?.length){ return ( -
    - {playlists.map(p => - openPlaylistDetails(p.path)} - onClickDelete={() => deletePlaylist(p)} - onClickSync={isOnline && (() => installPlaylist(p))} - onClickOpenFile={() => viewPlaylistFile(p.path)} - onClickCancelDownload={() => playlistDownloader.cancelDownload(p.customData?.syncURL ?? p.path, version)} - /> - )} -
+ rowPlaylists.map(p => p.path).join("-")} + /> ) } diff --git a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx index 1aaadc33..e364d458 100644 --- a/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/download-maps-modal.component.tsx @@ -21,6 +21,7 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"; import { useService } from "renderer/hooks/use-service.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date"; +import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; export const DownloadMapsModal: ModalComponent = ({ options: {data : { ownedMaps, version }} }) => { const beatSaver = useService(BeatSaverService); @@ -35,6 +36,7 @@ export const DownloadMapsModal: ModalComponent({}); const [query, setQuery] = useState(""); const [maps, setMaps] = useState([]); + const [downloadbleMaps, setDownloadbleMaps] = useState([]); const [sortOrder, setSortOrder] = useState(BsvSearchOrder.Latest); const [ownedMapHashs, setOwnedMapHashs] = useState(ownedMaps?.map(map => map.hash) ?? []); const [loading, setLoading] = useState(false); @@ -46,19 +48,27 @@ export const DownloadMapsModal: ModalComponent[] = useConstant(() => { return Object.values(BsvSearchOrder).map(sort => ({ text: `beat-saver.maps-sorts.${sort}`, value: sort })); }); + useEffect(() => { + setDownloadbleMaps(() => maps.map(map => { + 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 { map, isOwned: isMapOwned, idDownloading: isDownloading, isInQueue: inQueue }; + })); + }, [maps, currentDownload, mapsInQueue, ownedMapHashs, currentDownload]) + useEffect(() => { loadMaps(searchParams); }, [searchParams]); useEffect(() => { - const onMapDownloaded = (map: BsmLocalMap, targerVersion: BSVersion) => { - if (!equal(targerVersion, version)) { + const onMapDownloaded = (map: BsmLocalMap, targetVersion: BSVersion) => { + if (!equal(targetVersion, version)) { return; } const downloadedHash = map.hash; @@ -121,10 +131,11 @@ export const DownloadMapsModal: ModalComponent { - 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); + const renderMap = useCallback((downloadableMap: DownloadableMap) => { + const map = downloadableMap.map; + + const downloadable = !downloadableMap.isOwned && !downloadableMap.isInQueue; + const cancelable = downloadableMap.isInQueue && !downloadableMap.idDownloading; return ; - }; + }, [version]); const handleDownloadMap = useCallback((map: BsvMapDetail) => { mapsDownloader.addMapToDownload({ map, version }); @@ -176,6 +187,9 @@ export const DownloadMapsModal: ModalComponent { + + if(loading){ return; } + setSearchParams(prev => { return { ...prev, page: prev.page + 1 }; }); @@ -206,7 +220,62 @@ export const DownloadMapsModal: ModalComponent handleSortChange(sort)} />
-
    + + {(() => { + if(downloadbleMaps?.length) { + return ( + mapsInRow.map(map => map.map.id).join("-")} + renderItem={renderMap} + scrollEnd={{ + onScrollEnd: handleLoadMore, + margin: 100 + }} + + /> + ) + } + + return ( +
    +  + + {(() => { + if (loading) { + return t("modals.download-maps.loading-maps"); + } + if (isOnline) { + return t("modals.download-maps.no-maps-found"); + } + return t("modals.download-maps.no-internet"); + })()} + +
    + ) + + })()} + + + ); +}; + +type DownloadableMap = { + map: BsvMapDetail; + isOwned: boolean; + idDownloading: boolean; + isInQueue: boolean; +}; + +{/*
      {maps.length === 0 ? (
       @@ -228,7 +297,4 @@ export const DownloadMapsModal: ModalComponent )} -
    - - ); -}; +
*/} diff --git a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx index 42982b8f..a7c47480 100644 --- a/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx +++ b/src/renderer/components/modal/modal-types/models/download-models-modal.component.tsx @@ -1,4 +1,3 @@ -import { motion } from "framer-motion"; import { BsmButton } from "renderer/components/shared/bsm-button.component"; import { BsmSelect, BsmSelectOption } from "renderer/components/shared/bsm-select.component"; import { ModalComponent } from "renderer/services/modale.service"; @@ -22,6 +21,7 @@ import equal from "fast-deep-equal"; import { catchError, of } from "rxjs"; import Tippy from "@tippyjs/react"; import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface"; +import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"; export const DownloadModelsModal: ModalComponent = ({ options: {data: { version, type, owned }} }) => { const modelsDownloader = useService(ModelsDownloaderService); @@ -47,6 +47,7 @@ export const DownloadModelsModal: ModalComponent modelsDownloader.currentDownload$(), null); const downloadQueue = useObservable(() => modelsDownloader.getQueue$(), []); const [msModels, msModels$] = useBehaviorSubject([]); + const [renderableModels, setRenderableModels] = useState([]); const isOnline = useObservable(() => os.isOnline$, true); const [error, error$] = useBehaviorSubject(false); const [isLoading, isLoading$] = useBehaviorSubject(false); @@ -57,6 +58,15 @@ export const DownloadModelsModal: ModalComponent({ type: currentType, platform: MSModelPlatform.PC, start: 0, end: 25, sort: currentSort, sortDirection: MSGetSortDirection.Descending }); + useOnUpdate(() => { + setRenderableModels(() => msModels?.map(msModel => ({ + model: msModel, + isInQueue: downloadQueue.some(download => download.model.id === msModel.id && equal(download.version, version)), + isDownloading: equal(currentDownload, { model: msModel, version } as ModelDownload), + isOwned: ownedModels.some(owned => owned.hash === msModel.hash), + }))) + }, [msModels, currentDownload, downloadQueue, ownedModels]); + useOnUpdate(() => { const sub = modelsDownloader.onModelsDownloaded(model => { if (!equal(model.version, version)) { @@ -101,6 +111,9 @@ export const DownloadModelsModal: ModalComponent { + + if(isLoading){ return; } + const currentQuery = getQuery; currentQuery.start += 25; currentQuery.end += 25; @@ -128,13 +141,19 @@ export const DownloadModelsModal: ModalComponent { - return downloadQueue.some(download => download.model.id === model.id && equal(download.version, version)); - }; - - const isModelOwned = (model: MSModel) => { - return !!ownedModels.some(owned => owned.hash === model.hash); - }; + const renderModel = useCallback((renderModel: RenderableModel) => { + return ( + + ); + }, [version]); const renderFilterTips = useConstant(() => (
@@ -217,21 +236,37 @@ export const DownloadModelsModal: ModalComponent currentSort$.next(value)} />
-
    - {msModels.length === 0 ? ( -
    -  - {renderLoadingStatus()} -
    - ) : ( - <> - {msModels.map(model => ( - - ))} - - - )} -
+ + {!msModels.length ? ( +
+  + {renderLoadingStatus()} +
+ ) : ( + rowModels.map(m => m.model.id).join("-")} + renderItem={renderModel} + scrollEnd={{ + onScrollEnd: loadMore, + margin: 100, + }} + /> + )} ); }; + +type RenderableModel = { + model: MSModel; + isInQueue: boolean; + isDownloading: boolean; + isOwned: boolean; +}; 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 1218c554..ee1cea4f 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 @@ -31,7 +31,10 @@ export const DownloadPlaylistModal: ModalComponent(null); + const [downloadablePlaylists, setDownloadablePlaylists] = useState(null); + const ownedPlaylists = useObservable(() => ownedPlaylists$, []); + const ownedMaps = useObservable(() => ownedMaps$, []); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); @@ -41,6 +44,14 @@ export const DownloadPlaylistModal: ModalComponent { + setDownloadablePlaylists(() => playlists?.map(playlist => ({ + playlist, + isOwned: ownedPlaylists.some(ownedPlaylist => ownedPlaylist.id === playlist.playlistId), + ownedMaps: ownedMaps + }))); + }, [playlists, ownedPlaylists, ownedMaps]) + useOnUpdate(() => { setLoading(() => true); beatSaver.searchPlaylists(searchParams) @@ -63,19 +74,28 @@ export const DownloadPlaylistModal: ModalComponent ({ ...prev, page: prev.page + 1 })); }; - const renderPlaylist = useCallback((playlist: BsvPlaylist) => { + const renderPlaylist = useCallback((downloadablePlaylists: DownloadablePlaylist) => { - const onClickDownload = async () => { - const ownedMaps = await lastValueFrom(ownedMaps$.pipe(take(1))); - await lastValueFrom(playlistDownloader.downloadPlaylist({ downloadSource: playlist.downloadURL, ignoreSongsHashs: ownedMaps.map(map => map.hash), version })); + const playlist = downloadablePlaylists.playlist; + + const onClickDownload = () => { + lastValueFrom(playlistDownloader.downloadPlaylist({ + downloadSource: playlist.downloadURL, + ignoreSongsHashs: downloadablePlaylists.ownedMaps.map(map => map.hash), + version + })); } return ( openPlaylist(playlist)} - onClickDownload={onClickDownload} + onClickDownload={downloadablePlaylists.isOwned ? null : onClickDownload} + onClickSync={downloadablePlaylists.isOwned ? onClickDownload : null} + onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.downloadURL, version)} /> ); }, [version]); @@ -84,7 +104,7 @@ export const DownloadPlaylistModal: ModalComponent {(() => { - if(!Array.isArray(playlists)){ + if(!Array.isArray(downloadablePlaylists)){ return (
@@ -94,7 +114,7 @@ export const DownloadPlaylistModal: ModalComponent ) } - else if(playlists.length === 0){ + else if(downloadablePlaylists.length === 0){ return (
@@ -107,11 +127,11 @@ export const DownloadPlaylistModal: ModalComponent items.map(item => item.playlistId).join("-")} + rowKey={items => items.map(item => item.playlist.playlistId).join("-")} /> ) })()}
) } + +type DownloadablePlaylist = { + playlist: BsvPlaylist; + isOwned: boolean; + ownedMaps: BsmLocalMap[]; +} diff --git a/src/renderer/components/models-management/model-item.component.tsx b/src/renderer/components/models-management/model-item.component.tsx index ea0b90fb..a931bf40 100644 --- a/src/renderer/components/models-management/model-item.component.tsx +++ b/src/renderer/components/models-management/model-item.component.tsx @@ -1,5 +1,5 @@ import equal from "fast-deep-equal"; -import { memo, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { MSModel } from "shared/models/models/model-saber.model"; import { BsmImage } from "../shared/bsm-image.component"; import { motion } from "framer-motion"; @@ -19,6 +19,7 @@ import { useTranslation } from "renderer/hooks/use-translation.hook"; import useDoubleClick from "use-double-click"; import { useDelayedState } from "renderer/hooks/use-delayed-state.hook"; import { ChevronTopIcon } from "../svgs/icons/chevron-top-icon.component"; +import { typedMemo } from "renderer/helpers/typed-memo"; type Props = { selected?: boolean; @@ -193,6 +194,4 @@ function ModelItemElement(props: Props) { ); } -const typedMemo: (c: T, propsAreEqual?: (prevProps: Readonly

, nextProps: Readonly

) => boolean) => T = memo; - export const ModelItem = typedMemo(ModelItemElement, equal); diff --git a/src/renderer/components/models-management/models-grid.component.tsx b/src/renderer/components/models-management/models-grid.component.tsx index fdd28ee3..45f08554 100644 --- a/src/renderer/components/models-management/models-grid.component.tsx +++ b/src/renderer/components/models-management/models-grid.component.tsx @@ -1,5 +1,5 @@ import { BSVersion } from "shared/bs-version.interface"; -import { forwardRef, useImperativeHandle } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useState } from "react"; import { MSModelType } from "shared/models/models/model-saber.model"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; import { useConstant } from "renderer/hooks/use-constant.hook"; @@ -7,7 +7,6 @@ import { ModelsManagerService } from "renderer/services/models-management/models import { useSwitchableObservable } from "renderer/hooks/use-switchable-observable.hook"; import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface"; import { ModelItem } from "./model-item.component"; -import { useBehaviorSubject } from "renderer/hooks/use-behavior-subject.hook"; import { BsmImage } from "../shared/bsm-image.component"; import BeatConflict from "../../../../assets/images/apngs/beat-conflict.png"; import { BehaviorSubject, distinctUntilChanged, map, startWith } from "rxjs"; @@ -19,6 +18,7 @@ import { useService } from "renderer/hooks/use-service.hook"; import { ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service"; import { useTranslation } from "renderer/hooks/use-translation.hook"; import { BsContentLoader } from "../shared/bs-content-loader.component"; +import { VirtualScroll } from "../shared/virtual-scroll/virtual-scroll.component"; type Props = { className?: string; @@ -36,8 +36,9 @@ export const ModelsGrid = forwardRef(({ className, version, type const t = useTranslation(); const [models, setModelsLoadObservable, , setModels] = useSwitchableObservable(); + const [renderableModels, setRenderableModels] = useState([]); const progress$ = useConstant(() => new BehaviorSubject(0)); - const [modelsSelected, modelsSelected$] = useBehaviorSubject([]); + const [modelsSelected, setModelsSelected] = useState([]); const isLoading = !models; const hasModels = !!models?.length; @@ -61,13 +62,20 @@ export const ModelsGrid = forwardRef(({ className, version, type } const newModels = models.filter(m => !deletedModels.some(d => d.hash === m.hash)); setModels(() => newModels); - modelsSelected$.next([]); + setModelsSelected(() => []); }); }, }), [modelsSelected, models] ); + useOnUpdate(() => setRenderableModels(() => ( + models?.map(model => ({ + model, + selected: modelsSelected.some(m => m.hash === model.hash) + })) ?? []) + ), [modelsSelected, models]); + useOnUpdate(() => { if (!active) { return; @@ -134,27 +142,28 @@ export const ModelsGrid = forwardRef(({ className, version, type }; const handleModelClick = (model: BsmLocalModel) => { - const prunedArray = Array.from(new Set(modelsSelected)); - if (prunedArray.some(m => m.hash === model.hash)) { - prunedArray.splice( - prunedArray.findIndex(m => m.hash === model.hash), - 1 - ); - } else { - prunedArray.push(model); - } - modelsSelected$.next(prunedArray); + setModelsSelected(prev => { + const newModels = [...prev]; + const index = newModels.findIndex(m => m.hash === model.hash); + if (index === -1) { + newModels.push(model); + } else { + newModels.splice(index, 1); + } + return newModels; + + }); }; const filtredModels = () => { if (!active) { - return models; + return renderableModels; } const lowerSearch = search?.toLowerCase(); - return models?.filter(model => { - const findedInRawValues = Object.values(model).some(value => { + return renderableModels?.filter(model => { + const findedInRawValues = Object.values(model.model).some(value => { if (typeof value !== "string" && typeof value !== "number") { return false; } @@ -165,11 +174,11 @@ export const ModelsGrid = forwardRef(({ className, version, type return true; } - if (!model.model) { + if (!model.model.model) { return false; } - return Object.values(model.model).some(value => { + return Object.values(model.model.model).some(value => { if (typeof value !== "string" && typeof value !== "number" && !Array.isArray(value)) { return false; } @@ -189,10 +198,29 @@ export const ModelsGrid = forwardRef(({ className, version, type return; } setModels(prev => prev.filter(m => m.hash !== model.hash)); - modelsSelected$.next(modelsSelected.filter(m => m.hash !== model.hash)); + setModelsSelected(modelsSelected => modelsSelected.filter(m => m.hash !== model.hash)); }); }; + const renderModel = useCallback((renderableModel: RenderableModel) => { + + const model = renderableModel.model; + + return ( + handleModelClick(model)} + onDelete={() => handleDelete(model)} + /> + ) + }, [version]); + const renderContent = () => { if (isLoading) { return ( @@ -220,17 +248,30 @@ export const ModelsGrid = forwardRef(({ className, version, type } return ( -

    - {filtredModels().map(localModel => ( - m.hash === localModel.hash)} onClick={() => handleModelClick(localModel)} onDelete={() => handleDelete(localModel)} /> - ))} -
+ rowModels.map(m => m.model.path).join("-")} + renderItem={renderModel} + + /> ); }; return ( -
+
{renderContent()}
); }); + +type RenderableModel = { + model: BsmLocalModel; + selected: boolean; +}; diff --git a/src/renderer/components/shared/virtual-scroll/virtual-row.component.tsx b/src/renderer/components/shared/virtual-scroll/virtual-row.component.tsx index de473f73..f595241a 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-row.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-row.component.tsx @@ -1,5 +1,6 @@ -import { CSSProperties, memo } from "react"; +import { CSSProperties } from "react"; import { cn } from "renderer/helpers/css-class.helpers"; +import { typedMemo } from "renderer/helpers/typed-memo"; type Props = { className?: string; @@ -19,6 +20,4 @@ function VirtualRowComponent({ className, style, items, renderItem }: Props(c: T, propsAreEqual?: (prevProps: Readonly

, nextProps: Readonly

) => boolean) => T = memo; - export const VirtualRow = typedMemo(VirtualRowComponent); 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 1bc74f34..30e5c23d 100644 --- a/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx +++ b/src/renderer/components/shared/virtual-scroll/virtual-scroll.component.tsx @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useRef, useState } from "react"; +import { Key, useCallback, useLayoutEffect, useRef, useState } from "react"; import { ListChildComponentProps, ListOnScrollProps, VariableSizeList } from "react-window"; import { cn } from "renderer/helpers/css-class.helpers"; import { useOnUpdate } from "renderer/hooks/use-on-update.hook"; @@ -28,11 +28,11 @@ type Props = { itemHeight: number; items: T[]; renderItem: (item: T) => JSX.Element; - itemKey: (item: T[]) => string; + rowKey: (rowItems: T[]) => Key; scrollEnd?: ScrollEndHandler; } -export function VirtualScroll({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, scrollEnd, renderItem, itemKey}: Props) { +export function VirtualScroll({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, scrollEnd, renderItem, rowKey}: Props) { const ref = useRef(null); const listRef = useRef(null); @@ -93,7 +93,7 @@ export function VirtualScroll({ className, classNames, minItemWidth width="100%" height={listHeight} layout="vertical" itemCount={itemsToRender.length} - itemKey={i => itemKey?.(itemsToRender[i]) ?? i} + itemKey={i => rowKey?.(itemsToRender[i]) ?? i} itemSize={() => itemHeight} itemData={itemsToRender} style={{ scrollbarGutter: "stable both-edges" }} diff --git a/src/renderer/helpers/typed-memo.ts b/src/renderer/helpers/typed-memo.ts new file mode 100644 index 00000000..17ba625d --- /dev/null +++ b/src/renderer/helpers/typed-memo.ts @@ -0,0 +1,3 @@ +import { memo } from "react"; + +export const typedMemo: (c: T, propsAreEqual?: (prevProps: Readonly

, nextProps: Readonly

) => boolean) => T = memo; diff --git a/src/renderer/services/maps-downloader.service.ts b/src/renderer/services/maps-downloader.service.ts index b67e85ed..bf2cbcf7 100644 --- a/src/renderer/services/maps-downloader.service.ts +++ b/src/renderer/services/maps-downloader.service.ts @@ -45,7 +45,6 @@ export class MapsDownloaderService { }); this.ipc.watch<{map: BsmLocalMap, version?: BSVersion}>("map-downloaded").subscribe((data) => { - console.log(data); this.downloadedListerners.forEach(func => func(data.map, data.version)); }); }