[feature-107] playlist creation and edition is finished

This commit is contained in:
MathieuG-P
2024-06-19 22:25:00 +02:00
parent d299553d1e
commit 44c63a63c6
12 changed files with 235 additions and 30 deletions
+13
View File
@@ -258,6 +258,19 @@ export function resolveGUIDPath(guidPath: string): string {
return path.join(driveLetter, path.relative(guidVolume, guidPath));
}
export function getUniqueFileNamePath(filePath: string): string {
const { dir, name, ext } = path.parse(filePath);
let i = 0;
let newFileName = `${name}${ext}`;
while (pathExistsSync(path.join(dir, newFileName))) {
i++;
newFileName = `${name} (${i})${ext}`;
}
return path.join(dir, newFileName);
}
export interface Progression<T = unknown, D = unknown> {
total: number;
current: number;
+12 -1
View File
@@ -1,7 +1,7 @@
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 { mergeMap, of } from "rxjs";
import { from, lastValueFrom, mergeMap, of } from "rxjs";
import { BPList } from "shared/models/playlists/playlist.interface";
import path from "path";
import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service";
@@ -66,3 +66,14 @@ ipc.on("export-playlists", (args, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
reply(playlists.exportPlaylists(args));
});
ipc.on("install-playlist-file", (args, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
const promise = async () => {
const playlist = await lastValueFrom(playlists.writeBPListFile({ bpList: args.bplist, version: args.version, dest: args.dest}));
return playlists.getLocalBPListDetails(playlist);
}
reply(from(promise()));
})
@@ -11,7 +11,7 @@ import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists
import { readFileSync } from "fs";
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
import { copy, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFileSync } from "fs-extra";
import { Progression, unlinkPath } from "../../helpers/fs.helpers";
import { Progression, getUniqueFileNamePath, unlinkPath } from "../../helpers/fs.helpers";
import { FileAssociationService } from "../file-association.service";
import { SongDetailsCacheService } from "./maps/song-details-cache.service";
import { sToMs } from "shared/helpers/time.helpers";
@@ -85,6 +85,27 @@ export class LocalPlaylistsManagerService {
return fullPath;
}
public writeBPListFile(opt: { bpList: BPList, version?: BSVersion, dest?: string }): Observable<LocalBPList> {
return new Observable<LocalBPList>(obs => {
(async () => {
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);
const playlistPath = path.join(playlistFolder, `${sanitize(opt.bpList.playlistTitle)}.bplist`);
return getUniqueFileNamePath(playlistPath);
})();
writeFileSync(dest, JSON.stringify(opt.bpList, null, 2));
const localBPList: LocalBPList = { ...opt.bpList, path: dest };
obs.next(localBPList);
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
});
}
private async installBPListFile(opt: {
bslistSource: string,
version?: BSVersion,
@@ -38,6 +38,7 @@ import { enumerate } from "shared/helpers/array.helpers";
import { SyncPlaylistModal } from "renderer/components/modal/modal-types/playlist/sync-playlist-modal.component";
import { ExportPlaylistModal } from "renderer/components/modal/modal-types/playlist/export-playlist-modal.component";
import { EditPlaylistModal } from "renderer/components/modal/modal-types/playlist/edit-playlist-modal/edit-playlist-modal.component";
import { NeedCloneEditPlaylistModal } from "renderer/components/modal/modal-types/playlist/need-clone-edit-playlist-modal.component";
type Props = {
version: BSVersion;
@@ -89,6 +90,27 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
useImperativeHandle(forwardedRef, () => ({
createPlaylist: async () => {
const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { version, maps$ } });
if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; }
const { error, result } = await tryit(() => lastValueFrom(playlistDownloader.installPlaylistFile(modalRes.data, version)));
if(error){
logRenderError("Error occured while creating playlist", error);
notification.notifyError({ title: "Erreur lors de la création de la playlist", desc: "Une erreur est survenue lors de la création de la playlist." });
return;
}
setPlaylists([result, ...playlists$.value]);
const notifRes = await notification.notifySuccess({ title: "Playlist créée !", desc: "La playlist a été créée avec succès. Tu peut maintenant synchroniser ses maps !", duration: 8000, actions: [
{ id: "sync", title: "Synchroniser les maps" }
]});
if(notifRes === "sync"){
await lastValueFrom(installPlaylist(result));
}
},
syncPlaylists: async () => {
if(!isOnline){ return; }
@@ -249,6 +271,7 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
};
const openPlaylistDetails = (playlistPath: string) => {
const localPlaylist$ = playlists$.pipe(map(playlists => playlists.find(p => p.path === playlistPath)));
const installedMaps$ = combineLatest([maps$, localPlaylist$]).pipe(
filter(([maps, playlist]) => !!maps && !!playlist),
@@ -262,6 +285,48 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
})
};
const editPlaylist = async (playlist: LocalBPList) => {
const needClone = playlist?.customData?.syncURL;
const res = await (needClone ? modals.openModal(NeedCloneEditPlaylistModal) : Promise.resolve());
if(res && res.exitCode !== ModalExitCode.COMPLETED){
return;
}
const tmpPlaylist: LocalBPList = { ...playlist, playlistTitle: needClone ? `${playlist.playlistTitle} (${t("Clone")})` : playlist.playlistTitle };
const modalRes = await modals.openModal(EditPlaylistModal, { noStyle: true, data: { version, maps$, playlist: tmpPlaylist } });
if(modalRes.exitCode !== ModalExitCode.COMPLETED){ return; }
const { error, result } = await tryit(() => lastValueFrom(playlistDownloader.installPlaylistFile(modalRes.data, version, needClone ? undefined : playlist.path)));
if(error){
logRenderError("Error occured while editing playlist", error);
notification.notifyError({ title: "Erreur lors de la modification de la playlist", desc: "Une erreur est survenue lors de la modification de la playlist." });
return;
}
const newPlaylists = [...playlists$.value];
if(needClone){
newPlaylists.unshift(result);
}
else {
const index = newPlaylists.findIndex(p => p.path === playlist.path);
newPlaylists[index] = result;
}
setPlaylists(newPlaylists);
const notifRes = await notification.notifySuccess({ title: "Playlist modifiée !", desc: "La playlist a été modifiée avec succès. Tu peut maintenant synchroniser ses maps !", duration: 8000, actions: [
{ id: "sync", title: "Synchroniser les maps" }
]});
if(notifRes === "sync"){
await lastValueFrom(installPlaylist(result));
}
}
const renderPlaylist = useCallback((playlist: LocalBPListsDetails) => {
@@ -282,9 +347,10 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
}}
onClickOpen={() => openPlaylistDetails(playlist.path)}
onClickDelete={() => deletePlaylists([playlist])}
onClickSync={isOnline && (() => handleClickSync(playlist))}
onClickSync={(playlist?.songs?.length && isOnline) && (() => handleClickSync(playlist))}
onClickOpenFile={() => viewPlaylistFile(playlist.path)}
onClickCancelDownload={() => playlistDownloader.cancelDownload(playlist.customData?.syncURL ?? playlist.path, version)}
onClickEdit={() => editPlaylist(playlist)}
/>
);
}, [isOnline, version]);
@@ -35,6 +35,7 @@ export type PlaylistItemComponentProps = {
onClickSync?: () => void;
onClickDownload?: () => void;
onClickCancelDownload?: () => void;
onClickEdit?: () => void;
}
export function PlaylistItem({ title,
@@ -55,7 +56,8 @@ export function PlaylistItem({ title,
onClickSync,
onClickDownload,
onClickDelete,
onClickCancelDownload
onClickCancelDownload,
onClickEdit
}: PlaylistItemComponentProps) {
const color = useThemeColor("first-color");
@@ -112,7 +114,7 @@ export function PlaylistItem({ title,
<span className="absolute size-2.5 bottom-0 right-full bg-inherit translate-x-px" style={{ clipPath: 'path("M11 11 L11 0 L10 0 A10 10 0 0 1 0 10 L 0 11 Z")' }} />
<motion.div className="flex flex-col justify-center items-center flex-wrap gap-0.5 size-full px-1 *:size-6 *:!bg-inherit *:p-0.5 *:rounded-md" animate={{opacity: hovered || isDownloading ? 1 : 0}} transition={{duration: 0}}>
{(isDownloading || isInQueue) && onClickCancelDownload && (
{(isDownloading || isInQueue) && onClickCancelDownload ? (
<Tippy content={isDownloading ? "Arreter le téléchargement" : "Annuler le téléchargement"} placement="left" theme="default">
<BsmButton
icon="close"
@@ -122,8 +124,8 @@ export function PlaylistItem({ title,
withBar={false}
/>
</Tippy>
)}
{onClickSync && (
) : (<></>)}
{onClickSync ? (
isDownloading ? (
<BsmBasicSpinner className="hover:!bg-main-color-1" spinnerClassName="brightness-75 dark:brightness-200" style={{ color }} thikness="3px"/>
) : !isInQueue ? (
@@ -139,8 +141,8 @@ export function PlaylistItem({ title,
</Tippy>
) : (<></>)
)}
{onClickDownload && (
) : (<></>)}
{onClickDownload ? (
isDownloading ? (
<BsmBasicSpinner className="hover:!bg-main-color-1" spinnerClassName="brightness-75 dark:brightness-200" style={{ color }} thikness="3px"/>
) : !isInQueue ? (
@@ -156,8 +158,20 @@ export function PlaylistItem({ title,
</Tippy>
) : (<></>)
)}
{onClickOpenFile && <Tippy content="Afficher le fichier" placement="left" theme="default">
) : (<></>)}
{onClickEdit ? (
<Tippy content="Editer la playlist" placement="left" theme="default">
<BsmButton
icon="edit"
className="hover:!bg-main-color-1"
iconClassName="size-full brightness-75 dark:brightness-200"
style={{color}}
onClick={onClickEdit}
withBar={false}
/>
</Tippy>
) : (<></>)}
{onClickOpenFile ? <Tippy content="Afficher le fichier" placement="left" theme="default">
<BsmButton
icon="folder"
className="hover:!bg-main-color-1"
@@ -166,8 +180,8 @@ export function PlaylistItem({ title,
onClick={onClickOpenFile}
withBar={false}
/>
</Tippy>}
{(onClickDelete && !isDownloading && !isInQueue) && <Tippy content="Supprimer" placement="left" theme="default">
</Tippy> : (<></>)}
{(onClickDelete && !isDownloading && !isInQueue) ? <Tippy content="Supprimer" placement="left" theme="default">
<BsmButton
icon="trash"
className="hover:!bg-main-color-1 text-red-500"
@@ -175,7 +189,7 @@ export function PlaylistItem({ title,
onClick={onClickDelete}
withBar={false}
/>
</Tippy>}
</Tippy> : (<></>)}
</motion.div>
</motion.div>
</div>
@@ -8,18 +8,21 @@ import { IpcService } from "renderer/services/ipc.service";
import { lastValueFrom } from "rxjs";
import { logRenderError } from "renderer";
type Props = {
type OutProps = {
playlistTitle: string;
playlistDescription: string;
playlistAuthor: string;
base64Image: string;
}
export const EditPlaylistInfosModal: ModalComponent<Props, Props> = ({ resolver, options: { data: {
type Props = OutProps & { isEdit: boolean };
export const EditPlaylistInfosModal: ModalComponent<OutProps, Props> = ({ resolver, options: { data: {
playlistTitle,
playlistDescription,
playlistAuthor,
base64Image
base64Image,
isEdit
}}}) => {
const steamDownloader = useService(SteamDownloaderService);
@@ -49,7 +52,9 @@ export const EditPlaylistInfosModal: ModalComponent<Props, Props> = ({ resolver,
return (
<div className="text-gray-800 dark:text-gray-200">
<h1 className="text-3xl uppercase tracking-wide w-full text-center mb-4 px-4">Créer une playlist</h1>
<h1 className="text-3xl uppercase tracking-wide w-full text-center mb-4 px-4">
{isEdit ? "Modifier la playlist" : "Créer une playlist"}
</h1>
<div className="w-full flex flex-col justify-center items-center">
<button className="flex justify-center items-center relative size-36 border-2 border-gray-400 bg-theme-1 rounded-md overflow-hidden" onClick={handleClickImage}>
{base64 ? (
@@ -12,7 +12,7 @@ import { ModalComponent, ModalExitCode, ModalService } from "renderer/services/m
import { BehaviorSubject, Observable, lastValueFrom, map, take } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"
import { LocalBPList } from "shared/models/playlists/local-playlist.models"
import { BsvMapDetail, SongDetails } from "shared/models/maps";
import { logRenderError } from "renderer";
import { useService } from "renderer/hooks/use-service.hook";
@@ -30,9 +30,9 @@ import { ClockIcon } from "renderer/components/svgs/icons/clock-icon.component";
import { NpsIcon } from "renderer/components/svgs/icons/nps-icon.component";
import dateFormat from 'dateformat';
import { getCorrectTextColor } from "renderer/helpers/correct-text-color";
import { DeleteModelsModal } from "../../models/delete-models-modal.component";
import { BPList } from "shared/models/playlists/playlist.interface";
import { EditPlaylistInfosModal } from "./edit-playlist-infos-modal.component";
import { CrossIcon } from "renderer/components/svgs/icons/cross-icon.component";
type Props = {
version?: BSVersion;
@@ -354,12 +354,34 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
playlistDescription: playlist?.playlistDescription ?? "",
base64Image: playlist?.image ?? "",
playlistAuthor: playlist?.playlistAuthor ?? "",
isEdit: !!playlist
}});
resolver({ exitCode: ModalExitCode.CANCELED });
if(res.exitCode !== ModalExitCode.COMPLETED){ return; }
const bpList: BPList = {
image: res.data.base64Image,
playlistAuthor: res.data.playlistAuthor,
playlistTitle: res.data.playlistTitle,
playlistDescription: res.data.playlistDescription,
songs: Object.values(playlistMaps$.value ?? []).map(map => {
const props = MapItemComponentPropsMapper.from(map);
return {
key: props.mapId,
hash: props.hash,
songName: props.title
}
})
}
resolver({ exitCode: ModalExitCode.COMPLETED, data: bpList});
};
return (
<div className="w-screen h-screen max-h-[calc(100vh-2rem)] max-w-[55rem] lg:max-w-[66rem] xl:max-w-[77rem] bg-theme-3 p-4 rounded-md">
<div className="w-screen h-screen max-h-[calc(100vh-2rem)] max-w-[55rem] lg:max-w-[66rem] xl:max-w-[77rem] 2xl:max-w-[88rem] bg-theme-3 p-4 rounded-md relative">
<button className="absolute top-1.5 right-1.5 size-3" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })}>
<CrossIcon className="size-full"/>
</button>
{(() => {
if(!playlistMaps || !localMaps){
return <div className="flex items-center justify-center w-full h-full">Loading...</div>
@@ -0,0 +1,23 @@
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service"
import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
// TODO : Translate
export const NeedCloneEditPlaylistModal: ModalComponent<void, void> = ({ resolver }) => {
return (
<form className="text-gray-800 dark:text-gray-200 flex flex-col gap-2 max-w-sm">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">Attention</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p className="w-full">Cette playlist a é téléchargée depuis un site externe et contient un lien de synchronisation.</p>
<p className="w-full">Pour éviter de perdre vos modifications lors d'une synchronisation, la playlist va être dupliquée et son lien de synchronisation supprimé.</p>
<p className="w-full">Vous pourrez ensuite, si vous le souhaitez, supprimer la playlist originale.</p>
<div className="grid grid-flow-col grid-cols-2 gap-2 mt-2 h-8">
<BsmButton typeColor="cancel" className="rounded-md flex justify-center items-center transition-all h-full" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
<BsmButton typeColor="primary" className="rounded-md flex justify-center items-center transition-all h-full" onClick={() => resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="J'ai compris" />
</div>
</form>
);
}
@@ -85,7 +85,26 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
const renderMaps = () => {
if(!installedMaps.length && !isInQueue) {
if (!Array.isArray(installedMaps) || !localPlaylist) {
return (
<div className="grow bg-red-400">
</div>
);
}
if(!localPlaylist.songs?.length){
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 ne contient aucune map</p>
</div>
</div>
);
}
if(!installedMaps.length && localPlaylist.songs?.length && !isInQueue) {
return (
<div className="grow flex justify-center items-center flex-col">
<BsmImage image={BeatConflict} className="size-28"/>
@@ -97,7 +116,7 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
);
}
if(installedMaps.length === 0 && isInQueue) {
if(!installedMaps.length && isInQueue) {
return (
<div className="grow flex justify-center items-center flex-col">
<BsmImage image={BeatConflict} className="size-28"/>
@@ -4,12 +4,18 @@ import { useObservable } from "renderer/hooks/use-observable.hook";
import { useEffect } from "react";
import { BsmIcon } from "../svgs/bsm-icon.component";
import { ThemeColorGradientSpliter } from "../shared/theme-color-gradient-spliter.component";
import { map } from "rxjs";
import { useConstant } from "renderer/hooks/use-constant.hook";
export function Modal() {
const modalSevice = ModalService.getInstance();
const modals = useObservable(() => modalSevice.getModalToShow());
const currentModal = modals?.at(-1);
const modals$ = useConstant(() => modalSevice.getModalToShow());
const modals = useObservable(() => modals$);
const currentModal = useObservable<ModalObject>(() =>modals$.pipe(map(modals => modals?.at(-1))));
console.log(currentModal, modals, !!currentModal);
useEffect(() => {
const onEscape = (e: KeyboardEvent) => {
@@ -56,9 +62,9 @@ export function Modal() {
return (
<AnimatePresence>
{currentModal && <motion.span key="modal-overlay" onClick={() => currentModal.resolver({ exitCode: ModalExitCode.NO_CHOICE })} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} />}
{modals?.map((modal, index) => (
<motion.div key={index} className="fixed z-[90] top-1/2 left-1/2" initial={{ y: "100vh", x: "-50%" }} animate={{y: "-50%", scale: modal === currentModal ? 1 : 0, opacity: modal === currentModal ? 1 : 0, display: modal === currentModal ? "block" : ["block", "none"]}} exit={{ y: "100vh" }}>
{currentModal ? <motion.span key={crypto.randomUUID()} onClick={() => currentModal.resolver({ exitCode: ModalExitCode.NO_CHOICE })} className="fixed size-full bg-black z-[90]" initial={{ opacity: 0 }} animate={{ opacity: currentModal && 0.6 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }} /> : <></>}
{modals?.map(modal => (
<motion.div key={crypto.randomUUID()} className="fixed z-[90] top-1/2 left-1/2" initial={{ y: "100vh", x: "-50%" }} animate={{y: "-50%", scale: modal === currentModal ? 1 : 0, opacity: modal === currentModal ? 1 : 0, display: modal === currentModal ? "block" : ["block", "none"]}} exit={{ y: "100vh" }}>
{renderModal(modal)}
</motion.div>
))}
@@ -1,5 +1,5 @@
import { BehaviorSubject, Observable, Subject, Subscription, distinctUntilChanged, filter, lastValueFrom, map, shareReplay, take, takeUntil, takeWhile, tap } from "rxjs";
import { DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface";
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";
@@ -126,6 +126,10 @@ export class PlaylistDownloaderService {
public openDownloadPlaylistModal(version: BSVersion, ownedPlaylists$: Observable<LocalBPListsDetails[]>, ownedMaps$: Observable<BsmLocalMap[]>): Promise<ModalResponse<void>> {
return this.modal.openModal(DownloadPlaylistModal, { data: { version, ownedPlaylists$, ownedMaps$ } })
}
public installPlaylistFile(bplist: BPList, version?: BSVersion, dest?: string){
return this.ipc.sendV2("install-playlist-file", { bplist, version, dest });
}
}
export type PlaylistQueueInfo = {
+1
View File
@@ -86,6 +86,7 @@ export interface IpcChannelMapping {
"get-version-playlists-details": {request: BSVersion, response: Progression<LocalBPListsDetails[]>};
"delete-playlist": {request: {version: BSVersion, bpList: LocalBPList, deleteMaps?: boolean}, response: Progression};
"export-playlists": {request: {version?: BSVersion, bpLists: LocalBPList[], dest: string, playlistsMaps?: BsmLocalMap[]}, response: Progression<string>};
"install-playlist-file": {request: {bplist: BPList, version?: BSVersion, dest?: string}, response: LocalBPListsDetails};
/* ** bs-uninstall-ipcs ** */
"bs.uninstall": { request: BSVersion, response: boolean };