[feature-107] Fix various bugs and add new features for playlists

This commit is contained in:
MathieuG-P
2024-02-23 21:49:29 +01:00
parent 59b04ee33e
commit a36ccb0c13
26 changed files with 379 additions and 70 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
import { BSVersion } from "shared/bs-version.interface";
import { LocalPlaylistsManagerService } from "../services/additional-content/local-playlists-manager.service";
import { IpcService } from "../services/ipc.service";
import { of, throwError } from "rxjs";
@@ -39,7 +40,7 @@ ipc.on("is-playlists-deep-links-enabled", (_, reply) => {
}
});
ipc.on("get-version-playlists", (req, reply) => {
ipc.on<BSVersion>("get-version-playlists", (req, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
reply(playlists.getVersionPlaylists(req.args));
});
+11 -8
View File
@@ -5,7 +5,7 @@ import { Subject, debounceTime } from "rxjs";
export class JsonCache<T = unknown> {
private cache: Record<string, T> = {};
private _cache: Record<string, T> = {};
private readonly setEvent: Subject<void> = new Subject<void>();
public constructor(
@@ -22,40 +22,43 @@ export class JsonCache<T = unknown> {
private load(): void {
try {
this.cache = require(this.jsonPath);
this._cache = require(this.jsonPath);
} catch (error) {
log.warn("Failed to load cache or file cache not exist yet", this.jsonPath, error);
} finally {
this.cache ??= {};
this._cache ??= {};
}
}
public save(): void {
const res = tryit(() => writeFileSync(this.jsonPath, JSON.stringify(this.cache), { flush: true }));
const res = tryit(() => writeFileSync(this.jsonPath, JSON.stringify(this._cache), { flush: true }));
if(res.error){
log.error("Failed to save cache", this.jsonPath, res.error);
}
}
public get(key: string): T {
return this.cache[key];
return this._cache[key];
}
public set(key: string, value: T): void {
this.cache[key] = value;
this._cache[key] = value;
this.setEvent?.next();
}
public delete(key: string): void {
delete this.cache[key];
delete this._cache[key];
this.setEvent?.next();
}
public clear(): void {
this.cache = {};
this._cache = {};
this.setEvent?.next();
}
public get cache(): Record<string, T> {
return this._cache;
}
}
export type JsonCacheOptions = {
@@ -13,6 +13,11 @@ import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
import { copy, copyFile, pathExists, pathExistsSync, readdirSync, realpath } from "fs-extra";
import { Progression, ensureFolderExist, pathExist } 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";
import { LocalBPList, LocalBpListSong } from "shared/models/playlists/local-playlist.models";
import { SongCacheService } from "./maps/song-cache.service";
import { pathToFileURL } from "url";
export class LocalPlaylistsManagerService {
private static instance: LocalPlaylistsManagerService;
@@ -36,6 +41,8 @@ export class LocalPlaylistsManagerService {
private readonly fileAssociation: FileAssociationService;
private readonly windows: WindowManagerService;
private readonly bsaver: BeatSaverService;
private readonly songDetails: SongDetailsCacheService;
private readonly songCache: SongCacheService;
private constructor() {
this.maps = LocalMapsManagerService.getInstance();
@@ -45,6 +52,9 @@ export class LocalPlaylistsManagerService {
this.fileAssociation = FileAssociationService.getInstance();
this.windows = WindowManagerService.getInstance();
this.bsaver = BeatSaverService.getInstance();
this.songDetails = SongDetailsCacheService.getInstance();
this.songCache = SongCacheService.getInstance();
this.deepLink.addLinkOpenedListener(this.DEEP_LINKS.BeatSaver, link => {
log.info("DEEP-LINK RECEIVED FROM", this.DEEP_LINKS.BeatSaver, link);
@@ -100,34 +110,61 @@ export class LocalPlaylistsManagerService {
this.windows.openWindow(`oneclick-download-playlist.html?playlistUrl=${downloadUrl}`);
}
private getReadBPListOfFolder(folerPath: string): Observable<Progression<BPList[]>> {
return new Observable<Progression<BPList[]>>(obs => {
private readLocalBPListsOfFolder(folerPath: string, version?: BSVersion): Observable<Progression<LocalBPList[]>> {
return new Observable<Progression<LocalBPList[]>>(obs => {
const progress: Progression<BPList[]> = { current: 0, total: 0, data: [] };
const progress: Progression<LocalBPList[]> = { current: 0, total: 0, data: [] };
const bpLists: LocalBPList[] = [];
(async () => {
await this.songDetails.waitLoaded(sToMs(15));
if(!pathExistsSync(folerPath)) {
throw new Error(`Playlists folder not found ${folerPath}`);
}
const mapsFolder = version ? await this.maps.getMapsFolderPath(version) : null;
const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist");
progress.total = playlists.length;
for (const playlist of playlists) {
const playlistPath = path.join(folerPath, playlist);
const playlistContent = await this.readPlaylistFile(playlistPath);
progress.data.push(playlistContent);
const bpList = await this.readPlaylistFile(playlistPath);
const localBpListSongs = bpList.songs.map(song => {
const localInfos = mapsFolder ? this.songCache.getMapInfoFromHash(song.hash) : null;
const coverPath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._coverImageFilename) : null;
const songFilePath = localInfos ? path.join(mapsFolder, localInfos.path, localInfos.info.rawInfo._songFilename) : null;
return ({
song,
songDetails: this.songDetails.getSongDetails(song.hash),
coverUrl: (coverPath && pathExistsSync(coverPath)) ? pathToFileURL(coverPath).href : null,
songUrl: (songFilePath && pathExistsSync(songFilePath)) ? pathToFileURL(songFilePath).href : null,
} as LocalBpListSong)
});
const localBpList: LocalBPList = { ...bpList, path: playlistPath, songs: localBpListSongs };
bpLists.push(localBpList);
progress.current += 1;
obs.next(progress);
}
progress.data = bpLists;
obs.next(progress);
})().catch(err => obs.error(err)).finally(() => obs.complete());
});
}
public getVersionPlaylists(version: BSVersion): Observable<Progression<BPList[]>> {
return new Observable<Progression<BPList[]>>(obs => {
public getVersionPlaylists(version: BSVersion): Observable<Progression<LocalBPList[]>> {
return new Observable<Progression<LocalBPList[]>>(obs => {
this.getPlaylistsFolder(version)
.then(folder => this.getReadBPListOfFolder(folder).subscribe(obs))
.then(folder => this.readLocalBPListsOfFolder(folder, version))
.then(progress$ => lastValueFrom(progress$.pipe(
tap(progress => obs.next({...progress, data: []})),
)))
.then(res => obs.next(res))
.catch(err => obs.error(err))
.finally(() => obs.complete());
});
}
@@ -25,6 +25,7 @@ import { IpcService } from "../../ipc.service";
import { SongDetailsCacheService } from "./song-details-cache.service";
import { sToMs } from "shared/helpers/time.helpers";
import { SongCacheService } from "./song-cache.service";
import { pathToFileURL } from "url";
export class LocalMapsManagerService {
private static instance: LocalMapsManagerService;
@@ -38,6 +39,7 @@ export class LocalMapsManagerService {
public static readonly LEVELS_ROOT_FOLDER = "Beat Saber_Data";
public static readonly CUSTOM_LEVELS_FOLDER = "CustomLevels";
public static readonly RELATIVE_MAPS_FOLDER = path.join(LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
public static readonly SHARED_MAPS_FOLDER = "SharedMaps";
private readonly DEEP_LINKS = {
@@ -81,7 +83,7 @@ export class LocalMapsManagerService {
public async getMapsFolderPath(version?: BSVersion): Promise<string> {
if (version) {
return path.join(await this.localVersion.getVersionPath(version), LocalMapsManagerService.LEVELS_ROOT_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
return path.join(await this.localVersion.getVersionPath(version), LocalMapsManagerService.RELATIVE_MAPS_FOLDER);
}
const sharedMapsPath = path.join(await this.installLocation.sharedContentPath(), LocalMapsManagerService.SHARED_MAPS_FOLDER, LocalMapsManagerService.CUSTOM_LEVELS_FOLDER);
if (!(await pathExist(sharedMapsPath))) {
@@ -117,8 +119,8 @@ export class LocalMapsManagerService {
private async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap> {
const getUrlsAndReturn = (rawInfo: RawMapInfoData, hash: string, mapPath: string) => {
const coverUrl = new URL(`file:///${path.join(mapPath, rawInfo._coverImageFilename)}`).href;
const songUrl = new URL(`file:///${path.join(mapPath, rawInfo._songFilename)}`).href;
const coverUrl = pathToFileURL(path.join(mapPath, rawInfo._coverImageFilename)).href;
const songUrl = pathToFileURL(path.join(mapPath, rawInfo._songFilename)).href;
return { rawInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) } as BsmLocalMap;
};
@@ -26,6 +26,11 @@ export class SongCacheService {
return this.rawInfosCache.get(dirname);
}
public getMapInfoFromHash(hash: string): { path: string, info: CachedRawInfoWithHash } | undefined {
const res = Object.entries(this.rawInfosCache.cache).find(([, info]) => info.hash === hash);
return res ? { path: res[0], info: res[1] } : undefined;
}
public setMapInfoFromDirname(dirname: string, info: CachedRawInfoWithHash): void {
this.rawInfosCache.set(dirname, info);
}
@@ -160,7 +160,7 @@ export class SongDetailsCacheService {
}))));
}
public getSongDetails(hash: string): any {
public getSongDetails(hash: string): SongDetails {
return this.songDetailsCache[hash.toLocaleLowerCase()];
}
+1 -1
View File
@@ -47,7 +47,7 @@ export class WindowManagerService {
if(isValidUrl(url)){
shell.openExternal(url);
}
return { action: "deny"}
});
+2 -2
View File
@@ -1,5 +1,5 @@
/* eslint import/prefer-default-export: off, import/no-mutable-exports: off */
import { URL } from "url";
import { URL, pathToFileURL } from "url";
import path from "path";
export let resolveHtmlPath: (htmlFileName: string) => string;
@@ -12,6 +12,6 @@ if (process.env.NODE_ENV === "development") {
};
} else {
resolveHtmlPath = (htmlFileName: string) => {
return `file://${path.resolve(__dirname, "../renderer/", htmlFileName)}`;
return pathToFileURL(path.resolve(__dirname, "../renderer/", htmlFileName)).href;
};
}
@@ -123,8 +123,8 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
]}
>
<>
<LocalMapsListPanel isActive={isActive && tabIndex === 0} ref={mapsRef} className="w-full h-full shrink-0" version={version} filter={mapFilter} search={mapSearch} linkedState={mapsLinkedState} />
<LocalPlaylistsListPanel isActive={isActive && tabIndex === 1} version={version} linkedState={playlistLinkedState}/>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={mapSearch} linkedState={mapsLinkedState} />
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} version={version} linkedState={playlistLinkedState}/>
</>
</BsContentTabPanel>
</div>
@@ -9,15 +9,15 @@ import { VariableSizeList } from "react-window";
import { MapsRow } from "./maps-row.component";
import { debounceTime, last, tap } from "rxjs/operators";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import BeatWaitingImg from "../../../../../assets/images/apngs/beat-waiting.png";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BsmImage } from "../../shared/bsm-image.component";
import { BsmButton } from "../../shared/bsm-button.component";
import TextProgressBar from "../../progress-bar/text-progress-bar.component";
import { useChangeUntilEqual } from "renderer/hooks/use-change-until-equal.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { FolderLinkState } from "renderer/services/version-folder-linker.service";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { BsContentLoader } from "renderer/components/shared/bs-content-loader.component";
type Props = {
version: BSVersion;
@@ -28,7 +28,7 @@ type Props = {
isActive?: boolean;
};
export const LocalMapsListPanel = forwardRef(({ version, className, filter, search, linkedState, isActive }: Props, forwardRef) => {
export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, className, filter, search, linkedState, isActive }, forwardRef) => {
const mapsManager = useService(MapsManagerService);
const mapsDownloader = useService(MapsDownloaderService);
@@ -42,7 +42,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear
const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true });
const [linked, setLinked] = useState(false);
const [loadPercent$] = useState(new BehaviorSubject(0));
const loadPercent$ = useConstant(() => new BehaviorSubject(0));
useImperativeHandle(
forwardRef,
@@ -372,11 +372,7 @@ export const LocalMapsListPanel = forwardRef(({ version, className, filter, sear
if (!maps) {
return (
<div ref={ref} className={className}>
<div className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200">
<BsmImage className="w-32 h-32 spin-loading" image={BeatWaitingImg} />
<span className="font-bold">{t("modals.download-maps.loading-maps")}</span>
<TextProgressBar value$={loadPercent$} />
</div>
<BsContentLoader className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200" value$={loadPercent$} text="modals.download-maps.loading-maps" />
</div>
);
}
@@ -76,7 +76,7 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
const previewUrl = mapId ? `https://allpoland.github.io/ArcViewer/?id=${mapId}` : null;
const mapUrl = mapId ? `https://beatsaver.com/maps/${mapId}` : null;
const authorUrl = autorId ? `https://beatsaver.com/profile/${autorId}` : null;
const createdDate = createdAt ? dateFormat(createdAt, "d mmm yyyy") : null;
const createdDate = createdAt ? dateFormat(createdAt * 1000, "d mmm yyyy") : null;
const likesText = likes ? Intl.NumberFormat(undefined, { notation: "compact" }).format(likes).split(" ").join("") : null;
const durationText = (() => {
@@ -1,8 +1,16 @@
import { forwardRef, 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";
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, finalize, lastValueFrom, map, tap } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { noop } from "shared/helpers/function.helpers";
import { LocalBPList } from "shared/models/playlists/local-playlist.models";
import { PlaylistItem } from "./playlist-item.component";
type Props = {
version: BSVersion;
@@ -11,19 +19,116 @@ type Props = {
isActive?: boolean;
};
export function LocalPlaylistsListPanel({ version, className, isActive, linkedState }: Props) {
export const LocalPlaylistsListPanel = forwardRef<unknown, Props>(({ version, className, isActive, linkedState }, forwardedRef) => {
const playlistService = useService(PlaylistsManagerService);
const isActiveOnce = useChangeUntilEqual(isActive, { untilEqual: true });
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const [playlists, setPlaylists] = useState<LocalBPList[]>([]);
const loadPercent$ = useConstant(() => new BehaviorSubject<number>(0));
const loadPlaylists = (): Promise<LocalBPList[]> => {
setPlaylistsLoading(true);
const obs = playlistService.getVersionPlaylists(version).pipe(
tap({ next: load => loadPercent$.next((load.current / load.total) * 100)}),
tap({ next: console.log}),
map(load => load.data),
finalize(() => setPlaylistsLoading(false))
);
return lastValueFrom(obs);
}
const getNbMappersOfPlaylist = (playlist: LocalBPList) => {
return new Set(playlist.songs.map(s => s.songDetails?.uploader?.id ?? s.song.hash)).size;
};
const getDurationOfPlaylist = (playlist: LocalBPList) => {
return playlist.songs.reduce((acc, s) => acc + (s.songDetails?.duration ?? 0), 0);
}
const getMinNpsOfPlaylist = (playlist: LocalBPList) => {
let min = Infinity;
playlist.songs.forEach(s => {
s.songDetails?.difficulties.forEach(d => {
if(d.nps < min){
min = d.nps;
}
})
});
return min === Infinity ? null : min;
}
const getMaxNpsOfPlaylist = (playlist: LocalBPList) => {
let max = -Infinity;
playlist.songs.forEach(s => {
s.songDetails?.difficulties.forEach(d => {
if(d.nps > max){
max = d.nps;
}
})
});
return max === -Infinity ? null : max;
}
const getSongsOfPlaylist = (playlist: LocalBPList) => {
return playlist.songs.map(s => ({ url: s.songUrl ?? `https://cdn.beatsaver.com/${s.song.hash}.mp3`, bpm: s.songDetails?.bpm ?? 0}));
}
useOnUpdate(() => {
if(!isActiveOnce){ return noop(); }
// load playlists
loadPlaylists().then(loadedPlaylists => {
setPlaylists(() => loadedPlaylists);
}).catch(() => {
setPlaylists([])
}).finally(() => {
loadPercent$.next(0);
});
}, [isActiveOnce]);
}, [isActiveOnce, version]);
const render = () => {
if(playlistsLoading){
return (
<BsContentLoader className="w-full h-full flex justify-center flex-col items-center" value$={loadPercent$} text="aaaa"/>
)
}
if (playlists.length){
return (
<>
<ul className="size-full flex flex-row flex-wrap justify-start content-start p-3 gap-3 overflow-y-scroll">
{playlists.map(p =>
<PlaylistItem
key={p.path}
id={p.path}
title={p.playlistTitle}
author={p.playlistAuthor}
coverBase64={p.image}
nbMaps={p.songs.length}
nbMappers={getNbMappersOfPlaylist(p)}
duration={getDurationOfPlaylist(p)}
maxNps={getMaxNpsOfPlaylist(p)}
minNps={getMinNpsOfPlaylist(p)}
songs={getSongsOfPlaylist(p)}
/>
)}
</ul>
<br/>
</>
)
}
return null;
}
return (
<div>local-playlists-list-panel.component</div>
<div className={className}>
{render()}
</div>
)
}
});
@@ -0,0 +1,79 @@
import { motion } from 'framer-motion';
import { BsmImage } from 'renderer/components/shared/bsm-image.component';
import { ClockIcon } from 'renderer/components/svgs/icons/clock-icon.component';
import { MapIcon } from 'renderer/components/svgs/icons/map-icon.component';
import { PersonIcon } from 'renderer/components/svgs/icons/person-icon.component';
import { useThemeColor } from 'renderer/hooks/use-theme-color.hook';
import dateFormat from 'dateformat';
import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component';
import { useService } from 'renderer/hooks/use-service.hook';
import { AudioPlayerService } from 'renderer/services/audio-player.service';
import { filter, lastValueFrom, skip, take } from 'rxjs';
type Props = {
id: string;
className?: string;
title?: string;
author?: string;
coverBase64?: string;
coverUrl?: string;
nbMaps?: number;
nbMappers?: number;
duration?: number;
minNps?: number;
maxNps?: number;
songs?: { url: string, bpm: number }[];
}
export function PlaylistItem({ id, className, title, author, coverUrl, coverBase64, duration, nbMaps, nbMappers, minNps, maxNps, songs }: Props) {
const player = useService(AudioPlayerService);
const firstColor = useThemeColor("first-color");
const nbMapsText = nbMaps ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMaps).trim() : null;
const nbMappersText = nbMappers ? Intl.NumberFormat(undefined, { notation: "compact" }).format(nbMappers).trim() : null;
const minNpsText = minNps ? Math.round(minNps * 10) / 10 : null;
const maxNpsText = maxNps ? Math.round(maxNps * 10) / 10 : null;
const durationText = (() => {
if (!duration) {
return null;
}
const date = new Date(0);
date.setSeconds(duration);
return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss");
})();
const startPlay = async () => {
if (!songs || songs.length === 0) {
return;
}
const playlist = songs.map(s => ({ src: s.url, bpm: s.bpm }));
player.playlist(playlist, 0);
}
return (
<motion.li id={id} layoutId={id} className={`relative flex flex-row justify-start items-center flex-grow basis-0 min-w-80 h-28 cursor-pointer rounded-md overflow-hidden ${className}`} onClick={() => startPlay()}>
<div className="absolute top-0 left-0 size-full flex justify-center items-center -z-[1]">
<BsmImage className="size-full object-cover scale-[2] saturate-150 blur-xl" image={coverUrl} base64={coverBase64} loading="lazy" />
<div className="absolute top-0 left-0 size-full bg-black opacity-15"/>
</div>
<div className="h-full aspect-square p-2.5">
<BsmImage className="size-full flex-shrink-0 object-cover rounded-md shadow-center shadow-black bg-main-color-1" image={coverUrl} base64={coverBase64} loading="lazy" />
</div>
<div className="h-full py-2.5 text-white">
<h1 className="font-bold text-lg capitalize tracking-wide line-clamp-1">{title}</h1>
<p className="text-xs font-bold">Créé par <span className="brightness-200" style={{color: firstColor}}>{author}</span></p>
<div className="flex flex-row flex-wrap w-full gap-2 mt-1">
{ nbMapsText && <div className="flex items-center text-sm h-5 gap-0.5"> <MapIcon className='h-full aspect-square'/> <span className="mb-0.5">{nbMapsText}</span> </div> }
{ nbMappersText && <div className="flex items-center text-sm h-5 gap-0.5"> <PersonIcon className='h-full aspect-square'/> <span className="mb-0.5">{nbMappersText}</span> </div> }
{ durationText && <div className="flex items-center text-sm h-5 gap-0.5"> <ClockIcon className='h-full aspect-square'/> <span className="mb-0.5">{durationText}</span> </div> }
{ (minNps && maxNps) && <div className="flex items-center text-sm h-5 gap-0.5"> <NpsIcon className='h-full aspect-square scale-95'/> <span className="mb-0.5">{`${minNpsText} - ${maxNpsText}`}</span> </div> }
</div>
</div>
</motion.li>
)
}
@@ -1,5 +1,5 @@
import { BSVersion } from "shared/bs-version.interface";
import { useRef, forwardRef, useImperativeHandle } from "react";
import { forwardRef, useImperativeHandle } 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";
@@ -9,9 +9,7 @@ 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 BeatWaitingImg from "../../../../assets/images/apngs/beat-waiting.png";
import BeatConflict from "../../../../assets/images/apngs/beat-conflict.png";
import TextProgressBar from "../progress-bar/text-progress-bar.component";
import { BehaviorSubject, distinctUntilChanged, map, startWith } from "rxjs";
import { BsmButton } from "../shared/bsm-button.component";
import equal from "fast-deep-equal";
@@ -20,6 +18,7 @@ import { MODEL_TYPE_FOLDERS } from "shared/models/models/constants";
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";
type Props = {
className?: string;
@@ -30,14 +29,12 @@ type Props = {
downloadModels?: () => void;
};
export const ModelsGrid = forwardRef(({ className, version, type, search, active, downloadModels }: Props, forwardRef) => {
export const ModelsGrid = forwardRef<unknown, Props>(({ className, version, type, search, active, downloadModels }, forwardRef) => {
const modelsManager = useService(ModelsManagerService);
const modelsDownloader = useService(ModelsDownloaderService);
const t = useTranslation();
const ref = useRef();
const [models, setModelsLoadObservable, , setModels] = useSwitchableObservable<BsmLocalModel[]>();
const progress$ = useConstant(() => new BehaviorSubject(0));
const [modelsSelected, modelsSelected$] = useBehaviorSubject<BsmLocalModel[]>([]);
@@ -199,11 +196,7 @@ export const ModelsGrid = forwardRef(({ className, version, type, search, active
const renderContent = () => {
if (isLoading) {
return (
<div className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200">
<BsmImage className="w-32 h-32 spin-loading" image={BeatWaitingImg} />
<span className="font-bold">{t("models.panel.grid.loading")}</span>
<TextProgressBar value$={progress$} />
</div>
<BsContentLoader className="h-full flex flex-col items-center justify-center flex-wrap gap-1 text-gray-800 dark:text-gray-200" value$={progress$} text="models.panel.grid.loading" />
);
}
@@ -236,7 +229,7 @@ export const ModelsGrid = forwardRef(({ className, version, type, search, active
};
return (
<div ref={ref} className={`w-full h-full flex-shrink-0 ${className ?? ""}`}>
<div className={`w-full h-full flex-shrink-0 ${className ?? ""}`}>
{renderContent()}
</div>
);
@@ -8,7 +8,7 @@ import { useService } from "renderer/hooks/use-service.hook";
// Thanks to cheddZy for the icon : https://github.com/cheddZy
export const BsManagerIcon = memo(({ className }: { className?: string }) => {
const audioPlayer = useService(AudioPlayerService);
const { firstColor, secondColor } = useThemeColor();
@@ -27,6 +27,8 @@ export const BsManagerIcon = memo(({ className }: { className?: string }) => {
const clickAction = () => {
if (playing) {
audioPlayer.pause();
} else {
audioPlayer.resume();
}
};
@@ -0,0 +1,24 @@
import { useTranslation } from "renderer/hooks/use-translation.hook";
import TextProgressBar from "../progress-bar/text-progress-bar.component";
import { BsmImage } from "./bsm-image.component";
import BeatWaitingImg from "../../../../assets/images/apngs/beat-waiting.png";
import { Observable } from "rxjs";
type Props = {
className?: string;
value$: Observable<number | string>;
text: string;
}
export function BsContentLoader({className, value$, text}: Props) {
const t = useTranslation();
return (
<div className={className}>
<BsmImage className="size-32 spin-loading" image={BeatWaitingImg} />
<span>{t(text)}</span>
<TextProgressBar value$={value$} />
</div>
)
}
@@ -1,20 +1,26 @@
import { CSSProperties, forwardRef, SyntheticEvent, useState } from "react";
import { ComponentProps, CSSProperties, forwardRef, SyntheticEvent, useState } from "react";
type Props = {
className?: string;
image: string;
image?: string;
base64?: string;
errorImage?: string;
placeholder?: string;
loading?: "lazy" | "eager";
style?: CSSProperties;
title?: string;
onClick?: (e: MouseEvent) => void;
onClick?: ComponentProps<"img">["onClick"]
};
export const BsmImage = forwardRef(({ className, image, errorImage, placeholder, loading, style, title, onClick }: Props, ref) => {
export const BsmImage = forwardRef<HTMLImageElement, Props>(({ className, image, base64, errorImage, placeholder, loading, style, title, onClick }, ref) => {
const [isLoaded, setIsLoaded] = useState(false);
image = image || placeholder || errorImage;
const getBase64Url = () => {
if(base64?.startsWith("data:image")){ return base64; }
return base64 ? `data:image/png;base64,${base64}` : undefined;
};
const imageSrc = image || getBase64Url() || placeholder || errorImage;
const styles: CSSProperties = (() => {
return {
@@ -34,5 +40,5 @@ export const BsmImage = forwardRef(({ className, image, errorImage, placeholder,
setIsLoaded(() => true);
};
return <img ref={ref} title={title} className={className} src={image} loading={loading} onLoad={handleLoaded} onError={handleError} style={styles} onClick={e => onClick?.(e)} alt=" " decoding="async" />;
return <img ref={ref} title={title} className={className} src={imageSrc} loading={loading} onLoad={handleLoaded} onError={handleError} style={styles} onClick={e => onClick?.(e)} alt=" " decoding="async" />;
});
@@ -0,0 +1,9 @@
import { createSvgIcon } from "../svg-icon.type"
export const ClockIcon = createSvgIcon((props, ref) => {
return (
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
<path d="M523.587-497.913v-137.304q0-18.525-12.531-31.056-12.532-12.531-31.056-12.531t-31.056 12.531q-12.531 12.531-12.531 31.056v153.5q0 9.195 3.359 17.549 3.358 8.354 10.076 15.038l128.174 128.173q12.195 12.196 30.391 12.196 18.196 0 30.63-12.196 12.435-12.195 12.435-30.63t-12.435-30.87L523.587-497.913ZM480-71.869q-84.913 0-159.345-32.118t-129.491-87.177q-55.059-55.059-87.177-129.491Q71.869-395.087 71.869-480t32.118-159.345q32.118-74.432 87.177-129.491 55.059-55.059 129.491-87.177Q395.087-888.131 480-888.131t159.345 32.118q74.432 32.118 129.491 87.177 55.059 55.059 87.177 129.491Q888.131-564.913 888.131-480t-32.118 159.345q-32.118 74.432-87.177 129.491-55.059 55.059-129.491 87.177Q564.913-71.869 480-71.869Z"/>
</svg>
)
});
@@ -0,0 +1,10 @@
import { createSvgIcon } from "../svg-icon.type";
export const NpsIcon = createSvgIcon((props, ref) => {
return (
<svg ref={ref} {...props} viewBox="0 0 400 400.798" fill="currentColor">
<path d="M252.695 2.086c-71.105 4.699-129.741 64.87-129.741 133.139v6.891h25.207l.972-10.978c8.496-96.006 122.84-137.765 190.89-69.715 66.356 66.356 23.862 180.533-70.499 189.426l-10.043.946v25.251h6.092c52.708 0 106.03-39.28 124.438-91.667C422.425 93.134 349.427-4.306 252.695 2.086m-5.19 100.708v49.701h113.413l-.22-12.851-.219-12.852-43.513-.123-43.513-.122-.136-36.568-.137-36.569-12.837-.158-12.838-.158z" fillRule="evenodd"/>
<path d="M211.315 146.386H46.186c-23.679 0-42.875 19.195-42.875 42.874v165.129c0 23.68 19.196 42.875 42.875 42.875h165.129c23.679 0 42.874-19.195 42.874-42.875V189.26c0-23.679-19.195-42.874-42.874-42.874M45.124 188.199h167.252v20.907l-83.626 41.812-83.626-41.812z"/>
</svg>
)
});
@@ -0,0 +1,9 @@
import { createSvgIcon } from '../svg-icon.type';
export const PersonIcon = createSvgIcon((props, ref) => {
return (
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
<path d="M480-484.065q-69.587 0-118.859-49.272-49.272-49.272-49.272-118.859 0-69.587 49.272-118.739T480-820.087q69.587 0 118.859 49.152 49.272 49.152 49.272 118.739t-49.272 118.859Q549.587-484.065 480-484.065ZM151.869-238.804v-29.609q0-36.152 18.696-66.565 18.696-30.413 49.848-46.37 62.717-31.239 127.674-46.978Q413.043-444.065 480-444.065q67.435 0 132.391 15.619 64.957 15.62 127.196 46.859 31.152 15.957 49.848 46.25 18.696 30.294 18.696 66.924v29.609q0 37.782-26.609 64.391-26.609 26.609-64.392 26.609H242.87q-37.783 0-64.392-26.609-26.609-26.609-26.609-64.391Z"/>
</svg>
)
});
@@ -2,7 +2,7 @@ import { CSSProperties } from "react";
export function TimerFillIcon(props: { className?: string; style?: CSSProperties }) {
return (
<svg className={props.className} style={props.style} xmlns="http://www.w3.org/2000/svg" height="40" width="40" viewBox="0 0 40 40" fill="currentColor">
<svg className={props.className} style={props.style} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" fill="currentColor">
<path d="M16.25 4.458q-.625 0-1.062-.437-.438-.438-.438-1.104 0-.625.438-1.063.437-.437 1.062-.437h7.5q.625 0 1.062.437.438.438.438 1.063 0 .666-.438 1.104-.437.437-1.062.437ZM20 23.208q.625 0 1.062-.437.438-.438.438-1.104v-6.709q0-.625-.438-1.062-.437-.438-1.062-.438t-1.062.438q-.438.437-.438 1.062v6.709q0 .666.438 1.104.437.437 1.062.437Zm0 13.959q-3.167 0-5.938-1.209-2.77-1.208-4.854-3.291-2.083-2.084-3.291-4.855-1.209-2.77-1.209-5.937 0-3.167 1.209-5.937 1.208-2.771 3.291-4.855Q11.292 9 14.062 7.792 16.833 6.583 20 6.583q2.75 0 5.188.896 2.437.896 4.437 2.521l1.25-1.208q.417-.459 1.042-.459T33 8.792q.458.458.458 1.104 0 .646-.458 1.062l-1.208 1.209q1.541 1.833 2.52 4.229.98 2.396.98 5.479 0 3.167-1.209 5.937-1.208 2.771-3.291 4.855-2.084 2.083-4.854 3.291-2.771 1.209-5.938 1.209Z" />
</svg>
);
@@ -19,10 +19,7 @@ export function useChangeUntilEqual<T = unknown>(variableValue: T, { untilEqual
const isEqual = equal(variableValue, untilEqualRef.current);
console.log("isEqual", isEqual, variableValue, untilEqualRef.current);
if(!isEqual){
console.log("setValue", variableValue);
return setValue(variableValue);
}
+16 -1
View File
@@ -50,6 +50,15 @@ export class AudioPlayerService {
return this.player.play();
}
public playlist(songs: {src: string, bpm: number}[], index: number): void {
this.play(songs[index].src, songs[index].bpm);
this.player.onended = () => {
if (index < songs.length - 1) {
this.playlist(songs, index + 1);
}
};
}
public pause(): void {
this._playing$.next(false);
this.player.pause();
@@ -83,7 +92,10 @@ export class AudioPlayerService {
}
public toggleMute(): void {
this.muted ? this.unmute() : this.mute();
if(this.muted){
return this.unmute();
}
this.mute();
}
public get src$(): Observable<string> {
@@ -114,6 +126,9 @@ export class AudioPlayerService {
public get muted(): boolean {
return this.player.muted;
}
public get paused(): boolean {
return this.player.paused;
}
}
interface PlayerVolume {
@@ -2,6 +2,8 @@ import { BSVersion } from "shared/bs-version.interface";
import { IpcService } from "./ipc.service";
import { Observable, lastValueFrom } from "rxjs";
import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service";
import { Progression } from "main/helpers/fs.helpers";
import { LocalBPList } from "shared/models/playlists/local-playlist.models";
export class PlaylistsManagerService {
private static instance: PlaylistsManagerService;
@@ -23,8 +25,8 @@ export class PlaylistsManagerService {
this.linker = VersionFolderLinkerService.getInstance();
}
public getVersionPlaylists(version: BSVersion): Promise {
return this.ipc.sendV2("get-version-playlists", version);
public getVersionPlaylists(version: BSVersion): Observable<Progression<LocalBPList[]>> {
return this.ipc.sendV2("get-version-playlists", { args: version });
}
public isDeepLinksEnabled(): Promise<boolean> {
@@ -0,0 +1,13 @@
import { SongDetails } from "../maps";
import { BPList, PlaylistSong } from "./playlist.interface";
export interface LocalBPList extends BPList<LocalBpListSong> {
path: string;
}
export interface LocalBpListSong {
song: PlaylistSong;
songDetails?: SongDetails;
songUrl?: string;
coverUrl?: string;
}
@@ -1,13 +1,13 @@
import { BsvMapDetail } from "../maps";
import { BsvMapDetail, SongDetails } from "../maps";
import { BsmLocalMap } from "../maps/bsm-local-map.interface";
export interface BPList {
export interface BPList<SongType = PlaylistSong> {
playlistTitle: string;
playlistAuthor: string;
playlistDescription?: string;
image: string;
customData: unknown;
songs: PlaylistSong[];
songs: SongType[];
}
export interface PlaylistSong {
@@ -15,6 +15,7 @@ export interface PlaylistSong {
hash: string;
songName: string;
uploader?: string;
songDetails?: SongDetails;
}
export interface DownloadPlaylistProgressionData {