[feature-107] multiple lists now uses virtual scroll

This commit is contained in:
MathieuG-P
2024-06-01 20:25:50 +02:00
parent 044174c711
commit 0ea355d75c
12 changed files with 366 additions and 181 deletions
@@ -39,6 +39,7 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, classNa
const {maps$, setMaps} = useContext(InstalledMapsContext);
const maps = useObservable(() => maps$, undefined);
const [renderableMaps, setRenderableMaps] = useState<RenderableMap[]>([]);
const [subs] = useState<Subscription[]>([]);
const [selectedMaps, setSelectedMaps] = useState<BsmLocalMap[]>([]);
const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true });
@@ -46,25 +47,25 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ 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<unknown, Props>(({ 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<unknown, Props>(({ version, classNa
} else {
mapsCopy.push(map);
}
return mapsCopy;
});
}
setSelectedMaps(mapsCopy)
},
[selectedMaps]
);
const renderMap = useCallback((renderableMap: RenderableMap) => {
const map = renderableMap.map;
return (
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.duration}
selected={renderableMap.selected}
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}
/>
);
}, [version])
const isMapFitFilter = (map: BsmLocalMap): boolean => {
// Can be more clean and optimized i think
@@ -322,12 +345,8 @@ export const LocalMapsListPanel = forwardRef<unknown, Props>(({ 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<unknown, Props>(({ version, classNa
maxColumns={3}
minItemWidth={400}
items={preppedMaps}
renderItem={map => (
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.duration}
selected={selectedMaps.some(selectedMap => 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;
};
@@ -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<T = unknown> = {
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 <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>) {
const linkOpener = useService(LinkOpenerService);
const audioPlayer = useService(AudioPlayerService);
@@ -337,7 +338,9 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
</div>
</motion.li>
);
}, equal);
};
export const MapItem = typedMemo(MapItemComponent, equal)
export function extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> {
const res = new Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>();
@@ -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<unknown, Props>(({ 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<unknown, Props>(({ 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<unknown, Props>(({ version, cl
})
};
const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => {
return (
<PlaylistItem
key={playlist.path}
{...PlaylistItemComponentPropsMapper.fromLocalBPListDetails(playlist)}
isDownloading$={playlistDownloader.$isPlaylistDownloading(playlist.customData?.syncURL ?? playlist.path, version)}
isInQueue$={playlistDownloader.$isPlaylistInQueue(playlist.customData?.syncURL ?? playlist.path, version)}
onClickOpen={() => 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 (
<div className={className}>
{(() => {
@@ -155,21 +169,18 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, cl
if (playlists?.length){
return (
<ul className="relative size-full flex flex-row flex-wrap justify-start content-start p-3 gap-3">
{playlists.map(p =>
<PlaylistItem
key={p.path}
{...PlaylistItemComponentPropsMapper.fromLocalBPListDetails(p)}
isDownloading$={playlistDownloader.$isPlaylistDownloading(p.customData?.syncURL ?? p.path, version)}
isInQueue$={playlistDownloader.$isPlaylistInQueue(p.customData?.syncURL ?? p.path, version)}
onClickOpen={() => openPlaylistDetails(p.path)}
onClickDelete={() => deletePlaylist(p)}
onClickSync={isOnline && (() => installPlaylist(p))}
onClickOpenFile={() => viewPlaylistFile(p.path)}
onClickCancelDownload={() => playlistDownloader.cancelDownload(p.customData?.syncURL ?? p.path, version)}
/>
)}
</ul>
<VirtualScroll
classNames={{
mainDiv: "size-full",
rows: "gap-2 px-2 py-2"
}}
itemHeight={120}
maxColumns={4}
minItemWidth={390}
items={playlists}
renderItem={renderPlaylist}
rowKey={rowPlaylists => rowPlaylists.map(p => p.path).join("-")}
/>
)
}
@@ -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<void, { version: BSVersion; ownedMaps: BsmLocalMap[] }> = ({ options: {data : { ownedMaps, version }} }) => {
const beatSaver = useService(BeatSaverService);
@@ -35,6 +36,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
const [filter, setFilter] = useState<MapFilter>({});
const [query, setQuery] = useState("");
const [maps, setMaps] = useState<BsvMapDetail[]>([]);
const [downloadbleMaps, setDownloadbleMaps] = useState<DownloadableMap[]>([]);
const [sortOrder, setSortOrder] = useState<BsvSearchOrder>(BsvSearchOrder.Latest);
const [ownedMapHashs, setOwnedMapHashs] = useState<string[]>(ownedMaps?.map(map => map.hash) ?? []);
const [loading, setLoading] = useState(false);
@@ -46,19 +48,27 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
q: query,
});
const loaderRef = useRef(null);
const sortOptions: BsmSelectOption<BsvSearchOrder>[] = 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<void, { version: BSVersion; owned
})
};
const renderMap = (map: BsvMapDetail) => {
const isMapOwned = map.versions.some(version => ownedMapHashs.includes(version.hash));
const isDownloading = map.id === currentDownload?.map?.id;
const inQueue = mapsInQueue.some(toDownload => equal(toDownload.version, version) && toDownload.map.id === map.id);
const renderMap = useCallback((downloadableMap: DownloadableMap) => {
const map = downloadableMap.map;
const downloadable = !downloadableMap.isOwned && !downloadableMap.isInQueue;
const cancelable = downloadableMap.isInQueue && !downloadableMap.idDownloading;
return <MapItem
autor={map.metadata.levelAuthorName}
@@ -141,13 +152,13 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
diffs={extractMapDiffs({ bsvMap: map })}
songUrl={map.versions.at(0).previewURL}
key={map.id}
onDownload={!isMapOwned && !inQueue && handleDownloadMap}
onDoubleClick={!isMapOwned && !inQueue && handleDownloadMap}
onCancelDownload={inQueue && !isDownloading && handleCancelDownload}
downloading={isDownloading}
showOwned={isMapOwned}
onDownload={downloadable && handleDownloadMap}
onDoubleClick={downloadable && handleDownloadMap}
onCancelDownload={cancelable && handleCancelDownload}
downloading={downloadableMap.idDownloading}
showOwned={downloadableMap.isOwned}
callBackParam={map} />;
};
}, [version]);
const handleDownloadMap = useCallback((map: BsvMapDetail) => {
mapsDownloader.addMapToDownload({ map, version });
@@ -176,6 +187,9 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
};
const handleLoadMore = () => {
if(loading){ return; }
setSearchParams(prev => {
return { ...prev, page: prev.page + 1 };
});
@@ -206,7 +220,62 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={sortOptions} onChange={sort => handleSortChange(sort)} />
</div>
<ul className="w-full grow flex content-start flex-wrap gap-2 pt-1.5 px-2 overflow-y-scroll overflow-x-hidden scrollbar-default z-0">
{(() => {
if(downloadbleMaps?.length) {
return (
<VirtualScroll
classNames={{
mainDiv: "size-full",
rows: "gap-x-2 px-2 py-2"
}}
minColumns={1}
maxColumns={2}
itemHeight={108}
minItemWidth={400}
items={downloadbleMaps}
rowKey={mapsInRow => mapsInRow.map(map => map.map.id).join("-")}
renderItem={renderMap}
scrollEnd={{
onScrollEnd: handleLoadMore,
margin: 100
}}
/>
)
}
return (
<div className="w-full h-full flex flex-col items-center justify-center">
<img className={`w-32 h-32 ${loading && "spin-loading"}`} src={loading ? BeatWaitingImg : BeatConflictImg} alt=" " />
<span className="text-lg">
{(() => {
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");
})()}
</span>
</div>
)
})()}
</form>
);
};
type DownloadableMap = {
map: BsvMapDetail;
isOwned: boolean;
idDownloading: boolean;
isInQueue: boolean;
};
{/* <ul className="w-full grow flex content-start flex-wrap gap-2 pt-1.5 px-2 overflow-y-scroll overflow-x-hidden scrollbar-default z-0">
{maps.length === 0 ? (
<div className="w-full h-full flex flex-col items-center justify-center">
<img className={`w-32 h-32 ${loading && "spin-loading"}`} src={loading ? BeatWaitingImg : BeatConflictImg} alt=" " />
@@ -228,7 +297,4 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
<motion.span onViewportEnter={handleLoadMore} ref={loaderRef} className="block w-full h-8" />
</>
)}
</ul>
</form>
);
};
</ul> */}
@@ -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<void, { version: BSVersion; type: MSModelType; owned: BsmLocalModel[] }> = ({ options: {data: { version, type, owned }} }) => {
const modelsDownloader = useService(ModelsDownloaderService);
@@ -47,6 +47,7 @@ export const DownloadModelsModal: ModalComponent<void, { version: BSVersion; typ
const currentDownload = useObservable(() => modelsDownloader.currentDownload$(), null);
const downloadQueue = useObservable(() => modelsDownloader.getQueue$(), []);
const [msModels, msModels$] = useBehaviorSubject<MSModel[]>([]);
const [renderableModels, setRenderableModels] = useState<RenderableModel[]>([]);
const isOnline = useObservable(() => os.isOnline$, true);
const [error, error$] = useBehaviorSubject(false);
const [isLoading, isLoading$] = useBehaviorSubject(false);
@@ -57,6 +58,15 @@ export const DownloadModelsModal: ModalComponent<void, { version: BSVersion; typ
const [searhInput, searhInput$] = useBehaviorSubject("");
const [getQuery, getQuery$] = useBehaviorSubject<MSGetQuery>({ 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<void, { version: BSVersion; typ
}, [currentType, currentSort]);
const loadMore = () => {
if(isLoading){ return; }
const currentQuery = getQuery;
currentQuery.start += 25;
currentQuery.end += 25;
@@ -128,13 +141,19 @@ export const DownloadModelsModal: ModalComponent<void, { version: BSVersion; typ
modelsDownloader.removeFromDownloadQueue({ model, version });
}, []);
const modelPendingDownload = (model: MSModel) => {
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 (
<ModelItem
key={renderModel.model.id}
{...renderModel.model}
callbackValue={renderModel.model}
isDownloading={renderModel.isDownloading}
onDownload={!renderModel.isOwned ? handleDownloadModel : undefined}
onDoubleClick={!renderModel.isOwned ? handleDownloadModel : undefined}
onCancelDownload={renderModel.isInQueue ? handleCancelDownload : undefined}
/>
);
}, [version]);
const renderFilterTips = useConstant(() => (
<div className="w-fit flex">
@@ -217,21 +236,37 @@ export const DownloadModelsModal: ModalComponent<void, { version: BSVersion; typ
/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center capitalize" options={querySortsOptions} onChange={value => currentSort$.next(value)} />
</div>
<ul className="w-full grow flex content-start flex-wrap gap-4 pt-1.5 px-2 overflow-y-scroll overflow-x-hidden scrollbar-default z-0">
{msModels.length === 0 ? (
<div className="w-full h-full flex flex-col items-center justify-center">
<img className={`w-32 h-32 ${isLoading && "spin-loading"}`} src={isLoading && !error ? BeatWaitingImg : BeatConflictImg} alt=" " />
{renderLoadingStatus()}
</div>
) : (
<>
{msModels.map(model => (
<ModelItem key={model.id} {...model} callbackValue={model} isDownloading={equal(currentDownload, { model, version } as ModelDownload)} onDownload={!isModelOwned(model) ? handleDownloadModel : undefined} onDoubleClick={!isModelOwned(model) ? handleDownloadModel : undefined} onCancelDownload={modelPendingDownload(model) ? handleCancelDownload : undefined} />
))}
<motion.span onViewportEnter={loadMore} className="block w-full h-8" />
</>
)}
</ul>
{!msModels.length ? (
<div className="size-full flex flex-col items-center justify-center">
<img className={`w-32 h-32 ${isLoading && "spin-loading"}`} src={isLoading && !error ? BeatWaitingImg : BeatConflictImg} alt=" " />
{renderLoadingStatus()}
</div>
) : (
<VirtualScroll
classNames={{
mainDiv: "size-full",
rows: "gap-x-4 p-4 first:pt-1.5",
}}
maxColumns={Infinity}
itemHeight={272}
minItemWidth={256}
items={renderableModels}
rowKey={rowModels => rowModels.map(m => m.model.id).join("-")}
renderItem={renderModel}
scrollEnd={{
onScrollEnd: loadMore,
margin: 100,
}}
/>
)}
</form>
);
};
type RenderableModel = {
model: MSModel;
isInQueue: boolean;
isDownloading: boolean;
isOwned: boolean;
};
@@ -31,7 +31,10 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
const playlistDownloader = useService(PlaylistDownloaderService);
const [playlists, setPlaylists] = useState<BsvPlaylist[]>(null);
const [downloadablePlaylists, setDownloadablePlaylists] = useState<DownloadablePlaylist[]>(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<void, {version: BSVersion, ow
page: 0,
});
useOnUpdate(() => {
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<void, {version: BSVersion, ow
setSearchParams(prev => ({ ...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 (
<PlaylistItem
key={playlist.playlistId}
{...PlaylistItemComponentPropsMapper.fromBsvPlaylist(playlist)}
isDownloading$={playlistDownloader.$isPlaylistDownloading(playlist.downloadURL, version)}
isInQueue$={playlistDownloader.$isPlaylistInQueue(playlist.downloadURL, version)}
onClickOpen={() => 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<void, {version: BSVersion, ow
<div className="max-w-[95vw] w-[970px] h-[85vh] flex flex-col gap-3">
<DownloadPlaylistModalHeader className="h-9 w-full" value={searchParams} onSubmit={handleNewSearch}/>
{(() => {
if(!Array.isArray(playlists)){
if(!Array.isArray(downloadablePlaylists)){
return (
<div className="w-full flex flex-col justify-center items-center mt-44">
<BsmImage className={cn(["size-32", !error && "spin-loading"])} image={error ? BeatConflict : BeatWaiting} />
@@ -94,7 +114,7 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
</div>
)
}
else if(playlists.length === 0){
else if(downloadablePlaylists.length === 0){
return (
<div className="w-full flex flex-col justify-center items-center mt-44">
<BsmImage className="size-32" image={BeatConflict} />
@@ -107,11 +127,11 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
return (
<VirtualScroll
classNames={{
mainDiv: "size-full overflow-hidden",
mainDiv: "size-full",
rows: "gap-2 px-2 py-2"
}}
itemHeight={120}
items={playlists}
items={downloadablePlaylists}
maxColumns={2}
minItemWidth={80}
scrollEnd={{
@@ -119,10 +139,16 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
margin: 120
}}
renderItem={renderPlaylist}
itemKey={items => items.map(item => item.playlistId).join("-")}
rowKey={items => items.map(item => item.playlist.playlistId).join("-")}
/>
)
})()}
</div>
)
}
type DownloadablePlaylist = {
playlist: BsvPlaylist;
isOwned: boolean;
ownedMaps: BsmLocalMap[];
}
@@ -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<T> = {
selected?: boolean;
@@ -193,6 +194,4 @@ function ModelItemElement<T = unknown>(props: Props<T>) {
);
}
const typedMemo: <T, P>(c: T, propsAreEqual?: (prevProps: Readonly<P>, nextProps: Readonly<P>) => boolean) => T = memo;
export const ModelItem = typedMemo(ModelItemElement, equal);
@@ -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<unknown, Props>(({ className, version, type
const t = useTranslation();
const [models, setModelsLoadObservable, , setModels] = useSwitchableObservable<BsmLocalModel[]>();
const [renderableModels, setRenderableModels] = useState<RenderableModel[]>([]);
const progress$ = useConstant(() => new BehaviorSubject(0));
const [modelsSelected, modelsSelected$] = useBehaviorSubject<BsmLocalModel[]>([]);
const [modelsSelected, setModelsSelected] = useState<BsmLocalModel[]>([]);
const isLoading = !models;
const hasModels = !!models?.length;
@@ -61,13 +62,20 @@ export const ModelsGrid = forwardRef<unknown, Props>(({ 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<unknown, Props>(({ 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<unknown, Props>(({ 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<unknown, Props>(({ 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 (
<ModelItem
{...model?.model}
key={model.path}
hash={model.model?.hash ?? model.hash}
path={model.path}
type={model.type}
name={model.model?.name ?? model.fileName}
selected={renderableModel.selected}
onClick={() => handleModelClick(model)}
onDelete={() => handleDelete(model)}
/>
)
}, [version]);
const renderContent = () => {
if (isLoading) {
return (
@@ -220,17 +248,30 @@ export const ModelsGrid = forwardRef<unknown, Props>(({ className, version, type
}
return (
<ul className="flex flex-wrap shrink-0 justify-start content-start w-full h-full overflow-y-scroll overflow-x-hidden p-4 gap-4 scrollbar-default">
{filtredModels().map(localModel => (
<ModelItem {...localModel?.model} key={localModel.path} hash={localModel.model?.hash ?? localModel.hash} path={localModel.path} type={localModel.type} name={localModel.model?.name ?? localModel.fileName} selected={modelsSelected.some(m => m.hash === localModel.hash)} onClick={() => handleModelClick(localModel)} onDelete={() => handleDelete(localModel)} />
))}
</ul>
<VirtualScroll
classNames={{
mainDiv: "size-full",
rows: "gap-x-4 p-4"
}}
itemHeight={272}
minItemWidth={256}
maxColumns={Infinity}
items={filtredModels()}
rowKey={rowModels => rowModels.map(m => m.model.path).join("-")}
renderItem={renderModel}
/>
);
};
return (
<div className={`w-full h-full flex-shrink-0 ${className ?? ""}`}>
<div className={`size-full flex-shrink-0 ${className ?? ""}`}>
{renderContent()}
</div>
);
});
type RenderableModel = {
model: BsmLocalModel;
selected: boolean;
};
@@ -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<T> = {
className?: string;
@@ -19,6 +20,4 @@ function VirtualRowComponent<T>({ className, style, items, renderItem }: Props<T
);
}
const typedMemo: <T, P>(c: T, propsAreEqual?: (prevProps: Readonly<P>, nextProps: Readonly<P>) => boolean) => T = memo;
export const VirtualRow = typedMemo(VirtualRowComponent);
@@ -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<T = unknown> = {
itemHeight: number;
items: T[];
renderItem: (item: T) => JSX.Element;
itemKey: (item: T[]) => string;
rowKey: (rowItems: T[]) => Key;
scrollEnd?: ScrollEndHandler;
}
export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, scrollEnd, renderItem, itemKey}: Props<T>) {
export function VirtualScroll<T = unknown>({ className, classNames, minItemWidth, maxColumns, minColumns, itemHeight, items, scrollEnd, renderItem, rowKey}: Props<T>) {
const ref = useRef(null);
const listRef = useRef<HTMLDivElement>(null);
@@ -93,7 +93,7 @@ export function VirtualScroll<T = unknown>({ 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" }}
+3
View File
@@ -0,0 +1,3 @@
import { memo } from "react";
export const typedMemo: <T, P>(c: T, propsAreEqual?: (prevProps: Readonly<P>, nextProps: Readonly<P>) => boolean) => T = memo;
@@ -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));
});
}