[feature-107] minor fixes + use virtual scroll in playlists details view

This commit is contained in:
MathieuG-P
2024-06-10 16:22:52 +02:00
parent 901137ada9
commit 15ebe8e929
8 changed files with 181 additions and 115 deletions
@@ -1,5 +1,5 @@
import path from "path";
import { Observable, Subject, from, lastValueFrom, mergeMap, take, takeUntil, tap } from "rxjs";
import { Observable, Subject, from, lastValueFrom, takeUntil, tap } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BSLocalVersionService } from "../bs-local-version.service";
import { DeepLinkService } from "../deep-link.service";
@@ -7,11 +7,11 @@ import { RequestService } from "../request.service";
import { LocalMapsManagerService } from "./maps/local-maps-manager.service";
import log from "electron-log";
import { WindowManagerService } from "../window-manager.service";
import { BPList, DownloadPlaylistProgressionData, PlaylistSong } from "shared/models/playlists/playlist.interface";
import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface";
import { readFileSync } from "fs";
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFile, writeFileSync } from "fs-extra";
import { Progression, ensurePathNotAlreadyExist, ensurePathNotAlreadyExistSync, pathExist, unlinkPath } from "../../helpers/fs.helpers";
import { copy, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFileSync } from "fs-extra";
import { Progression, unlinkPath } from "../../helpers/fs.helpers";
import { FileAssociationService } from "../file-association.service";
import { SongDetailsCacheService } from "./maps/song-details-cache.service";
import { sToMs } from "shared/helpers/time.helpers";
@@ -20,9 +20,9 @@ import { SongCacheService } from "./maps/song-cache.service";
import { InstallationLocationService } from "../installation-location.service";
import sanitize from "sanitize-filename";
import { isValidUrl } from "shared/helpers/url.helpers";
import { allSettled } from "shared/helpers/promise.helpers";
import { Archive } from "main/models/archive.class";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
export class LocalPlaylistsManagerService {
private static instance: LocalPlaylistsManagerService;
@@ -291,7 +291,7 @@ export class LocalPlaylistsManagerService {
return from(unlinkPath(bpList.path));
}
public exportPlaylists(opt: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable<Progression<string>> {
public exportPlaylists(opt: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}): Observable<Progression<string>> {
if(!pathExistsSync(opt.dest)) {
throw new CustomError(`Destination folder not found ${opt.dest}`, "DEST_ENOENT");
@@ -310,55 +310,31 @@ export class LocalPlaylistsManagerService {
for(const bpList of opt.bpLists) {
if(!pathExistsSync(bpList.path)) {
throw new CustomError(`Playlist file not found ${bpList.path}`, "PLAYLIST_ENOENT");
log.warn(`Playlist file not found for export`, bpList.path);
continue;
}
archive.addFile(bpList.path, path.join(this.PLAYLISTS_FOLDER, path.basename(bpList.path)));
}
if(!opt.exportMaps) {
if(!Array.isArray(opt.playlistsMaps) || opt.playlistsMaps.length === 0){
return archive.finalize();
}
const mapsHashsToExport = Array.from(
new Set<string>(opt.bpLists.reduce((acc, bpList) => acc.concat((bpList.songs ?? []).map(s => s.hash)), [])).values()
);
for(const map of opt.playlistsMaps) {
const zipMaps$ = new Observable<Progression<string>>(obs => {
(async () => {
const progress: Progression<string> = { total: mapsHashsToExport.length, current: 0, data: zipDest };
if(!map?.path || !pathExistsSync(map.path)) {
log.warn(`Map file not found for playlist export`, map?.path);
continue;
}
for(const hash of mapsHashsToExport) {
const mapInfo = await this.maps.getMapInfoFromHash(hash, opt.version);
if(!mapInfo || !pathExistsSync(mapInfo.path)) { continue; }
archive.addDirectory(
mapInfo.path,
path.join("Maps", path.basename(mapInfo.path)) // Dont't know why, but "CustomLevels" not work
);
progress.current += 1;
obs.next(progress);
}
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
});
return new Observable<Progression<string>>(obs => {
(async () => {
const maps$ = zipMaps$.pipe(tap({ next: p => obs.next(p) }));
const archive$ = archive.finalize().pipe(tap({ next: p => obs.next(p) }));
await lastValueFrom(maps$);
await lastValueFrom(archive$);
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
})
archive.addDirectory(
map.path,
path.join("Maps", path.basename(map.path)) // Dont't know why, but "CustomLevels" not work
);
}
return archive.finalize();
}
public oneClickInstallPlaylist(bpListUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
@@ -287,9 +287,9 @@ export class LocalMapsManagerService {
const versionMapsPath = await this.getMapsFolderPath(version);
const mapInfo = this.songCache.getMapInfoFromHash(hash);
const cachedMapPath = path.join(versionMapsPath, mapInfo.dirname);
const cachedMapPath = (versionMapsPath && mapInfo?.dirname) && path.join(versionMapsPath, mapInfo.dirname);
if(pathExistsSync(cachedMapPath)){
if(cachedMapPath && pathExistsSync(cachedMapPath)){
return this.loadMapInfoFromPath(cachedMapPath);
}
@@ -53,6 +53,8 @@ export type LocalPlaylistsListRef = {
exportPlaylists: () => Promise<void>;
}
// TODO : Translate
export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(({ version, className, filter: playlistFiler, search, isActive, linkedState }, forwardedRef) => {
const t = useTranslation();
@@ -113,14 +115,17 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
if(!toExport.length){ return; }
const modalRes = await modals.openModal(ExportPlaylistModal, { data: toExport });
if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; }
const { exitCode, data: exportMaps } = await modals.openModal(ExportPlaylistModal, { data: toExport });
if(exitCode !== ModalExitCode.COMPLETED){ return; }
const folderRes = await lastValueFrom(ipc.sendV2("choose-folder"));
if(!folderRes || folderRes.canceled || !folderRes.filePaths?.length){ return; }
if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; }
const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), exportMaps: modalRes.data });
const mapsToExport = exportMaps ? (
maps$?.value?.filter(m => toExport.some(p => p.songs.some(s => s.hash.toLocaleLowerCase() === m.hash.toLocaleLowerCase()))) ?? []
) : [];
const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), playlistsMaps: mapsToExport });
progess.show(obs$, true);
@@ -60,7 +60,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
return { map, isOwned: isMapOwned, idDownloading: isDownloading, isInQueue: inQueue };
}));
}, [maps, currentDownload, mapsInQueue, ownedMapHashs, currentDownload])
}, [maps, currentDownload, mapsInQueue, ownedMapHashs])
useEffect(() => {
loadMaps(searchParams);
@@ -5,19 +5,19 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Observable } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { MapItem, extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service";
import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date";
import { motion } from "framer-motion";
import { MapsDownloaderService } from "renderer/services/maps-downloader.service";
import equal from "fast-deep-equal";
import BeatWaiting from "../../../../../../../assets/images/apngs/beat-waiting.png"
import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict.png"
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { cn } from "renderer/helpers/css-class.helpers";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
type Props = {
version: BSVersion;
@@ -32,10 +32,12 @@ export const BsvPlaylistDetailsModal: ModalComponent<void, Props> = ({ resolver,
const beatsaver = useService(BeatSaverService);
const mapsDownloader = useService(MapsDownloaderService);
const currentMapDownload = useObservable(() => mapsDownloader.currentMapDownload$, null);
const downloadingMaps = useObservable(() => mapsDownloader.mapsInQueue$, []);
const installedMaps = useObservable(() => installedMaps$, null);
const [page, setPage] = useState(0);
const [playlistMaps, setPlaylistMaps] = useState<BsvMapDetail[]>(null);
const [downloadbleMaps, setDownloadbleMaps] = useState<DownloadableMap[]>([]);
const [error, setError] = useState<boolean>(false);
useOnUpdate(() => {
@@ -44,17 +46,61 @@ export const BsvPlaylistDetailsModal: ModalComponent<void, Props> = ({ resolver,
.catch(() => setError(() => true));
}, [page]);
useEffect(() => {
const ownedMapHashs = installedMaps?.map(map => map.hash) ?? [];
if(!Array.isArray(playlistMaps)){
return setDownloadbleMaps([]);
}
setDownloadbleMaps(() => playlistMaps.map(map => {
const isMapOwned = map.versions.some(version => ownedMapHashs.includes(version.hash));
const isDownloading = map.id === currentMapDownload?.map?.id;
const inQueue = downloadingMaps.some(toDownload => equal(toDownload.version, version) && toDownload.map.id === map.id);
return { map, isOwned: isMapOwned, idDownloading: isDownloading, isInQueue: inQueue };
}));
}, [playlistMaps, currentMapDownload, downloadingMaps, installedMaps])
const renderMapItem = 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}
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={extractMapDiffs({ bsvMap: map })}
songUrl={map.versions.at(0).previewURL}
key={map.id}
onDownload={downloadable && (() => mapsDownloader.addMapToDownload({version, map}))}
onCancelDownload={cancelable && (() => mapsDownloader.removeMapToDownload({version, map}))}
downloading={downloadableMap.idDownloading}
callBackParam={map} />;
}, [version]);
return (
<PlaylistDetailsTemplate
title={playlist.name}
author={playlist.owner.name}
description={playlist.description}
duration={playlist.stats.totalDuration}
duration={playlist.stats?.totalDuration}
imageUrl={playlist.playlistImage}
maxNps={playlist.stats.maxNps}
minNps={playlist.stats.minNps}
nbMappers={playlist.stats.mapperCount}
nbMaps={playlist.stats.totalMaps}
maxNps={playlist.stats?.maxNps}
minNps={playlist.stats?.minNps}
nbMappers={playlist.stats?.mapperCount}
nbMaps={playlist.stats?.totalMaps}
onClose={() => resolver({ exitCode: ModalExitCode.CLOSED })}
>
{(() => {
@@ -82,41 +128,63 @@ export const BsvPlaylistDetailsModal: ModalComponent<void, Props> = ({ resolver,
}
return (
<ul className="min-h-0 w-full grow space-y-2 pl-2.5 pr-2 py-3 overflow-y-scroll overflow-x-hidden scrollbar-default">
{playlistMaps.map(map => {
<VirtualScroll
maxColumns={1}
minItemWidth={300}
itemHeight={110}
items={downloadbleMaps}
renderItem={renderMapItem}
classNames={{
mainDiv: "min-h-0 w-full grow",
rows: "my-2.5 px-2.5"
}}
scrollEnd={{
onScrollEnd: () => setPage(prev => prev + 1),
margin: 100
}}
/>
// <ul className="min-h-0 w-full grow space-y-2 pl-2.5 pr-2 py-3 overflow-y-scroll overflow-x-hidden scrollbar-default">
// {playlistMaps.map(map => {
const downloadingMap = downloadingMaps.at(0);
const isMapOwned = installedMaps?.some(installedMap => installedMap.hash === map.versions.at(0).hash);
const isMapInQueue = downloadingMaps.some(downloadingMap => downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash);
const isMapDownloading = (isMapInQueue && downloadingMap) ? equal(downloadingMap.version, version) && downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash : false;
// const downloadingMap = downloadingMaps.at(0);
// const isMapOwned = installedMaps?.some(installedMap => installedMap.hash === map.versions.at(0).hash);
// const isMapInQueue = downloadingMaps.some(downloadingMap => downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash);
// const isMapDownloading = (isMapInQueue && downloadingMap) ? equal(downloadingMap.version, version) && downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash : false;
return (
<MapItem
key={map.id}
title={map.name}
autor={map.uploader.name}
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}
mapId={map.id}
likes={map.stats.upvotes}
ranked={map.ranked}
songAutor={map.metadata.songAuthorName}
songUrl={map.versions.at(0).previewURL}
diffs={extractMapDiffs({ bsvMap: map })}
downloading={isMapDownloading}
onDownload={!isMapOwned && !isMapInQueue && (() => mapsDownloader.addMapToDownload({version, map}))}
onCancelDownload={isMapInQueue && !isMapDownloading && (() => mapsDownloader.removeMapToDownload({version, map}))}
callBackParam={map}
/>);
})}
<motion.span className="w-full h-8" onViewportEnter={() => setPage(prev => prev + 1)} />
</ul>
// return (
// <MapItem
// key={map.id}
// title={map.name}
// autor={map.uploader.name}
// 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}
// mapId={map.id}
// likes={map.stats.upvotes}
// ranked={map.ranked}
// songAutor={map.metadata.songAuthorName}
// songUrl={map.versions.at(0).previewURL}
// diffs={extractMapDiffs({ bsvMap: map })}
// downloading={isMapDownloading}
// onDownload={!isMapOwned && !isMapInQueue && (() => mapsDownloader.addMapToDownload({version, map}))}
// onCancelDownload={isMapInQueue && !isMapDownloading && (() => mapsDownloader.removeMapToDownload({version, map}))}
// callBackParam={map}
// />);
// })}
// <motion.span className="w-full h-8" onViewportEnter={() => setPage(prev => prev + 1)} />
// </ul>
)
})()}
</PlaylistDetailsTemplate>
)
}
type DownloadableMap = {
map: BsvMapDetail;
isOwned: boolean;
idDownloading: boolean;
isInQueue: boolean;
};
@@ -1,4 +1,4 @@
import { ModalComponent } from "renderer/services/modale.service"
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"
import { PlaylistDetailsTemplate } from "./playlist-details-template.component"
import { Observable, combineLatest, lastValueFrom, map, switchMap } from "rxjs"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
@@ -14,6 +14,8 @@ import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.mode
import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service";
import { PlaylistHeaderState } from "./playlist-header-state.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { useCallback } from "react";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
// TODO : Translate
@@ -58,6 +60,29 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
return lastValueFrom(obs$);
}
const renderMapItem = useCallback((map: BsmLocalMap) => {
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}
diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
callBackParam={null}
/>
);
}, []);
const renderMaps = () => {
if (!Array.isArray(installedMaps) && !isInQueue) {
return (
@@ -98,28 +123,18 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
isPlaylistInQueue$={isPlaylistInQueue$}
installPlaylist={installPlaylist}
/>
<ul className="min-h-0 w-full grow space-y-2 pl-2.5 pr-2 py-3 overflow-y-scroll overflow-x-hidden scrollbar-default">
{installedMaps.map(map => (
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.duration}
diffs={extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
callBackParam={null}
/>
))}
</ul>
<VirtualScroll
maxColumns={1}
minItemWidth={300}
itemHeight={110}
items={installedMaps}
renderItem={renderMapItem}
classNames={{
mainDiv: "min-h-0 w-full grow",
rows: "my-2.5 px-2.5"
}}
/>
</div>
)
}
@@ -135,6 +150,7 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
nbMaps={localPlaylist?.nbMaps}
nbMappers={localPlaylist?.nbMappers}
title={localPlaylist?.playlistTitle}
onClose={() => resolver({ exitCode: ModalExitCode.CLOSED })}
>
{renderMaps()}
</PlaylistDetailsTemplate>
@@ -7,6 +7,7 @@ import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-
import { ModalExitCode, ModalService } from "./modale.service";
import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component";
import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
export class PlaylistsManagerService {
private static instance: PlaylistsManagerService;
@@ -38,12 +39,12 @@ export class PlaylistsManagerService {
return this.ipc.sendV2("delete-playlist", opt);
}
public exportPlaylists(opt: {version: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}): Observable<Progression<string>> {
public exportPlaylists(opt: {version: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}): Observable<Progression<string>> {
return this.ipc.sendV2("export-playlists", {
version: opt.version,
bpLists: opt.bpLists,
dest: opt.dest,
exportMaps: opt.exportMaps
playlistsMaps: opt.playlistsMaps
});
}
+1 -1
View File
@@ -84,7 +84,7 @@ export interface IpcChannelMapping {
"download-playlist": {request: {downloadSource: string, dest?: string, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression<DownloadPlaylistProgressionData>};
"get-version-playlists-details": {request: BSVersion, response: Progression<LocalBPListsDetails[]>};
"delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression};
"export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, exportMaps?: boolean}, response: Progression<string>};
"export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}, response: Progression<string>};
/* ** bs-uninstall-ipcs ** */
"bs.uninstall": { request: BSVersion, response: boolean };