[feature-136] models management almost finished, still need to add translations

This commit is contained in:
MathieuG-P
2023-05-19 19:06:59 +02:00
parent 76bdde0fe8
commit bdc893d8a5
21 changed files with 308 additions and 128 deletions
+17 -6
View File
@@ -40,6 +40,7 @@
"rxjs": "^7.8.0",
"sanitize-filename": "^1.6.3",
"semver": "^7.3.8",
"striptags": "^4.0.0-alpha.4",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-scoped-groups": "^2.0.0",
"tippy.js": "^6.3.7",
@@ -9553,9 +9554,9 @@
}
},
"node_modules/html-tags": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz",
"integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
"integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
"dev": true,
"engines": {
"node": ">=8"
@@ -16737,6 +16738,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/striptags": {
"version": "4.0.0-alpha.4",
"resolved": "https://registry.npmjs.org/striptags/-/striptags-4.0.0-alpha.4.tgz",
"integrity": "sha512-/0jWyVWhpg9ciRHfjKYBpMHXct/HrFRfsR2HU77nGPbc8SPcVSIHZlZR/0TG3MyPq2C+HiHuwx8BlbcdI/cNbw=="
},
"node_modules/style-loader": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz",
@@ -26005,9 +26011,9 @@
}
},
"html-tags": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz",
"integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
"integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
"dev": true
},
"html-webpack-plugin": {
@@ -31422,6 +31428,11 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true
},
"striptags": {
"version": "4.0.0-alpha.4",
"resolved": "https://registry.npmjs.org/striptags/-/striptags-4.0.0-alpha.4.tgz",
"integrity": "sha512-/0jWyVWhpg9ciRHfjKYBpMHXct/HrFRfsR2HU77nGPbc8SPcVSIHZlZR/0TG3MyPq2C+HiHuwx8BlbcdI/cNbw=="
},
"style-loader": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.1.tgz",
+1
View File
@@ -259,6 +259,7 @@
"rxjs": "^7.8.0",
"sanitize-filename": "^1.6.3",
"semver": "^7.3.8",
"striptags": "^4.0.0-alpha.4",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-scoped-groups": "^2.0.0",
"tippy.js": "^6.3.7",
+1 -1
View File
@@ -130,6 +130,6 @@ export async function copyDirectoryWithJunctions(src: string, dest: string, opti
export interface Progression<T = unknown>{
total: number;
current: number;
extra?: T;
data?: T;
}
+6
View File
@@ -8,6 +8,7 @@ import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { from } from "rxjs";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
import { ModelDownload } from "renderer/services/models-management/models-downloader.service";
const ipc = IpcService.getInstance();
@@ -67,6 +68,11 @@ ipcMain.on("is-models-deep-links-enabled", async (event, request: IpcRequest<voi
});
ipc.on<ModelDownload>("download-model", async (req, reply) => {
const models = LocalModelsManagerService.getInstance();
reply(models.downloadModel(req.args.model, req.args.version));
});
ipc.on<{version: BSVersion, type: MSModelType}>("get-version-models", async (req, reply) => {
const models = LocalModelsManagerService.getInstance();
const res = await models.getModels(req.args.type, req.args.version);
+8 -7
View File
@@ -5,6 +5,7 @@ import recursive from "recursive-readdir";
import { lstatSync } from "fs";
import * as _path from "path";
import { ArchiveProgress } from "shared/models/archive.interface";
import { Progression } from "main/helpers/fs.helpers";
export class Archive{
@@ -49,21 +50,21 @@ export class Archive{
}
public finalize(): Observable<ArchiveProgress>{
public finalize(): Observable<Progression>{
const progress: ArchiveProgress = {
totalFiles: 0,
prossesedFiles: 0
const progress: Progression = {
total: 0,
current: 0
};
return new Observable<ArchiveProgress>(observer => {
return new Observable<Progression>(observer => {
(async () => {
progress.totalFiles = await this.loadTotalFiles();
progress.total = await this.loadTotalFiles();
this.archive.on("entry", e => {
if(e.stats.isDirectory()){ return; }
progress.prossesedFiles++;
progress.current++;
observer.next(progress);
});
@@ -16,7 +16,7 @@ import log from 'electron-log';
import { WindowManagerService } from "../window-manager.service";
import { ipcMain } from "electron";
import { IpcRequest } from 'shared/models/ipc';
import { Observable } from "rxjs";
import { Observable, lastValueFrom } from "rxjs";
import { Archive } from "../../models/archive.class";
import { deleteFolder, ensureFolderExist, getFoldersInFolder, pathExist } from "../../helpers/fs.helpers";
import { readFile } from "fs/promises";
@@ -116,7 +116,7 @@ export class LocalMapsManagerService {
await ensureFolderExist(this.utils.getTempPath());
const dest = path.join(tempPath, fileName);
const zipPath = await this.reqService.downloadFile(zipUrl, dest);
const zipPath = (await lastValueFrom(this.reqService.downloadFile(zipUrl, dest))).data;
const zip = new StreamZip.async({file : zipPath});
return {zip, zipPath};
@@ -14,7 +14,7 @@ import sanitize from "sanitize-filename";
import { Progression, ensureFolderExist, unlinkPath } from "../../helpers/fs.helpers";
import { MODEL_FILE_EXTENSIONS, MODEL_TYPES, MODEL_TYPE_FOLDERS } from "../../../shared/models/models/constants";
import { InstallationLocationService } from "../installation-location.service";
import { Observable, lastValueFrom } from "rxjs";
import { Observable, Subscription, lastValueFrom, map } from "rxjs";
import { readdir } from "fs/promises";
import md5File from "md5-file";
import { allSettled } from "../../../shared/helpers/promise.helpers";
@@ -85,13 +85,42 @@ export class LocalModelsManagerService {
}
public async downloadModel(model: MSModel, version: BSVersion): Promise<string>{
const modelFolder = await this.getModelFolderPath(model.type, version);
const modelDest = path.join(modelFolder, sanitize(path.basename(model.download)));
public downloadModel(model: MSModel, version: BSVersion): Observable<Progression<BsmLocalModel>>{
return new Observable<Progression<BsmLocalModel>>(subscriber => {
return this.request.downloadFile(model.download, modelDest);
const subs: Subscription[] = [];
(async () => {
const modelFolder = await this.getModelFolderPath(model.type, version);
const modelDest = path.join(modelFolder, sanitize(path.basename(model.download)));
let url = model.download.split("/");
url[url.length - 1] = encodeURIComponent(url[url.length - 1]);
const download$ = this.request.downloadFile(url.join("/"), modelDest);
subs.push(download$.subscribe({ next: value => subscriber.next({...value, data: undefined}), error: e => subscriber.error(e) }));
const downloaded = await lastValueFrom(download$);
const res: BsmLocalModel = {
path: downloaded.data,
fileName: path.basename(downloaded.data),
hash: await md5File(downloaded.data),
type: model.type,
model,
version
}
subscriber.next({...downloaded, data: res});
})().catch(err => subscriber.error(err)).then(() => subscriber.complete());
return () => {
subs.forEach(sub => sub.unsubscribe());
}
});
}
public async oneClickDownloadModel(model: MSModel): Promise<void>{
@@ -104,13 +133,13 @@ export class LocalModelsManagerService {
const fisrtVersion = versions.shift();
const downloaded = await this.downloadModel(model, fisrtVersion);
const downloaded = await lastValueFrom(this.downloadModel(model, fisrtVersion));
for(const version of versions){
const modelDest = path.join(await this.getModelFolderPath(model.type, version), path.basename(downloaded));
const modelDest = path.join(await this.getModelFolderPath(model.type, version), path.basename(downloaded.data.path));
copyFileSync(downloaded, modelDest);
copyFileSync(downloaded.data.path, modelDest);
}
@@ -133,7 +162,7 @@ export class LocalModelsManagerService {
const progression: Progression<BsmLocalModel[]> = {
total: 0,
current: 0,
extra: null
data: null
};
return new Observable<Progression<BsmLocalModel[]>>(subscriber => {
@@ -149,7 +178,7 @@ export class LocalModelsManagerService {
path: modelPath,
fileName: path.basename(modelPath, MODEL_FILE_EXTENSIONS[type]),
model: await this.modelSaber.getModelByHash(hash),
type, hash
type, hash, version
}
progression.current++;
@@ -159,7 +188,7 @@ export class LocalModelsManagerService {
return localModel;
}));
progression.extra = models;
progression.data = models;
subscriber.next(progression);
})().catch(e => subscriber.error(e)).finally(() => subscriber.complete());
@@ -167,11 +196,9 @@ export class LocalModelsManagerService {
}
public exportModels(output: string, version?: BSVersion, models?: BsmLocalModel[]): Observable<ArchiveProgress>{
// TOTO NOT ASYNC
public exportModels(output: string, version?: BSVersion, models?: BsmLocalModel[]): Observable<Progression>{
return new Observable<ArchiveProgress>(subscriber => {
return new Observable<Progression>(subscriber => {
const archive = new Archive(output);
@@ -199,7 +226,7 @@ export class LocalModelsManagerService {
const progression: Progression = {
total: models.length,
current: 0,
extra: null
data: null
};
for(const model of models){
@@ -1,5 +1,5 @@
import path from "path";
import { BehaviorSubject, Observable } from "rxjs";
import { BehaviorSubject, Observable, lastValueFrom } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BSLocalVersionService } from "../bs-local-version.service";
import { DeepLinkService } from "../deep-link.service";
@@ -97,7 +97,7 @@ export class LocalPlaylistsManagerService {
copyFileSync(bpListUrlOrPath, bpListDest);
}
else{
await this.request.downloadFile(bpListUrlOrPath, bpListDest);
await lastValueFrom(this.request.downloadFile(bpListUrlOrPath, bpListDest));
}
return bpListDest;
@@ -12,6 +12,7 @@ import { spawn } from "child_process";
import { BS_EXECUTABLE } from "../../constants";
import log from "electron-log";
import { deleteFolder, ensureFolderExist, pathExist, unlinkPath } from "../../helpers/fs.helpers";
import { lastValueFrom } from "rxjs";
export class BsModsManagerService {
@@ -102,7 +103,7 @@ export class BsModsManagerService {
await ensureFolderExist(this.utilsService.getTempPath());
const dest = path.join(tempPath, fileName);
const zipPath = await this.requestService.downloadFile(zipUrl, dest);
const zipPath = (await lastValueFrom(this.requestService.downloadFile(zipUrl, dest))).data;
const zip = new StreamZip.async({file : zipPath});
return {zip, zipPath};
+52 -12
View File
@@ -1,5 +1,8 @@
import { RequestOptions, get } from "https";
import { createWriteStream, unlink } from "fs";
import { Progression, unlinkPath } from "main/helpers/fs.helpers";
import { Observable, buffer, shareReplay, tap } from "rxjs";
import log from "electron-log";
export class RequestService {
@@ -13,6 +16,7 @@ export class RequestService {
private constructor(){}
public get<T = any>(options: string|RequestOptions): Promise<T>{
return new Promise((resolve, reject) => {
let body = ''
get(options, (res) => {
@@ -25,21 +29,57 @@ export class RequestService {
});
}
public downloadFile(url: string, dest: string): Promise<string>{
return new Promise((resolve, reject) => {
public downloadFile(url: string, dest: string): Observable<Progression<string>>{
return new Observable<Progression<string>>(subscriber => {
const progress: Progression<string> = { current: 0, total: 0 };
const file = createWriteStream(dest);
get(url, res => {
res.pipe(file);
file.on("close", () => {
file.close(() => resolve(dest))
}).on("error", err => {
unlink(dest, () => reject(err));
});
}).on("error", err => {
unlink(dest, () => reject(err));
file.on("close", () => {
progress["data"] = dest;
subscriber.next(progress); subscriber.complete();
});
});
file.on("error", err => unlink(dest, () => subscriber.error(err)));
const req = get(url, res => {
progress.total = parseInt(res.headers?.["content-length"] || "0", 10);
res.on("data", chunk => {
progress.current += chunk.length;
subscriber.next(progress);
});
res.pipe(file);
});
req.on("error", err => { subscriber.error(err); });
}).pipe(tap({error: e => log.error(e)}), shareReplay(1));
}
public downloadBuffer(url: string): Observable<Buffer>{
return new Observable<Buffer>(subscriber => {
const allChunks: Buffer[] = [];
const req = get(url, res => {
res.on("data", chunk => {
allChunks.push(chunk);
});
res.on('end', () => {
subscriber.next(Buffer.concat(allChunks));
subscriber.complete();
});
res.on('error', (err) => subscriber.error(err))
});
req.on("error", err => { subscriber.error(err); });
}).pipe(tap({error: e => log.error(e)}), shareReplay(1));
}
}
@@ -2,6 +2,7 @@ import { Observable } from "rxjs";
import { MSGetQuery, MSGetQueryFilterType, MSModel, MSModelPlatform } from "../../../../shared/models/models/model-saber.model";
import { ModelSaberApiService } from "./model-saber-api.service";
import log from "electron-log";
import striptags from "striptags"
export class ModelSaberService {
@@ -71,6 +72,8 @@ export class ModelSaberService {
const model = Array.from(Object.values(res.data)).at(0);
model.name = striptags(model.name ?? "");
this.modelsHashCache.set(hash, model);
return model
@@ -87,7 +90,11 @@ export class ModelSaberService {
(async () => {
const res = await this.modelSaberApi.searchModel(query);
if(res.status !== 200){ observer.error(res.status); }
observer.next(Object.values(res.data));
observer.next(Object.values(res.data).map(model => {
if(!model || !model.name){ return model; }
(model as MSModel).name = striptags(model.name);
return model;
}));
})().catch(e => observer.error(e)).then(() => observer.complete());
});
}
@@ -5,9 +5,8 @@ import { ModalComponent } from "renderer/services/modale.service";
import { BSVersion } from "shared/bs-version.interface";
import BeatWaitingImg from "../../../../../../assets/images/apngs/beat-waiting.png";
import BeatConflictImg from "../../../../../../assets/images/apngs/beat-conflict.png";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
import { MSGetQuery, MSGetSort, MSGetSortDirection, MSModel, MSModelPlatform, MSModelType } from "shared/models/models/model-saber.model";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { ModelItem } from "renderer/components/models-management/model-item.component";
import { useService } from "renderer/hooks/use-service.hook";
import { ModelDownload, ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service";
@@ -21,11 +20,10 @@ import { MODEL_TYPES, MS_GET_QUERY_SORTS } from "shared/models/models/constants"
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import equal from "fast-deep-equal";
import { catchError, of } from "rxjs";
import { BsmIcon } from "renderer/components/svgs/bsm-icon.component";
import Tippy from "@tippyjs/react";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type: MSModelType}> = ({resolver, data: { version, type }}) => {
export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type: MSModelType, owned: BsmLocalModel[]}> = ({resolver, data: { version, type, owned }}) => {
const modelsDownloader = useService(ModelsDownloaderService);
const modelSaber = useService(ModelSaberService);
@@ -42,34 +40,46 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
})));
const t = useTranslation();
const color = useThemeColor("first-color");
const currentDownload = useObservable(modelsDownloader.currentDownload$(), null);
const downloadQueue = useObservable(modelsDownloader.getQueue$(), []);
const [msModels, msModels$] = useBehaviorSubject<MSModel[]>([]);
const isOnline = useObservable(os.isOnline$, true);
const [error, error$] = useBehaviorSubject(false);
const [isLoading, isLoading$] = useBehaviorSubject(false);
console.log(msModels);
const [ownedModels, setOwnedModels] = useState<BsmLocalModel[]>(owned ?? []);
const [currentType, currentType$] = useBehaviorSubject<MSModelType>(type);
const [currentSort, currentSort$] = useBehaviorSubject<MSGetSort>(MSGetSort.Date);
const [searhInput, searhInput$] = useBehaviorSubject("");
const [getQuery, getQuery$] = useBehaviorSubject<MSGetQuery>({ type: currentType, platform: MSModelPlatform.PC, start: 0, end: 25, sort: currentSort, sortDirection: MSGetSortDirection.Descending });
useOnUpdate(() => {
const sub = modelsDownloader.onModelsDownloaded((model => {
if(!equal(model.version, version)) { return; }
setOwnedModels(prev => [...prev ?? [], model]);
}));
return () => sub.unsubscribe();
});
useOnUpdate(() => {
error$.next(false);
isLoading$.next(true);
const sub = modelSaber.searchModels(getQuery).pipe(catchError(() => {
error$.next(true);
return of([]);
})).subscribe(models => msModels$.next([...msModels, ...models]));
})).subscribe(models => {
msModels$.next([...msModels, ...models]);
isLoading$.next(false);
});
return () => sub.unsubscribe();
}, [getQuery])
useOnUpdate(() => {
msModels$.next([]);
getQuery$.next({
...getQuery$.value,
start: 0,
@@ -81,7 +91,6 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
}, [currentType, currentSort]);
const loadMore = () => {
console.log("load more");
const currentQuery = getQuery;
currentQuery.start += 25;
currentQuery.end += 25;
@@ -90,8 +99,6 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
const search = () => {
const filters = searhInput.split(" ").join(",");
const getQuery: MSGetQuery = {
start: 0, end: 25,
type: currentType,
@@ -105,12 +112,24 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
}
const handleDownloadModel = useCallback((model: MSModel) => {
modelsDownloader.addModelToDownload({...model, version});
modelsDownloader.addModelToDownload({model, version});
}, []);
const handleCancelDownload = useCallback((model: MSModel) => {
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 filterTipsHTML = useConstant(() => (
<div className="w-fit flex">
<table className="w-fit whitespace-nowrap grow shrink-0 text-base">
<table className="w-fit whitespace-nowrap grow shrink-0 text-sm">
<tbody>
<tr className="font-bold"><td className="mr-3 block">Tag</td><td>Description</td></tr>
<tr><td className="mr-3 block">author:</td><td>Only show models by the specified author.</td></tr>
@@ -127,7 +146,7 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
return (
<form className="text-gray-800 dark:text-gray-200 flex flex-col max-w-[95vw] w-[970px] h-[85vh] gap-3" onSubmit={e => {e.preventDefault(); search()}}>
<div className="flex h-9 gap-2 shrink-0">
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={modelTypesOptions} onChange={(value) => currentType$.next(value)}/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={modelTypesOptions} selected={currentType} onChange={(value) => currentType$.next(value)}/>
<div className="h-ful grow relative flex justify-center items-center">
<input className="h-full w-full bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-2 pb-0.5" type="text" name="" id="" placeholder="TODO TRANSLATE Rechercher un modèle" value={searhInput} onChange={e => searhInput$.next(e.target.value)}/>
<Tippy placement="bottom" content={filterTipsHTML} allowHTML={true} maxWidth={Infinity}>
@@ -153,8 +172,9 @@ export const DownloadModelsModal: ModalComponent<void, {version: BSVersion, type
key={model.id}
{...model}
callbackValue={model}
isDownloading={equal(currentDownload, {...model, version} as ModelDownload)}
onDownload={handleDownloadModel}
isDownloading={equal(currentDownload, {model, version} as ModelDownload)}
onDownload={!isModelOwned(model) ? handleDownloadModel: undefined}
onCancelDownload={modelPendingDownload(model) ? handleCancelDownload : undefined}
/>
))}
<motion.span onViewportEnter={loadMore} className="block w-full h-8"/>
@@ -59,12 +59,10 @@ function modelItem<T = unknown>(props: Props<T>) {
return [...new Set(props.tags)];
})();
console.log(thumbnailUrl, "la");
const actionButtons = (): {text: string, icon: BsmIconType, action: () => void}[] => {
const buttons: {text: string, icon: BsmIconType, action: () => void}[] = [];
const actionButtons = (): {text: string, icon: BsmIconType, action: () => void, iconColor?: string}[] => {
const buttons: {text: string, icon: BsmIconType, action: () => void, iconColor?: string}[] = [];
if(props.onDownload){
if(props.onDownload && !props.onCancelDownload){
buttons.push({ text: "Download", icon: "download", action: () => props.onDownload(props.callbackValue) }); // TODO TRANSLATE
}
@@ -73,7 +71,7 @@ function modelItem<T = unknown>(props: Props<T>) {
}
if(props.onCancelDownload){
buttons.push({ text: "Cancel download", icon: "cross", action: () => props.onCancelDownload(props.callbackValue) }); // TODO TRANSLATE
buttons.push({ text: "Cancel download", icon: "cross", action: () => props.onCancelDownload(props.callbackValue), iconColor: "red" }); // TODO TRANSLATE
}
return buttons;
@@ -91,18 +89,18 @@ function modelItem<T = unknown>(props: Props<T>) {
// TODO TRANSLATE ALL TEXT
<motion.li className={`relative flex-grow min-w-[14rem] h-56 cursor-pointer ${props.className ?? ""}`} onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} onClick={props.onClick}>
<GlowEffect visible={props.selected || hovered}/>
<div className="absolute top-0 left-0 w-full h-full rounded-lg overflow-hidden blur-none bg-black">
<div className="absolute top-0 left-0 w-full h-full rounded-lg overflow-hidden blur-none bg-black shadow-sm shadow-black">
<BsmImage className={`absolute top-0 left-0 w-full h-full object-cover ${props.type === MSModelType.Avatar ? "object-top" : ""}`} image={thumbnailUrl} placeholder={defaultImage} loading="lazy"/>
<div className="absolute top-0 right-0 h-full w-0 flex flex-col items-end gap-1 pt-1.5 pr-1.5">
{!props.isDownloading ? (
actionButtons().map((button, index) => (
<BsmButton key={index} className="w-7 h-7 p-1 rounded-md transition-transform duration-150 shadow-black shadow-sm" style={{transitionDelay: `${index * 50}ms`, transform: hovered ? "translate(0%)" : "translate(150%)"}} icon={button.icon} onClick={e => {e.stopPropagation(); e.preventDefault(); button.action();}} withBar={false}/>
<BsmButton key={index} className="w-7 h-7 p-1 rounded-md transition-transform duration-150 shadow-black shadow-sm" style={{transitionDelay: `${index * 50}ms`, transform: hovered ? "translate(0%)" : "translate(150%)"}} icon={button.icon} iconColor={button.iconColor} onClick={e => {e.stopPropagation(); e.preventDefault(); button.action();}} withBar={false}/>
))
): (
<BsmBasicSpinner className="w-7 h-7 p-1 rounded-md bg-main-color-2 flex items-center justify-center shadow-black shadow-sm" spinnerClassName="brightness-200" thikness="3.5px" style={{color}}/>
)}
</div>
<motion.div className="absolute cursor-default top-[80%] left-0 w-full h-full p-2 flex flex-col gap-1.5 bg-main-color-3 bg-opacity-60 backdrop-blur-md transition-all delay-150 hover:top-0" onClick={e =>{e.stopPropagation(); e.preventDefault()}}>
<motion.div className="absolute cursor-default top-[80%] left-0 w-full h-full px-2 pt-2.5 flex flex-col gap-1.5 bg-main-color-3 bg-opacity-60 backdrop-blur-md transition-all delay-150 hover:top-0" onClick={e =>{e.stopPropagation(); e.preventDefault()}}>
<BsmLink className={`block w-fit max-w-full overflow-hidden font-bold whitespace-nowrap text-ellipsis ${props.id ? "cursor-pointer hover:underline" : ""}`} href={modelPageUrl}>{props.name}</BsmLink>
<BsmLink className={`block w-fit max-w-full overflow-hidden whitespace-nowrap text-ellipsis brightness-200 ${authorPageUrl ? "cursor-pointer hover:underline" : ""}`} style={{color}} href={authorPageUrl}>{props.author ?? ''}</BsmLink>
<ul className="flex flex-row flex-wrap gap-1">
@@ -18,6 +18,8 @@ import { BsmButton } from "../shared/bsm-button.component";
import equal from "fast-deep-equal";
import { VersionLinkerAction } from "renderer/services/version-folder-linker.service";
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";
type Props = {
className?: string,
@@ -29,13 +31,13 @@ type Props = {
export const ModelsGrid = forwardRef(({className, version, type, search, active}: Props, forwardRef) => {
const modelsManager = useConstant(() => ModelsManagerService.getInstance());
const modelsManager = useService(ModelsManagerService);
const modelsDownloader = useService(ModelsDownloaderService);
const ref = useRef();
const isVisible = useInView(ref, {once: true, amount: .1});
const [models, setModelsLoadObservable,, setModels] = useSwitchableObservable<BsmLocalModel[]>();
const [progress$, setProgress$] = useState(new BehaviorSubject(0).asObservable());
const progress$ = useConstant(() => new BehaviorSubject(0));
const [modelsSelected, modelsSelected$] = useBehaviorSubject<BsmLocalModel[]>([]);
const isLoading = !models;
@@ -63,29 +65,45 @@ export const ModelsGrid = forwardRef(({className, version, type, search, active}
}), [modelsSelected, models]);
useOnUpdate(() => {
if(!isVisible || !active){ return; }
if(!active){ return; }
if(models && models.length){ return; }
loadModels();
}, [active]);
useOnUpdate(() => {
if(!active){
setModels(() => null);
}
else {
loadModels();
}
const onLinkStateChangeCb = (action: VersionLinkerAction) => {
if(!equal(version, action.version) || !action.relativeFolder.includes(MODEL_TYPE_FOLDERS[type])){ return; }
loadModels();
}
const sub = modelsDownloader.onModelsDownloaded(localModel => {
if(localModel.type !== type || !equal(localModel.version, version)){ return; }
setModels(models => [localModel, ...models ?? []]);
});
modelsManager.onModelsFolderLinked(onLinkStateChangeCb);
modelsManager.onModelsFolderUnlinked(onLinkStateChangeCb);
return () => {
modelsManager.removeModelsFolderLinkedListener(onLinkStateChangeCb);
modelsManager.removeModelsFolderUnlinkedListener(onLinkStateChangeCb);
sub.unsubscribe();
}
}, [version, isVisible, type]);
}, [version, type]);
const loadModels = () => {
const modelsObs$ = modelsManager.$getModels(type, version);
setModels(() => null);
setModelsLoadObservable(() => modelsObs$.pipe(map(models => models?.extra), distinctUntilChanged()));
setProgress$(modelsObs$.pipe(map(models => Math.floor((models.current / models.total) * 100)), startWith(0), distinctUntilChanged()))
setModelsLoadObservable(() => modelsObs$.pipe(map(models => models?.data), distinctUntilChanged()));
modelsObs$.pipe(map(models => Math.floor((models.current / models.total) * 100)), startWith(0), distinctUntilChanged()).subscribe({next: v => progress$.next(v)});
}
const handleModelClick = (model: BsmLocalModel) => {
@@ -159,7 +177,7 @@ export const ModelsGrid = forwardRef(({className, version, type, search, active}
{filtredModels().map(localModel => (
<ModelItem
{...localModel?.model}
key={localModel.model?.hash ?? localModel.hash}
key={localModel.path}
hash={localModel.model?.hash ?? localModel.hash}
path={localModel.path}
type={localModel.type}
@@ -178,4 +196,4 @@ export const ModelsGrid = forwardRef(({className, version, type, search, active}
{renderContent()}
</div>
)
})
});
@@ -1,42 +1,57 @@
import { BSVersion } from "shared/bs-version.interface";
import { useRef, useState, MutableRefObject } from "react";
import { useRef, useState } from "react";
import { ModelsTabsNavbar } from "./models-tabs-navbar.component";
import { ModelsGrid } from "./models-grid.component";
import { MSModelType } from "shared/models/models/model-saber.model";
import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { ModelsManagerService } from "renderer/services/models-management/models-manager.service";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
import { BsmButton } from "../shared/bsm-button.component";
import { useService } from "renderer/hooks/use-service.hook";
import { ModelsDownloaderService } from "renderer/services/models-management/models-downloader.service";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { NotificationService } from "renderer/services/notification.service";
import { ConfigurationService } from "renderer/services/configuration.service";
export function ModelsPanel({version}: {version?: BSVersion}) {
export function ModelsPanel({version, isActive, goToMods}: {version?: BSVersion, isActive: boolean, goToMods: () => void}) {
const modelsManager = useService(ModelsManagerService);
const modelDownloader = useService(ModelsDownloaderService);
const modelDownloader = useService(ModelsDownloaderService);
const notification = useService(NotificationService);
const config = useService(ConfigurationService);
const [avatarsRef, sabersRef, platformsRef, bloqsRef] = [useRef(null), useRef(null), useRef(null), useRef(null)];
const ref = useRef();
const modelsGridRefs = [useRef(null), useRef(null), useRef(null), useRef(null)];
const [modelTypeTab, setModelTypeTab] = useState<MSModelType>(MSModelType.Avatar);
const [currentTabIndex, setCurrentTabIndex] = useState<number>(0);
const [search, setSearch] = useState<string>("");
const getActiveTabRef = () => [avatarsRef, sabersRef, platformsRef, bloqsRef][currentTabIndex];
useOnUpdate(() => {
if(!isActive){ return; }
//if(config.get("prevented-for-mods-models")){ return; }
config.set("prevented-for-mods-models", true);
notification.notifyWarning({ title: "Mods needed", desc: "Be sure to have the mods installed to use models in BeatSaber", actions: [{id: "0", title: "Go to mods"}], duration: 9_000 }).then(res => {
if(res !== "0"){ return; }
goToMods();
});
}, [isActive]);
const exportModels = () => {
const selectedModels = [avatarsRef, sabersRef, platformsRef, bloqsRef].map(ref =>(ref.current?.getSelectedModels() as BsmLocalModel[])).flat()
const selectedModels = modelsGridRefs.map(ref =>(ref.current?.getSelectedModels() as BsmLocalModel[])).flat()
modelsManager.exportModels(selectedModels, version);
}
const deleteModels = () => {
const activeTab = getActiveTabRef();
const activeTab = modelsGridRefs[currentTabIndex];
activeTab.current?.deleteSelectedModels();
}
const openDownloadModal = () => {
modelDownloader.openDownloadModelsModal(version, modelTypeTab);
const allLoadedModels = modelsGridRefs.map(ref =>(ref.current?.getModels() as BsmLocalModel[])).flat();
modelDownloader.openDownloadModelsModal(version, modelTypeTab, allLoadedModels);
}
const threeDotsItems: DropDownItem[] = [
@@ -45,7 +60,7 @@ export function ModelsPanel({version}: {version?: BSVersion}) {
];
return (
<div className="w-full h-full flex flex-col items-center justify-center">
<div ref={ref} className="w-full h-full flex flex-col items-center justify-center">
<div className="w-full shrink-0 flex h-9 justify-center px-40 gap-2 mb-3 text-main-color-1 dark:text-white">
<BsmButton className="flex items-center justify-center w-fit rounded-full px-2 py-1 font-bold" icon="add" text="misc.add" typeColor="primary" withBar={false} onClick={e => {e.preventDefault(); openDownloadModal()}}/>
<div className="h-full rounded-full bg-light-main-color-2 dark:bg-main-color-2 grow p-[6px]">
@@ -57,10 +72,10 @@ export function ModelsPanel({version}: {version?: BSVersion}) {
<ModelsTabsNavbar className="flex-shrink-0" version={version} tabIndex={currentTabIndex} onTabChange={(index, tab) => {setModelTypeTab(() => tab.extra), setCurrentTabIndex(() => index)}}/>
<div className="flex-grow h-full flex flex-col transition-all duration-300" style={{translate: `0 ${0 - currentTabIndex * 100}%`}}>
<ModelsGrid ref={avatarsRef} version={version} type={MSModelType.Avatar} active={modelTypeTab === MSModelType.Avatar} search={search}/>
<ModelsGrid ref={sabersRef} version={version} type={MSModelType.Saber} active={modelTypeTab === MSModelType.Saber} search={search}/>
<ModelsGrid ref={platformsRef} version={version} type={MSModelType.Platfrom} active={modelTypeTab === MSModelType.Platfrom} search={search}/>
<ModelsGrid ref={bloqsRef} version={version} type={MSModelType.Bloq} active={modelTypeTab === MSModelType.Bloq} search={search}/>
<ModelsGrid ref={modelsGridRefs[0]} version={version} type={MSModelType.Avatar} active={isActive && modelTypeTab === MSModelType.Avatar} search={search}/>
<ModelsGrid ref={modelsGridRefs[1]} version={version} type={MSModelType.Saber} active={isActive && modelTypeTab === MSModelType.Saber} search={search}/>
<ModelsGrid ref={modelsGridRefs[2]} version={version} type={MSModelType.Platfrom} active={isActive && modelTypeTab === MSModelType.Platfrom} search={search}/>
<ModelsGrid ref={modelsGridRefs[3]} version={version} type={MSModelType.Bloq} active={isActive && modelTypeTab === MSModelType.Bloq} search={search}/>
</div>
</div>
</div>
@@ -1,23 +1,25 @@
import equal from "fast-deep-equal";
import { ComponentProps } from "react"
import { useTranslation } from "renderer/hooks/use-translation.hook"
type Props<T> = Omit<ComponentProps<"select">, "onChange"> & {
options?: BsmSelectOption<T>[],
selected?: T,
onChange?: (value: T) => void,
}
export function BsmSelect<T = unknown>({className, style, options, onChange}: Props<T>) {
export function BsmSelect<T = unknown>(props: Props<T>) {
const t = useTranslation();
const handleChange: ComponentProps<"select">["onChange"] = (e) => {
e.preventDefault();
onChange?.(options?.[parseInt(e.target.value)]?.value);
props.onChange?.(props.options?.[parseInt(e.target.value)]?.value);
}
return (
<select className={className} style={style} onChange={handleChange}>
{options && options.map((option, index) => (
<select {...props} onChange={handleChange} defaultValue={props.options?.findIndex(opt => equal(opt.value, props.selected))}>
{props.options && props.options.map((option, index) => (
<option key={index} value={index}>{t(option.text)}</option>
))}
</select>
@@ -83,7 +83,7 @@ export function VersionViewer() {
<MapsPlaylistsPanel version={state}/>
</div>
<div className="w-full shrink-0 px-3 pb-3 flex flex-col items-center">
<ModelsPanel version={state}/>
<ModelsPanel version={state} isActive={currentTabIndex === 2} goToMods={() => setCurrentTabIndex(() => 3)}/>
</div>
<ModsSlide version={state} onDisclamerDecline={handleModsDisclaimerDecline}/>
</div>
@@ -21,7 +21,7 @@ export class ModelDownloaderService {
}
public async oneClickInstallModel(model: MSModel): Promise<boolean>{
public async oneClickInstallModel(model: MSModel): Promise<boolean>{ // TODO : Put this function in the new ModelDownloaderService
this.progress.showFake(0.04);
@@ -1,10 +1,13 @@
import { BehaviorSubject, Observable, Subscription, distinctUntilChanged, filter, map, shareReplay } from "rxjs";
import { BehaviorSubject, Observable, Subscription, distinctUntilChanged, filter, lastValueFrom, map, shareReplay, startWith } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalModel } from "shared/models/models/bsm-local-model.interface";
import { MSModel, MSModelType } from "shared/models/models/model-saber.model";
import { ModalResponse, ModalService } from "../modale.service";
import { IpcService } from "../ipc.service";
import { DownloadModelsModal } from "renderer/components/modal/modal-types/models/download-models-modal.component";
import { ProgressBarService } from "../progress-bar.service";
import { Progression } from "main/helpers/fs.helpers";
import { ProgressionInterface } from "shared/models/progress-bar";
export class ModelsDownloaderService {
@@ -16,9 +19,10 @@ export class ModelsDownloaderService {
}
private readonly ipc: IpcService;
private readonly modal: ModalService
private readonly modal: ModalService;
private readonly progress: ProgressBarService;
private readonly lastDownloadedModel$ = new BehaviorSubject<ModelDownload>(null);
private readonly lastDownload$ = new BehaviorSubject<BsmLocalModel>(null);
private readonly queue$ = new BehaviorSubject<ModelDownload[]>([]);
private readonly _currentDownload$ = this.queue$.pipe(map(queue => queue.at(0)), distinctUntilChanged(), shareReplay(1));
@@ -26,39 +30,60 @@ export class ModelsDownloaderService {
this.ipc = IpcService.getInstance();
this.modal = ModalService.getInsance();
this.progress = ProgressBarService.getInstance();
this._currentDownload$.pipe(filter(v => !!v)).subscribe(model => this.downloadModel(model));
this._currentDownload$.pipe(filter(v => !v)).subscribe(() => this.lastDownload$.next(null));
}
private downloadModel(model: ModelDownload){
// TODO: Download model
private async downloadModel(download: ModelDownload){
const download$ = this.ipc.sendV2<Progression<BsmLocalModel>>("download-model", {args: download});
if(!this.progress.isVisible){
const progress$: Observable<ProgressionInterface> = download$.pipe(map(progress => {
return {
progression: progress.total ? (progress.current / progress.total) * 100 : 0,
label: download.model.name
}
}), startWith({progression: .1, label: download.model.name}));
this.progress.show(progress$, true);
}
const downloaded = await lastValueFrom(download$);
this.lastDownload$.next(downloaded.data);
this.progress.hide(true);
this.queue$.next(this.queue$.value.filter(m => m.model.hash !== download.model.hash));
}
public addModelToDownload(model: ModelDownload): ModelDownload{
const queue = this.queue$.value;
queue.push(model);
this.queue$.next(queue);
this.queue$.next([...queue]);
return model;
}
public removeFromDownloadQueue(model: ModelDownload){
public removeFromDownloadQueue(download: ModelDownload){
const queue = this.queue$.value;
const index = queue.findIndex(m => m.hash === model.hash);
const index = queue.findIndex(m => m.model.hash === download.model.hash);
if(index === -1){ return; }
queue.splice(index, 1);
this.queue$.next(queue);
this.queue$.next([...queue]);
}
public onModelsDownloaded$(cb: (model: ModelDownload) => void): Subscription{
return this.lastDownloadedModel$.pipe(filter(v => !!v)).subscribe(cb);
public onModelsDownloaded(cb: (model: BsmLocalModel) => void): Subscription{
return this.lastDownload$.pipe(filter(download => !!download)).subscribe(cb);
}
public isDownloading$(model: ModelDownload): Observable<boolean>{
public isDownloading$(download: ModelDownload): Observable<boolean>{
return this._currentDownload$.pipe(map(current => {
if(!current){ return false; }
return current.hash === model.hash && current.version === model.version
return current.model.hash === download.model.hash && current.version === download.version
}), distinctUntilChanged());
}
@@ -66,15 +91,21 @@ export class ModelsDownloaderService {
return this._currentDownload$;
}
public isPending$(model: ModelDownload): Observable<boolean>{
return this.queue$.pipe(map(queue => queue.at(0).hash !== model.hash && queue.at(0).version !== model.version && queue.some(m => m.hash === model.hash && m.version === model.version)), distinctUntilChanged());
public isPending$(download: ModelDownload): Observable<boolean>{
return this.queue$.pipe(map(queue => queue.at(0).model.hash !== download.model.hash && queue.at(0).version !== download.version && queue.some(d => d.model.hash === download.model.hash && d.version === download.version)), distinctUntilChanged());
}
public openDownloadModelsModal(version: BSVersion, type?: MSModelType): Promise<ModalResponse<void>>{
return this.modal.openModal(DownloadModelsModal, {version, type}); // TODO MODELS
public getQueue$(): Observable<ModelDownload[]>{
return this.queue$.asObservable();
}
public openDownloadModelsModal(version: BSVersion, type?: MSModelType, owned?: BsmLocalModel[]): Promise<ModalResponse<void>>{
return this.modal.openModal(DownloadModelsModal, {version, type, owned}); // TODO MODELS
}
}
export type ModelDownload = Partial<MSModel> & Pick<MSModel, "hash"> & {version: BSVersion};
export type ModelDownloaded = BsmLocalModel & {version: BSVersion};
export type ModelDownload = {
model: MSModel,
version: BSVersion
}
@@ -110,9 +110,9 @@ export class ModelsManagerService {
if(!resFile.success){ return; }
const exportProgress$: Observable<ProgressionInterface> = this.ipc.sendV2<ArchiveProgress, {version: BSVersion, models: BsmLocalModel[], outPath: string}>("export-models", {args: {version, models, outPath: resFile.data}}).pipe(
const exportProgress$: Observable<ProgressionInterface> = this.ipc.sendV2<Progression, {version: BSVersion, models: BsmLocalModel[], outPath: string}>("export-models", {args: {version, models, outPath: resFile.data}}).pipe(
map(p => {
return { progression: (p.prossesedFiles / p.totalFiles) * 100, label: `${p.prossesedFiles} / ${p.totalFiles}` } as ProgressionInterface
return { progression: (p.current / p.total) * 100, label: `${p.current} / ${p.total}` } as ProgressionInterface
})
);
@@ -1,3 +1,4 @@
import { BSVersion } from "shared/bs-version.interface";
import { MSModel, MSModelType } from "./model-saber.model";
export interface BsmLocalModel{
@@ -6,4 +7,5 @@ export interface BsmLocalModel{
readonly hash: string;
readonly type: MSModelType;
readonly model?: MSModel
readonly version?: BSVersion;
}