[feature-107] open playlist details from download modal + use new proto format for maps details cache

This commit is contained in:
MathieuG-P
2024-05-27 00:25:36 +02:00
parent d54464401f
commit 0196206ec1
23 changed files with 326 additions and 80 deletions
+12 -6
View File
@@ -6,13 +6,20 @@ message SongDetailsCache {
repeated SongDetails songs = 1;
uint32 lastUpdated = 2;
uint32 total = 3;
UploadersList uploaders = 4;
repeated string difficultyLabels = 5;
}
message UploadersList {
repeated string names = 1;
repeated uint32 ids = 2;
}
message SongDetails {
uint32 idInt = 1;
string hash = 2;
uint32 duration = 3;
Uploader uploader = 4;
UploaderRef uploaderRef = 4;
uint32 uploadedAt = 5;
repeated MapTag tags = 6;
bool ranked = 7;
@@ -31,7 +38,7 @@ message SongDetails {
message Difficulty {
DifficultyLabel difficulty = 1;
DifficultyCharacteristic characteristic = 2;
string label = 3;
uint32 labelIndex = 3;
uint32 starsT100 = 4;
uint32 starsBlT100 = 5;
uint32 njsT100 = 6;
@@ -46,10 +53,9 @@ message Difficulty {
uint32 obstacles = 15;
}
message Uploader {
string name = 1;
uint32 id = 2;
bool verified = 3;
message UploaderRef {
uint32 uploaderRefIndex = 1;
bool verified = 2;
}
enum DifficultyLabel {
+15 -12
View File
@@ -27,7 +27,7 @@
"electron-store": "^8.1.0",
"electron-updater": "^6.1.8",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^11.0.6",
"framer-motion": "^11.2.6",
"fs-extra": "^11.2.0",
"got": "^14.2.0",
"history": "^5.3.0",
@@ -38,7 +38,7 @@
"node-fetch": "^3.3.2",
"node-stream-zip": "^1.15.0",
"pako": "^2.1.0",
"protobufjs": "^7.2.6",
"protobufjs": "^7.3.0",
"qrcode.react": "^3.1.0",
"query-process": "^0.0.3",
"react": "^18.2.0",
@@ -1416,6 +1416,7 @@
"version": "0.8.8",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emotion/memoize": "0.7.4"
}
@@ -1423,7 +1424,8 @@
"node_modules/@emotion/memoize": {
"version": "0.7.4",
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.20.1",
@@ -12960,20 +12962,21 @@
}
},
"node_modules/framer-motion": {
"version": "11.0.6",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.0.6.tgz",
"integrity": "sha512-BpO3mWF8UwxzO3Ca5AmSkrg14QYTeJa9vKgoLOoBdBdTPj0e81i1dMwnX6EQJXRieUx20uiDBXq8bA6y7N6b8Q==",
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.2.6.tgz",
"integrity": "sha512-XUrjjBt57e5YoHQtjwc3eNchFBuHvIgN/cS8SC4oIaAn2J/0+bLanUxXizidJKZVeHJam/JrmMnPRjYMglVn5g==",
"dependencies": {
"tslib": "^2.4.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
@@ -18753,9 +18756,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.2.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.6.tgz",
"integrity": "sha512-dgJaEDDL6x8ASUZ1YqWciTRrdOuYNzoOf27oHNfdyvKqHr5i0FV7FSLU+aIeFjyFgVxrpTOtQUi0BLLBymZaBw==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.0.tgz",
"integrity": "sha512-YWD03n3shzV9ImZRX3ccbjqLxj7NokGN0V/ESiBV5xWqrommYHYiihuIyavq03pWSGqlyvYUFmfoMKd+1rPA/g==",
"hasInstallScript": true,
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
+2 -2
View File
@@ -266,7 +266,7 @@
"electron-store": "^8.1.0",
"electron-updater": "^6.1.8",
"fast-deep-equal": "^3.1.3",
"framer-motion": "^11.0.6",
"framer-motion": "^11.2.6",
"fs-extra": "^11.2.0",
"got": "^14.2.0",
"history": "^5.3.0",
@@ -277,7 +277,7 @@
"node-fetch": "^3.3.2",
"node-stream-zip": "^1.15.0",
"pako": "^2.1.0",
"protobufjs": "^7.2.6",
"protobufjs": "^7.3.0",
"qrcode.react": "^3.1.0",
"query-process": "^0.0.3",
"react": "^18.2.0",
+5
View File
@@ -23,3 +23,8 @@ ipc.on("bsv-search-playlist", (args, reply) => {
const bsvService = BeatSaverService.getInstance();
reply(from(bsvService.searchPlaylists(args)));
});
ipc.on("bsv-get-playlist-details-by-id", (args, reply) => {
const bsvService = BeatSaverService.getInstance();
reply(from(bsvService.getPlaylistDetailsById(args.id, args.page)));
});
@@ -77,6 +77,9 @@ export class SongDetailsCacheService {
const res: Record<string, SongDetails> = {};
RawSongDetailsDeserializer.setUploadersList(messageObj.uploaders);
RawSongDetailsDeserializer.setDifficultyLabels(messageObj.difficultyLabels);
for(const rawSong of messageObj.songs){
res[rawSong.hash.toLocaleLowerCase()] = RawSongDetailsDeserializer.deserialize(rawSong);
}
@@ -95,4 +95,8 @@ export class BeatSaverApiService {
url.search = new URLSearchParams(this.objectToStringRecord(search)).toString();
return this.request.getJSON<PlaylistSearchResponse>(url.toString());
}
public getPlaylistDetailsById(id: string, page = 0): Promise<BsvPlaylistPage> {
return this.request.getJSON<BsvPlaylistPage>(`${this.bsaverApiUrl}/playlists/id/${id}/${page}`);
}
}
@@ -1,6 +1,6 @@
import { splitIntoChunk } from "../../../../shared/helpers/array.helpers";
import { BsvMapDetail } from "shared/models/maps";
import { BsvPlaylist, PlaylistSearchParams, SearchParams } from "shared/models/maps/beat-saver.model";
import { BsvPlaylist, BsvPlaylistPage, PlaylistSearchParams, SearchParams } from "shared/models/maps/beat-saver.model";
import { BeatSaverApiService } from "./beat-saver-api.service";
import log from "electron-log";
@@ -69,7 +69,15 @@ export class BeatSaverService {
.then(res => res.docs)
.catch(e => {
log.error(e);
return e;
throw e;
});
}
public getPlaylistDetailsById(id: string, page = 0): Promise<BsvPlaylistPage> {
return this.bsaverApi.getPlaylistDetailsById(id, page)
.catch(e => {
log.error(e);
throw e;
});
}
}
@@ -22,6 +22,7 @@ import { useService } from "renderer/hooks/use-service.hook";
import Tippy from "@tippyjs/react";
import { SongDetailDiffCharactertistic, SongDiffName } from "shared/models/maps";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { CalendarDateTime, getLocalTimeZone } from "@internationalized/date";
export type ParsedMapDiff = { type: SongDiffName; name: string; stars: number };
@@ -39,7 +40,7 @@ export type MapItemProps<T = unknown> = {
bpm: number;
duration: number;
likes: number;
createdAt: number;
createdAt: number | CalendarDateTime;
selected?: boolean;
downloading?: boolean;
showOwned?: boolean;
@@ -72,12 +73,18 @@ export const MapItem = memo(({ hash, title, autor, songAutor, coverUrl, songUrl,
const songPlaying = useObservable(() => audioPlayer.playing$.pipe(map(playing => playing && audioPlayer.src === songUrl)));
console.log("aaa", autorId);
const MAP_DIFFICULTIES = useConstant(() => Object.values(SongDiffName))
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 * 1000, "d mmm yyyy") : null;
const likesText = likes ? Intl.NumberFormat(undefined, { notation: "compact" }).format(likes).split(" ").join("") : null;
const createdDate = useConstant(() => {
if(!createdAt){ return null; }
const date = typeof createdAt === "number" ? new Date(createdAt * 1000) : createdAt.toDate(getLocalTimeZone());
return dateFormat(date, "d mmm yyyy");
});
const durationText = (() => {
if (!duration) {
@@ -13,7 +13,7 @@ import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-
import { PlaylistItem } from "./playlist-item.component";
import { useStateMap } from "renderer/hooks/use-state-map.hook";
import { ModalExitCode, ModalService } from "renderer/services/modale.service";
import { LocalPlaylistDetailsModal } from "renderer/components/modal/modal-types/playlist/local-playlist-details-modal.component";
import { LocalPlaylistDetailsModal } from "renderer/components/modal/modal-types/playlist/playlist-details-modal/local-playlist-details-modal.component";
import { InstalledMapsContext } from "../maps-playlists-panel.component";
import { IpcService } from "renderer/services/ipc.service";
import equal from "fast-deep-equal";
@@ -32,6 +32,7 @@ export type PlaylistItemComponentProps = {
onClickOpenFile?: () => void;
onClickDelete?: () => void;
onClickSync?: () => void;
onClickDownload?: () => void;
onClickCancelDownload?: () => void;
}
@@ -50,6 +51,7 @@ export function PlaylistItem({ title,
onClickOpen,
onClickOpenFile,
onClickSync,
onClickDownload,
onClickDelete,
onClickCancelDownload
}: PlaylistItemComponentProps) {
@@ -80,7 +82,7 @@ export function PlaylistItem({ title,
return (
<motion.li className='relative flex-grow basis-0 min-w-80 h-28 cursor-pointer group' onHoverStart={() => setHovered(() => true)} onHoverEnd={() => setHovered(() => false)} >
<GlowEffect visible={selected || hovered}/>
<div className="size-full relative flex flex-row justify-start items-center overflow-hidden rounded-md *:z-[1]">
<div className="size-full relative flex flex-row justify-start items-center overflow-hidden bg-black rounded-md *:z-[1]">
<div className="absolute inset-0 flex justify-center items-center z-0">
<BsmImage className="size-full object-cover saturate-150 blur-lg" image={coverUrl} base64={coverBase64} />
<div className="absolute inset-0 bg-black opacity-20"/>
@@ -136,6 +138,23 @@ export function PlaylistItem({ title,
</Tippy>
) : (<></>)
)}
{onClickDownload && (
isDownloading ? (
<BsmBasicSpinner className="hover:!bg-main-color-1" spinnerClassName="brightness-75 dark:brightness-200" style={{ color }} thikness="3px"/>
) : !isInQueue ? (
<Tippy content="Télécharger la playlist" placement="left" theme="default">
<BsmButton
icon="download"
className="hover:!bg-main-color-1"
iconClassName="size-full brightness-75 dark:brightness-200"
style={{color}}
onClick={onClickDownload}
withBar={false}
/>
</Tippy>
) : (<></>)
)}
{onClickOpenFile && <Tippy content="Afficher le fichier" placement="left" theme="default">
<BsmButton
@@ -21,6 +21,7 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { useService } from "renderer/hooks/use-service.hook";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date";
export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; ownedMaps: BsmLocalMap[] }> = ({ options: {data : { ownedMaps, version }} }) => {
const beatSaver = useService(BeatSaverService);
@@ -131,7 +132,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
autorId={map.uploader.id}
bpm={map.metadata.bpm}
coverUrl={map.versions.at(0).coverURL}
createdAt={map.createdAt}
createdAt={map.createdAt && toCalendarDateTime(parseAbsolute(map.createdAt, getLocalTimeZone()))}
duration={map.metadata.duration}
hash={map.versions.at(0).hash}
likes={map.stats.upvotes}
@@ -5,8 +5,8 @@ import { cn } from "renderer/helpers/css-class.helpers";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { PlaylistSearchParams } from "shared/models/maps/beat-saver.model"
import {DateRangePicker} from "@nextui-org/date-picker";
import { DateValue, Radio, RadioGroup, RadioProps, RangeValue } from "@nextui-org/react";
import { today, getLocalTimeZone, parseAbsoluteToLocal, now } from "@internationalized/date";
import { Radio, RadioGroup, RadioProps, RangeValue } from "@nextui-org/react";
import { today, getLocalTimeZone, parseAbsoluteToLocal, toCalendarDate, toZoned, CalendarDate } from "@internationalized/date";
import { motion } from "framer-motion";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
@@ -26,27 +26,37 @@ type Props = {
const MAX_NPS = 17;
const MIN_NPS = 0;
// TODO : Translate
export function DownloadPlaylistFilterPanel({ className, params, onChange, onSubmit }: Props) {
const t = useTranslation();
const timeZone = useConstant(() => getLocalTimeZone());
const [curated, setCurated] = useState<boolean>(params?.curated);
const [verified, setVerified] = useState<boolean>(params?.verified);
const [includeEmpty, setIncludeEmpty] = useState<boolean>(params?.includeEmpty);
const [minNps, setMinNps] = useState<number>(params?.minNps);
const [maxNps, setMaxNps] = useState<number>(params?.maxNps);
const [dateRange, setDateRange] = useState<RangeValue<DateValue>>({
start: null,
end: null,
const [dateRange, setDateRange] = useState<RangeValue<CalendarDate>>({
start: params?.from ? toCalendarDate(parseAbsoluteToLocal(params.from)) : null,
end: params?.from ? toCalendarDate(parseAbsoluteToLocal(params.from)) : null,
});
const filterValue: FilterPlaylistSearchParams = { curated, verified, includeEmpty, minNps, maxNps, from: dateRange.start?.toString(), to: dateRange.end?.toString() };
const filterValue: FilterPlaylistSearchParams = {
curated,
verified,
includeEmpty, minNps,
maxNps,
from: dateRange?.start ? toZoned(dateRange.start, timeZone).set({ hour: 0, minute: 0 }).toAbsoluteString() : undefined,
to: dateRange?.end ? toZoned(dateRange.end, timeZone).set({ hour: 23, minute: 59, second: 59 }).toAbsoluteString() : undefined,
};
const firstFilterValue = useConstant(() => ({...filterValue}));
const filterHasBeenChanged = !equal(firstFilterValue, filterValue);
useOnUpdate(() => {
onChange?.(filterValue);
}, [curated, verified, includeEmpty, minNps, maxNps, dateRange.end, dateRange.start]);
}, [curated, verified, includeEmpty, minNps, maxNps, dateRange]);
const handleOnNpsChange = ([min, max]: number[]) => {
setMinNps(() => min <= MIN_NPS ? undefined : min);
@@ -92,11 +102,11 @@ export function DownloadPlaylistFilterPanel({ className, params, onChange, onSub
return setDateRange(() => ({ start: null, end: null }));
};
const end = today(getLocalTimeZone());
const end = today(timeZone);
setDateRange(() => ({
start: end.subtract({ [type]: value }),
end: end.add({days: 1})
end,
}))
};
@@ -1,5 +1,5 @@
import { useObservable } from "renderer/hooks/use-observable.hook"
import { ModalComponent } from "renderer/services/modale.service"
import { ModalComponent, ModalService } from "renderer/services/modale.service"
import { Observable, lastValueFrom } from "rxjs"
import { BSVersion } from "shared/bs-version.interface"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
@@ -14,6 +14,11 @@ import { PlaylistItem } from "renderer/components/maps-playlists-panel/playlists
import { PlaylistItemComponentPropsMapper } from "shared/mappers/playlist/playlist-item-component-props.mapper"
import { motion } from "framer-motion"
import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service"
import { BsvPlaylistDetailsModal } from "../playlist-details-modal/bsv-playlist-details-modal.component"
import { BsmImage } from "renderer/components/shared/bsm-image.component"
import BeatWaiting from "../../../../../../../assets/images/apngs/beat-waiting.png"
import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict.png"
import { cn } from "renderer/helpers/css-class.helpers"
// TODO : Translate
@@ -21,14 +26,15 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
{ resolver, options: { data: { version, ownedPlaylists$, ownedMaps$ }} }
) => {
const modal = useService(ModalService);
const beatSaver = useService(BeatSaverService);
const playlistDownloader = useService(PlaylistDownloaderService);
const [playlists, setPlaylists] = useState<BsvPlaylist[]>([]);
const [playlists, setPlaylists] = useState<BsvPlaylist[]>(null);
const ownedPlaylists = useObservable(() => ownedPlaylists$, []);
const ownedMaps = useObservable(() => ownedMaps$, []);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const [searchParams, setSearchParams] = useState<PlaylistSearchParams>({
q: "",
sortOrder: BsvSearchOrder.Relevance,
@@ -36,11 +42,9 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
});
useOnUpdate(() => {
setLoading(() => true);
beatSaver.searchPlaylists(searchParams)
.then(playlists => setPlaylists(prev => [...prev, ...(playlists ?? [])]))
.catch(console.error)
.finally(() => setLoading(() => false));
.then(playlists => setPlaylists(prev => [...(prev ?? []), ...(playlists ?? [])] ))
.catch(() => setError(() => true));
}, [searchParams])
const handleNewSearch = (value: Omit<PlaylistSearchParams, "page">) => {
@@ -52,15 +56,33 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
setSearchParams(prev => ({ ...prev, page: prev.page + 1 }));
};
const openPlaylist = (playlist: BsvPlaylist) => {
modal.openModal(BsvPlaylistDetailsModal, { data: { playlist, version, installedMaps$: ownedMaps$ }, noStyle: true })
};
return (
<div className="max-w-[95vw] w-[970px] h-[85vh] flex flex-col gap-3">
<DownloadPlaylistModalHeader className="h-9 w-full" value={searchParams} onSubmit={handleNewSearch}/>
{(() => {
if(loading && playlists.length === 0){
return <div className="flex justify-center items-center h-full w-full">Loading...</div>
if(!Array.isArray(playlists)){
return (
<div className="w-full flex flex-col justify-center items-center mt-44">
<BsmImage className={cn(["size-32", !error && "spin-loading"])} image={error ? BeatConflict : BeatWaiting} />
<p className="text-lg font-bold px-10 text-center">
{error ? "Une erreur est survenue lors du chargement des playlists" : "Chargement des playlists..."}
</p>
</div>
)
}
else if(playlists.length === 0){
return <div className="flex justify-center items-center h-full w-full">No playlists found</div>
return (
<div className="w-full flex flex-col justify-center items-center mt-44">
<BsmImage className="size-32" image={BeatConflict} />
<p className="text-lg font-bold px-10 text-center">
Aucune playlist trouvée
</p>
</div>
);
}
return (
<ul className="p-2 size-full flex flex-row flex-wrap justify-start content-start gap-3 grow overflow-y-scroll overflow-x-hidden z-[1]">
@@ -68,7 +90,8 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
<PlaylistItem
key={playlist.playlistId}
{...PlaylistItemComponentPropsMapper.fromBsvPlaylist(playlist)}
onClickSync={() => lastValueFrom(playlistDownloader.downloadPlaylist({ downloadSource: playlist.downloadURL, ignoreSongsHashs: ownedMaps.map(map => map.hash), version }))}
onClickOpen={() => openPlaylist(playlist)}
onClickDownload={() => lastValueFrom(playlistDownloader.downloadPlaylist({ downloadSource: playlist.downloadURL, ignoreSongsHashs: ownedMaps.map(map => map.hash), version }))}
/>
))}
<motion.span className="block w-full h-8" onViewportEnter={loadMorePlaylists}/>
@@ -0,0 +1,123 @@
import { BsvMapDetail, BsvPlaylist } from "shared/models/maps/beat-saver.model";
import { PlaylistDetailsTemplate } from "./playlist-details-template.component";
import { BSVersion } from "shared/bs-version.interface";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Observable } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { useState } from "react";
import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { BeatSaverService } from "renderer/services/thrird-partys/beat-saver.service";
import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date";
import { motion } from "framer-motion";
import { MapsDownloaderService } from "renderer/services/maps-downloader.service";
import equal from "fast-deep-equal";
import BeatWaiting from "../../../../../../../assets/images/apngs/beat-waiting.png"
import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict.png"
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { cn } from "renderer/helpers/css-class.helpers";
type Props = {
version: BSVersion;
playlist: BsvPlaylist;
installedMaps$: Observable<BsmLocalMap[]>;
}
// TODO : Translate
export const BsvPlaylistDetailsModal: ModalComponent<void, Props> = ({ resolver, options: { data: { playlist, installedMaps$, version } } }) => {
const beatsaver = useService(BeatSaverService);
const mapsDownloader = useService(MapsDownloaderService);
const downloadingMaps = useObservable(() => mapsDownloader.mapsInQueue$, []);
const installedMaps = useObservable(() => installedMaps$, null);
const [page, setPage] = useState(0);
const [playlistMaps, setPlaylistMaps] = useState<BsvMapDetail[]>(null);
const [error, setError] = useState<boolean>(false);
useOnUpdate(() => {
beatsaver.getPlaylistDetailsById(`${playlist.playlistId}`, page)
.then(playlistDetails => setPlaylistMaps(prev => [...(prev ?? []), ...(playlistDetails?.maps?.map(map => map.map) ?? [])]))
.catch(() => setError(() => true));
}, [page]);
return (
<PlaylistDetailsTemplate
title={playlist.name}
author={playlist.owner.name}
description={playlist.description}
duration={playlist.stats.totalDuration}
imageUrl={playlist.playlistImage}
maxNps={playlist.stats.maxNps}
minNps={playlist.stats.minNps}
nbMappers={playlist.stats.mapperCount}
nbMaps={playlist.stats.totalMaps}
onClose={() => resolver({ exitCode: ModalExitCode.CLOSED })}
>
{(() => {
if(!Array.isArray(playlistMaps)) {
return (
<div className="w-full flex flex-col justify-center items-center mt-10">
<BsmImage className={cn(["size-32", !error && "spin-loading"])} image={error ? BeatConflict : BeatWaiting} />
<p className="text-lg font-bold px-10 text-center">
{error ? "Une erreur est survenue lors du chargement de la playlist" : "Chargement des maps..."}
</p>
</div>
);
}
if(playlistMaps.length === 0) {
return (
<div className="w-full flex flex-col justify-center items-center mt-10">
<BsmImage className="size-32" image={BeatConflict} />
<p className="text-lg font-bold px-10 text-center">
Aucune map trouvée pour cette playlist
</p>
</div>
);
}
return (
<ul className="min-h-0 w-full grow space-y-2 pl-2.5 pr-2 py-3 overflow-y-scroll overflow-x-hidden scrollbar-default">
{playlistMaps.map(map => {
const downloadingMap = downloadingMaps.at(0);
const isMapOwned = installedMaps?.some(installedMap => installedMap.hash === map.versions.at(0).hash);
const isMapInQueue = downloadingMaps.some(downloadingMap => downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash);
const isMapDownloading = (isMapInQueue && downloadingMap) ? equal(downloadingMap.version, version) && downloadingMap.map.versions.at(0).hash === map.versions.at(0).hash : false;
return (
<MapItem
key={map.id}
title={map.name}
autor={map.uploader.name}
autorId={map.uploader.id}
bpm={map.metadata.bpm}
coverUrl={map.versions.at(0).coverURL}
createdAt={map.createdAt && toCalendarDateTime(parseAbsolute(map.createdAt, getLocalTimeZone()))}
duration={map.metadata.duration}
hash={map.versions.at(0).hash}
mapId={map.id}
likes={map.stats.upvotes}
ranked={map.ranked}
songAutor={map.metadata.songAuthorName}
songUrl={map.versions.at(0).previewURL}
diffs={extractMapDiffs({ bsvMap: map })}
downloading={isMapDownloading}
onDownload={!isMapOwned && !isMapInQueue && (() => mapsDownloader.addMapToDownload({version, map}))}
onCancelDownload={isMapInQueue && !isMapDownloading && (() => mapsDownloader.removeMapToDownload({version, map}))}
callBackParam={map}
/>);
})}
<motion.span className="w-full h-8" onViewportEnter={() => setPage(prev => prev + 1)} />
</ul>
)
})()}
</PlaylistDetailsTemplate>
)
}
@@ -1,6 +1,6 @@
import { ModalComponent } from "renderer/services/modale.service"
import { PlaylistDetailsTemplate, PlaylistDetailsTemplateProps } from "./playlist-details-template.component"
import { Observable, combineLatest, first, lastValueFrom, map, mergeAll, mergeMap, shareReplay, switchMap, take, tap } from "rxjs"
import { PlaylistDetailsTemplate } from "./playlist-details-template.component"
import { Observable, combineLatest, lastValueFrom, map, switchMap } from "rxjs"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
import { BSVersion } from "shared/bs-version.interface";
import { useObservable } from "renderer/hooks/use-observable.hook";
@@ -8,10 +8,9 @@ import { MapItem } from "renderer/components/maps-playlists-panel/maps/map-item.
import { extractMapDiffs } from "renderer/components/maps-playlists-panel/maps/maps-row.component";
import { useService } from "renderer/hooks/use-service.hook";
import { AudioPlayerService } from "renderer/services/audio-player.service";
import { AnimatePresence, motion } from "framer-motion";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png";
import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict.png";
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service";
import { PlaylistHeaderState } from "./playlist-header-state.component";
@@ -34,8 +33,8 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
const installedMaps = useObservable(() => options.data.installedMaps$, null);
const isMissingMaps$ = useConstant(() => combineLatest([options.data.installedMaps$, options.data.localPlaylist$]).pipe(map(([maps, playlist]) => maps.length !== playlist.songs.length)));
const isPlaylistDownloading$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistDownloading(playlist, options.data.version))));
const isPlaylistInQueue$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistInQueue(playlist, options.data.version))));
const isPlaylistDownloading$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistDownloading(playlist.path, options.data.version))));
const isPlaylistInQueue$ = useConstant(() => options.data.localPlaylist$.pipe(switchMap(playlist => playlistDownloader.$isPlaylistInQueue(playlist.path, options.data.version))));
const isInQueue = useObservable(() => isPlaylistInQueue$, false);
@@ -50,7 +49,12 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
const ignoreSongsHashs = installedMaps?.map(map => map.hash);
const obs$ = playlistDownloader.installPlaylist(localPlaylist, options.data.version, ignoreSongsHashs);
const obs$ = playlistDownloader.downloadPlaylist({
downloadSource: localPlaylist.path,
dest: localPlaylist.path,
version: options.data.version,
ignoreSongsHashs,
});
return lastValueFrom(obs$);
}
@@ -125,7 +129,7 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
<PlaylistDetailsTemplate
author={localPlaylist?.playlistAuthor}
description={localPlaylist?.playlistDescription}
image={localPlaylist?.image}
imagebase64={localPlaylist?.image}
duration={localPlaylist?.duration}
maxNps={localPlaylist?.maxNps}
minNps={localPlaylist?.minNps}
@@ -7,10 +7,13 @@ import { ClockIcon } from "renderer/components/svgs/icons/clock-icon.component";
import { NpsIcon } from "renderer/components/svgs/icons/nps-icon.component";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { ThemeColorGradientSpliter } from "renderer/components/shared/theme-color-gradient-spliter.component";
import { CrossIcon } from "renderer/components/svgs/icons/cross-icon.component";
import { ModalComponent, ModalExitCode, ModalObject, ModalResponse } from "renderer/services/modale.service";
export type PlaylistDetailsTemplateProps = {
title: string;
image: string;
imagebase64?: string;
imageUrl?: string;
author: string;
description: string;
nbMaps: number;
@@ -19,9 +22,10 @@ export type PlaylistDetailsTemplateProps = {
minNps: number;
maxNps: number;
children?: ReactNode;
onClose?: () => void;
}
export function PlaylistDetailsTemplate({title, image, author, description, nbMaps, duration, nbMappers, minNps, maxNps, children}: PlaylistDetailsTemplateProps) {
export function PlaylistDetailsTemplate({title, imagebase64, imageUrl, author, description, nbMaps, duration, nbMappers, minNps, maxNps, children, onClose}: PlaylistDetailsTemplateProps) {
const color = useThemeColor("first-color");
@@ -43,11 +47,14 @@ export function PlaylistDetailsTemplate({title, image, author, description, nbMa
return (
<div className="flex flex-col w-screen max-w-2xl h-screen max-h-[calc(100vh-1.25rem)] translate-y-2.5 bg-main-color-1 rounded-t-lg overflow-hidden">
<header className="shrink-0 relative h-36 overflow-hidden flex flex-row p-3">
<BsmImage className="absolute top-0 left-0 size-full object-cover blur-xl scale-150 brightness-75 saturate-200" base64={image}/>
<BsmImage className="h-full aspect-square object-cover z-[1] rounded-md shadow-black shadow-center" base64={image}/>
<button className="absolute top-2 right-2 z-[1]" onClickCapture={onClose}>
<CrossIcon className="size-3.5 text-gray-200"/>
</button>
<BsmImage className="absolute top-0 left-0 size-full object-cover blur-xl scale-150 brightness-75 saturate-200" base64={imagebase64} image={imageUrl}/>
<BsmImage className="h-full aspect-square object-cover z-[1] rounded-md shadow-black shadow-center" base64={imagebase64} image={imageUrl}/>
<div className="h-full px-3 text-white z-[1]">
<h1 className="font-bold text-xl tracking-wide line-clamp-1 w-fit">{title}</h1>
<p>{description}</p>
<p className="line-clamp-2" title={description}>{description}</p>
<p className="text-xs font-bold">Créé par <span className="brightness-200" style={{color}}>{author}</span></p>
<div className="flex flex-row flex-wrap w-full gap-2 mt-1">
@@ -1,7 +1,7 @@
import { AnimatePresence, motion } from "framer-motion";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import BeatConflict from "../../../../../../assets/images/apngs/beat-conflict.png";
import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict.png";
import { Observable } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
@@ -1,7 +1,6 @@
import { ModalExitCode, ModalObject, ModalService } from "renderer/services/modale.service";
import { AnimatePresence, motion } from "framer-motion";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import { useEffect } from "react";
import { BsmIcon } from "../svgs/bsm-icon.component";
import { ThemeColorGradientSpliter } from "../shared/theme-color-gradient-spliter.component";
@@ -12,8 +11,6 @@ export function Modal() {
const modals = useObservable(() => modalSevice.getModalToShow());
const currentModal = modals?.at(-1);
const { firstColor, secondColor } = useThemeColor();
useEffect(() => {
const onEscape = (e: KeyboardEvent) => {
if (e.key !== "Escape") {
@@ -61,7 +58,7 @@ export function Modal() {
<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 className="fixed z-[90] left-1/2 top-1/2" key={index} initial={{ y: "100vh", x: "-50%" }} animate={{y: "-50%", scale: modal === currentModal ? 1 : 0, opacity: modal === currentModal ? 1 : 0}} exit={{ y: "100vh" }}>
<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" }}>
{renderModal(modal)}
</motion.div>
))}
@@ -10,10 +10,13 @@ type Props = {
};
export function BsmLink({ className, href, children, style, internal }: Props) {
const linkOpener = useService(LinkOpenerService);
const openLink = () => {
console.log("Opening link", href, internal);
if (!href) {
return;
}
@@ -1,5 +1,5 @@
import { BsvMapDetail } from "shared/models/maps";
import { BsvPlaylist, PlaylistSearchParams, SearchParams } from "shared/models/maps/beat-saver.model";
import { BsvPlaylist, BsvPlaylistPage, PlaylistSearchParams, SearchParams } from "shared/models/maps/beat-saver.model";
import { IpcService } from "../ipc.service";
import { lastValueFrom } from "rxjs";
@@ -33,4 +33,8 @@ export class BeatSaverService {
public async searchPlaylists(search: PlaylistSearchParams): Promise<BsvPlaylist[]> {
return lastValueFrom(this.ipc.sendV2("bsv-search-playlist", search));
}
public async getPlaylistDetailsById(id: string, page = 0): Promise<BsvPlaylistPage> {
return lastValueFrom(this.ipc.sendV2("bsv-get-playlist-details-by-id", { id, page }));
}
}
+2 -1
View File
@@ -4,7 +4,7 @@ import { BSVersion } from "shared/bs-version.interface";
import { BSLaunchEventData, LaunchOption } from "shared/models/bs-launch";
import { BsvMapDetail } from "shared/models/maps";
import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface";
import { BsvPlaylist, PlaylistSearchParams, SearchParams } from "../maps/beat-saver.model";
import { BsvPlaylist, BsvPlaylistPage, PlaylistSearchParams, SearchParams } from "../maps/beat-saver.model";
import { ImportVersionOptions } from "main/services/bs-local-version.service";
import { DownloadInfo, DownloadSteamInfo } from "main/services/bs-version-download/bs-steam-downloader.service";
import { DepotDownloaderEvent } from "../bs-version-download/depot-downloader.model";
@@ -42,6 +42,7 @@ export interface IpcChannelMapping {
"bsv-get-map-details-from-hashs": {request: string[], response: BsvMapDetail[]};
"bsv-get-map-details-by-id": {request: string, response: BsvMapDetail};
"bsv-search-playlist": {request: PlaylistSearchParams, response: BsvPlaylist[]};
"bsv-get-playlist-details-by-id": {request: {id: string, page: number}, response: BsvPlaylistPage};
/* ** bs-launcher-ipcs ** */
"create-launch-shortcut": { request: LaunchOption, response: boolean };
@@ -3,15 +3,22 @@
export interface RawSongDetailsCache {
songs: RawSongDetails[];
lastUpdated: number;
uploaders: UploadersList;
difficultyLabels: string[];
total: number;
}
export interface UploadersList {
names: string[];
ids: number[];
}
// SongDetails Message
export interface RawSongDetails {
idInt: number;
hash: string;
duration: number;
uploader: RawUploader;
uploaderRef: UploaderRef;
uploadedAt: number;
tags: RawMapTag[];
ranked: boolean;
@@ -31,7 +38,7 @@ export interface RawSongDetails {
export interface RawDifficulty {
difficulty: RawDifficultyLabel;
characteristic: RawDifficultyCharacteristic;
label: string;
labelIndex: number;
starsT100: number;
starsBlT100: number;
njsT100: number;
@@ -47,9 +54,8 @@ export interface RawDifficulty {
}
// Uploader Message
export interface RawUploader {
name: string;
id: number;
export interface UploaderRef {
uploaderRefIndex: number;
verified: boolean;
}
@@ -1,11 +1,22 @@
import { MapStyle, MapTag, MapType } from "../beat-saver.model";
import { RawDifficulty, RawDifficultyCharacteristic, RawDifficultyLabel, RawMapTag, RawSongDetails, RawUploader } from "./raw-song-details-cache.model";
import { RawDifficulty, RawDifficultyCharacteristic, RawDifficultyLabel, RawMapTag, RawSongDetails, UploaderRef, UploadersList } from "./raw-song-details-cache.model";
import { SongDetailDiffCharactertistic, SongDetails, SongDiffName, SongDifficulty, SongUploader } from "./song-details-cache.model";
export abstract class RawSongDetailsDeserializer {
public static uploaderList: UploadersList = { names: [], ids: [] };
public static difficultyLabels: string[] = [];
private constructor() {}
public static setUploadersList(uploadersList: UploadersList): void {
this.uploaderList = uploadersList;
}
public static setDifficultyLabels(difficultyLabels: string[]): void {
this.difficultyLabels = difficultyLabels;
}
private static deserializeRawTag(rawTag: RawMapTag): MapTag {
switch (rawTag) {
case RawMapTag.DANCE: return MapStyle.Dance;
@@ -88,7 +99,7 @@ export abstract class RawSongDetailsDeserializer {
return {
difficulty: this.deserializeRawDifficultyLabel(rawDifficulty.difficulty),
characteristic: this.deserializeRawDifficultyCharacteristic(rawDifficulty.characteristic),
label: rawDifficulty.label,
label: RawSongDetailsDeserializer.difficultyLabels[rawDifficulty.labelIndex],
stars: Math.round(rawDifficulty.starsT100) / 100,
starsBL: Math.round(rawDifficulty.starsBlT100) / 100,
njs: Math.round(rawDifficulty.njsT100) / 100,
@@ -108,11 +119,12 @@ export abstract class RawSongDetailsDeserializer {
return rawDifficulties.map(rawDifficulty => this.deserializeRawDifficulty(rawDifficulty));
}
private static deserializeRawUploader(rawUploader: RawUploader): SongUploader {
private static deserializeRawUploader(uploaderRef: UploaderRef): SongUploader {
return {
name: rawUploader.name,
id: rawUploader.id,
verified: rawUploader.verified
name: RawSongDetailsDeserializer.uploaderList.names[uploaderRef.uploaderRefIndex],
id: RawSongDetailsDeserializer.uploaderList.ids[uploaderRef.uploaderRefIndex],
verified: uploaderRef.verified
};
}
@@ -138,7 +150,7 @@ export abstract class RawSongDetailsDeserializer {
id: this.deserializeMapId(rawSongDetails.idInt),
hash: rawSongDetails.hash,
duration: rawSongDetails.duration,
uploader: this.deserializeRawUploader(rawSongDetails.uploader),
uploader: this.deserializeRawUploader(rawSongDetails.uploaderRef),
uploadedAt: rawSongDetails.uploadedAt,
tags: this.deserializeRawTags(rawSongDetails.tags),
ranked: rawSongDetails.ranked,