[feature-107] advancement on playlists feature

This commit is contained in:
MathieuG-P
2024-03-24 17:51:18 +01:00
parent ebbe2963b1
commit 0460a0dab3
14 changed files with 380 additions and 122 deletions
+22 -3
View File
@@ -1,6 +1,10 @@
import { LocalBPList } from "shared/models/playlists/local-playlist.models";
import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service";
import { IpcService } from "../services/ipc.service";
import { of } from "rxjs";
import { BPList } from "shared/models/playlists/playlist.interface";
import path from "path";
import { pathToFileURL } from "url";
const ipc = IpcService.getInstance();
@@ -27,11 +31,26 @@ ipc.on("is-playlists-deep-links-enabled", (args, reply) => {
ipc.on("install-playlist", (args, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
if(args.playlist?.customData?.syncURL){
return reply(playlists.downloadPlaylist(args.playlist.customData.syncURL, args.version));
const isLocalBPList = (bpList: BPList): bpList is LocalBPList => {
return (bpList as LocalBPList).path !== undefined && path.isAbsolute((bpList as LocalBPList).path);
}
reply(playlists.downloadPlaylistSongs(args.playlist.songs, args.version));
const playListUrl = (() => {
if (args.playlist.customData?.syncURL) {
return args.playlist.customData.syncURL;
}
if (isLocalBPList(args.playlist)) {
return args.playlist.path;
}
return undefined;
})();
return reply(playlists.downloadPlaylist({
bpListUrl: playListUrl,
version: args.version,
ignoreSongsHashs: args.ignoreSongsHashs,
dest: (args.playlist as LocalBPList)?.path
}));
});
+4
View File
@@ -41,3 +41,7 @@ ipc.on("notify-system", (args, reply) => {
const systemNotification = NotificationService.getInstance();
reply(of(systemNotification.notify(args)));
});
ipc.on("view-path-in-explorer", (args, reply) => {
reply(of(shell.showItemInFolder(args)));
});
@@ -1,5 +1,5 @@
import path from "path";
import { Observable, lastValueFrom, tap } from "rxjs";
import { Observable, Subject, from, lastValueFrom, mergeMap, take, takeUntil, tap } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BSLocalVersionService } from "../bs-local-version.service";
import { DeepLinkService } from "../deep-link.service";
@@ -10,7 +10,7 @@ import { WindowManagerService } from "../window-manager.service";
import { BPList, DownloadPlaylistProgressionData, PlaylistSong } 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 } from "fs-extra";
import { copy, copyFile, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFile, writeFileSync } from "fs-extra";
import { Progression, pathExist, unlinkPath } from "../../helpers/fs.helpers";
import { FileAssociationService } from "../file-association.service";
import { SongDetailsCacheService } from "./maps/song-details-cache.service";
@@ -18,6 +18,8 @@ import { sToMs } from "shared/helpers/time.helpers";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
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";
export class LocalPlaylistsManagerService {
private static instance: LocalPlaylistsManagerService;
@@ -80,27 +82,37 @@ export class LocalPlaylistsManagerService {
return fullPath;
}
private async installBPListFile(bslistSource: string, version: BSVersion): Promise<string> {
const playlistFolder = await this.getPlaylistsFolder(version);
const isLocalFile = await pathExists(bslistSource).catch(e => { log.error(e); return false; });
const filename = isLocalFile ? path.basename(bslistSource) : new URL(bslistSource).pathname.split('/').pop()
const destFile = path.join(playlistFolder, filename);
private async installBPListFile(opt: {
bslistSource: string,
version?: BSVersion,
dest?: string
}): Promise<{path: string, localBPList: LocalBPList}> {
const bplist = await this.readPlaylistFromSource(opt.bslistSource);
if (isLocalFile) {
return copyFile(bslistSource, destFile).then(() => destFile);
}
return lastValueFrom(this.request.downloadFile(bslistSource, destFile)).then(res => res.data);
const dest = await (async () => {
if(opt.dest && path.isAbsolute(opt.dest) && path.extname(opt.dest) === ".bplist") { return opt.dest; }
const playlistFolder = await this.getPlaylistsFolder(opt.version);
return path.join(playlistFolder, `${sanitize(bplist.playlistTitle)}.bplist`);
})();
writeFileSync(dest, JSON.stringify(bplist, null, 2));
const localBPList: LocalBPList = { ...bplist, path: dest };
return { path: dest, localBPList };
}
private async readPlaylistFile(filePath: string): Promise<BPList> {
if (!(await pathExist(filePath))) {
throw new Error(`bplist file not exist at ${filePath}`);
private async readPlaylistFromSource(source: string): Promise<BPList> {
const isLocalFile = await pathExists(source).catch(e => { log.error(e); return false; });
if(!isLocalFile && !isValidUrl(source)) {
throw new Error(`Invalid source ${source}`);
}
const rawContent = readFileSync(filePath).toString();
const bpList = isLocalFile ? JSON.parse(readFileSync(source).toString()) : await this.request.getJSON<BPList>(source);
return JSON.parse(rawContent);
return bpList;
}
private openOneClickDownloadPlaylistWindow(downloadUrl: string): void {
@@ -124,7 +136,7 @@ export class LocalPlaylistsManagerService {
for (const playlist of playlists) {
const playlistPath = path.join(folerPath, playlist);
const bpList = await this.readPlaylistFile(playlistPath);
const bpList = await this.readPlaylistFromSource(playlistPath);
const localBpList: LocalBPList = { ...bpList, path: playlistPath };
@@ -139,6 +151,32 @@ export class LocalPlaylistsManagerService {
});
}
public getLocalBPListDetails(localBPList: LocalBPList): LocalBPListsDetails {
const tryExtractPlaylistId = (url: string) => {
const regex = /\/id\/(\d+)\/download/;
const match = url.match(regex);
return match ? Number(match[1]) : undefined;
}
const bpListDetails: LocalBPListsDetails = {
...localBPList,
nbMaps: localBPList.songs?.length ?? 0,
id: localBPList.customData?.syncURL ? tryExtractPlaylistId(localBPList.customData.syncURL) : undefined
}
const songsDetails = localBPList.songs?.map(s => this.songDetails.getSongDetails(s.hash));
if(songsDetails){
bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0);
bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size;
bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0))));
bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0))));
}
return bpListDetails;
}
public getVersionPlaylistsDetails(version: BSVersion): Observable<Progression<LocalBPListsDetails[]>> {
return new Observable<Progression<LocalBPListsDetails[]>>(obs => {
@@ -151,31 +189,9 @@ export class LocalPlaylistsManagerService {
await this.songDetails.waitLoaded(sToMs(15));
const tryExtractPlaylistId = (url: string) => {
const regex = /\/id\/(\d+)\/download/;
const match = url.match(regex);
return match ? Number(match[1]) : undefined;
}
const bpListsDetails: LocalBPListsDetails[] = [];
for(const bpList of localBPListsRes.data){
const bpListDetails: LocalBPListsDetails = {
...bpList,
nbMaps: bpList.songs?.length ?? 0,
id: bpList.customData?.syncURL ? tryExtractPlaylistId(bpList.customData.syncURL) : undefined
}
const songsDetails = bpList.songs?.map(s => this.songDetails.getSongDetails(s.hash));
if(songsDetails){
bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0);
bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size;
bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0))));
bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0))));
}
bpListsDetails.push(bpListDetails);
bpListsDetails.push(this.getLocalBPListDetails(bpList));
}
obs.next({...localBPListsRes, data: bpListsDetails});
@@ -185,23 +201,34 @@ export class LocalPlaylistsManagerService {
});
}
public downloadPlaylistSongs(bpList: PlaylistSong[], version: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
public downloadPlaylistSongs(localBPList: LocalBPList, ignoreSongsHashs: string[] = [], version: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
let destroyed = false;
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
(async () => {
const progress: Progression<DownloadPlaylistProgressionData> = {
total: bpList.length,
total: localBPList.songs.length,
current: 0,
data: {
downloadedMaps: [],
currentDownload: null,
playlistInfos: null,
playlistPath: null,
playlist: this.getLocalBPListDetails(localBPList),
}
};
obs.next(progress);
for (const song of bpList) {
for (const song of localBPList.songs) {
if(destroyed) { break; }
if(ignoreSongsHashs.includes(song.hash)) {
progress.current += 1;
obs.next(progress);
continue;
}
const [ mapDetail ] = await this.bsaver.getMapDetailsFromHashs([song.hash]);
if(!mapDetail) {
@@ -219,24 +246,40 @@ export class LocalPlaylistsManagerService {
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
return () => {
destroyed = true;
}
});
}
public downloadPlaylist(bpListUrl: string, version: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
public downloadPlaylist({ bpListUrl, version, ignoreSongsHashs = [], dest }: {
bpListUrl: string,
version?: BSVersion
ignoreSongsHashs?: string[]
dest?: string
}): Observable<Progression<DownloadPlaylistProgressionData>> {
const destroyed$ = new Subject<void>()
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
(async () => {
const bpListFilePath = await this.installBPListFile(bpListUrl, version);
const bpList = await this.readPlaylistFile(bpListFilePath);
const { localBPList } = await this.installBPListFile({ bslistSource: bpListUrl, version, dest });
await lastValueFrom(this.downloadPlaylistSongs(bpList.songs, version).pipe(
await lastValueFrom(this.downloadPlaylistSongs(localBPList, ignoreSongsHashs, version).pipe(
tap({ next: p => obs.next(p) }),
takeUntil(destroyed$),
));
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
return () => {
destroyed$.next();
destroyed$.complete();
}
});
}
@@ -246,7 +289,7 @@ export class LocalPlaylistsManagerService {
return new Observable<Progression>(obs => {
(async () => {
const bpList = await this.readPlaylistFile(opt.path);
const bpList = await this.readPlaylistFromSource(opt.path);
const progress: Progression = { current: 0, total: opt.deleteMaps ? bpList.songs.length + 1 : 1};
@@ -277,19 +320,19 @@ export class LocalPlaylistsManagerService {
(async () => {
const versions = await this.versions.getInstalledVersions();
const download$ = this.downloadPlaylist(bpListUrl, versions.pop()).pipe(tap({
const download$ = this.downloadPlaylist({ bpListUrl, version: versions.pop() }).pipe(tap({
next: progress => obs.next(progress),
error: err => obs.error(err),
}));
const { data: {downloadedMaps, playlistPath} } = await lastValueFrom(download$);
const { data: {downloadedMaps, playlist} } = await lastValueFrom(download$);
if(downloadedMaps?.length === 0 || !playlistPath) { return; }
if(downloadedMaps?.length === 0 || !playlist.path) { return; }
const realSourceMapsFolder = await realpath(path.dirname(downloadedMaps[0].path));
for (const version of versions) {
await this.installBPListFile(playlistPath, version);
await this.installBPListFile({ bslistSource: playlist.path, version});
const versionMapsFolder = await this.maps.getMapsFolderPath(version);
const realDestMapsFolder = await realpath(versionMapsFolder).catch(e => {
@@ -268,6 +268,8 @@ export class LocalMapsManagerService {
throw new Error("Cannot download map, no hash found");
}
log.info("Downloading map", map.name, map.id);
const zipUrl = map.versions.at(0).downloadURL;
const mapFolderName = sanitize(`${map.id}-${map.name}`);
const mapsFolder = await this.getMapsFolderPath(version);
@@ -29,7 +29,7 @@ type Props = {
export const InstalledMapsContext = createContext<{
maps$?: BehaviorSubject<BsmLocalMap[]>;
setMaps: (maps: BsmLocalMap[]) => void;
playlists$?: Observable<LocalBPListsDetails[]>;
playlists$?: BehaviorSubject<LocalBPListsDetails[]>;
setPlaylists: (playlist: LocalBPListsDetails[]) => void;
}>(null);
@@ -6,10 +6,10 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { PlaylistsManagerService } from "renderer/services/playlists-manager.service";
import { FolderLinkState } from "renderer/services/version-folder-linker.service";
import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, merge, mergeAll, of, pipe, tap } from "rxjs";
import { BehaviorSubject, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { noop } from "shared/helpers/function.helpers";
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { PlaylistItem } from "./playlist-item.component";
import { useStateMap } from "renderer/hooks/use-state-map.hook";
import { ModalService } from "renderer/services/modale.service";
@@ -18,6 +18,9 @@ import { InstalledMapsContext } from "../maps-playlists-panel.component";
import { IpcService } from "renderer/services/ipc.service";
import equal from "fast-deep-equal";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service";
import { ProgressBarService } from "renderer/services/progress-bar.service";
import { NotificationService } from "renderer/services/notification.service";
type Props = {
version: BSVersion;
@@ -29,8 +32,11 @@ type Props = {
export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, className, isActive, linkedState }, forwardedRef) => {
const playlistService = useService(PlaylistsManagerService);
const playlistDownloader = useService(PlaylistDownloaderService);
const modals = useService(ModalService);
const ipc = useService(IpcService);
const progress = useService(ProgressBarService);
const notification = useService(NotificationService);
const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true });
@@ -65,10 +71,44 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, cl
loadPercent$.next(0);
});
const onPlaylistDownloadedCB = (downloaded: LocalBPListsDetails) => {
const newPlaylist = (() => {
console.log(playlists);
const index = playlists$.value.findIndex(p => p.path === downloaded.path);
if(index === -1){
return [...playlists$.value, downloaded];
}
const newPlaylists = [...playlists$.value];
newPlaylists[index] = downloaded;
return newPlaylists;
})();
setPlaylists(newPlaylist);
}
playlistDownloader.addOnPlaylistDownloadedListener(version, onPlaylistDownloadedCB);
return () => {
playlistDownloader.removeOnPlaylistDownloadedListener(version, onPlaylistDownloadedCB);
}
}, [isActiveOnce, version, linked]);
const installPlaylist = (playlist: LocalBPList) => {
const ignoreSongsHashs = (maps$.value || []).map(m => m.hash.toLocaleLowerCase());
const obs$ = playlistDownloader.installPlaylist(playlist, version, ignoreSongsHashs);
return lastValueFrom(obs$).then(res => {
if(res.current === res.total){
notification.notifySuccess({ title: "Playlist synchronisée !", desc: "La playlist et ses maps on été téléchargées.", duration: 5000 })
}
});
}
const viewPlaylistFile = (path: string) => {
return lastValueFrom(ipc.sendV2("view-path-in-explorer", { args: path }));
return lastValueFrom(ipc.sendV2("view-path-in-explorer", path));
};
const deletePlaylist = (path: string) => {
@@ -115,7 +155,7 @@ export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, cl
minNps={p.minNps}
onClickOpen={() => openPlaylistDetails(p.path)}
onClickDelete={() => deletePlaylist(p.path)}
onClickSync={() => console.log("sync")}
onClickSync={() => installPlaylist(p)}
onClickOpenFile={() => viewPlaylistFile(p.path)}
/>
)}
@@ -1,6 +1,6 @@
import { ModalComponent } from "renderer/services/modale.service"
import { PlaylistDetailsTemplate, PlaylistDetailsTemplateProps } from "./playlist-details-template.component"
import { Observable, first, lastValueFrom, map, shareReplay, switchMap, take, tap } from "rxjs"
import { Observable, combineLatest, first, lastValueFrom, map, mergeAll, mergeMap, shareReplay, switchMap, take, tap } from "rxjs"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
import { BSVersion } from "shared/bs-version.interface";
import { useObservable } from "renderer/hooks/use-observable.hook";
@@ -14,7 +14,10 @@ import { BsmButton } from "renderer/components/shared/bsm-button.component";
import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png";
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service";
import { ProgressBarService } from "renderer/services/progress-bar.service";
import { PlaylistHeaderState } from "./playlist-header-state.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
// TODO : Translate
interface Props {
version: BSVersion;
@@ -26,11 +29,16 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
const audioPlayer = useService(AudioPlayerService);
const playlistDownloader = useService(PlaylistDownloaderService);
const progressBar = useService(ProgressBarService);
const localPlaylist = useObservable(() => options.data.localPlaylist$, null);
const installedMaps = useObservable(() => options.data.installedMaps$, null);
const isMissingMaps$ = useConstant(() => combineLatest([options.data.installedMaps$, options.data.localPlaylist$]).pipe(map(([maps, playlist]) => maps.length !== playlist.songs.length)));
const isPlaylistDownloading$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistDownloading(playlist, options.data.version))));
const isPlaylistInQueue$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistInQueue(playlist, options.data.version))));
const isInQueue = useObservable(() => isPlaylistInQueue$, false);
const playPlaylist = () => {
if (!installedMaps) {
return;
@@ -39,55 +47,54 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
};
const installPlaylist = () => {
const obs$ = playlistDownloader.installPlaylist(localPlaylist, options.data.version);
const progress$ = obs$.pipe(
map(progress => (progress.current / progress.total) * 100),
);
const ignoreSongsHashs = installedMaps?.map(map => map.hash);
const obsWithProgress$ = obs$.pipe(
take(1),
tap(() => progressBar.show(progress$, true)),
switchMap(() => obs$),
tap({ complete: () => progressBar.hide(true) })
);
const obs$ = playlistDownloader.installPlaylist(localPlaylist, options.data.version, ignoreSongsHashs);
return lastValueFrom(obsWithProgress$);
return lastValueFrom(obs$);
}
const renderMaps = () => {
if (!installedMaps) {
// loading maps
return null;
if (!Array.isArray(installedMaps) && !isInQueue) {
return (
<div className="grow bg-red-400">
</div>
);
}
if(installedMaps.length === 0) {
// no maps
return null;
if(installedMaps.length === 0 && !isInQueue) {
return (
<div className="grow flex justify-center items-center flex-col">
<BsmImage image={BeatConflict} className="size-28"/>
<div className="text-white font-bold w-fit space-y-1.5 flex flex-col justify-center items-center -translate-y-5">
<p>Aucune maps installée pour cette playlist</p>
<BsmButton withBar={false} onClick={installPlaylist} className="rounded-md h-8 flex items-center justify-center px-4" typeColor="primary" text="Télécharger.les.maps"/>
</div>
</div>
);
}
if(installedMaps.length === 0 && isInQueue) {
return (
<div className="grow flex justify-center items-center flex-col">
<BsmImage image={BeatConflict} className="size-28"/>
<div className="text-white font-bold w-fit space-y-1.5 flex flex-col justify-center items-center -translate-y-5">
<p>La Playlist est en attente de téléchargment</p>
</div>
</div>
);
}
return (
<div className="grow min-h-0 overflow-hidden flex flex-col justify-start items-center">
<AnimatePresence>
{/* If nb installed maps not correspond to nb maps of the playlist */}
{installedMaps.length !== localPlaylist.nbMaps && (
<motion.div
initial={{ height: 0 }}
animate={{ height: "7rem" }}
exit={{ height: 0 }}
transition={{delay: .25, duration: .25}}
className="shrink-0 w-full text-center overflow-hidden flex justify-center items-center"
>
<div className="size-[calc(100%-1rem)] bg-main-color-2 rounded-md translate-y-1.5 flex flex-row justify-center items-center gap-3">
<BsmImage image={BeatConflict} className="size-24"/>
<div className="text-white font-bold w-fit space-y-1.5 flex flex-col justify-center items-center">
<p>Certaines maps de cette playlist sont manquantes</p>
<BsmButton withBar={false} onClick={installPlaylist} className="rounded-md h-8 flex items-center justify-center px-4" typeColor="primary" text="Télécharger.les.maps.manquantes"/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<PlaylistHeaderState
isMissingMaps$={isMissingMaps$}
isPlaylistDownloading$={isPlaylistDownloading$}
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
@@ -0,0 +1,73 @@
import { AnimatePresence, motion } from "framer-motion";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png";
import { Observable } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
type Props = {
isPlaylistInQueue$: Observable<boolean>;
isPlaylistDownloading$: Observable<boolean>;
isMissingMaps$: Observable<boolean>;
installPlaylist: () => void;
}
export function PlaylistHeaderState({isPlaylistInQueue$, isPlaylistDownloading$, isMissingMaps$, installPlaylist}: Props) {
const isPlaylistInQueue = useObservable(() => isPlaylistInQueue$, false);
const isPlaylistDownloading = useObservable(() => isPlaylistDownloading$, false);
const isMissingMaps = useObservable(() => isMissingMaps$, false);
console.log(isPlaylistInQueue, isPlaylistDownloading, isMissingMaps);
const renderHeaderContent = () => {
if(isPlaylistDownloading) {
return (
<>
<BsmImage image={BeatConflict} className="size-24"/>
<div className="text-white font-bold w-fit space-y-1.5 flex flex-col justify-center items-center">
<p>La playlist est en cours de téléchargement</p>
</div>
</>
)
}
if(isPlaylistInQueue) {
return (
<>
<BsmImage image={BeatConflict} className="size-24"/>
<div className="text-white font-bold w-fit space-y-1.5 flex flex-col justify-center items-center">
<p>La Playlist est en attente de téléchargment</p>
</div>
</>
)
}
return (
<>
<BsmImage image={BeatConflict} className="size-24"/>
<div className="text-white font-bold w-fit space-y-1.5 flex flex-col justify-center items-center">
<p>Certaines maps de cette playlist sont manquantes</p>
<BsmButton withBar={false} onClick={installPlaylist} className="rounded-md h-8 flex items-center justify-center px-4" typeColor="primary" text="Télécharger.les.maps.manquantes"/>
</div>
</>
);
}
return (
<AnimatePresence>
{(isPlaylistInQueue || isPlaylistDownloading || isMissingMaps) && <motion.div
initial={{ height: 0 }}
animate={{ height: "7rem" }}
exit={{ height: 0 }}
transition={{delay: .25, duration: .25}}
className="shrink-0 w-full text-center overflow-hidden flex justify-center items-center"
>
<div className="size-[calc(100%-1rem)] bg-main-color-2 rounded-md translate-y-1.5 flex flex-row justify-center items-center gap-3">
{renderHeaderContent()}
</div>
</motion.div>}
</AnimatePresence>
)
}
@@ -162,7 +162,7 @@ export class BSVersionManagerService {
shareReplay({ bufferSize: 1, refCount: true })
);
this.progressBar.show(obs$.pipe(map(progress => (progress.current / progress.total) * 100)), true);
this.progressBar.show(obs$, true);
return obs$;
}
@@ -1,10 +1,11 @@
import { BehaviorSubject, Observable, filter, lastValueFrom, map, shareReplay, take, tap } from "rxjs";
import { BehaviorSubject, Observable, Subscription, distinctUntilChanged, filter, lastValueFrom, map, shareReplay, take, takeUntil, takeWhile, tap } from "rxjs";
import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface";
import { IpcService } from "./ipc.service";
import { ProgressBarService } from "./progress-bar.service";
import { Progression } from "main/helpers/fs.helpers";
import equal from "fast-deep-equal";
import { BSVersion } from "shared/bs-version.interface";
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
export class PlaylistDownloaderService {
private static instance: PlaylistDownloaderService;
@@ -20,32 +21,82 @@ export class PlaylistDownloaderService {
private readonly progress: ProgressBarService;
private readonly ipc: IpcService;
private readonly playlistQueue$ = new BehaviorSubject<BPList[]>([]);
private readonly canceled$ = new BehaviorSubject<boolean>(false);
private readonly playlistQueue$ = new BehaviorSubject<PlaylistQueueInfo[]>([]);
private readonly onPlaylistDownloadedListeners = new Map<BSVersion, ((playlist: LocalBPListsDetails) => void)[]>();
private constructor() {
this.progress = ProgressBarService.getInstance();
this.ipc = IpcService.getInstance();
}
public installPlaylist(bpList: BPList, version?: BSVersion): Observable<Progression<DownloadPlaylistProgressionData>> {
this.playlistQueue$.next([...this.playlistQueue$.value, bpList]);
public installPlaylist(bpList: BPList, version?: BSVersion, ignoreSongsHashs?: string[]): Observable<Progression<DownloadPlaylistProgressionData>> {
const clear = () => {
this.playlistQueue$.next(this.playlistQueue$.value.filter(p => p !== bpList));
}
this.playlistQueue$.next([...this.playlistQueue$.value, { version, source: bpList }]);
console.log("AAAAA");
return new Observable<Progression<DownloadPlaylistProgressionData>>(subscriber => {
(async () => {
const playlist = await lastValueFrom(this.playlistQueue$.pipe(map(queue => queue.at(0)), filter(p => equal(bpList, p)), take(1)));
const download$ = this.ipc.sendV2("install-playlist", { version, playlist });
await lastValueFrom(download$.pipe(tap(subscriber)));
let subs: Subscription[] = [];
(async () => {
const queueInfo = await lastValueFrom(this.playlistQueue$.pipe(map(queue => queue.at(0)), filter(p => equal(bpList, p.source)), take(1)));
console.log(queueInfo);
const download$ = this.ipc.sendV2("install-playlist", { version: queueInfo.version, playlist: queueInfo.source, ignoreSongsHashs }).pipe(takeWhile(() => !this.canceled$.value));
subs.push(download$.pipe(map(progress => progress?.data?.playlist), filter(Boolean), take(1)).subscribe(playlist => {
queueInfo.downloaded = playlist;
this.onPlaylistDownloadedListeners.get(version)?.forEach(cb => cb(playlist));
}));
const canShowProgress = !this.progress.isVisible;
if (canShowProgress) {
this.progress.show(download$.pipe(map(data => (data.current / data.total) * 100)), true);
}
await lastValueFrom(download$.pipe(tap(subscriber))).finally(() => {
if (canShowProgress) {
this.progress.hide(true);
}
})
})()
.then(() => subscriber.complete())
.catch(err => subscriber.error(err))
.finally(clear);
.finally(() => {
this.playlistQueue$.next(this.playlistQueue$.value.filter(p => !equal(p.version, version) || !equal(p.source, bpList)));
this.canceled$.next(false);
});
}).pipe(shareReplay(1))
return () => subs.forEach(s => s.unsubscribe());
}).pipe(shareReplay(1));
}
public cancelCurrentDownload() {
this.canceled$.next(true);
}
public get currentDownloading$(): Observable<PlaylistQueueInfo> {
return this.playlistQueue$.pipe(map(queue => queue.at(0)), distinctUntilChanged(equal));
}
public $isPlaylistInQueue(bpList: BPList, version?: BSVersion): Observable<boolean> {
return this.playlistQueue$.pipe(map(queue => queue.some(p => p && (equal(p.source, bpList) || equal(p.downloaded, bpList)) && equal(p.version, version))), distinctUntilChanged());
}
public $isPlaylistDownloading(bpList: BPList, version?: BSVersion): Observable<boolean> {
return this.currentDownloading$.pipe(map(p => p && (equal(p.source, bpList) || equal(p.downloaded, bpList)) && equal(p.version, version)), distinctUntilChanged());
}
public addOnPlaylistDownloadedListener(version: BSVersion, cb: (playlist: LocalBPListsDetails) => void){
this.onPlaylistDownloadedListeners.set(version, [...(this.onPlaylistDownloadedListeners.get(version) || []), cb]);
}
public removeOnPlaylistDownloadedListener(version: BSVersion, cb: (playlist: LocalBPListsDetails) => void){
this.onPlaylistDownloadedListeners.set(version, (this.onPlaylistDownloadedListeners.get(version) || []).filter(c => c !== cb));
}
public oneClickInstallPlaylist(bpListUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
@@ -61,3 +112,9 @@ export class PlaylistDownloaderService {
}));
}
}
export type PlaylistQueueInfo = {
version?: BSVersion;
source: BPList;
downloaded?: LocalBPListsDetails;
}
+14 -2
View File
@@ -4,6 +4,8 @@ import { IpcService } from "./ipc.service";
import { NotificationService } from "./notification.service";
import { CSSProperties } from "react";
import { ProgressionInterface } from "shared/models/progress-bar";
import { Progression } from "main/helpers/fs.helpers";
import { satisfies } from "semver";
export class ProgressBarService {
private static instance: ProgressBarService;
@@ -39,7 +41,7 @@ export class ProgressBarService {
lastValueFrom(this.ipcService.sendV2("window.progression", progression));
}
public subscribreTo(obs: Observable<ProgressionInterface | number>) {
public subscribreTo(obs: Observable<ProgressionInterface | number | Progression>) {
if (this.subscription) {
this.unsubscribe();
}
@@ -47,6 +49,16 @@ export class ProgressBarService {
if (typeof value === "number") {
return this._progression$.next({ progression: value });
}
const isProgression = (progression: unknown): progression is Progression => {
return typeof (progression as Progression).current === "number" && typeof (progression as Progression).total === "number";
}
if (isProgression(value)) {
const progress = (value as Progression).current / (value as Progression).total * 100;
return this._progression$.next({ progression: Math.floor(progress)});
}
this._progression$.next(value);
});
}
@@ -57,7 +69,7 @@ export class ProgressBarService {
this.subscription = null;
}
public show(obs?: Observable<ProgressionInterface | number>, unsubscribe?: boolean, style?: CSSProperties) {
public show(obs?: Observable<ProgressionInterface | number | Progression>, unsubscribe?: boolean, style?: CSSProperties) {
if (unsubscribe) {
this.unsubscribe();
}
@@ -27,7 +27,7 @@ export default function OneClickDownloadPlaylist() {
const mapsContainer = useRef<HTMLDivElement>(null);
const { playlistUrl } = useWindowArgs("playlistUrl");
const download$ = useConstant(() => playlistDownloader.oneClickInstallPlaylist(playlistUrl));
const playlistInfos = useObservable<BPList>(() => download$.pipe(filter(progress => !!progress.data?.playlistInfos), map(progress => progress.data.playlistInfos), take(1)));
const playlistInfos = useObservable(() => download$.pipe(filter(progress => !!progress.data?.playlist), map(progress => progress.data.playlist), take(1)));
const downloadedMaps = useObservable(() => download$.pipe(filter(progress => !!progress.data?.downloadedMaps), map(progress => progress.data.downloadedMaps)));
useEffect(() => {
+2 -1
View File
@@ -80,7 +80,7 @@ export interface IpcChannelMapping extends Record<string, { request: unknown, re
"register-playlists-deep-link": { request: void, response: boolean };
"unregister-playlists-deep-link": { request: void, response: boolean };
"is-playlists-deep-links-enabled": { request: void, response: boolean };
"install-playlist": {request: {playlist: BPList, version: BSVersion}, response: Progression<DownloadPlaylistProgressionData>};
"install-playlist": {request: {playlist: BPList, version?: BSVersion, ignoreSongsHashs?: string[]}, response: Progression<DownloadPlaylistProgressionData>};
"get-version-playlists-details": {request: BSVersion, response: Progression<LocalBPListsDetails[]>};
"delete-playlist": {request: {path: string, deleteMaps?: boolean}, response: Progression};
@@ -117,6 +117,7 @@ export interface IpcChannelMapping extends Record<string, { request: unknown, re
"current-version": { request: void, response: string };
"open-logs": { request: void, response: string };
"notify-system": { request: SystemNotificationOptions, response: void };
"view-path-in-explorer": { request: string, response: void };
/* ** supporters-ipcs ** */
"get-supporters": { request: void, response: Supporter[] };
@@ -1,5 +1,6 @@
import { BsvMapDetail, SongDetails } from "../maps";
import { BsmLocalMap } from "../maps/bsm-local-map.interface";
import { LocalBPListsDetails } from "./local-playlist.models";
export interface BPList<SongType = PlaylistSong> {
playlistTitle: string;
@@ -21,8 +22,7 @@ export interface PlaylistSong {
export interface DownloadPlaylistProgressionData {
downloadedMaps: BsmLocalMap[];
currentDownload: BsvMapDetail;
playlistInfos: BPList;
playlistPath: string;
playlist: LocalBPListsDetails;
}
export interface CustomDataBPList {