Compare commits

..

43 Commits

Author SHA1 Message Date
MathieuG-P ed1960c469 Merge pull request #628 from Zagrios/chore/remove-dlc-folder-from-default-shared-folders
[chore] remove DLC folder from default shared folders
2024-10-23 21:10:03 +02:00
MathieuG-P dc9e181c54 [chore] remove DLC folder from default shared folders 2024-10-23 20:52:57 +02:00
MathieuG-P 4a91fa04e7 Merge pull request #626 from Zagrios/bugfix/download-from-steam-all-errors-are-unknown-errors
[bugfix] Fixed all errors from depotdownloader was unknown errors
2024-10-22 22:09:28 +02:00
MathieuG-P af3e5d1a56 [bugfix] Fixed all errors from depotdownloader was unknown errors 2024-10-22 21:55:34 +02:00
MathieuG-P 7b4e6340ca Merge pull request #600 from silentrald/bugfix/remember-playlist-sort
[bugfix] remember option for map and playlist sorting
2024-10-22 20:00:58 +02:00
MathieuG-P 2e3febf66a [bugfix] added missing translations 2024-10-22 14:11:53 +02:00
MathieuG-P a5ff3482e5 [bugfix] added missing bl-ranked indicator on maps items 2024-10-22 14:08:04 +02:00
MathieuG-P 8de279ac90 Merge pull request #625 from Zagrios/chore/feat-move-create-playlist-button/521
[chore-521] Move create playlist action
2024-10-22 13:57:37 +02:00
MathieuG-P 0efc56f8d6 [chore-521] Move create playlist action 2024-10-22 13:45:10 +02:00
MathieuG-P b59c37ebbd add patreon 2024-10-19 09:06:35 +02:00
MathieuG-P ff194e724b [bugfix] fix potential issue when adding a map to a playlist 2024-10-18 15:59:07 +02:00
MathieuG-P ca636c86e6 Merge pull request #622 from Zagrios/bugfix/bug-advanced-launch-unable-to-be-deactivated/620
[bugfix-620] fix weird UX with advanced launch args
2024-10-18 15:54:46 +02:00
MathieuG-P 8b1117543e Merge pull request #621 from silentrald/bugfix/618
[bugfix-618] support json files for playlists
2024-10-18 15:54:07 +02:00
silentrald ef123d8513 [bugfix-618] support json files for playlists
* fixed some issues from #616
2024-10-18 21:39:48 +08:00
MathieuG-P 5aac3ba47e [bugfix-620] fix weird UX with advanced launch args 2024-10-18 15:01:10 +02:00
MathieuG-P d2f3bdf29a Merge pull request #619 from silentrald/bugfix/song-filters
[bugfix] updated tags to the correct names
2024-10-18 11:13:15 +02:00
MathieuG-P e70313f66a [bugfix-619] update translations 2024-10-17 20:13:37 +02:00
silentrald 70926283ff [bugfix] updated tags to the correct names
* fixed issue where enabledTags and excludedTags are appended to the query string
2024-10-18 00:45:57 +08:00
MathieuG-P 8357c09e9f Merge pull request #575 from silentrald/feat/75
[feat-75] support drag and drop for map zip files
2024-10-17 15:16:01 +02:00
MathieuG-P 21f97df788 [feature-75] fix regex 2024-10-17 14:00:06 +02:00
MathieuG-P 00d6d7de30 [feature-75] fix some sonarcloud issues + other potentials issues + add logs 2024-10-17 13:31:41 +02:00
MathieuG-P b10900618a [feature-75] update translations + fix error notification translations 2024-10-17 12:52:36 +02:00
MathieuG-P 9b8d6ddda0 [feature-75] rework fontend functions to import maps 2024-10-16 20:35:34 +02:00
MathieuG-P 4e6948693d [feature-75] update drop zone UI 2024-10-16 19:11:57 +02:00
MathieuG-P 6e3c65cced Merge pull request #616 from silentrald/bugfix/614
[bugfix-614] handle bplist source string within readPlaylistFromSource
2024-10-16 09:49:40 +02:00
silentrald c499fb83e7 [bugfix-614] handle bplist source string within readPlaylistFromSource
* refactored to just use bplistSource each time it gets passed into another function
* removed unused songCache variable
2024-10-16 13:38:20 +08:00
silentrald be347dc6ba [feat-75] removed unused variable 2024-10-16 08:33:57 +08:00
MathieuG-P 716f068adf [feat-75] way to much RAM was used + some indirect fixes 2024-10-16 08:12:04 +08:00
MathieuG-P e9d2cb1482 [feat-75] fix paths of dropped zips are undefined 2024-10-16 08:11:15 +08:00
silentrald 132394524e [feat-75] added fix on rebasing 1 2024-10-16 08:11:15 +08:00
silentrald 20499b821e [feat-75] fix issue with Windows pathing on jsZip pattern 2024-10-16 08:11:15 +08:00
silentrald 803842d579 [feat-75] use map hash for comparing 2024-10-16 08:11:15 +08:00
silentrald eb119a31f0 [feat-75] support import for maps exported by BSM 2024-10-16 08:11:13 +08:00
silentrald 9d4acea654 [feat-75] applied PR comments 1 2024-10-16 08:10:24 +08:00
silentrald 6a2f982292 [feat-75] add translations 2024-10-16 08:10:24 +08:00
silentrald 7c88ea46e0 [feat-75] support multiple drag and drop zip files for maps
fixed issue in map downloads where download listener in MapsDownloaderService was not being removed
2024-10-16 08:10:24 +08:00
silentrald 0f3de55e9e [feat-75] support drag and drop for map zip files 2024-10-16 08:10:21 +08:00
MathieuG-P d5b7dff7a9 Merge pull request #615 from Zagrios/feat/feat-schema-v4-support-for-mapssongs/607
[feat-607] add support for map info v4
2024-10-15 20:36:04 +02:00
MathieuG-P 7468554ea0 [feat-607] add support for map info v4 2024-10-15 20:29:56 +02:00
MathieuG-P 0d93c984be Add patreon 2024-10-15 16:20:44 +02:00
silentrald 50322b69cc [refactor] refactored DefaultConfig on client side to resemble typing in StaticConfig 2024-10-06 00:33:50 +08:00
silentrald fc212cecb4 [bugfix] remember option for map sorting 2024-10-06 00:13:57 +08:00
silentrald 3d6a9b59bf [bugfix] remember option for playlist sorting 2024-10-06 00:13:57 +08:00
55 changed files with 1696 additions and 429 deletions
+1
View File
@@ -59,6 +59,7 @@ module.exports = {
"react/function-component-definition": "off",
"jsx-a11y/control-has-associated-label": "off",
"react/button-has-type": "off",
"no-labels": ["error", { "allowLoop": true }]
},
parserOptions: {
ecmaVersion: 2020,
+7 -2
View File
@@ -108,6 +108,11 @@
{
"username": "Taurus Arcade",
"type": "gold"
}
},
{
"username": "Mozz_Zm"
},
{
"username": "clapxz"
}
]
+94 -41
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "Hinzufügen"
"drop-down": {
"browse-maps": "Karten durchsuchen",
"import-maps": "Karten importieren"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "Keine Maps",
"button": "Maps herunterladen"
},
"drop-zone": {
"text": "Deine Karten importieren",
"subtext": "Lasse deine ZIP-Dateien hier fallen, um deine Karten zu importieren"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "Wiedergabelisten durchsuchen",
"create-a-playlist": "Eine Wiedergabeliste erstellen",
"import-playlists": "Wiedergabelisten importieren"
},
"drop-zone": {
"text": "Importiere deine Wiedergabelisten",
"subtext": "Ziehe deine \".bplist\" oder \".json\" Dateien hierhin, um sie zu importieren"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "Vorgang läuft",
"no-internet": "Kein Internet"
"no-internet": "Kein Internet",
"file-not-supported": "Datei wird nicht unterstützt"
},
"msg": {
"operation-running": "Warte auf das Ende des aktuellen Vorgangs und versuche es erneut.",
"no-internet": "Überprüfe deine Verbindung und versuche es erneut."
"no-internet": "Überprüfe deine Verbindung und versuche es erneut.",
"file-not-supported": "Nur {types}-Dateien werden unterstützt."
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "Duplikate gelöscht",
"msg": "Duplikate wurden gelöscht"
},
"import-map": {
"titles": {
"success": "Kartenimport abgeschlossen",
"error": "Beim Kartenimport ist ein Fehler aufgetreten"
},
"msgs": {
"success": "Die Karten wurden erfolgreich importiert.",
"some-success": "Einige Karten wurden erfolgreich importiert.",
"only-accept-zip": "Nur Zip-Dateien werden unterstützt.",
"invalid-zip": "Die Zip-Datei(en) enthalten keine Karten.",
"unknown": "Ein unbekannter Fehler ist aufgetreten."
}
}
},
"playlists": {
@@ -813,42 +844,42 @@
"tech": "Tech"
},
"map-styles": {
"dance": "Tanz",
"swing": "Swing",
"nightcore": "Nightcore",
"folk": "Folk",
"family": "Familie",
"ambient": "Umgebung",
"funk": "Funk",
"jazz": "Jazz",
"soul": "Soul",
"speedcore": "Speedcore",
"punk": "Punk",
"rb": "R&B",
"holiday": "Urlaub",
"vocaloid": "Vokaloid",
"j-rock": "J-Rock",
"trance": "Trance",
"drumbass": "Basstrommel",
"comedy": "Komödie",
"instrumental": "Instrumental",
"hardcore": "Hardcore",
"k-pop": "K-Pop",
"indie": "Indie",
"techno": "Techno",
"house": "House",
"game": "Videospiele",
"film": "Filme",
"alt": "Alternativ",
"dubstep": "Dubstep",
"metal": "Metal",
"anime": "Anime",
"hiphop": "HipHop",
"j-pop": "J-Pop",
"rock": "Rock",
"pop": "Pop",
"electronic": "Elektronisch",
"classical-orchestral": "Klassisch & Orchester"
"dance": "tanz",
"swing": "swing",
"nightcore": "nightcore",
"folk-acoustic": "folk & akustisch",
"kids-family": "kinder & familie",
"ambient": "ambient",
"funk-disco": "funk & disco",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
"punk": "punk",
"rb": "r&b",
"holiday": "urlaub",
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drum-and-bass": "drum & bass",
"comedy-meme": "komödie & meme",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indie",
"techno": "techno",
"house": "house",
"video-game-soundtrack": "videospiel",
"tv-movie-soundtrack": "TV & film",
"alternative": "alternativ",
"dubstep": "dubstep",
"metal": "metal",
"anime": "anime",
"hip-hop-rap": "hip hop & rap",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
"electronic": "elektronisch",
"classical-orchestral": "Klassisch & Orchestral"
},
"map-specificities": {
"automapper": "KI",
@@ -875,7 +906,8 @@
"bsr-code" : "BSR Code",
"download" : "Karte herunterladen",
"downloading" :"Karte wird heruntergeladen",
"cancel-download" : "Download abbrechen"
"cancel-download" : "Download abbrechen",
"hightlight-difficulty": "Schwierigkeit hervorheben"
}
},
"models": {
@@ -1067,6 +1099,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "Oder Dateien durchsuchen"
},
"playlist": {
"error-playlist-creation-title": "Fehler beim Erstellen der Playlist",
"error-playlist-creation-desc": "Beim Erstellen der Playlist ist ein Fehler aufgetreten.",
@@ -1181,6 +1216,24 @@
"last-week": "Letzte Woche",
"last-month": "Letzter Monat",
"3-last-month": "Letzte 3 Monate"
},
"playlists-imported": "Wiedergabelisten importiert",
"all-playlists-have-been-successfully-imported": "Alle Wiedergabelisten wurden erfolgreich importiert",
"no-playlist-found": "Keine Wiedergabeliste gefunden",
"no-playlist-found-in-selected-files": "Keine Wiedergabeliste in den ausgewählten Dateien gefunden",
"some-playlists-not-imported": "Einige Wiedergabelisten wurden nicht importiert",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "Einige Wiedergabelisten konnten nicht gefunden werden",
"INVALID_PLAYLIST_FILE": "Einige Wiedergabelisten sind ungültig",
"CANNOT_PARSE_PLAYLIST": "Einige Wiedergabelisten sind nicht lesbar",
"unknown": "Einige Wiedergabelisten konnten nicht importiert werden"
},
"no-playlists-imported": "Keine Wiedergabelisten importiert",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "Wiedergabelisten konnten nicht gefunden werden",
"INVALID_PLAYLIST_FILE": "Wiedergabelisten sind ungültig",
"CANNOT_PARSE_PLAYLIST": "Wiedergabelisten sind nicht lesbar",
"unknown": "Es konnte keine Wiedergabeliste importiert werden"
}
},
"dateformat": {
+67 -14
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "Add"
"drop-down": {
"browse-maps": "Browse maps",
"import-maps": "Import maps"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "No maps",
"button": "Download maps"
},
"drop-zone": {
"text": "Import your maps",
"subtext": "Drop your zip files here to import your maps"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "Browse playlists",
"create-a-playlist": "Create a playlist",
"import-playlists": "Import playlists"
},
"drop-zone": {
"text": "Import your playlists",
"subtext": "Drop your \".bplist\" or \".json\" files here to import them"
}
}
}
@@ -255,11 +271,13 @@
"errors": {
"titles": {
"operation-running": "Operation running",
"no-internet": "No internet"
"no-internet": "No internet",
"file-not-supported": "File not supported"
},
"msg": {
"operation-running": "Wait for the current operation to finish, then try again.",
"no-internet": "Check your connection and try again."
"no-internet": "Check your connection and try again.",
"file-not-supported": "Only {types} files are supported."
}
}
},
@@ -512,6 +530,19 @@
"duplicates-maps-deleted": {
"title": "Duplicates Deleted",
"msg": "Duplicates were deleted"
},
"import-map": {
"titles": {
"success": "Maps import completed",
"error": "An error occurred during the maps import"
},
"msgs": {
"success": "Maps successfully imported.",
"some-success": "Some maps were successfully imported.",
"only-accept-zip": "Only zip files are supported.",
"invalid-zip": "The zip file(s) do not contain any maps.",
"unknown": "An unknown error occurred."
}
}
},
"playlists": {
@@ -823,10 +854,10 @@
"dance": "dance",
"swing": "swing",
"nightcore": "nightcore",
"folk": "folk",
"family": "family",
"folk-acoustic": "folk & acoustic",
"kids-family": "kids & family",
"ambient": "ambient",
"funk": "funk",
"funk-disco": "funk & disco",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
@@ -836,21 +867,21 @@
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drumbass": "drum & bass",
"comedy": "comedy",
"drum-and-bass": "drum & bass",
"comedy-meme": "comedy & meme",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indie",
"techno": "techno",
"house": "house",
"game": "video game",
"film": "film",
"alt": "alternative",
"video-game-soundtrack": "video game",
"tv-movie-soundtrack": "TV & film",
"alternative": "alternative",
"dubstep": "dubstep",
"metal": "metal",
"anime": "anime",
"hiphop": "hiphop",
"hip-hop-rap": "hip hop & rap",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
@@ -882,7 +913,8 @@
"bsr-code" : "BSR code",
"download" : "Download map",
"downloading" :"Downloading map",
"cancel-download" : "Cancel download"
"cancel-download" : "Cancel download",
"hightlight-difficulty": "Highlight difficulty"
}
},
"models": {
@@ -1074,6 +1106,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "Or browse files"
},
"playlist": {
"error-playlist-creation-title": "Error creating playlist",
"error-playlist-creation-desc": "An error occurred while creating the playlist.",
@@ -1188,6 +1223,24 @@
"last-week": "Last week",
"last-month": "Last month",
"3-last-month": "Last 3 months"
},
"playlists-imported": "Playlists imported",
"all-playlists-have-been-successfully-imported": "All playlists have been successfully imported",
"no-playlist-found": "No playlist found",
"no-playlist-found-in-selected-files": "No playlist found in the selected files",
"some-playlists-not-imported": "Some playlists not imported",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "Some playlists could not be found",
"INVALID_PLAYLIST_FILE": "Some playlists are invalid",
"CANNOT_PARSE_PLAYLIST": "Some playlists are unreadable",
"unknown": "Some playlists could not be imported"
},
"no-playlists-imported": "No playlists imported",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "Playlists could not be found",
"INVALID_PLAYLIST_FILE": "Playlists are invalid",
"CANNOT_PARSE_PLAYLIST": "Playlists are unreadable",
"unknown": "No playlist could be imported"
}
},
"dateformat": {
+72 -19
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "Añadir"
"drop-down": {
"browse-maps": "Explorar mapas",
"import-maps": "Importar mapas"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "No hay mapas",
"button": "Descargar mapas"
},
"drop-zone": {
"text": "Importar tus mapas",
"subtext": "Suelta tus archivos zip aquí para importar tus mapas"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "Explorar listas de reproducción",
"create-a-playlist": "Crear una lista de reproducción",
"import-playlists": "Importar listas de reproducción"
},
"drop-zone": {
"text": "Importa tus listas de reproducción",
"subtext": "Suelta tus archivos \".bplist\" o \".json\" aquí para importarlos"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "Operación en curso",
"no-internet": "Sin internet"
"no-internet": "Sin internet",
"file-not-supported": "Archivo no compatible"
},
"msg": {
"operation-running": "Espera a que termine la operación actual y vuelve a empezar.",
"no-internet": "Comprueba tu conexión a Internet e inténtalo de nuevo."
"no-internet": "Comprueba tu conexión a Internet e inténtalo de nuevo.",
"file-not-supported": "Solo se admiten archivos {types}."
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "Duplicados eliminados",
"msg": "Se eliminaron los duplicados"
},
"import-map": {
"titles": {
"success": "Importación de mapas completada",
"error": "Ocurrió un error durante la importación de mapas"
},
"msgs": {
"success": "Los mapas se han importado con éxito.",
"some-success": "Algunos mapas se han importado con éxito.",
"only-accept-zip": "Solo se admiten archivos zip.",
"invalid-zip": "El o los archivos zip no contienen mapas.",
"unknown": "Se produjo un error desconocido."
}
}
},
"playlists": {
@@ -813,13 +844,13 @@
"tech": "tech"
},
"map-styles": {
"dance": "baile",
"dance": "danza",
"swing": "swing",
"nightcore": "nightcore",
"folk": "folk",
"family": "familia",
"ambient": "ambiente",
"funk": "funk",
"folk-acoustic": "folk & acústico",
"kids-family": "niños & familia",
"ambient": "ambiental",
"funk-disco": "funk & disco",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
@@ -829,26 +860,26 @@
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drumbass": "drum & bass",
"comedy": "comedia",
"drum-and-bass": "drum & bass",
"comedy-meme": "comedia & meme",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indie",
"techno": "tecno",
"techno": "techno",
"house": "house",
"game": "videojuego",
"film": "film",
"alt": "alternativa",
"video-game-soundtrack": "videojuego",
"tv-movie-soundtrack": "TV & cine",
"alternative": "alternativo",
"dubstep": "dubstep",
"metal": "metal",
"anime": "anime",
"hiphop": "hiphop",
"hip-hop-rap": "hip hop & rap",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
"electronic": "electrónico",
"classical-orchestral": "Clásico y orquestal"
"electronic": "electrónica",
"classical-orchestral": "Clásica & Orquestal"
},
"map-specificities": {
"automapper": "IA",
@@ -875,7 +906,8 @@
"bsr-code" : "Código BSR",
"download" : "Descargar mapa",
"downloading" :"Descargando mapa",
"cancel-download" : "Cancelar descarga"
"cancel-download" : "Cancelar descarga",
"hightlight-difficulty": "Resaltar la dificultad"
}
},
"models": {
@@ -1067,6 +1099,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "O navegar por los archivos"
},
"playlist": {
"error-playlist-creation-title": "Error al crear la lista de reproducción",
"error-playlist-creation-desc": "Ocurrió un error al crear la lista de reproducción.",
@@ -1181,6 +1216,24 @@
"last-week": "Última semana",
"last-month": "Último mes",
"3-last-month": "Últimos 3 meses"
},
"playlists-imported": "Listas de reproducción importadas",
"all-playlists-have-been-successfully-imported": "Todas las listas de reproducción se han importado con éxito",
"no-playlist-found": "No se encontró ninguna lista de reproducción",
"no-playlist-found-in-selected-files": "No se encontró ninguna lista de reproducción en los archivos seleccionados",
"some-playlists-not-imported": "Algunas listas de reproducción no se importaron",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "No se pudieron encontrar algunas listas de reproducción",
"INVALID_PLAYLIST_FILE": "Algunas listas de reproducción no son válidas",
"CANNOT_PARSE_PLAYLIST": "Algunas listas de reproducción no se pueden leer",
"unknown": "No se pudieron importar algunas listas de reproducción"
},
"no-playlists-imported": "No se importaron listas de reproducción",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "No se pudieron encontrar las listas de reproducción",
"INVALID_PLAYLIST_FILE": "Las listas de reproducción no son válidas",
"CANNOT_PARSE_PLAYLIST": "Las listas de reproducción no se pueden leer",
"unknown": "No se pudo importar ninguna lista de reproducción"
}
},
"dateformat": {
+72 -19
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "Ajouter"
"drop-down": {
"browse-maps": "Explorer les maps",
"import-maps": "Importer des maps"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "Aucune map",
"button": "Télécharger des maps"
},
"drop-zone": {
"text": "Importer vos maps",
"subtext": "Déposez vos fichiers zip ici pour importer vos maps"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "Explorer les playlists",
"create-a-playlist": "Créer une playlist",
"import-playlists": "Importer des playlists"
},
"drop-zone": {
"text": "Importer vos playlists",
"subtext": "Déposez vos fichiers \".bplist\" ou \".json\" ici pour les importer"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "Opération en cours",
"no-internet": "Pas d'accès Internet"
"no-internet": "Pas d'accès Internet",
"file-not-supported": "Fichier non supporté"
},
"msg": {
"operation-running": "Attends la fin de l'opération en cours puis recommence.",
"no-internet": "Vérifie ta connexion internet et ressaye."
"no-internet": "Vérifie ta connexion internet et ressaye.",
"file-not-supported": "Seuls les fichiers {types} sont pris en charge."
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "Doublons supprimés",
"msg": "Les doublons ont été supprimés"
},
"import-map": {
"titles": {
"success": "Importation des maps terminée",
"error": "Une erreur est survenue lors de l'importation des maps"
},
"msgs": {
"success": "Les maps ont été importées avec succès.",
"some-success": "Certaines maps ont été importées avec succès.",
"only-accept-zip": "Seules les fichiers zip sont pris en charge.",
"invalid-zip": "Le ou les fichiers zip ne contiennent aucune maps.",
"unknown": "Une erreur inconnue s'est produite."
}
}
},
"playlists": {
@@ -814,42 +845,42 @@
"tech": "tech"
},
"map-styles": {
"dance": "dance",
"dance": "danse",
"swing": "swing",
"nightcore": "nightcore",
"folk": "folk",
"family": "famille",
"ambient": "ambiante",
"funk": "funk",
"folk-acoustic": "folk & acoustique",
"kids-family": "enfants & famille",
"ambient": "ambiant",
"funk-disco": "funk & disco",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
"punk": "punk",
"rb": "r&b",
"holiday": "vacance",
"holiday": "vacances",
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drumbass": "drum & bass",
"comedy": "comédie",
"drum-and-bass": "drum & bass",
"comedy-meme": "comédie & meme",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indé",
"indie": "indie",
"techno": "techno",
"house": "house",
"game": "jeu vidéo",
"film": "film",
"alt": "alternative",
"video-game-soundtrack": "jeu vidéo",
"tv-movie-soundtrack": "TV & film",
"alternative": "alternatif",
"dubstep": "dubstep",
"metal": "metal",
"metal": "métal",
"anime": "anime",
"hiphop": "hiphop",
"hip-hop-rap": "hip hop & rap",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
"electronic": "électronique",
"classical-orchestral": "classique & orchestrale"
"classical-orchestral": "Classique & Orchestral"
},
"map-specificities": {
"automapper": "IA",
@@ -1069,6 +1100,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "Ou parcourir les fichiers"
},
"playlist": {
"error-playlist-creation-title": "Erreur lors de la création de la playlist",
"error-playlist-creation-desc": "Une erreur est survenue lors de la création de la playlist.",
@@ -1183,7 +1217,26 @@
"last-week": "Dernière semaine",
"last-month": "Dernier mois",
"3-last-month": "3 derniers mois"
},
"playlists-imported": "Playlists importées",
"all-playlists-have-been-successfully-imported": "Toutes les playlists ont été importées avec succès",
"no-playlist-found": "Aucune playlist trouvée",
"no-playlist-found-in-selected-files": "Aucune playlist trouvée dans les fichiers sélectionnés",
"some-playlists-not-imported": "Certaines playlists non importées",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "Certaines playlists n'ont pas pu être trouvées",
"INVALID_PLAYLIST_FILE": "Certaines playlists ne sont pas valides",
"CANNOT_PARSE_PLAYLIST": "Certaines playlists ne sont pas lisibles",
"unknown": "Certaines playlists n'ont pas pu être importées"
},
"no-playlists-imported": "Aucune playlist importée",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "Les playlists n'ont pas été trouvées",
"INVALID_PLAYLIST_FILE": "Les playlists ne sont pas valides",
"CANNOT_PARSE_PLAYLIST": "Les playlists ne sont pas lisibles",
"unknown": "Aucune playlist n'a pu être importée"
}
},
"dateformat": {
"dayNames": ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"],
+74 -21
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "追加"
"drop-down": {
"browse-maps": "マップを閲覧",
"import-maps": "マップをインポート"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "マップがありません",
"button": "マップをダウンロードする"
},
"drop-zone": {
"text": "マップをインポート",
"subtext": "ZIPファイルをここにドロップしてマップをインポート"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "プレイリストを閲覧",
"create-a-playlist": "プレイリストを作成",
"import-playlists": "プレイリストをインポート"
},
"drop-zone": {
"text": "プレイリストをインポート",
"subtext": "\".bplist\" または \".json\" ファイルをここにドロップしてインポート"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "稼働中",
"no-internet": "インターネット接続がありません"
"no-internet": "インターネット接続がありません",
"file-not-supported": "ファイルはサポートされていません"
},
"msg": {
"operation-running": "現在の処理が終了するのを待ってから、もう一度試してください。",
"no-internet": "インターネット接続を確認し、もう一度お試しください。"
"no-internet": "インターネット接続を確認し、もう一度お試しください。",
"file-not-supported": "{types}ファイルのみがサポートされています。"
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "重複削除",
"msg": "重複が削除されました"
},
"import-map": {
"titles": {
"success": "マップのインポートが完了しました",
"error": "マップのインポート中にエラーが発生しました"
},
"msgs": {
"success": "マップが正常にインポートされました。",
"some-success": "一部のマップが正常にインポートされました。",
"only-accept-zip": "zipファイルのみがサポートされています。",
"invalid-zip": "zipファイルにマップが含まれていません。",
"unknown": "不明なエラーが発生しました。"
}
}
},
"playlists": {
@@ -816,39 +847,39 @@
"dance": "ダンス",
"swing": "スウィング",
"nightcore": "ナイトコア",
"folk": "フォーク",
"family": "ファミリー",
"folk-acoustic": "フォーク & アコースティック",
"kids-family": "キッズ & ファミリー",
"ambient": "アンビエント",
"funk": "ファンク",
"funk-disco": "ファンク & ディスコ",
"jazz": "ジャズ",
"soul": "ソウル",
"speedcore": "スピードコア ",
"speedcore": "スピードコア",
"punk": "パンク",
"rb": "r&b",
"holiday": "休日",
"holiday": "ホリデー",
"vocaloid": "ボーカロイド",
"j-rock": "j-rock",
"j-rock": "J-ロック",
"trance": "トランス",
"drumbass": "ドラムベース",
"comedy": "コメディ",
"instrumental": "器楽",
"drum-and-bass": "ドラム & ベース",
"comedy-meme": "コメディ & ミーム",
"instrumental": "インストゥルメンタル",
"hardcore": "ハードコア",
"k-pop": "k-pop",
"indie": "インディー",
"techno": "テクノ",
"house": "",
"game": "ビデオゲーム",
"film": "映画音楽",
"alt": "オルタナティ",
"house": "ハウス",
"video-game-soundtrack": "ゲーム音楽",
"tv-movie-soundtrack": "TV & 映画",
"alternative": "オルタナティ",
"dubstep": "ダブステップ",
"metal": "メタル",
"anime": "アニメ",
"hiphop": "ヒップップ",
"j-pop": "j-pop",
"hip-hop-rap": "ヒップホップ & ラップ",
"j-pop": "J-ポップ",
"rock": "ロック",
"pop": "ポップ",
"electronic": "エレクトロニック",
"classical-orchestral": "Classical & Orchestral"
"classical-orchestral": "クラシック & オーケストラ"
},
"map-specificities": {
"automapper": "AI",
@@ -875,7 +906,8 @@
"bsr-code" : "BSRコード",
"download" : "マップをダウンロード",
"downloading" :"マップをダウンロード中",
"cancel-download" : "ダウンロードをキャンセル"
"cancel-download" : "ダウンロードをキャンセル",
"hightlight-difficulty": "難易度をハイライト"
}
},
"models": {
@@ -1067,6 +1099,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "またはファイルを参照"
},
"playlist": {
"error-playlist-creation-title": "プレイリストの作成エラー",
"error-playlist-creation-desc": "プレイリストの作成中にエラーが発生しました。",
@@ -1181,6 +1216,24 @@
"last-week": "先週",
"last-month": "先月",
"3-last-month": "過去3ヶ月"
},
"playlists-imported": "プレイリストがインポートされました",
"all-playlists-have-been-successfully-imported": "すべてのプレイリストが正常にインポートされました",
"no-playlist-found": "プレイリストが見つかりません",
"no-playlist-found-in-selected-files": "選択されたファイルにプレイリストが見つかりません",
"some-playlists-not-imported": "一部のプレイリストはインポートされませんでした",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "一部のプレイリストが見つかりませんでした",
"INVALID_PLAYLIST_FILE": "一部のプレイリストは無効です",
"CANNOT_PARSE_PLAYLIST": "一部のプレイリストは読み取れません",
"unknown": "一部のプレイリストをインポートできませんでした"
},
"no-playlists-imported": "プレイリストはインポートされませんでした",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "プレイリストが見つかりませんでした",
"INVALID_PLAYLIST_FILE": "プレイリストは無効です",
"CANNOT_PARSE_PLAYLIST": "プレイリストは読み取れません",
"unknown": "プレイリストをインポートできませんでした"
}
},
"dateformat": {
+75 -22
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "Добавить"
"drop-down": {
"browse-maps": "Просмотр карт",
"import-maps": "Импорт карт"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "Нет карт",
"button": "Скачать карты"
},
"drop-zone": {
"text": "Импортируйте свои карты",
"subtext": "Перетащите сюда файлы ZIP, чтобы импортировать свои карты"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "Просмотр плейлистов",
"create-a-playlist": "Создать плейлист",
"import-playlists": "Импорт плейлистов"
},
"drop-zone": {
"text": "Импортируйте ваши плейлисты",
"subtext": "Перетащите сюда файлы \".bplist\" или \".json\" для их импорта"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "Операция в процессе",
"no-internet": "Нет интернета"
"no-internet": "Нет интернета",
"file-not-supported": "Файл не поддерживается"
},
"msg": {
"operation-running": "Дождитесь завершения операции, потом попробуйте снова.",
"no-internet": "Проверьте своё соединение и попробуйте снова."
"no-internet": "Проверьте своё соединение и попробуйте снова.",
"file-not-supported": "Поддерживаются только файлы {types}."
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "Дубликаты удалены",
"msg": "Дубликаты были удалены"
},
"import-map": {
"titles": {
"success": "Импорт карт завершен",
"error": "Произошла ошибка при импорте карт"
},
"msgs": {
"success": "Карты были успешно импортированы.",
"some-success": "Некоторые карты успешно импортированы.",
"only-accept-zip": "Поддерживаются только zip-файлы.",
"invalid-zip": "В zip-файле(ах) нет карт.",
"unknown": "Произошла неизвестная ошибка."
}
}
},
"playlists": {
@@ -815,36 +846,36 @@
"dance": "танец",
"swing": "свинг",
"nightcore": "найткор",
"folk": "народные",
"family": "семейные",
"folk-acoustic": "фолк и акустика",
"kids-family": "дети и семья",
"ambient": "эмбиент",
"funk": "фанк",
"funk-disco": "фанк и диско",
"jazz": "джаз",
"soul": "соул",
"speedcore": "спидкор",
"punk": "панк",
"rb": "ар-н-би",
"holiday": "праздник",
"vocaloid": "вокалоиды",
"rb": "r&b",
"holiday": "праздники",
"vocaloid": "вокалоид",
"j-rock": "джей-рок",
"trance": "транс",
"drumbass": "драм-н-бейс",
"comedy": "комедия",
"instrumental": "инструментальные",
"drum-and-bass": "драм-н-бейс",
"comedy-meme": "комедия и мемы",
"instrumental": "инструментал",
"hardcore": "хардкор",
"k-pop": "кей-поп",
"k-pop": "k-pop",
"indie": "инди",
"techno": "техно",
"house": "хаус",
"game": "видеоигры",
"film": "фильмы",
"alt": "альтернативные",
"dubstep": "дапстеп",
"video-game-soundtrack": "видеоигра",
"tv-movie-soundtrack": "ТВ и кино",
"alternative": "альтернатива",
"dubstep": "дабстеп",
"metal": "метал",
"anime": "аниме",
"hiphop": "хипхоп",
"j-pop": "джей-поп",
"rock": "прк",
"hip-hop-rap": "хип-хоп и рэп",
"j-pop": "j-pop",
"rock": "рок",
"pop": "поп",
"electronic": "электроника",
"classical-orchestral": "Классика и Оркестр"
@@ -874,7 +905,8 @@
"bsr-code" : "Код BSR",
"download" : "Скачать карту",
"downloading" :"Загрузка карты",
"cancel-download" : "Отменить загрузку"
"cancel-download" : "Отменить загрузку",
"hightlight-difficulty": "Выделить сложность"
}
},
"models": {
@@ -1066,6 +1098,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "Или просмотреть файлы"
},
"playlist": {
"error-playlist-creation-title": "Ошибка при создании плейлиста",
"error-playlist-creation-desc": "Произошла ошибка при создании плейлиста.",
@@ -1180,6 +1215,24 @@
"last-week": "Последняя неделя",
"last-month": "Последний месяц",
"3-last-month": "Последние 3 месяца"
},
"playlists-imported": "Плейлисты импортированы",
"all-playlists-have-been-successfully-imported": "Все плейлисты успешно импортированы",
"no-playlist-found": "Плейлист не найден",
"no-playlist-found-in-selected-files": "Плейлист не найден в выбранных файлах",
"some-playlists-not-imported": "Некоторые плейлисты не импортированы",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "Некоторые плейлисты не найдены",
"INVALID_PLAYLIST_FILE": "Некоторые плейлисты недействительны",
"CANNOT_PARSE_PLAYLIST": "Некоторые плейлисты нечитаемы",
"unknown": "Некоторые плейлисты не удалось импортировать"
},
"no-playlists-imported": "Плейлисты не импортированы",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "Плейлисты не найдены",
"INVALID_PLAYLIST_FILE": "Плейлисты недействительны",
"CANNOT_PARSE_PLAYLIST": "Плейлисты нечитаемы",
"unknown": "Не удалось импортировать плейлисты"
}
},
"dateformat": {
+94 -41
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "新增"
"drop-down": {
"browse-maps": "瀏覽地圖",
"import-maps": "匯入地圖"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "無譜面",
"button": "下載譜面"
},
"drop-zone": {
"text": "導入您的地圖",
"subtext": "將ZIP文件拖放到此處以導入您的地圖"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "瀏覽播放列表",
"create-a-playlist": "建立播放列表",
"import-playlists": "匯入播放列表"
},
"drop-zone": {
"text": "匯入您的播放列表",
"subtext": "將您的 \".bplist\" 或 \".json\" 檔案拖曳到這裡進行匯入"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "操作進行中",
"no-internet": "無網路"
"no-internet": "無網路",
"file-not-supported": "文件不受支持"
},
"msg": {
"operation-running": "等待當前操作完成,然後重試。",
"no-internet": "檢查你的連接並重試。"
"no-internet": "檢查你的連接並重試。",
"file-not-supported": "僅支援{types}檔案。"
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "重複已刪除",
"msg": "重複已刪除"
},
"import-map": {
"titles": {
"success": "地圖匯入完成",
"error": "地圖匯入時發生錯誤"
},
"msgs": {
"success": "地圖已成功匯入。",
"some-success": "部分地圖已成功匯入。",
"only-accept-zip": "僅支援zip檔案。",
"invalid-zip": "zip檔案中沒有地圖。",
"unknown": "發生了未知錯誤。"
}
}
},
"playlists": {
@@ -813,42 +844,42 @@
"tech": "技術"
},
"map-styles": {
"dance": "Dance",
"swing": "Swing",
"nightcore": "Nightcore",
"folk": "Folk",
"family": "Family",
"ambient": "Ambient",
"funk": "Funk",
"jazz": "Jazz",
"soul": "Soul",
"speedcore": "Speedcore",
"punk": "Punk",
"rb": "R&B",
"holiday": "Holiday",
"vocaloid": "Vocaloid",
"j-rock": "J-rock",
"trance": "Trance",
"drumbass": "Drum & Bass",
"comedy": "Comedy",
"instrumental": "Instrumental",
"hardcore": "Hardcore",
"k-pop": "K-pop",
"indie": "Indie",
"techno": "Techno",
"house": "House",
"game": "Video game",
"film": "Film",
"alt": "Alternative",
"dubstep": "Dubstep",
"metal": "Metal",
"anime": "Anime",
"hiphop": "Hiphop",
"j-pop": "J-pop",
"rock": "Rock",
"pop": "Pop",
"electronic": "Electronic",
"classical-orchestral": "Classical & Orchestral"
"dance": "舞蹈",
"swing": "搖擺",
"nightcore": "夜核",
"folk-acoustic": "民謠 & 原聲",
"kids-family": "兒童 & 家庭",
"ambient": "氛圍音樂",
"funk-disco": "放克 & 迪斯科",
"jazz": "爵士",
"soul": "靈魂",
"speedcore": "極速核",
"punk": "龐克",
"rb": "r&b",
"holiday": "假日",
"vocaloid": "聲庫",
"j-rock": "日式搖滾",
"trance": "迷幻",
"drum-and-bass": "鼓 & 貝斯",
"comedy-meme": "喜劇 & 表情包",
"instrumental": "純音樂",
"hardcore": "硬核",
"k-pop": "k-pop",
"indie": "獨立",
"techno": "電子舞曲",
"house": "浩室音樂",
"video-game-soundtrack": "遊戲音樂",
"tv-movie-soundtrack": "電視 & 電影",
"alternative": "另類",
"dubstep": "電音鼓",
"metal": "金屬",
"anime": "動漫",
"hip-hop-rap": "嘻哈 & 說唱",
"j-pop": "日式流行",
"rock": "搖滾",
"pop": "流行",
"electronic": "電子",
"classical-orchestral": "古典 & 管弦樂"
},
"map-specificities": {
"automapper": "AI",
@@ -875,7 +906,8 @@
"bsr-code" : "BSR代碼",
"download" : "下載地圖",
"downloading" :"正在下載地圖",
"cancel-download" : "取消下載"
"cancel-download" : "取消下載",
"hightlight-difficulty": "突出顯示難度"
}
},
"models": {
@@ -1067,6 +1099,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "或瀏覽檔案"
},
"playlist": {
"error-playlist-creation-title": "建立播放清單時發生錯誤",
"error-playlist-creation-desc": "建立播放清單時發生錯誤。",
@@ -1181,6 +1216,24 @@
"last-week": "上週",
"last-month": "上個月",
"3-last-month": "最近3個月"
},
"playlists-imported": "播放清單已匯入",
"all-playlists-have-been-successfully-imported": "所有播放清單已成功匯入",
"no-playlist-found": "未找到播放清單",
"no-playlist-found-in-selected-files": "在選定檔案中未找到播放清單",
"some-playlists-not-imported": "部分播放清單未匯入",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "某些播放清單未找到",
"INVALID_PLAYLIST_FILE": "某些播放清單無效",
"CANNOT_PARSE_PLAYLIST": "某些播放清單無法解析",
"unknown": "某些播放清單無法匯入"
},
"no-playlists-imported": "未匯入任何播放清單",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "播放清單未找到",
"INVALID_PLAYLIST_FILE": "播放清單無效",
"CANNOT_PARSE_PLAYLIST": "播放清單無法解析",
"unknown": "無法匯入任何播放清單"
}
},
"dateformat": {
+92 -39
View File
@@ -52,8 +52,9 @@
"tabs": {
"maps": {
"actions": {
"add-maps": {
"text": "添加"
"drop-down": {
"browse-maps": "浏览地图",
"import-maps": "导入地图"
},
"link-maps": {
"tooltips": {
@@ -65,6 +66,21 @@
"empty-maps": {
"text": "无谱面",
"button": "下载谱面"
},
"drop-zone": {
"text": "导入您的地图",
"subtext": "将ZIP文件拖放到此处以导入您的地图"
}
},
"playlists": {
"drop-down": {
"browse-playlists": "浏览播放列表",
"create-a-playlist": "创建播放列表",
"import-playlists": "导入播放列表"
},
"drop-zone": {
"text": "导入您的播放列表",
"subtext": "将您的 \".bplist\" 或 \".json\" 文件拖放到这里进行导入"
}
}
}
@@ -250,11 +266,13 @@
"errors": {
"titles": {
"operation-running": "操作进行中",
"no-internet": "无网络"
"no-internet": "无网络",
"file-not-supported": "文件不受支持"
},
"msg": {
"operation-running": "等待当前操作完成,然后重试。",
"no-internet": "检查你的连接并重试。"
"no-internet": "检查你的连接并重试。",
"file-not-supported": "仅支持{types}文件。"
}
}
},
@@ -505,6 +523,19 @@
"duplicates-maps-deleted": {
"title": "重复已删除",
"msg": "重复已删除"
},
"import-map": {
"titles": {
"success": "地图导入完成",
"error": "导入地图时发生错误"
},
"msgs": {
"success": "地图已成功导入。",
"some-success": "部分地图已成功导入。",
"only-accept-zip": "仅支持zip文件。",
"invalid-zip": "zip文件中没有地图。",
"unknown": "发生了未知错误。"
}
}
},
"playlists": {
@@ -813,42 +844,42 @@
"tech": "技术"
},
"map-styles": {
"dance": "dance",
"swing": "swing",
"nightcore": "nightcore",
"folk": "folk",
"family": "family",
"ambient": "ambient",
"funk": "funk",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
"punk": "punk",
"dance": "舞蹈",
"swing": "摇摆",
"nightcore": "夜核",
"folk-acoustic": "民谣 & 原声",
"kids-family": "儿童 & 家庭",
"ambient": "氛围音乐",
"funk-disco": "放克 & 迪斯科",
"jazz": "爵士",
"soul": "灵魂",
"speedcore": "极速核",
"punk": "朋克",
"rb": "r&b",
"holiday": "holiday",
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drumbass": "drum & bass",
"comedy": "comedy",
"instrumental": "instrumental",
"hardcore": "hardcore",
"holiday": "假日",
"vocaloid": "声库",
"j-rock": "日式摇滚",
"trance": "迷幻",
"drum-and-bass": "鼓 & 贝斯",
"comedy-meme": "喜剧 & 表情包",
"instrumental": "纯音乐",
"hardcore": "硬核",
"k-pop": "k-pop",
"indie": "indie",
"techno": "techno",
"house": "house",
"game": "video game",
"film": "film",
"alt": "alternative",
"dubstep": "dubstep",
"metal": "metal",
"anime": "anime",
"hiphop": "hiphop",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
"electronic": "electronic",
"classical-orchestral": "Classical & Orchestral"
"indie": "独立",
"techno": "电子舞曲",
"house": "浩室音乐",
"video-game-soundtrack": "游戏音乐",
"tv-movie-soundtrack": "电视 & 电影",
"alternative": "另类",
"dubstep": "电音鼓",
"metal": "金属",
"anime": "动漫",
"hip-hop-rap": "嘻哈 & 说唱",
"j-pop": "日式流行",
"rock": "摇滚",
"pop": "流行",
"electronic": "电子",
"classical-orchestral": "古典 & 管弦乐"
},
"map-specificities": {
"automapper": "AI",
@@ -875,7 +906,8 @@
"bsr-code" : "BSR代码",
"download" : "下载地图",
"downloading" :"正在下载地图",
"cancel-download" : "取消下载"
"cancel-download" : "取消下载",
"hightlight-difficulty": "突出显示难度"
}
},
"models": {
@@ -1067,6 +1099,9 @@
}
}
},
"drop-zone": {
"or-browse-files": "或浏览文件"
},
"playlist": {
"error-playlist-creation-title": "创建播放列表时出错",
"error-playlist-creation-desc": "创建播放列表时发生错误。",
@@ -1181,6 +1216,24 @@
"last-week": "上周",
"last-month": "上个月",
"3-last-month": "最近3个月"
},
"playlists-imported": "播放列表已导入",
"all-playlists-have-been-successfully-imported": "所有播放列表已成功导入",
"no-playlist-found": "未找到播放列表",
"no-playlist-found-in-selected-files": "在选定文件中未找到播放列表",
"some-playlists-not-imported": "部分播放列表未导入",
"some-playlists-have-been-imported": {
"INVALID_SOURCE": "某些播放列表未找到",
"INVALID_PLAYLIST_FILE": "某些播放列表无效",
"CANNOT_PARSE_PLAYLIST": "某些播放列表无法解析",
"unknown": "某些播放列表无法导入"
},
"no-playlists-imported": "未导入任何播放列表",
"no-playlists-imported-errors": {
"INVALID_SOURCE": "播放列表未找到",
"INVALID_PLAYLIST_FILE": "播放列表无效",
"CANNOT_PARSE_PLAYLIST": "播放列表无法解析",
"unknown": "无法导入任何播放列表"
}
},
"dateformat": {
+2
View File
@@ -8,6 +8,7 @@ import crypto from "crypto";
import { execSync } from "child_process";
import { tryit } from "../../shared/helpers/error.helpers";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { ErrorObject } from "serialize-error";
export async function pathExist(path: string): Promise<boolean> {
try {
@@ -279,4 +280,5 @@ export interface Progression<T = unknown, D = unknown> {
diff?: number;
data?: T;
extra?: D;
lastError?: ErrorObject;
}
+6 -1
View File
@@ -22,7 +22,12 @@ ipc.on("export-maps", async (args, reply) => {
reply(await maps.exportMaps(args.version, args.maps, args.outPath));
});
ipc.on("download-map", async (args, reply) => {
ipc.on("bs-maps.import-maps", async (args, reply) => {
const maps = LocalMapsManagerService.getInstance();
reply(maps.importMaps(args.paths, args.version));
})
ipc.on("bs-maps.download-map", async (args, reply) => {
const maps = LocalMapsManagerService.getInstance();
reply(from(maps.downloadMap(args.map, args.version)));
});
+6 -5
View File
@@ -3,8 +3,6 @@ import { IpcService } from "../services/ipc.service";
import { from, lastValueFrom, mergeMap, of } from "rxjs";
import { LocalMapsManagerService } from "../services/additional-content/maps/local-maps-manager.service";
import { Progression } from "main/helpers/fs.helpers";
import { isValidUrl } from "shared/helpers/url.helpers";
import { pathToFileURL } from "url";
const ipc = IpcService.getInstance();
@@ -31,10 +29,8 @@ ipc.on("is-playlists-deep-links-enabled", (args, reply) => {
ipc.on("download-playlist", (args, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
const downloadUrl = isValidUrl(args.downloadSource) ? args.downloadSource : pathToFileURL(args.downloadSource).href;
return reply(playlists.downloadPlaylist({
bpListUrl: downloadUrl,
bplistSource: args.downloadSource,
version: args.version,
ignoreSongsHashs: args.ignoreSongsHashs,
dest: args.dest
@@ -63,6 +59,11 @@ ipc.on("export-playlists", (args, reply) => {
reply(playlists.exportPlaylists(args));
});
ipc.on("import-playlists", (args, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
reply(playlists.importPlaylists(args));
});
ipc.on("install-playlist-file", (args, reply) => {
const playlists = LocalPlaylistsManagerService.getInstance();
+5
View File
@@ -13,6 +13,11 @@ ipc.on("new-window", (args, reply) => {
reply(from(shell.openExternal(args)));
});
ipc.on("open-dialog", (args, reply) => {
// Use this ipc instead of "choose-folder" or "choose-file" to have more control over the dialog
reply(from(dialog.showOpenDialog(args)));
})
ipc.on("choose-folder", (args, reply) => {
reply(from(dialog.showOpenDialog({ properties: ["openDirectory"], defaultPath: args ?? "" })));
});
+4 -1
View File
@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from "electron";
import { ProviderPlatform } from "shared/models/provider-platform.enum";
const sep = process.platform === ProviderPlatform.WINDOWS ? "\\" : "/";
@@ -30,5 +30,8 @@ contextBridge.exposeInMainWorld("electron", {
join: (...args: string[]): string => {
return args.join(sep);
}
},
webUtils: {
getPathForFile: webUtils.getPathForFile
}
});
@@ -16,7 +16,6 @@ import { FileAssociationService } from "../file-association.service";
import { SongDetailsCacheService } from "./maps/song-details-cache.service";
import { sToMs } from "shared/helpers/time.helpers";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
import { SongCacheService } from "./maps/song-cache.service";
import { InstallationLocationService } from "../installation-location.service";
import sanitize from "sanitize-filename";
import { isValidUrl } from "shared/helpers/url.helpers";
@@ -27,6 +26,7 @@ import { tryit } from "shared/helpers/error.helpers";
import recursiveReadDir from "recursive-readdir";
import { BsvMapDetail, SongDetails } from "shared/models/maps";
import { findHashInString } from "shared/helpers/string.helpers";
import { serializeError } from "serialize-error";
export class LocalPlaylistsManagerService {
private static instance: LocalPlaylistsManagerService;
@@ -42,6 +42,7 @@ export class LocalPlaylistsManagerService {
private readonly DEEP_LINKS = {
BeatSaver: "bsplaylist",
};
private readonly PLAYLIST_FILETYPES = [".bplist", ".json"];
private readonly versions: BSLocalVersionService;
private readonly maps: LocalMapsManagerService;
@@ -51,7 +52,6 @@ export class LocalPlaylistsManagerService {
private readonly windows: WindowManagerService;
private readonly bsaver: BeatSaverService;
private readonly songDetails: SongDetailsCacheService;
private readonly songCache: SongCacheService;
private readonly bsmFs: InstallationLocationService;
private constructor() {
@@ -63,7 +63,6 @@ export class LocalPlaylistsManagerService {
this.windows = WindowManagerService.getInstance();
this.bsaver = BeatSaverService.getInstance();
this.songDetails = SongDetailsCacheService.getInstance();
this.songCache = SongCacheService.getInstance();
this.bsmFs = InstallationLocationService.getInstance();
@@ -89,11 +88,15 @@ export class LocalPlaylistsManagerService {
return fullPath;
}
private acceptPlaylistFiletype(filename: string): boolean {
return this.PLAYLIST_FILETYPES.includes(path.extname(filename).toLowerCase());
}
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; }
if(opt.dest && path.isAbsolute(opt.dest) && this.acceptPlaylistFiletype(opt.dest)) { return opt.dest; }
const playlistFolder = await this.getPlaylistsFolder(opt.version);
const playlistPath = path.join(playlistFolder, `${sanitize(opt.bpList.playlistTitle)}.bplist`);
return getUniqueFileNamePath(playlistPath);
@@ -111,14 +114,14 @@ export class LocalPlaylistsManagerService {
}
private async installBPListFile(opt: {
bslistSource: string,
bplistSource: string,
version?: BSVersion,
dest?: string
}): Promise<{path: string, localBPList: LocalBPList}> {
const bplist = await this.readPlaylistFromSource(opt.bslistSource);
const bplist = await this.readPlaylistFromSource(opt.bplistSource);
const dest = await (async () => {
if(opt.dest && path.isAbsolute(opt.dest) && path.extname(opt.dest) === ".bplist") { return opt.dest; }
if(opt.dest && path.isAbsolute(opt.dest) && this.acceptPlaylistFiletype(opt.dest)) { return opt.dest; }
const playlistFolder = await this.getPlaylistsFolder(opt.version);
return path.join(playlistFolder, `${sanitize(bplist.playlistTitle)}.bplist`);
})();
@@ -135,13 +138,22 @@ export class LocalPlaylistsManagerService {
const isLocalFile = await pathExists(source).catch(e => { log.error(e); return false; });
if(!isLocalFile && !isValidUrl(source)) {
throw new Error(`Invalid source ${source}`);
throw new CustomError(`Invalid source (${source})`, "INVALID_SOURCE");
}
const bpList: BPList = isLocalFile ? JSON.parse(readFileSync(source).toString()) : await this.request.getJSON<BPList>(source);
const bpList: BPList = await (async () => {
if(isLocalFile){
const res = await tryit(async () => JSON.parse(readFileSync(source).toString()));
if(res.error) {
throw CustomError.fromError(res.error, "CANNOT_PARSE_PLAYLIST");
}
return res.result;
}
return this.request.getJSON<BPList>(source);
})();
if(!bpList?.playlistTitle) {
throw new Error(`Invalid playlist file ${source}`);
throw new CustomError(`Invalid playlist file (${source})`, "INVALID_PLAYLIST_FILE");
}
bpList.songs = (bpList.songs ?? []).map(s => s.hash ? (
@@ -168,7 +180,7 @@ export class LocalPlaylistsManagerService {
}
const ignoreFunc = (file: string, stats: Stats): boolean => {
if(stats.isFile() && path.extname(file) !== ".bplist") { return true; }
if(stats.isFile() && !this.acceptPlaylistFiletype(file)) { return true; }
return false;
}
@@ -347,8 +359,8 @@ export class LocalPlaylistsManagerService {
});
}
public downloadPlaylist({ bpListUrl, version, ignoreSongsHashs = [], dest }: {
bpListUrl: string,
public downloadPlaylist({ bplistSource, version, ignoreSongsHashs = [], dest }: {
bplistSource: string,
version?: BSVersion
ignoreSongsHashs?: string[]
dest?: string
@@ -359,7 +371,7 @@ export class LocalPlaylistsManagerService {
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
(async () => {
const { localBPList } = await this.installBPListFile({ bslistSource: bpListUrl, version, dest });
const { localBPList } = await this.installBPListFile({ bplistSource, version, dest });
await lastValueFrom(this.downloadPlaylistSongs(localBPList, ignoreSongsHashs, version).pipe(
tap({ next: p => obs.next(p) }),
@@ -428,13 +440,56 @@ export class LocalPlaylistsManagerService {
return archive.finalize();
}
public oneClickInstallPlaylist(bpListUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
public importPlaylists({ version, paths }: { version?: BSVersion, paths: string[] }): Observable<Progression<LocalBPListsDetails>> {
return new Observable<Progression<LocalBPListsDetails>>(obs => {
let canceled = false;
(async () => {
const bplistPaths = paths.filter(p => this.acceptPlaylistFiletype(p));
const progress: Progression<LocalBPListsDetails> = { current: 0, total: bplistPaths.length, data: null };
obs.next(progress);
for(const playlistPath of bplistPaths) {
if(canceled) {
log.info("Playlist import canceled");
return;
}
const { result: localBPList, error } = await tryit(() => this.installBPListFile({ bplistSource: playlistPath, version }));
if(error) {
progress.lastError = serializeError(CustomError.fromError(error));
obs.next(progress);
log.error(error);
continue;
}
const bpListDetails = this.getLocalBPListDetails(localBPList.localBPList);
progress.current += 1;
progress.data = bpListDetails;
obs.next(progress);
}
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
return () => {
canceled = true;
}
});
}
public oneClickInstallPlaylist(bplistUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
(async () => {
const versions = await this.versions.getInstalledVersions();
const download$ = this.downloadPlaylist({ bpListUrl, version: versions.pop() }).pipe(tap({
const download$ = this.downloadPlaylist({ bplistSource: bplistUrl, version: versions.pop() }).pipe(tap({
next: progress => obs.next(progress),
error: err => obs.error(err),
}));
@@ -446,7 +501,7 @@ export class LocalPlaylistsManagerService {
const realSourceMapsFolder = await realpath(path.dirname(downloadedMaps[0].path));
for (const version of versions) {
await this.installBPListFile({ bslistSource: playlist.path, version});
await this.installBPListFile({ bplistSource: playlist.path, version});
const versionMapsFolder = await this.maps.getMapsFolderPath(version);
const realDestMapsFolder = await realpath(versionMapsFolder).catch(e => {
@@ -1,11 +1,11 @@
import path from "path";
import { BSVersion } from "shared/bs-version.interface";
import { BsvMapDetail, RawMapInfoData } from "shared/models/maps";
import { BsvMapDetail } from "shared/models/maps";
import { BsmLocalMap, BsmLocalMapsProgress, DeleteMapsProgress } from "shared/models/maps/bsm-local-map.interface";
import { BSLocalVersionService } from "../../bs-local-version.service";
import { InstallationLocationService } from "../../installation-location.service";
import { UtilsService } from "../../utils.service";
import crypto from "crypto";
import crypto, { BinaryLike } from "crypto";
import { lstatSync } from "fs";
import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink } from "fs-extra";
import StreamZip from "node-stream-zip";
@@ -26,6 +26,10 @@ import { SongCacheService } from "./song-cache.service";
import { pathToFileURL } from "url";
import { sToMs } from "../../../../shared/helpers/time.helpers";
import { FieldRequired } from "shared/helpers/type.helpers";
import { MapInfo } from "shared/models/maps/info/map-info.model";
import { parseMapInfoDat } from "shared/parsers/maps/map-info.parser";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { tryit } from "shared/helpers/error.helpers";
export class LocalMapsManagerService {
private static instance: LocalMapsManagerService;
@@ -97,24 +101,35 @@ export class LocalMapsManagerService {
}
private async computeMapHash(mapPath: string, rawInfoString: string): Promise<string> {
const mapRawInfo: RawMapInfoData = JSON.parse(rawInfoString);
const { result: mapInfo, error } = tryit(() => parseMapInfoDat(JSON.parse(rawInfoString)));
if(!mapInfo || error) {
log.error(`Unable to cumpute hash, cannot parse map info at ${mapPath}`, error);
throw CustomError.fromError(error, `Unable to cumpute hash, cannot parse map info at ${mapPath}`, "cannot-parse-map-info");
}
const shasum = crypto.createHash("sha1");
shasum.update(rawInfoString);
const hashFile = (filePath: string): Promise<void> => {
return new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on("data", data => shasum.update(data));
stream.on("data", data => shasum.update(data as BinaryLike));
stream.on("error", reject);
stream.on("close", resolve);
});
};
for (const set of mapRawInfo._difficultyBeatmapSets) {
for (const diff of set._difficultyBeatmaps) {
const diffFilePath = path.join(mapPath, diff._beatmapFilename);
for (const diff of mapInfo.difficulties) {
if(diff.beatmapFilename){
const diffFilePath = path.join(mapPath, diff.beatmapFilename);
await hashFile(diffFilePath);
}
if(diff.lightshowDataFilename) {
const lightshowFilePath = path.join(mapPath, diff.lightshowDataFilename);
await hashFile(lightshowFilePath);
}
}
return shasum.digest("hex");
@@ -122,16 +137,16 @@ export class LocalMapsManagerService {
public async loadMapInfoFromPath(mapPath: string): Promise<BsmLocalMap> {
const getUrlsAndReturn = (rawInfo: RawMapInfoData, hash: string, mapPath: string) => {
const coverUrl = pathToFileURL(path.join(mapPath, rawInfo._coverImageFilename)).href;
const songUrl = pathToFileURL(path.join(mapPath, rawInfo._songFilename)).href;
return { rawInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) } as BsmLocalMap;
const getUrlsAndReturn = (mapInfo: MapInfo, hash: string, mapPath: string): BsmLocalMap => {
const coverUrl = pathToFileURL(path.join(mapPath, mapInfo.coverImageFilename)).href;
const songUrl = pathToFileURL(path.join(mapPath, mapInfo.songFilename)).href;
return { mapInfo, coverUrl, songUrl, hash, path: mapPath, songDetails: this.songDetailsCache.getSongDetails(hash) };
};
const cachedInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath));
const cachedMapInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath));
if (cachedInfos) {
return getUrlsAndReturn(cachedInfos.rawInfo, cachedInfos.hash, mapPath);
if (cachedMapInfos) {
return getUrlsAndReturn(cachedMapInfos.mapInfo, cachedMapInfos.hash, mapPath);
}
const files = await getFilesInFolder(mapPath);
@@ -142,10 +157,16 @@ export class LocalMapsManagerService {
}
const rawInfoString = await readFile(infoFile, { encoding: "utf-8" });
const rawInfo: RawMapInfoData = JSON.parse(rawInfoString);
const { result: mapInfo, error } = tryit(() => parseMapInfoDat(JSON.parse(rawInfoString)));
if (error) {
log.error(`Cannot parse map info.dat. Map path: ${mapPath}`, error);
throw CustomError.fromError(error, `Cannot read map info.dat. Map path: ${mapPath}`, "cannot-parse-map-info");
}
const hash = await this.computeMapHash(mapPath, rawInfoString);
return getUrlsAndReturn(rawInfo, hash, mapPath);
return getUrlsAndReturn(mapInfo, hash, mapPath);
}
private async downloadMapZip(zipUrl: string): Promise<{ zip: StreamZip.StreamZipAsync; zipPath: string }> {
@@ -193,7 +214,7 @@ export class LocalMapsManagerService {
return null;
}
this.songCache.setMapInfoFromDirname(path.basename(mapPath), { rawInfo: mapInfo.rawInfo, hash: mapInfo.hash });
this.songCache.setMapInfoFromDirname(path.basename(mapPath), { mapInfo: mapInfo.mapInfo, hash: mapInfo.hash });
progression.loaded++;
observer.next(progression);
@@ -267,8 +288,8 @@ export class LocalMapsManagerService {
const mapsPaths = await getFoldersInFolder(versionMapsPath);
for (const mapPath of mapsPaths) {
const mapInfo = await this.loadMapInfoFromPath(mapPath);
if (hashs.includes(mapInfo.hash)) {
const { result: mapInfo } = await tryit(() => this.loadMapInfoFromPath(mapPath));
if (mapInfo && hashs.includes(mapInfo.hash)) {
await deleteFolder(mapPath);
this.songCache.deleteMapInfoFromDirname(path.basename(mapPath));
progress.current++;
@@ -296,8 +317,8 @@ export class LocalMapsManagerService {
const mapsPaths = await getFoldersInFolder(versionMapsPath);
for (const mapPath of mapsPaths) {
const mapInfo = await this.loadMapInfoFromPath(mapPath);
if (mapInfo.hash === hash) {
const { result: mapInfo } = await tryit(() => this.loadMapInfoFromPath(mapPath));
if (mapInfo?.hash === hash) {
return mapInfo;
}
}
@@ -305,6 +326,111 @@ export class LocalMapsManagerService {
return null;
}
public importMaps(zipPaths: string[], version?: BSVersion): Observable<Progression<BsmLocalMap>> {
return new Observable<Progression<BsmLocalMap>>(obs => {
let unsubscribed = false;
let progress: Progression<BsmLocalMap> = { total: 0, current: 0 };
let nbImportedMaps = 0;
(async () => {
const mapsPath = await this.getMapsFolderPath(version);
for(const zipPath of zipPaths) {
if(unsubscribed) {
log.info("Maps importation from zip has been cancelled");
return;
}
progress = { total: 0, current: 0 };
obs.next(progress); // reset progress for each zip
if(!pathExistsSync(zipPath)) { continue; }
const zip = new StreamZip.async({ file: zipPath });
const { result: zipEntries, error } = await tryit(() => zip.entries());
if(error) {
const res = await tryit(() => zip.close());
log.error("Could not read zip entries", zipPath, error, res?.error);
continue;
}
const zipEntriesValues = Object.values(zipEntries);
const mapsFolders = zipEntriesValues.reduce((acc, entry) => {
if(!/(^|\/)[Ii]nfo\.dat$/.test(entry.name)){ return acc; }
acc.push(path.dirname(entry.name));
return acc;
}, []);
if(mapsFolders.length === 0) {
log.warn("No maps \"info.dat\" found in zip", zipPath);
}
progress.total = mapsFolders.length;
obs.next(progress);
for(const folder of mapsFolders) {
if(unsubscribed) {
log.info("Maps importation from zip has been cancelled");
await zip.close();
return;
}
const isRoot = folder === ".";
const dest = isRoot ? path.join(mapsPath, path.basename(zipPath, ".zip")) : path.join(mapsPath, folder);
let extract: () => Promise<BsmLocalMap>;
if(isRoot){
const entries = zipEntriesValues.filter(entry => entry.isFile && path.dirname(entry.name) === ".");
extract = async () => {
await Promise.all(entries.map(entry => {
log.info("Extracting", `"${entry.name}"`, "from", `"${zipPath}"`, "into", `"${path.join(dest, path.basename(entry.name))}"`);
return zip.extract(entry.name, path.join(dest, path.basename(entry.name)));
}));
return this.loadMapInfoFromPath(dest);
};
} else {
extract = async () => {
log.info("Extracting", `"${folder}"`, "from", `"${zipPath}"`, "into", `"${mapsPath}"`);
await zip.extract(folder, dest);
return this.loadMapInfoFromPath(dest);
}
}
await ensureDir(dest);
const { result: bsmMap, error } = await tryit(extract);
if(error) {
log.error("Could not extract map", zipPath, folder, mapsPath, error);
continue;
}
nbImportedMaps++;
progress.current++;
progress.data = bsmMap;
obs.next(progress);
}
await zip.close();
}
})()
.then(() => {
if(!nbImportedMaps){
throw new CustomError("No \"Info.dat\" file located in any of the zip files", "invalid-zip");
}
return log.info("Successfully imported", nbImportedMaps, "maps from", zipPaths.length, "zips");
})
.catch(e => obs.error(e))
.finally(() => obs.complete());
return () => {
unsubscribed = true;
};
});
}
public async downloadMap(map: BsvMapDetail, version?: BSVersion): Promise<BsmLocalMap> {
if (!map.versions.at(0).hash) {
@@ -1,7 +1,7 @@
import { CACHE_PATH } from "main/constants";
import { JsonCache } from "main/models/json-cache.class";
import path from "path";
import { RawMapInfoData } from "shared/models/maps";
import { MapInfo } from "shared/models/maps/info/map-info.model";
export class SongCacheService {
@@ -14,34 +14,35 @@ export class SongCacheService {
return SongCacheService.instance;
}
private readonly RAW_INFOS_CACHE_PATH = path.join(CACHE_PATH, "song-raw-info-cache.json");
private readonly MAPS_INFO_CACHE_PATH = path.join(CACHE_PATH, "map-info-cache.json");
private readonly rawInfosCache: JsonCache<CachedRawInfoWithHash>;
private readonly mapsInfoCache: JsonCache<CachedMapInfoWithHash>;
private constructor(){
this.rawInfosCache = new JsonCache(this.RAW_INFOS_CACHE_PATH);
console.log(this.MAPS_INFO_CACHE_PATH);
this.mapsInfoCache = new JsonCache(this.MAPS_INFO_CACHE_PATH);
}
public getMapInfoFromDirname(dirname: string): CachedRawInfoWithHash {
return this.rawInfosCache.get(dirname);
public getMapInfoFromDirname(dirname: string): CachedMapInfoWithHash {
return this.mapsInfoCache.get(dirname);
}
public getMapInfoFromHash(hash: string): { dirname: string, info: CachedRawInfoWithHash } | undefined {
const res = Object.entries(this.rawInfosCache.cache).find(([, info]) => info.hash === hash);
public getMapInfoFromHash(hash: string): { dirname: string, info: CachedMapInfoWithHash } | undefined {
const res = Object.entries(this.mapsInfoCache.cache).find(([, info]) => info.hash === hash);
return res ? { dirname: res[0], info: res[1] } : undefined;
}
public setMapInfoFromDirname(dirname: string, info: CachedRawInfoWithHash): void {
this.rawInfosCache.set(dirname, info);
public setMapInfoFromDirname(dirname: string, info: CachedMapInfoWithHash): void {
this.mapsInfoCache.set(dirname, info);
}
public deleteMapInfoFromDirname(dirname: string): void {
this.rawInfosCache.delete(dirname);
this.mapsInfoCache.delete(dirname);
}
}
export type CachedRawInfoWithHash = {
export type CachedMapInfoWithHash = {
hash: string;
rawInfo: RawMapInfoData;
mapInfo: MapInfo;
};
@@ -12,6 +12,7 @@ import { DepotDownloaderArgsOptions, DepotDownloaderErrorEvent, DepotDownloaderE
import { DepotDownloader } from "../../models/depot-downloader.class";
import { app } from "electron";
import { BsStore } from "../../../shared/models/bs-store.enum";
import { CustomError } from "shared/models/exceptions/custom-error.class";
export class BsSteamDownloaderService {
private static instance: BsSteamDownloaderService;
@@ -96,7 +97,7 @@ export class BsSteamDownloaderService {
depotDownloader.$events().pipe(
map(event => {
if(event.type === DepotDownloaderEventType.Error){
throw event;
throw new CustomError(JSON.stringify(event.data) ?? "An error occur in the DepotDownloader process", event?.subType ?? DepotDownloaderErrorEvent.Unknown, event)
}
return event;
}),
@@ -40,6 +40,9 @@ export class BeatSaverApiService {
const params: Record<string, string> = this.objectToStringRecord({...filter, tags});
delete params.enabledTags;
delete params.excludedTags;
return new URLSearchParams(params);
}
@@ -10,11 +10,10 @@ import { useTranslation } from "renderer/hooks/use-translation.hook";
import { FolderLinkState } from "renderer/services/version-folder-linker.service";
import { useService } from "renderer/hooks/use-service.hook";
import { BsContentTabPanel } from "../shared/bs-content-tab-panel/bs-content-tab-panel.component";
import { BsmButton } from "../shared/bsm-button.component";
import { MapIcon } from "../svgs/icons/map-icon.component";
import { PlaylistIcon } from "../svgs/icons/playlist-icon.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { BehaviorSubject, of } from "rxjs";
import { BehaviorSubject, lastValueFrom, of } from "rxjs";
import { LocalPlaylistsListPanel, LocalPlaylistsListRef } from "./playlists/local-playlists-list-panel.component";
import { PlaylistsManagerService } from "renderer/services/playlists-manager.service";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
@@ -23,6 +22,8 @@ import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.mode
import { PlaylistDownloaderService } from "renderer/services/playlist-downloader.service";
import { LocalPlaylistFilter, LocalPlaylistFilterPanel } from "./playlists/local-playlist-filter-panel.component";
import { noop } from "shared/helpers/function.helpers";
import { Dropzone } from "../shared/dropzone.component";
import { logRenderError } from "renderer";
type Props = {
readonly version?: BSVersion;
@@ -46,6 +47,9 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
const t = useTranslation();
const [tabIndex, setTabIndex] = useState(0);
const [mapsDropZoneOpen, setMapsDropZoneOpen] = useState(false);
const [playlistsDropZoneOpen, setPlaylistsDropZoneOpen] = useState(false);
const maps$ = useConstant(() => new BehaviorSubject<BsmLocalMap[]>(undefined));
const playlists$ = useConstant(() => new BehaviorSubject<LocalBPListsDetails[]>(undefined));
@@ -73,11 +77,16 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
return playlistsManager.$playlistsFolderLinkState(version);
}, FolderLinkState.Unlinked, [version]);
const handleAddClick = () => {
const handleBrowse = () => {
switch (tabIndex) {
case 0: return mapsDownloader.openDownloadMapModal(version, maps$.value);
case 1: return playlistsDownloader.openDownloadPlaylistModal(version, playlists$, maps$);
default: return noop();
case 0:
mapsDownloader.openDownloadMapModal(version, maps$.value);
return;
case 1:
playlistsDownloader.openDownloadPlaylistModal(version, playlists$, maps$);
return;
default:
return noop();
}
}
@@ -101,6 +110,31 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
return playlistsManager.unlinkVersion(version);
}
const importMaps = async (paths: string[]) => {
setMapsDropZoneOpen(() => false);
return lastValueFrom(mapsManager.importMaps(paths, version)).catch(logRenderError);
}
const importPlaylists = async (paths: string[]) => {
setPlaylistsDropZoneOpen(() => false);
return lastValueFrom(playlistsManager.importPlaylists({ version, paths })).catch(logRenderError);
}
const addDropDownItems = ((): DropDownItem[] => {
if(tabIndex === 0){
return [
{ icon: "browse", text: "pages.version-viewer.maps.tabs.maps.actions.drop-down.browse-maps", onClick: handleBrowse },
{ icon: "download", text: "pages.version-viewer.maps.tabs.maps.actions.drop-down.import-maps", onClick: () => setMapsDropZoneOpen(true) }
];
}
return [
{ icon: "browse", text: "pages.version-viewer.maps.tabs.playlists.drop-down.browse-playlists", onClick: handleBrowse },
{ icon: "add-file", text: "pages.version-viewer.maps.tabs.playlists.drop-down.create-a-playlist", onClick: () => playlistsRef?.current?.createPlaylist?.() },
{ icon: "download", text: "pages.version-viewer.maps.tabs.playlists.drop-down.import-playlists", onClick: () => setPlaylistsDropZoneOpen(true) }
];
})();
const dropDownItems = ((): DropDownItem[] => {
if (tabIndex === 0) {
return [
@@ -111,7 +145,6 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
}
return [
{ icon: "add", text: t("playlist.create-a-playlist"), onClick: () => playlistsRef?.current?.createPlaylist?.() },
{ icon: "sync", text: t("playlist.synchronize-playlists"), onClick: () => playlistsRef?.current?.syncPlaylists?.() },
{ icon: "export", text: t("playlist.export-playlists"), onClick: () => playlistsRef?.current?.exportPlaylists?.() },
{ icon: "trash", text: t("playlist.delete-playlists"), onClick: () => playlistsRef?.current?.deletePlaylists?.() },
@@ -121,13 +154,20 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
return (
<>
<nav className="w-full shrink-0 flex h-9 justify-center px-40 gap-2 text-main-color-1 dark:text-white mb-4">
<BsmButton
className="flex items-center justify-center w-fit rounded-full px-2 py-1 font-bold whitespace-nowrap"
<BsmDropdownButton
classNames={{
mainContainer: "h-full relative z-[1] flex justify-center w-fit",
button: "flex items-center justify-center h-full rounded-full px-2 py-1 font-bold whitespace-nowrap shadow-none",
itemsContainer: "bg-theme-3",
}}
textClassName="whitespace-nowrap"
icon="add"
buttonColor="primary"
text="misc.add"
typeColor="primary"
align="center"
menuTranslationY="6px"
withBar={false}
onClick={handleAddClick}
items={addDropDownItems}
/>
<div className="h-full rounded-full bg-light-main-color-2 dark:bg-main-color-2 grow p-[6px]">
<input
@@ -175,8 +215,37 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
]}
>
<InstalledMapsContext.Provider value={mapsContextValue}>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={search} linkedState={mapsLinkedState} />
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} ref={playlistsRef} version={version} linkedState={playlistLinkedState} filter={playlistFilter} search={search}/>
<Dropzone
className="w-full h-full shrink-0"
onFiles={importMaps}
text={t("pages.version-viewer.maps.tabs.maps.drop-zone.text")}
subtext={t("pages.version-viewer.maps.tabs.maps.drop-zone.subtext")}
open={mapsDropZoneOpen}
onClose={mapsDropZoneOpen ? () => setMapsDropZoneOpen(() => false) : undefined}
filters={[{ name: ".zip", extensions: ["zip"] }]}
dialogOptions={{ dialog: {
properties: ["openFile", "multiSelections"],
}}}
>
<LocalMapsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 0} ref={mapsRef} version={version} filter={mapFilter} search={search} linkedState={mapsLinkedState} />
</Dropzone>
<Dropzone
className="w-full h-full shrink-0"
onFiles={importPlaylists}
text={t("pages.version-viewer.maps.tabs.playlists.drop-zone.text")}
subtext={t("pages.version-viewer.maps.tabs.playlists.drop-zone.subtext")}
open={playlistsDropZoneOpen}
onClose={playlistsDropZoneOpen ? () => setPlaylistsDropZoneOpen(() => false) : undefined}
filters={[{ name: ".bplist, .json", extensions: ["bplist", "json"] }]}
dialogOptions={{ dialog: {
properties: ["openFile", "multiSelections"],
}}}
>
<LocalPlaylistsListPanel className="w-full h-full shrink-0" isActive={isActive && tabIndex === 1} ref={playlistsRef} version={version} linkedState={playlistLinkedState} filter={playlistFilter} search={search}/>
</Dropzone>
</InstalledMapsContext.Provider>
</BsContentTabPanel>
</>
@@ -13,6 +13,7 @@ import { GlowEffect } from "../../shared/glow-effect.component";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { SongDetails } from "shared/models/maps";
import formatDuration from "format-duration";
import { MapInfo } from "shared/models/maps/info/map-info.model";
export type Props = {
className?: string;
@@ -302,9 +303,10 @@ function isFitVerified(filter: MapFilter, verified: boolean): boolean {
return verified;
}
function isFitSearch(search: string, {songName, songAuthorName, levelAuthorName}: {songName: string, songAuthorName: string, levelAuthorName: string}): boolean {
function isFitSearch(search: string, {songName, songAuthorName, levelMappers}: {songName: MapInfo["songName"], songAuthorName: MapInfo["songAuthorName"], levelMappers: MapInfo["levelMappers"]}): boolean {
if (!search) { return true; }
return songName?.toLowerCase().includes(search.toLowerCase()) || songAuthorName?.toLowerCase().includes(search.toLowerCase()) || levelAuthorName?.toLowerCase().includes(search.toLowerCase());
if(levelMappers?.some(mapper => mapper?.toLowerCase().includes(search.toLowerCase()))) { return true; }
return songName?.toLowerCase().includes(search.toLowerCase()) || songAuthorName?.toLowerCase().includes(search.toLowerCase());
}
export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap, search: string }): boolean => {
@@ -323,7 +325,7 @@ export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilte
if (!isFitRanked(filter, map.songDetails?.ranked || map.songDetails?.blRanked)) { return false; }
if (!isFitCurated(filter, map.songDetails?.curated)) { return false; }
if (!isFitVerified(filter, map.songDetails?.uploader.verified)) { return false; }
if (!isFitSearch(search, {songName: map.rawInfo?._songName, songAuthorName: map.rawInfo?._songAuthorName, levelAuthorName: map.rawInfo?._levelAuthorName})) { return false; }
if (!isFitSearch(search, {songName: map.mapInfo?.songName, songAuthorName: map.mapInfo?.songAuthorName, levelMappers: map.mapInfo?.levelMappers})) { return false; }
return true;
};
@@ -344,7 +346,7 @@ export const isBsvMapFitMapFilter = ({filter, map, search}: { filter: MapFilter,
if (!isFitRanked(filter, map.ranked || map.blRanked)) { return false; }
if (!isFitCurated(filter, !!map.curator)) { return false; }
if (!isFitVerified(filter, !!map.curatedAt)) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelMappers: [map.metadata.levelAuthorName]})) { return false; }
return true;
};
@@ -364,12 +366,12 @@ export const isSongDetailsFitMapFilter = ({filter, map, search}: { filter: MapFi
if (!isFitRanked(filter, map.ranked || map.blRanked)) { return false; }
if (!isFitCurated(filter, map.curated)) { return false; }
if (!isFitVerified(filter, map.uploader.verified)) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelAuthorName: map.uploader.name})) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelMappers: [map.uploader.name]})) { return false; }
return true;
}
export const isMapFitFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap | BsvMapDetail | SongDetails, search: string }): boolean => {
if ((map as BsmLocalMap)?.rawInfo) { return isLocalMapFitMapFilter({filter, map: (map as BsmLocalMap), search}); }
if ((map as BsmLocalMap)?.mapInfo) { return isLocalMapFitMapFilter({filter, map: (map as BsmLocalMap), search}); }
if ((map as BsvMapDetail)?.metadata) { return isBsvMapFitMapFilter({filter, map: (map as BsvMapDetail), search}); }
if ((map as SongDetails).hash) { return isSongDetailsFitMapFilter({filter, map: (map as SongDetails), search}); }
return false;
@@ -5,7 +5,7 @@ import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Subscription, BehaviorSubject, lastValueFrom } from "rxjs";
import { MapFilter } from "shared/models/maps/beat-saver.model";
import { MapsDownloaderService } from "renderer/services/maps-downloader.service";
import { last, tap } from "rxjs/operators";
import { bufferTime, last, tap } from "rxjs/operators";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { BsmImage } from "../../shared/bsm-image.component";
@@ -104,19 +104,24 @@ export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ ve
useEffect(() => {
let sub: Subscription;
const subs: Subscription[] = [];
if(isActiveOnce){
sub = mapsDownloader.lastDownloadedMap$.subscribe({ next: ({map, version: targetVersion}) => {
subs.push(mapsDownloader.lastDownloadedMap$.subscribe({ next: ({map, version: targetVersion}) => {
if (!equal(targetVersion, version)) {
return;
}
setMaps((maps$.value ? [map, ...maps$.value] : [map]));
}});
}}));
subs.push(mapsManager.$onMapImported(version).pipe(bufferTime(500)).subscribe({ next: newMaps => {
const maps = (maps$.value ?? []).filter(map => !newMaps.some(newMap => newMap.path === map.path));
setMaps([...newMaps, ...maps]);
}}));
}
return () => {
sub?.unsubscribe();
subs.forEach(s => s.unsubscribe());
}
}, [isActiveOnce, version])
@@ -176,17 +181,18 @@ export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ ve
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
title={map.mapInfo.songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
autor={map.mapInfo.levelMappers.at(0)}
songAutor={map.mapInfo.songAuthorName}
bpm={map.mapInfo.beatsPerMinute}
duration={map.songDetails?.duration}
selected={renderableMap.selected}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ mapInfo: map.mapInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
blRanked={map.songDetails?.blRanked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
@@ -6,7 +6,7 @@ import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { PlaylistsManagerService } from "renderer/services/playlists-manager.service";
import { FolderLinkState } from "renderer/services/version-folder-linker.service";
import { BehaviorSubject, Observable, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs";
import { BehaviorSubject, Observable, Subscription, bufferTime, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
import { noop } from "shared/helpers/function.helpers";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
@@ -52,7 +52,7 @@ type Props = {
export type LocalPlaylistsListRef = {
createPlaylist: () => Promise<void>;
syncPlaylists: () => Promise<void>;
syncPlaylists: (playlists?: LocalBPList[]) => Promise<void>;
deletePlaylists: () => Promise<void>;
exportPlaylists: () => Promise<void>;
}
@@ -111,9 +111,9 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
}
},
syncPlaylists: async () => {
syncPlaylists: async (playlists?: LocalBPList[]) => {
if(!isOnline){ return; }
const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value;
const toSync = playlists ?? (selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value);
if(!toSync.length){ return; }
const modalRes = await modals.openModal(SyncPlaylistModal, { data: toSync });
@@ -211,10 +211,22 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
setPlaylists(newPlaylist);
}
const sub = playlistDownloader.currentDownload$.pipe(filter(download => download?.downloaded && equal(download?.info.version, version))).subscribe(download => onPlaylistDownloadedCB(download.downloaded));
const subs: Subscription[] = [];
subs.push(playlistDownloader.currentDownload$.pipe(
filter(download => download?.downloaded && equal(download?.info.version, version))
).subscribe(download => onPlaylistDownloadedCB(download.downloaded)));
subs.push(playlistService.$onPlaylistImported(version).pipe(bufferTime(500)).subscribe({
next: newPlaylists => {
if(!newPlaylists.length){ return; }
const playlists = (playlists$.value ?? []).filter(p => !newPlaylists.some(np => np.path === p.path));
setPlaylists([...newPlaylists, ...playlists]);
}
}));
return () => {
sub.unsubscribe();
subs.forEach(s => s.unsubscribe());
}
}, [isActiveOnce, version, linked]);
@@ -4,7 +4,7 @@ import { useTranslation } from "renderer/hooks/use-translation.hook";
import { ModalComponent, ModalExitCode, ModalService } from "renderer/services/modale.service";
import { WhySteamCredentialsModal } from "./why-steam-credentials-modal.component";
import { useService } from "renderer/hooks/use-service.hook";
import { Observable } from "rxjs";
import { catchError, Observable, of } from "rxjs";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { QRCodeSVG } from "qrcode.react";
import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
@@ -21,7 +21,7 @@ export const LoginToSteamModal: ModalComponent<
const [password, setPassword] = useState("");
const [stay, setStay] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const qrCodeUrl = useObservable(() => data.qrCode$);
const qrCodeUrl = useObservable(() => data.qrCode$.pipe(catchError(() => of(null))));
const t = useTranslation();
useEffect(() => {
@@ -18,7 +18,7 @@ export const DeleteDuplicateMapsModal: ModalComponent<void, { maps: BsmLocalMap[
<p>{
multiple
? t("modals.maps-actions.delete-duplicate-maps.desc-plural", { nb: `${maps.length}` })
: t("modals.maps-actions.delete-duplicate-maps.desc", { map: `${maps.at(0).rawInfo._songName}` })
: t("modals.maps-actions.delete-duplicate-maps.desc", { map: `${maps.at(0).mapInfo.songName}` })
}</p>
<div className="grid grid-flow-col grid-cols-2 gap-4 mt-4 h-8">
<BsmButton typeColor="cancel" className="rounded-md flex justify-center items-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.CANCELED })} withBar={false} text="misc.cancel" />
@@ -34,7 +34,7 @@ export const DeleteMapsModal: ModalComponent<void, { linked: boolean; maps: BsmL
<form className="text-gray-800 dark:text-gray-200">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t(titleText)}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<p className="max-w-sm w-full">{t(descText, multiple ? { nb: maps.length.toString() } : { name: maps.at(0).rawInfo._songName })}</p>
<p className="max-w-sm w-full">{t(descText, multiple ? { nb: maps.length.toString() } : { name: maps.at(0).mapInfo.songName })}</p>
{linked && (
<p className="text-sm italic mt-2 cursor-help w-fit" title={t(infoTitleText)}>
{t(infoText)}
@@ -22,9 +22,11 @@ import { useConstant } from "renderer/hooks/use-constant.hook";
import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date";
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component";
import { MapItemComponentPropsMapper } from "shared/mappers/map/map-item-component-props.mapper";
import { ConfigurationService } from "renderer/services/configuration.service";
export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; ownedMaps: BsmLocalMap[] }> = ({ options: {data : { ownedMaps, version }} }) => {
const beatSaver = useService(BeatSaverService);
const config = useService(ConfigurationService);
const mapsDownloader = useService(MapsDownloaderService);
const progressBar = useService(ProgressBarService);
const os = useService(OsDiagnosticService);
@@ -37,12 +39,11 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
const [query, setQuery] = useState("");
const [maps, setMaps] = useState<BsvMapDetail[]>([]);
const [downloadbleMaps, setDownloadbleMaps] = useState<DownloadableMap[]>([]);
const [sortOrder, setSortOrder] = useState<BsvSearchOrder>(BsvSearchOrder.Latest);
const [ownedMapHashs, setOwnedMapHashs] = useState<string[]>(ownedMaps?.map(map => map.hash) ?? []);
const [loading, setLoading] = useState(false);
const isOnline = useObservable(() => os.isOnline$);
const [searchParams, setSearchParams] = useState<SearchParams>({
sortOrder,
sortOrder: config.get("map-sort-order"),
filter,
page: 0,
q: query,
@@ -145,7 +146,9 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
duration={map.metadata.duration}
hash={map.versions.at(0).hash}
likes={map.stats.upvotes}
mapId={map.id} ranked={map.ranked}
mapId={map.id}
ranked={map.ranked}
blRanked={map.blRanked}
title={map.name}
songAutor={map.metadata.songAuthorName}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ bsvMap: map })}
@@ -168,21 +171,21 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
}, []);
const handleSortChange = (newSort: BsvSearchOrder) => {
setSortOrder(() => newSort);
config.set("map-sort-order", newSort);
setMaps(() => []);
setSearchParams(() => ({ ...searchParams, sortOrder: newSort }));
};
const handleSearch = () => {
const searchParams: SearchParams = {
sortOrder,
const searchParamsLocal: SearchParams = {
sortOrder: searchParams.sortOrder,
filter,
q: query.trim(),
page: 0,
};
setMaps(() => []);
setSearchParams(() => searchParams);
setSearchParams(() => searchParamsLocal);
};
const handleLoadMore = () => {
@@ -217,7 +220,7 @@ export const DownloadMapsModal: ModalComponent<void, { version: BSVersion; owned
handleSearch();
}}
/>
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={sortOptions} onChange={sort => handleSortChange(sort)} />
<BsmSelect className="bg-light-main-color-1 dark:bg-main-color-1 rounded-full px-1 pb-0.5 text-center" options={sortOptions} selected={searchParams.sortOrder} onChange={sort => handleSortChange(sort)} />
</div>
{(() => {
@@ -5,7 +5,7 @@ import { BSVersion } from "shared/bs-version.interface"
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface"
import { LocalBPListsDetails } from "shared/models/playlists/local-playlist.models"
import { DownloadPlaylistModalHeader } from "./download-playlist-modal-header.component"
import { BsvPlaylist, BsvSearchOrder, PlaylistSearchParams } from "shared/models/maps/beat-saver.model"
import { BsvPlaylist, PlaylistSearchParams } from "shared/models/maps/beat-saver.model"
import { useCallback, useState } from "react"
import { useOnUpdate } from "renderer/hooks/use-on-update.hook"
import { useService } from "renderer/hooks/use-service.hook"
@@ -20,6 +20,7 @@ import BeatConflict from "../../../../../../../assets/images/apngs/beat-conflict
import { cn } from "renderer/helpers/css-class.helpers"
import { VirtualScroll } from "renderer/components/shared/virtual-scroll/virtual-scroll.component"
import { useTranslation } from "renderer/hooks/use-translation.hook"
import { ConfigurationService } from "renderer/services/configuration.service"
export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ownedPlaylists$: Observable<LocalBPListsDetails[]>, ownedMaps$: Observable<BsmLocalMap[]>}> = (
{ options: { data: { version, ownedPlaylists$, ownedMaps$ }} }
@@ -27,8 +28,9 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
const t = useTranslation();
const modal = useService(ModalService);
const beatSaver = useService(BeatSaverService);
const config = useService(ConfigurationService);
const modal = useService(ModalService);
const playlistDownloader = useService(PlaylistDownloaderService);
const [playlists, setPlaylists] = useState<BsvPlaylist[]>(null);
@@ -41,7 +43,7 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
const [error, setError] = useState(false);
const [searchParams, setSearchParams] = useState<PlaylistSearchParams>({
q: "",
sortOrder: BsvSearchOrder.Relevance,
sortOrder: config.get("playlist-sort-order"),
page: 0,
});
@@ -63,6 +65,7 @@ export const DownloadPlaylistModal: ModalComponent<void, {version: BSVersion, ow
const handleNewSearch = (value: Omit<PlaylistSearchParams, "page">) => {
setPlaylists(() => undefined);
config.set("playlist-sort-order", value.sortOrder);
setSearchParams(() => ({ ...value, page: 0}));
};
@@ -303,7 +303,7 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
availabledHashsSelected$.value.map(hash => tmpLocalMaps.find(map => getHashOfMap(map) === hash)).filter(Boolean)
) : tmpLocalMaps;
const newPlaylistMaps = {...playlistMaps$.value} ?? {};
const newPlaylistMaps = {...playlistMaps$.value ?? {}};
mapsToAdd.forEach(map =>{ newPlaylistMaps[getHashOfMap(map)] = { map }; });
playlistMaps$.next(newPlaylistMaps);
@@ -348,8 +348,8 @@ export const EditPlaylistModal: ModalComponent<BPList, Props> = ({ resolver, opt
const { map } = playlistMap;
if((map as BsmLocalMap).rawInfo?._levelAuthorName){
mappersSet.add((map as BsmLocalMap).rawInfo._levelAuthorName);
if((map as BsmLocalMap).mapInfo?.levelMappers){
(map as BsmLocalMap).mapInfo.levelMappers.forEach(mapper => mappersSet.add(mapper));
}
else if((map as SongDetails).uploader?.name){
mappersSet.add((map as SongDetails).uploader.name);
@@ -80,7 +80,9 @@ export const BsvPlaylistDetailsModal: ModalComponent<void, Props> = ({ resolver,
duration={map.metadata.duration}
hash={map.versions.at(0).hash}
likes={map.stats.upvotes}
mapId={map.id} ranked={map.ranked}
mapId={map.id}
ranked={map.ranked}
blRanked={map.blRanked}
title={map.name}
songAutor={map.metadata.songAuthorName}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ bsvMap: map })}
@@ -62,16 +62,17 @@ export const LocalPlaylistDetailsModal: ModalComponent<void, Props> = ({resolver
<MapItem
key={map.path}
hash={map.hash}
title={map.rawInfo._songName}
title={map.mapInfo.songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
autor={map.mapInfo.levelMappers.at(0)}
songAutor={map.mapInfo.songAuthorName}
bpm={map.mapInfo.beatsPerMinute}
duration={map.songDetails?.duration}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails })}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ mapInfo: map.mapInfo, songDetails: map.songDetails })}
mapId={map.songDetails?.id}
ranked={map.songDetails?.ranked}
blRanked={map.songDetails?.blRanked}
autorId={map.songDetails?.uploader.id}
likes={map.songDetails?.upVotes}
createdAt={map.songDetails?.uploadedAt}
@@ -0,0 +1,169 @@
import { AnimatePresence, motion } from "framer-motion";
import { DragEvent, ReactNode, useCallback, useMemo, useState } from "react";
import { BsmImage } from "./bsm-image.component";
import BeatImpatient from "../../../../assets/images/apngs/beat-impatient.png";
import { useThemeColor } from "renderer/hooks/use-theme-color.hook";
import Color from "color";
import { cn } from "renderer/helpers/css-class.helpers";
import { CloseIcon } from "../svgs/icons/close-icon.component";
import { OpenDialogOptions } from "electron";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { useService } from "renderer/hooks/use-service.hook";
import { IpcService } from "renderer/services/ipc.service";
import { NotificationService } from "renderer/services/notification.service";
import { lastValueFrom } from "rxjs";
type Props = Readonly<{
children?: ReactNode;
className?: string;
open?: boolean;
text?: string;
subtext?: string;
onFiles?: (paths: string[]) => void;
onClose?: () => void;
filters?: OpenDialogOptions["filters"];
dialogOptions?: {
text?: string;
dialog?: Exclude<OpenDialogOptions, "filters">;
}
}>;
// Supports drop and drop functionality for files.
export function Dropzone({
children,
className,
open,
text,
subtext,
filters,
dialogOptions,
onFiles,
onClose
}: Props) {
const ipc = useService(IpcService);
const notification = useService(NotificationService);
const t = useTranslation();
const [dragCount, setDragCount] = useState(0);
const [dropZoneDragging, setDropZoneDragging] = useState(false);
const themeColor = useThemeColor("first-color");
const color = new Color(themeColor).lighten(.25).saturate(.8).hex();
const supportedExtensions = useMemo(() => {
if(!filters?.length) {
return [];
}
return filters.flatMap(filter => filter.extensions ?? []);
}, [filters]);
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragCount(() => 0);
setDropZoneDragging(() => false);
if (!onFiles || !event.dataTransfer?.files?.length) {
return;
}
const paths = Array.from(event.dataTransfer.files).reduce<string[]>((acc, file) => {
const path = window.electron.webUtils.getPathForFile(file);
if(supportedExtensions.some(ext => path.endsWith(ext))) {
acc.push(path);
}
return acc;
}, []);
if(!paths.length) {
notification.notifyError({
title: t("notifications.shared.errors.titles.file-not-supported"),
desc: t("notifications.shared.errors.msg.file-not-supported", { types: supportedExtensions.join(", ") }),
duration: 8000
});
return;
}
onFiles(paths);
}, [supportedExtensions]);
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
}, []);
const onDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragCount(prev => prev + 1);
}, []);
const onDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setDragCount(prev => prev - 1);
}, []);
const openDialog = useCallback(async () => {
if(!dialogOptions?.dialog) {
return;
}
const res = await lastValueFrom(ipc.sendV2("open-dialog", { ...dialogOptions.dialog, filters }));
if(res?.canceled || !res?.filePaths?.length) {
return;
}
onFiles?.(res.filePaths);
}, []);
return (
<div className={cn("relative", className)} onDragEnter={onDragEnter} onDragLeave={onDragLeave}>
<AnimatePresence>
{(dragCount || open) ? (
<div onDragEnter={onDragEnter} onDragLeave={onDragLeave} onDrop={onDragLeave} className="absolute size-full top-0 right-0 z-10">
<motion.div className="absolute size-full top-0 right-0 bg-black" initial={{ opacity: 0 }} animate={{ opacity: .6 }} exit={{ opacity: 0 }}/>
<motion.div className="absolute size-full top-0 right-0 flex justify-center items-center p-5" initial={{ y: "100%" }} animate={{ y: "0" }} exit={{ y: "100%" }}>
<div className="relative h-72 max-h-full w-full max-w-lg sm:h-96 sm:max-w-2xl bg-theme-3 rounded-md shadow-black shadow-lg p-7">
{onClose && (
<button className="absolute top-0.5 right-0.5 size-7" onClick={onClose}>
<CloseIcon className="size-full"/>
</button>
)}
<div
onDragOver={onDragOver}
onDrop={handleDrop}
onDragEnter={() => setDropZoneDragging(true)}
onDragLeave={() => setDropZoneDragging(false)}
className="relative size-full z-[1] border-dashed border-3 rounded-md flex flex-col gap-1 justify-center items-center"
style={{ borderColor: color }}
>
<div className="absolute size-full top-0 right-0 pointer-events-none -z-[1] transition-opacity duration-150" style={{
backgroundColor: "transparent",
backgroundImage: `radial-gradient(${color} 1px, transparent 0)`,
backgroundSize: "60px 60px",
backgroundPosition: "5px",
opacity: dropZoneDragging ? .7 : .2,
}}/>
<BsmImage className="size-28 pointer-events-none" image={BeatImpatient}/>
<span className="text-2xl font-bold pointer-events-none">{text}</span>
<span className="pointer-events-none">{subtext}</span>
{dialogOptions?.dialog && (
<button className="underline hover:no-underline" onClick={openDialog}>{dialogOptions?.text ?? t("drop-zone.or-browse-files")}</button>
)}
</div>
</div>
</motion.div>
</div>
): undefined}
</AnimatePresence>
{children}
</div>
);
}
@@ -62,9 +62,11 @@ import { ShortcutIcon } from "./icons/shortcut-icon.component";
import { BackupRestoreIcon } from "./icons/backup-restore-icon.component";
import { SongDetailDiffCharactertistic } from "shared/models/maps/song-details-cache/song-details-cache.model";
import { CleanIcon } from "./icons/clean-icon.component";
import { BrowseIcon } from "./icons/browse-icon.component";
import { AddFileIcon } from "./icons/add-file-icon.component";
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
export type BsmIconType = SongDetailDiffCharactertistic | ("settings" | "trash" | "favorite" | "folder" | "bsNote" | "check" | "three-dots" | "twitch" | "eye" | "play" | "checkCircleIcon" | "discord" | "info" | "eye-cross" | "terminal" | "desktop" | "oculus" | "add" | "cross" | "task" | "github" | "close" | "thumbUpFill" | "timerFill" | "pause" | "twitter" | "sync" | "chevron-top" | "copy" | "steam" | "edit" | "export" | "patreon" | "search" | "bsMapDifficulty" | "link" | "unlink" | "download" | "filter" | "mee6" | "volume-up" | "volume-off" | "volume-down" | "shortcut" | "backup-restore" | "web-site" | "clean" | "browse" | "add-file" | "fr-FR-flag" | "es-ES-flag" | "en-US-flag" | "en-EN-flag" | "de-DE-flag" | "ru-RU-flag" | "zh-CN-flag" | "zh-TW-flag" | "ja-JP-flag");
export const BsmIcon = memo(({ className, icon, style }: { className?: string; icon: BsmIconType; style?: CSSProperties }) => {
// TODO : Very ugly very messy, need to find a better way to do this
@@ -262,6 +264,14 @@ export const BsmIcon = memo(({ className, icon, style }: { className?: string; i
return <CleanIcon className={className} style={style} />;
}
if (icon === "browse") {
return <BrowseIcon className={className} style={style} />;
}
if(icon === "add-file") {
return <AddFileIcon className={className} style={style} />;
}
return <TrashIcon className={className} style={style} />;
@@ -0,0 +1,9 @@
import { createSvgIcon } from "../svg-icon.type";
export const AddFileIcon = createSvgIcon(( props, ref ) => {
return (
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
<path d="M440-357.37v80q0 17 11.5 28.5t28.5 11.5q17 0 28.5-11.5t11.5-28.5v-80h80q17 0 28.5-11.5t11.5-28.5q0-17-11.5-28.5t-28.5-11.5h-80v-80q0-17-11.5-28.5t-28.5-11.5q-17 0-28.5 11.5t-11.5 28.5v80h-80q-17 0-28.5 11.5t-11.5 28.5q0 17 11.5 28.5t28.5 11.5h80ZM242.87-71.87q-37.78 0-64.39-26.61t-26.61-64.39v-634.26q0-37.78 26.61-64.39t64.39-26.61H525.8q18.22 0 34.72 6.84 16.5 6.83 29.18 19.51L781.78-669.7q12.68 12.68 19.51 29.18 6.84 16.5 6.84 34.72v442.93q0 37.78-26.61 64.39t-64.39 26.61H242.87Zm274.26-570.76v-154.5H242.87v634.26h474.26v-434.26h-154.5q-19.15 0-32.33-13.17-13.17-13.18-13.17-32.33Zm-274.26-154.5V-597.13v-200 634.26-634.26Z"/>
</svg>
);
})
@@ -0,0 +1,11 @@
import { createSvgIcon } from "../svg-icon.type";
export const BrowseIcon = createSvgIcon(( props, ref ) => {
return (
<svg ref={ref} {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor">
<path d="M73.38-480q0-84.36 32.02-158.55 32.02-74.2 87.06-129.15 55.03-54.96 129.23-87.06 74.19-32.1 158.55-32.1 134.3 0 238.05 76.51t145.17 196.84q5.8 15.77-1.12 30.78-6.93 15.01-22.85 19.75-14.93 4.2-28.19-4.11-13.25-8.3-18.95-22.99-22.75-67.42-71.58-119.41-48.83-52-116.66-79.69v17.01q0 34.1-23.83 59.02-23.84 24.93-57.93 24.93h-83.87v84.11q0 16.64-12.84 27.87-12.83 11.23-29.75 11.23h-81.28v84.53h60.85q16 0 26.94 11.02 10.93 11.02 10.93 27.02v84.42h-57.2L157.96-556.35q-4.33 19.26-6.5 38.13-2.16 18.87-2.16 37.74 0 122.43 77.84 214.51 77.85 92.08 197.26 111.19 14.87 2.74 23.8 14.97 8.92 12.22 7.57 27.5-1.6 15.83-13.52 25.68-11.92 9.84-27.64 6.72-145.92-23.35-243.57-135.47Q73.38-327.5 73.38-480Zm749.37 355.88L717.04-229.17q-20.76 13.44-44.66 21.04-23.89 7.6-49.14 7.6-73.46 0-125.14-51.53-51.67-51.54-51.67-125.07 0-73.37 51.61-124.79 51.62-51.41 124.99-51.41 73.53 0 124.94 51.55 51.42 51.56 51.42 125.02 0 25.4-7.93 49.26-7.94 23.86-20.71 45.28l105.13 105.05q11.26 11.03 11.47 26.53.22 15.5-10.81 26.52-11.1 11.26-26.89 11.26-15.79 0-26.9-11.26ZM622.86-276.28q42.13 0 71.37-29.27 29.25-29.28 29.25-71.41 0-42.13-29.15-71.37-29.16-29.25-71.29-29.25-42.13 0-71.41 29.15-29.29 29.16-29.29 71.29 0 42.13 29.19 71.49 29.2 29.37 71.33 29.37Z"/>
</svg>
)
});
@@ -1,4 +1,3 @@
import { motion } from "framer-motion";
import { ChangeEvent, useEffect, useState } from "react";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
@@ -15,6 +14,7 @@ import { lastValueFrom } from "rxjs";
import { BsDownloaderService } from "renderer/services/bs-version-download/bs-downloader.service";
import equal from "fast-deep-equal";
import { GlowEffect } from "renderer/components/shared/glow-effect.component";
import { cn } from "renderer/helpers/css-class.helpers";
type Props = { version: BSVersion };
@@ -44,6 +44,7 @@ export function LaunchSlide({ version }: Props) {
}, [desktopMode]);
const setMode = (mode: LaunchMods, value: boolean) => {
if (mode === LaunchMods.DEBUG_MOD) {
setDebugMode(value);
} else if (mode === LaunchMods.OCULUS_MOD) {
@@ -64,7 +65,7 @@ export function LaunchSlide({ version }: Props) {
oculus: version.oculus ? false : oculusMode,
desktop: desktopMode,
debug: debugMode,
additionalArgs,
additionalArgs: advancedLaunch ? additionalArgs : [],
protonPath: bsLauncherService.getProtonPath(),
});
@@ -84,9 +85,9 @@ export function LaunchSlide({ version }: Props) {
</div>
<div className="pt-4 w-2/3 flex flex-col items-center gap-3">
<div className="relative">
<GlowEffect className="!rounded-full" visible={!!additionalArgsString}/>
<GlowEffect className="!rounded-full" visible={!!(advancedLaunch && additionalArgsString)}/>
<BsmButton
className="rounded-full w-fit text-lg py-1 px-7 shadow-md shadow-black bg-light-main-color-2 dark:bg-main-color-2 text-gray-800 dark:text-white"
className={cn("rounded-full w-fit text-lg py-1 px-7 bg-theme-2 text-gray-800 dark:text-white", (advancedLaunch && additionalArgsString) ? "" : "shadow-md shadow-black")}
text="pages.version-viewer.launch-mods.advanced-launch.button"
withBar={false}
onClick={e => {
@@ -95,9 +96,9 @@ export function LaunchSlide({ version }: Props) {
}}
/>
</div>
<motion.div className="bg-light-main-color-2 dark:bg-main-color-2 h-9 rounded-full overflow-hidden flex items-center justify-center" initial={{ width: "0px" }} animate={{ width: advancedLaunch ? "100%" : "0px" }}>
<div className="bg-light-main-color-2 dark:bg-main-color-2 h-9 rounded-full overflow-hidden flex items-center justify-center transition-all duration-100 ease-in-out w-full origin-top shadow-black shadow-sm" style={{ scale: advancedLaunch ? "100% 100%" : "0 0" }}>
<input className="w-[calc(100%-12px)] h-[calc(100%-12px)] bg-light-main-color-1 dark:bg-main-color-1 text-black dark:text-white rounded-full outline-none text-center" type="text" placeholder={t("pages.version-viewer.launch-mods.advanced-launch.placeholder")} value={additionalArgsString} onChange={handleAdditionalArgsChange} />
</motion.div>
</div>
</div>
<div className='grow flex justify-center items-center'>
<BsmButton
@@ -1,4 +1,24 @@
export const defaultConfiguration: { [key in DefaultConfigKey]: any } = {
import { BsvSearchOrder } from "shared/models/maps/beat-saver.model";
// NOTE: To refactor. Rename to LocalStorageConfigKeyValues since these are stored in the
// localStorage in the browser, and for readability
export interface DefaultConfigKeyValues {
"first-color": string;
"second-color": string;
theme: ThemeConfig;
language: string;
supported_languages: string[];
default_mods: string[];
"default-shared-folders": string[];
"playlist-sort-order": BsvSearchOrder;
"map-sort-order": BsvSearchOrder;
};
export type DefaultConfigKey = keyof DefaultConfigKeyValues;
export const defaultConfiguration: {
[key in DefaultConfigKey]: DefaultConfigKeyValues[key]
} = {
"first-color": "#3b82ff",
"second-color": "#ff4444",
theme: "os",
@@ -8,10 +28,9 @@ export const defaultConfiguration: { [key in DefaultConfigKey]: any } = {
"default-shared-folders": [
window.electron.path.join("Beat Saber_Data", "CustomLevels"),
window.electron.path.join("Beat Saber_Data", "CustomWIPLevels"),
"DLC"
],
"playlist-sort-order": BsvSearchOrder.Relevance,
"map-sort-order": BsvSearchOrder.Latest,
};
export type DefaultConfigKey = "first-color" | "second-color" | "theme" | "language" | "supported_languages" | "default_mods" | "default-shared-folders";
export type ThemeConfig = "dark" | "light" | "os";
+5
View File
@@ -1,3 +1,5 @@
import { webUtils } from "electron";
declare global {
interface Window {
electron: {
@@ -13,6 +15,9 @@ declare global {
basename: (path: string) => string;
join: (...args: string[]) => string;
};
webUtils: {
getPathForFile: typeof webUtils.getPathForFile;
};
};
}
}
@@ -13,6 +13,7 @@ import { SteamMobileApproveModal } from "renderer/components/modal/modal-types/b
import { DownloaderServiceInterface } from "./bs-store-downloader.interface";
import { AbstractBsDownloaderService } from "./abstract-bs-downloader.service";
import { addFilterStringLog } from "renderer";
import { CustomError } from "shared/models/exceptions/custom-error.class";
export class SteamDownloaderService extends AbstractBsDownloaderService implements DownloaderServiceInterface{
@@ -120,11 +121,11 @@ export class SteamDownloaderService extends AbstractBsDownloaderService implemen
return subs;
}
private hanndleErrorEvent(errorEvent: DepotDownloaderEvent) {
private hanndleErrorEvent(errorEvent: CustomError) {
const handledErrors = Object.values(DepotDownloaderErrorEvent);
if(handledErrors.includes(errorEvent?.subType as DepotDownloaderErrorEvent)){
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.steam-download.errors.msg.${errorEvent.subType}`, duration: 10_000});
if(handledErrors.includes(errorEvent?.code as DepotDownloaderErrorEvent)){
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.steam-download.errors.msg.${errorEvent.code}`, duration: 10_000});
}
return this.notificationService.notifyError({title: "notifications.types.error", desc: `notifications.bs-download.steam-download.errors.msg.${DepotDownloaderErrorEvent.Unknown}`, duration: 10_000});
@@ -30,7 +30,7 @@ export class ConfigurationService {
public get<Type>(key: string | DefaultConfigKey): Type {
const rawValue = (window.sessionStorage.getItem(key) ?? window.localStorage.getItem(key));
const tryParse = tryit<Type>(() => JSON.parse(rawValue));
const res = (tryParse.error ? rawValue : tryParse.result) as Type;
if(!res && Object.keys(defaultConfiguration).includes(key)){
@@ -40,7 +40,7 @@ export class ConfigurationService {
return res;
}
public set(key: string, value: unknown, persistant = true) {
public set(key: string | DefaultConfigKey, value: unknown, persistant = true) {
if(value != null){
this.getPropperStorage(persistant).setItem(key, JSON.stringify(value));
@@ -51,7 +51,7 @@ export class ConfigurationService {
this.emitChange(key);
}
public delete(key: string) {
public delete(key: string | DefaultConfigKey) {
window.localStorage.removeItem(key);
window.sessionStorage.removeItem(key);
this.emitChange(key);
@@ -71,7 +71,7 @@ export class MapsDownloaderService {
if (this.os.isOffline) {
return null;
}
return this.ipc.sendV2("download-map", { map, version });
return this.ipc.sendV2("bs-maps.download-map", { map, version });
}
public async openDownloadMapModal(version?: BSVersion, ownedMaps: BsmLocalMap[] = []): Promise<ModalResponse<void>> {
+66 -1
View File
@@ -9,12 +9,13 @@ import { DeleteMapsModal } from "renderer/components/modal/modal-types/delete-ma
import { ProgressBarService } from "./progress-bar.service";
import { NotificationService } from "./notification.service";
import { ConfigurationService } from "./configuration.service";
import { map, last, catchError } from "rxjs/operators";
import { map, last, catchError, filter, distinctUntilChanged } from "rxjs/operators";
import { ProgressionInterface } from "shared/models/progress-bar";
import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service";
import { SongDetails } from "shared/models/maps";
import { Progression } from "main/helpers/fs.helpers";
import { DeleteDuplicateMapsModal } from "renderer/components/modal/modal-types/delete-duplicate-maps-modal.component";
import equal from "fast-deep-equal";
export class MapsManagerService {
private static instance: MapsManagerService;
@@ -39,6 +40,8 @@ export class MapsManagerService {
private readonly lastLinkedVersion$: Subject<BSVersion> = new Subject();
private readonly lastUnlinkedVersion$: Subject<BSVersion> = new Subject();
private readonly lastImportedMap$: Subject<{ version: BSVersion, map: BsmLocalMap }> = new Subject();
private constructor() {
this.ipcService = IpcService.getInstance();
this.modal = ModalService.getInstance();
@@ -196,6 +199,64 @@ export class MapsManagerService {
})
}
public importMaps(paths: string[], version?: BSVersion): Observable<Progression<BsmLocalMap>> {
if(!this.progressBar.require()){
return throwError(() => new Error("Another operation is already in progress (importMaps)"));
}
return new Observable<Progression<BsmLocalMap>>(obs => {
const subs: Subscription[] = [];
(async () => {
const import$ = this.ipcService.sendV2("bs-maps.import-maps", { paths, version });
this.progressBar.show(import$.pipe(
catchError(() => of()),
map(progress => ({
progression: (progress.current / progress.total) * 100,
label: progress.data?.songDetails?.name
} as ProgressionInterface))
));
subs.push(import$.subscribe(obs));
subs.push(import$.pipe(catchError(() => of()), map(progress => progress?.data), filter(Boolean), distinctUntilChanged((a, b) => equal(a, b))).subscribe({ next: map => {
this.lastImportedMap$.next({ version, map });
}}));
return lastValueFrom(import$);
})()
.then(progress => {
if (progress.current === progress.total) {
this.notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.success",
});
} else if (progress.current > 0) {
this.notifications.notifySuccess({
title: "notifications.maps.import-map.titles.success",
desc: "notifications.maps.import-map.msgs.some-success",
});
}
}).catch(err => {
this.notifications.notifyError({
title: "notifications.maps.import-map.titles.error",
desc: ["invalid-zip"].includes(err?.code)
? `notifications.maps.import-map.msgs.${err.code}`
: "notifications.maps.import-map.msgs.unknown"
});
obs.error(err);
})
.finally(() => obs.complete());
return () => {
if(subs.length){
this.progressBar.hide();
subs.forEach(s => s.unsubscribe());
}
}
});
}
public getMapsInfoFromHashs(hashs: string[]): Observable<SongDetails[]> {
return this.ipcService.sendV2("get-maps-info-from-cache", hashs);
}
@@ -220,6 +281,10 @@ export class MapsManagerService {
return this.lastUnlinkedVersion$.asObservable();
}
public $onMapImported(version: BSVersion): Observable<BsmLocalMap> {
return this.lastImportedMap$.pipe(filter(newMap => equal(newMap.version, version)), map(m => m.map));
}
public $mapsFolderLinkState(version: BSVersion): Observable<FolderLinkState> {
return this.linker.$folderLinkedState(version, MapsManagerService.RELATIVE_MAPS_FOLDER);
}
@@ -1,6 +1,6 @@
import { BSVersion } from "shared/bs-version.interface";
import { IpcService } from "./ipc.service";
import { Observable, lastValueFrom } from "rxjs";
import { Observable, Subject, Subscription, catchError, distinctUntilChanged, filter, lastValueFrom, map, of } from "rxjs";
import { FolderLinkState, VersionFolderLinkerService } from "./version-folder-linker.service";
import { Progression } from "main/helpers/fs.helpers";
import { LocalBPList, LocalBPListsDetails } from "shared/models/playlists/local-playlist.models";
@@ -8,6 +8,11 @@ import { ModalExitCode, ModalService } from "./modale.service";
import { UnlinkPlaylistModal } from "renderer/components/modal/modal-types/unlink-playlist-modal.component";
import { LinkPlaylistModal } from "renderer/components/modal/modal-types/link-playlist-modal.component";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { ProgressBarService } from "./progress-bar.service";
import equal from "fast-deep-equal";
import { noop } from "shared/helpers/function.helpers";
import { NotificationService } from "./notification.service";
import { CustomError } from "shared/models/exceptions/custom-error.class";
export class PlaylistsManagerService {
private static instance: PlaylistsManagerService;
@@ -21,14 +26,20 @@ export class PlaylistsManagerService {
public static readonly RELATIVE_PLAYLISTS_FOLDER = "Playlists";
private readonly lastPlaylistImported$ = new Subject<{ version: BSVersion, playlist: LocalBPListsDetails }>();
private readonly ipc: IpcService;
private readonly linker: VersionFolderLinkerService;
private readonly modal: ModalService;
private readonly progressBar: ProgressBarService;
private readonly notification: NotificationService;
private constructor() {
this.ipc = IpcService.getInstance();
this.linker = VersionFolderLinkerService.getInstance();
this.modal = ModalService.getInstance();
this.progressBar = ProgressBarService.getInstance();
this.notification = NotificationService.getInstance();
}
public getVersionPlaylistsDetails(version: BSVersion): Observable<Progression<LocalBPListsDetails[]>> {
@@ -48,6 +59,69 @@ export class PlaylistsManagerService {
});
}
public importPlaylists(opt: { version?: BSVersion, paths: string[] }): Observable<Progression<LocalBPListsDetails>> {
return new Observable<Progression<LocalBPListsDetails>>(obs => {
if(!this.progressBar.require()){
obs.error("A progression is already running");
return noop;
}
const subs: Subscription[] = [];
const import$ = this.ipc.sendV2("import-playlists", opt);
subs.push(import$.subscribe(obs));
subs.push(import$.pipe(catchError(() => of()), map(progress => progress?.data), filter(Boolean), distinctUntilChanged((a, b) => equal(a, b))).subscribe({
next: playlist => {
this.lastPlaylistImported$.next({ version: opt.version, playlist });
}
}));
this.progressBar.show(import$);
const knownErrors = ["INVALID_SOURCE", "INVALID_PLAYLIST_FILE", "CANNOT_PARSE_PLAYLIST"];
lastValueFrom(import$).then(res => {
if(res.lastError && !res.current){
throw res.lastError;
}
if(!res.total){
return this.notification.notifyWarning({
title: "playlist.no-playlist-found",
desc: "playlist.no-playlist-found-in-selected-files"
});
}
if(res.current < res.total){
return this.notification.notifyWarning({
title: "playlist.some-playlists-not-imported",
desc: knownErrors.includes(res.lastError?.code) ? `playlist.some-playlists-have-been-imported.${res.lastError.code}` : "playlist.some-playlists-have-been-imported.unknown"
});
}
return this.notification.notifySuccess({
title: "playlist.playlists-imported",
desc: "playlist.all-playlists-have-been-successfully-imported",
});
}).catch((err: CustomError) => {
this.notification.notifyError({
title: "playlist.no-playlists-imported",
desc: knownErrors.includes(err.code) ? `playlist.no-playlists-imported-errors.${err.code}` : "playlist.no-playlists-imported-errors.unknown"
});
}).finally(() => {
obs.complete();
})
return () => {
subs.forEach(s => s.unsubscribe());
this.progressBar.hide();
}
});
}
public async linkVersion(version: BSVersion): Promise<boolean> {
const modalRes = await this.modal.openModal(LinkPlaylistModal);
@@ -91,4 +165,11 @@ export class PlaylistsManagerService {
public $playlistsFolderLinkState(version: BSVersion): Observable<FolderLinkState> {
return this.linker.$folderLinkedState(version, PlaylistsManagerService.RELATIVE_PLAYLISTS_FOLDER);
}
public $onPlaylistImported(version?: BSVersion): Observable<LocalBPListsDetails> {
return this.lastPlaylistImported$.pipe(
filter((val) => val?.playlist && equal(val.version, version)),
map((val) => val.playlist)
);
}
}
+4
View File
@@ -53,6 +53,10 @@ export default function App() {
const appVersion = await lastValueFrom(autoUpdater.getAppVersion());
const lastAppVersion = autoUpdater.getLastAppVersion();
if(!lastAppVersion) {
return;
}
if (lastAppVersion.toLowerCase().includes("alpha")) {
autoUpdater.setLastAppVersion("0"); // Reset last app version if it's an alpha version for users with alpha versions in the var stored.
}
@@ -1,13 +1,14 @@
import { getLocalTimeZone, parseAbsolute, toCalendarDateTime } from "@internationalized/date";
import { MapItemComponentProps } from "renderer/components/maps-playlists-panel/maps/map-item.component";
import { BsvMapDetail, RawMapInfoData, SongDetailDiffCharactertistic, SongDetails, SongDiffName } from "shared/models/maps";
import { BsvMapDetail, SongDetailDiffCharactertistic, SongDetails, SongDiffName } from "shared/models/maps";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { MapInfo } from "shared/models/maps/info/map-info.model";
export type ParsedMapDiff = { name: SongDiffName; libelle: string; stars: number, nps: number, njs: number };
export abstract class MapItemComponentPropsMapper {
public static extractMapDiffs({rawMapInfo, songDetails, bsvMap}: {rawMapInfo?: RawMapInfoData, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> {
public static extractMapDiffs({mapInfo, songDetails, bsvMap}: {mapInfo?: MapInfo, songDetails?: SongDetails, bsvMap?: BsvMapDetail}): Map<SongDetailDiffCharactertistic, ParsedMapDiff[]> {
const res = new Map<SongDetailDiffCharactertistic, ParsedMapDiff[]>();
if (bsvMap?.versions?.at(0)?.diffs) {
@@ -22,20 +23,18 @@ export abstract class MapItemComponentPropsMapper {
if (songDetails?.difficulties) {
songDetails?.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
const diffName = rawMapInfo?._difficultyBeatmapSets?.find(set => set._beatmapCharacteristicName === diff.characteristic)._difficultyBeatmaps.find(rawDiff => rawDiff._difficulty === diff.difficulty)?._customData?._difficultyLabel || diff.difficulty;
const diffName = mapInfo?.difficulties?.find(mapDiff => mapDiff.characteristic === diff.characteristic && mapDiff.difficulty === diff.difficulty)?.difficultyLabel || diff.difficulty;
arr.push({ libelle: diffName, name: diff.difficulty, stars: diff.stars, nps: diff.nps, njs: diff.njs });
res.set(diff.characteristic, arr);
});
return res;
}
if(rawMapInfo?._difficultyBeatmapSets){
rawMapInfo._difficultyBeatmapSets.forEach(set => {
set._difficultyBeatmaps.forEach(diff => {
const arr = res.get(set._beatmapCharacteristicName) || [];
arr.push({ libelle: diff._customData?._difficultyLabel || diff._difficulty, name: diff._difficulty, stars: null, nps: null, njs: diff._noteJumpMovementSpeed });
res.set(set._beatmapCharacteristicName, arr);
});
if(mapInfo?.difficulties){
mapInfo.difficulties.forEach(diff => {
const arr = res.get(diff.characteristic) || [];
arr.push({ libelle: diff.difficultyLabel || diff.difficulty, name: diff.difficulty, stars: null, nps: null, njs: diff.noteJumpMovementSpeed });
res.set(diff.characteristic, arr);
});
}
@@ -45,14 +44,14 @@ export abstract class MapItemComponentPropsMapper {
public static fromBsmLocalMap(map: BsmLocalMap): MapItemComponentProps<BsmLocalMap> {
return {
hash: map.hash,
title: map.rawInfo._songName,
title: map.mapInfo.songName,
coverUrl: map.coverUrl,
songUrl: map.songUrl,
autor: map.rawInfo._levelAuthorName,
songAutor: map.rawInfo._songAuthorName,
bpm: map.rawInfo._beatsPerMinute,
autor: map.mapInfo.levelMappers.at(0),
songAutor: map.mapInfo.songAuthorName,
bpm: map.mapInfo.beatsPerMinute,
duration: map.songDetails?.duration,
diffs: MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, songDetails: map.songDetails }),
diffs: MapItemComponentPropsMapper.extractMapDiffs({ mapInfo: map.mapInfo, songDetails: map.songDetails }),
mapId: map.songDetails?.id,
ranked: map.songDetails?.ranked,
blRanked: map.songDetails?.blRanked,
@@ -102,7 +101,7 @@ export abstract class MapItemComponentPropsMapper {
}
public static from(mapDetails: BsmLocalMap|BsvMapDetail|SongDetails): MapItemComponentProps<BsmLocalMap|BsvMapDetail|SongDetails> {
if ((mapDetails as BsmLocalMap).rawInfo) {
if ((mapDetails as BsmLocalMap).mapInfo) {
return MapItemComponentPropsMapper.fromBsmLocalMap(mapDetails as BsmLocalMap) as MapItemComponentProps<BsmLocalMap|BsvMapDetail|SongDetails>;
}
if ((mapDetails as BsvMapDetail).metadata) {
@@ -1,17 +1,14 @@
export class CustomError extends Error {
private readonly _code: string;
private readonly _data?: unknown;
public readonly code: string;
public readonly data?: unknown;
constructor(message: string, code: string, data?: unknown){
super(message);
this._code = code;
this._data = data;
this.code = code;
this.data = data;
}
public get code(): string { return this._code; }
public get data(): unknown { return this._data; }
public static fromError(error: Error, code?: string, data?: unknown): CustomError{
if(error instanceof CustomError){
@@ -33,4 +30,4 @@ export class CustomError extends Error {
export enum CustomErrorCodes {
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
}
+5 -2
View File
@@ -14,7 +14,7 @@ import { BsmLocalModel } from "../models/bsm-local-model.interface";
import { Mod } from "../mods";
import { BPList, DownloadPlaylistProgressionData } from "../playlists/playlist.interface";
import { VersionLinkerAction } from "renderer/services/version-folder-linker.service";
import { FileFilter, OpenDialogReturnValue } from "electron";
import { FileFilter, OpenDialogOptions, OpenDialogReturnValue } from "electron";
import { SystemNotificationOptions } from "../notification/system-notification.model";
import { Supporter } from "../supporters";
import { AppWindow } from "../window-manager/app-window.model";
@@ -58,7 +58,8 @@ export interface IpcChannelMapping {
"load-version-maps": { request: BSVersion, response: BsmLocalMapsProgress};
"delete-maps": { request: BsmLocalMap[], response: DeleteMapsProgress };
"export-maps": { request: { version: BSVersion; maps: BsmLocalMap[]; outPath: string }, response: Progression };
"download-map": { request: { map: BsvMapDetail; version: BSVersion }, response: BsmLocalMap };
"bs-maps.import-maps": { request: { paths: string[]; version: BSVersion | undefined }, response: Progression<BsmLocalMap> };
"bs-maps.download-map": { request: { map: BsvMapDetail; version: BSVersion | undefined }, response: BsmLocalMap };
"last-downloaded-map": { request: void, response: { version?: BSVersion, map: BsmLocalMap } };
"one-click-install-map": { request: BsvMapDetail, response: void };
"register-maps-deep-link": { request: void, response: boolean };
@@ -92,6 +93,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>};
"import-playlists": {request: {version?: BSVersion, paths: string[]}, response: Progression<LocalBPListsDetails>};
"install-playlist-file": {request: {bplist: BPList, version?: BSVersion, dest?: string}, response: LocalBPListsDetails};
/* ** bs-uninstall-ipcs ** */
@@ -121,6 +123,7 @@ export interface IpcChannelMapping {
/* ** os-controls-ipcs ** */
"new-window": { request: string, response: void };
"open-dialog": { request: OpenDialogOptions, response: OpenDialogReturnValue };
"choose-folder": { request: string, response: OpenDialogReturnValue };
"choose-file": { request: string, response: OpenDialogReturnValue }
"choose-image": { request: { multiple?: boolean, base64?: boolean }, response: string[] }
+31 -31
View File
@@ -147,42 +147,42 @@ export interface BsvMapParitySummary {
}
export enum MapStyle {
Dance = "dance",
Swing = "swing",
Nightcore = "nightcore",
Folk = "folk",
Family = "family",
Alt = "alternative",
Ambient = "ambient",
Funk = "funk",
Anime = "anime",
ClassicalOrchestral = "classical-orchestral",
Comedy = "comedy-meme",
Dance = "dance",
DrumBass = "drum-and-bass",
Dubstep = "dubstep",
Electronic = "electronic",
Folk = "folk-acoustic",
Funk = "funk-disco",
Hardcore = "hardcore",
Hiphop = "hip-hop-rap",
Holiday = "holiday",
House = "house",
Indie = "indie",
Instrumental = "instrumental",
JPop = "j-pop",
JRock = "j-rock",
Jazz = "jazz",
Soul = "soul",
Speedcore = "speedcore",
KPop = "k-pop",
Family = "kids-family",
Metal = "metal",
Nightcore = "nightcore",
Pop = "pop",
Punk = "punk",
Rb = "rb",
Holiday = "holiday",
Vocaloid = "vocaloid",
JRock = "j-rock",
Trance = "trance",
DrumBass = "drumbass",
Comedy = "comedy",
Instrumental = "instrumental",
Hardcore = "hardcore",
KPop = "k-pop",
Indie = "indie",
Techno = "techno",
House = "house",
Game = "game",
Film = "film",
Alt = "alt",
Dubstep = "dubstep",
Metal = "metal",
Anime = "anime",
Hiphop = "hiphop",
JPop = "j-pop",
Rock = "rock",
Pop = "pop",
Electronic = "electronic",
ClassicalOrchestral = "classical-orchestral"
Soul = "soul",
Speedcore = "speedcore",
Swing = "swing",
Film = "tv-movie-soundtrack",
Techno = "techno",
Trance = "trance",
Game = "video-game-soundtrack",
Vocaloid = "vocaloid",
}
// MapType Enum
@@ -1,11 +1,11 @@
import { RawMapInfoData } from "./raw-map.model";
import { MapInfo } from "./info/map-info.model";
import { SongDetails } from "./song-details-cache/song-details-cache.model";
export interface BsmLocalMap {
hash: string;
coverUrl: string;
songUrl: string;
rawInfo: RawMapInfoData;
mapInfo: MapInfo;
songDetails?: SongDetails;
path: string;
}
-1
View File
@@ -1,3 +1,2 @@
export { RawMapInfoData, RawMapDifficulty, RawDifficultySet } from "./raw-map.model";
export { BsvInstant, BsvMapDetail, BsvMapDetailMetadata, BsvMapDifficulty, BsvMapParitySummary, BsvMapStats, BsvMapTestplay, BsvMapVersion, BsvUserDetail } from "./beat-saver.model";
export { SongDetails, SongDifficulty, SongUploader, SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache/song-details-cache.model";
@@ -0,0 +1,42 @@
import { SongDetailDiffCharactertistic, SongDiffName } from "../song-details-cache/song-details-cache.model";
import { RawMapInfoDataV2 } from "./raw-map-info-v2.model";
import { RawMapInfoDataV4 } from "./raw-map-info-v4.model";
export type AnyRawMapInfo = RawMapInfoDataV2 | RawMapInfoDataV4;
// interfaces/mapInfo.ts
export interface MapInfo {
version: string;
songName: string;
songSubName?: string;
songAuthorName: string;
levelMappers: string[];
levelLighters: string[];
beatsPerMinute: number;
shuffle?: number;
shufflePeriod?: number;
previewStartTime: number;
previewDuration: number;
songFilename: string;
songPreviewFilename: string;
coverImageFilename: string;
environmentNames: string[];
difficulties: MapDifficulty[];
}
export interface MapDifficulty {
characteristic: SongDetailDiffCharactertistic;
difficulty: SongDiffName;
difficultyLabel?: string;
beatmapFilename: string;
noteJumpMovementSpeed: number;
noteJumpStartBeatOffset: number;
beatmapColorSchemeIdx?: number;
environmentNameIdx?: number;
// Additional fields from version 4.0.0
beatmapAuthors?: {
mappers: string[];
lighters: string[];
};
lightshowDataFilename?: string;
}
@@ -1,6 +1,6 @@
import { SongDetailDiffCharactertistic, SongDiffName } from "./song-details-cache/song-details-cache.model";
import { SongDetailDiffCharactertistic, SongDiffName } from "../song-details-cache/song-details-cache.model";
export interface RawMapInfoData<T = unknown> {
export interface RawMapInfoDataV2 {
_version: string;
_songName: string;
_songSubName: string;
@@ -16,24 +16,24 @@ export interface RawMapInfoData<T = unknown> {
_environmentName: string;
_allDirectionsEnvironmentName: string;
_songTimeOffset: number;
_customData: T;
_difficultyBeatmapSets: RawDifficultySet[];
_difficultyBeatmapSets: RawDifficultySetV2[];
// Additional fields for 2.1.0
_environmentNames?: string[];
_colorSchemes?: unknown[];
}
export interface RawDifficultySet {
interface RawDifficultySetV2 {
_beatmapCharacteristicName: SongDetailDiffCharactertistic;
_difficultyBeatmaps: RawMapDifficulty[];
_difficultyBeatmaps: RawMapDifficultyV2[];
}
export interface RawMapDifficulty {
interface RawMapDifficultyV2 {
_difficulty: SongDiffName;
_difficultyRank: string;
_difficultyRank: number;
_beatmapFilename: string;
_noteJumpMovementSpeed: number;
_noteJumpStartBeatOffset: number;
_customData?: RawMapDifficultyCustomData;
}
export interface RawMapDifficultyCustomData {
_difficultyLabel?: string;
_beatmapColorSchemeIdx?: number;
_environmentNameIdx?: number;
_customData?: { _difficultyLabel?: string; };
}
@@ -0,0 +1,40 @@
import { SongDetailDiffCharactertistic, SongDiffName } from "../song-details-cache/song-details-cache.model";
// interfaces/version4.ts
export interface RawMapInfoDataV4 {
version: string;
song: {
title: string;
subTitle: string;
author: string;
};
audio: {
songFilename: string;
songDuration: number;
audioDataFilename: string;
bpm: number;
lufs: number;
previewStartTime: number;
previewDuration: number;
};
songPreviewFilename: string;
coverImageFilename: string;
environmentNames: string[];
colorSchemes: unknown[];
difficultyBeatmaps: RawMapDifficultyV4[];
}
interface RawMapDifficultyV4 {
characteristic: SongDetailDiffCharactertistic;
difficulty: SongDiffName;
beatmapAuthors: {
mappers: string[];
lighters: string[];
};
environmentNameIdx: number;
beatmapColorSchemeIdx: number;
noteJumpMovementSpeed: number;
noteJumpStartBeatOffset: number;
beatmapDataFilename: string;
lightshowDataFilename: string;
}
@@ -0,0 +1,82 @@
import { MapDifficulty, MapInfo, AnyRawMapInfo } from "shared/models/maps/info/map-info.model";
import { RawMapInfoDataV2 } from "shared/models/maps/info/raw-map-info-v2.model";
import { RawMapInfoDataV4 } from "shared/models/maps/info/raw-map-info-v4.model";
function parseVersion2(data: RawMapInfoDataV2): MapInfo {
return {
version: data._version,
songName: data._songName,
songSubName: data._songSubName,
songAuthorName: data._songAuthorName,
levelMappers: [data._levelAuthorName],
levelLighters: [],
beatsPerMinute: data._beatsPerMinute,
shuffle: data._shuffle,
shufflePeriod: data._shufflePeriod,
previewStartTime: data._previewStartTime,
previewDuration: data._previewDuration,
songFilename: data._songFilename,
songPreviewFilename: data._songFilename,
coverImageFilename: data._coverImageFilename,
environmentNames: data._environmentNames || [data._environmentName],
difficulties: data._difficultyBeatmapSets.flatMap(set =>
set._difficultyBeatmaps.map<MapDifficulty>((diff) => ({
characteristic: set._beatmapCharacteristicName,
difficulty: diff._difficulty,
difficultyLabel: diff._customData?._difficultyLabel,
beatmapFilename: diff._beatmapFilename,
noteJumpMovementSpeed: diff._noteJumpMovementSpeed,
noteJumpStartBeatOffset: diff._noteJumpStartBeatOffset,
beatmapColorSchemeIdx: diff._beatmapColorSchemeIdx,
environmentNameIdx: diff._environmentNameIdx,
}))
),
};
}
function parseVersion4(data: RawMapInfoDataV4): MapInfo {
return {
version: data.version,
songName: data.song.title,
songSubName: data.song.subTitle,
songAuthorName: data.song.author,
levelMappers: data.difficultyBeatmaps.flatMap(diff => diff.beatmapAuthors.mappers),
levelLighters: data.difficultyBeatmaps.flatMap(diff => diff.beatmapAuthors.lighters),
beatsPerMinute: data.audio.bpm,
previewStartTime: data.audio.previewStartTime,
previewDuration: data.audio.previewDuration,
songFilename: data.audio.songFilename,
songPreviewFilename: data.songPreviewFilename,
coverImageFilename: data.coverImageFilename,
environmentNames: data.environmentNames,
difficulties: data.difficultyBeatmaps.map<MapDifficulty>(diff => ({
characteristic: diff.characteristic,
difficulty: diff.difficulty,
beatmapFilename: diff.beatmapDataFilename,
noteJumpMovementSpeed: diff.noteJumpMovementSpeed,
noteJumpStartBeatOffset: diff.noteJumpStartBeatOffset,
beatmapColorSchemeIdx: diff.beatmapColorSchemeIdx,
environmentNameIdx: diff.environmentNameIdx,
beatmapAuthors: diff.beatmapAuthors,
lightshowDataFilename: diff.lightshowDataFilename,
})),
};
}
export function parseMapInfoDat(info: AnyRawMapInfo): MapInfo | never {
const version = (info as RawMapInfoDataV2)?._version || (info as RawMapInfoDataV4)?.version;
if(!version) {
throw new Error('Cannot determine info.dat version');
}
if (version.startsWith('2.')) {
return parseVersion2(info as RawMapInfoDataV2);
}
if (version.startsWith('4.')) {
return parseVersion4(info as RawMapInfoDataV4);
}
throw new Error(`Unsupported info.dat version: ${version}`);
}