Compare commits

..

2 Commits

Author SHA1 Message Date
MathieuG-P b59f3f6ad5 Merge branch 'master' into feature/see-available-mods-for-a-version 2024-07-13 15:26:53 +02:00
MathieuG-P cfde548150 [feature] we can open a modal to see available mods for a version 2024-06-06 21:57:09 +02:00
173 changed files with 3109 additions and 5985 deletions
+1 -13
View File
@@ -6,20 +6,8 @@ import webpack from "webpack";
import webpackPaths from "./webpack.paths";
import { dependencies as externals } from "../../release/app/package.json";
function createExternals(): string[] {
const webpackExternals: string[] = [...Object.keys(externals || {})];
const excludedExternals: string[] = [];
if (process.platform === "linux") {
// Linux only uses regedit-rs types
excludedExternals.push("regedit-rs");
}
return webpackExternals.filter(external => !excludedExternals.includes(external));
}
const configuration: webpack.Configuration = {
externals: createExternals(),
externals: [...Object.keys(externals || {})],
stats: "errors-only",
-64
View File
@@ -1,64 +0,0 @@
/**
* Webpack config for development electron main process
*/
import path from 'path';
import webpack from 'webpack';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import { merge } from 'webpack-merge';
import checkNodeEnv from '../scripts/check-node-env';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: 'electron-main',
entry: {
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
},
output: {
path: webpackPaths.dllPath,
filename: '[name].bundle.dev.js',
library: {
type: 'umd',
},
},
plugins: [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8888,
}),
new webpack.DefinePlugin({
'process.type': '"browser"',
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
};
export default merge(baseConfig, configuration);
-4
View File
@@ -2,9 +2,6 @@ const path = require("path");
const rootPath = path.join(__dirname, "../..");
const erbPath = path.join(__dirname, '..');
const erbNodeModulesPath = path.join(erbPath, 'node_modules');
const dllPath = path.join(__dirname, "../dll");
const srcPath = path.join(rootPath, "src");
@@ -25,7 +22,6 @@ const buildPath = path.join(releasePath, "build");
export default {
rootPath,
erbNodeModulesPath,
dllPath,
srcPath,
srcMainPath,
+5 -8
View File
@@ -1,12 +1,9 @@
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
import fs from "fs";
import webpackPaths from "../configs/webpack.paths";
const { srcNodeModulesPath, appNodeModulesPath, erbNodeModulesPath } = webpackPaths;
const { srcNodeModulesPath } = webpackPaths;
const { appNodeModulesPath } = webpackPaths;
if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
}
if (!fs.existsSync(erbNodeModulesPath) && fs.existsSync(appNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, erbNodeModulesPath, 'junction');
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, "junction");
}
-1
View File
@@ -59,7 +59,6 @@ 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,
-1
View File
@@ -15,4 +15,3 @@
*.ttf binary
*.woff binary
*.woff2 binary
assets/scripts/* binary
@@ -0,0 +1,35 @@
---
name: "[BUG] Bug report"
about: Create a report to help us improve
title: "[BUG] : "
labels: bug
assignees: Zagrios
---
## Bug Description
<!-- A clear and concise description of what the bug is. -->
## Reproduction Steps
<!-- Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
-->
## Expected Behavior
<!-- A clear and concise description of what you expected to happen. -->
## Screenshots
<!-- If applicable, add screenshots to help explain your problem. -->
## System Specs
<!-- **Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Version [e.g. 22]
-->
## Additional context
<!-- Add any other context about the problem here. -->
@@ -0,0 +1,22 @@
---
name: "[FEAT.] Feature request"
about: Suggest an idea for this project
title: "[FEAT.] : "
labels: enhancement
assignees: Zagrios
---
## Problem
<!-- **Is your feature request related to a problem? Please describe.** -->
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
## Solution
<!-- **Describe the solution you'd like** -->
<!-- A clear and concise description of what you want to happen. -->
## Alternative solutions (if any)
<!-- **Describe alternatives you've considered** -->
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
## Additional context
<!-- Add any other context or screenshots about the feature request here. -->
-54
View File
@@ -1,54 +0,0 @@
name: "[BUG] Bug report"
description: Create a report to help us improve
title: "[BUG] : "
labels: bug
assignees: Zagrios
body:
- type: markdown
attributes:
value: |
## Thanks for taking the time to fill out this bug report! 😊
- type: textarea
id: description
attributes:
label: Issue encountered
description: Tell us what issue you've encountered.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: Tell us what should expected to happen.
- type: textarea
id: replication
attributes:
label: Steps to Reproduce
description: Provide a link to a live example, or an unambiguous set of steps to reproduce this bug. Include code to reproduce, if relevant.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
- type: dropdown
id: os
attributes:
label: Operating System
options:
- Windows 10
- Windows 11
- Linux
- type: input
id: version
attributes:
label: Version
description: What version of BSManager are you running?
placeholder: eg. 1.4.8 or 1.5.0-alpha-4
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any other context that you may share about the issue. You may add your log files here.
@@ -1,45 +0,0 @@
name: "[FEAT.] Feature request"
description: Suggest an idea for this project
title: "[FEAT.] : "
labels: enhancement
assignees: Zagrios
body:
- type: markdown
attributes:
value: |
## Thanks for taking the time to fill out this feature request! 😊
- type: textarea
id: problem
attributes:
label: Problem
description: Is your feature request related to a problem? Please describe.
placeholder: Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Solution
description: Describe the solution you'd like.
validations:
required: true
- type: textarea
id: alternative-solution
attributes:
label: Alternative Solution
description: Describe alternatives you've considered.
- type: dropdown
id: os
attributes:
label: Operating System
description: If the feature is only applicable to a specific OS. Leave it to `None` if it's applicable to all OS'es.
options:
- Windows
- Linux
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.
-5
View File
@@ -1,5 +0,0 @@
lank_issues_enabled: true
contact_links:
- name: Discord Support
url: https://discord.gg/uSqbHVpKdV
about: You can join our Discord server for a quick and interactive support.
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [windows-latest, ubuntu-latest]
os: [windows-latest]
steps:
- name: Check out Git repository
@@ -30,5 +30,5 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.os }}
name: release
path: release
-20
View File
@@ -1,20 +0,0 @@
name: Publish wiki
on:
push:
branches: [master]
paths:
- docs/wiki/**
- .github/workflows/docs.yml
concurrency:
group: publish-wiki
cancel-in-progress: true
permissions:
contents: write
jobs:
publish-wiki:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: Andrew-Chen-Wang/github-wiki-action@v4
with:
path: docs/wiki/
-26
View File
@@ -1,26 +0,0 @@
name: Labeler
on:
issues:
types: [opened, edited]
jobs:
label-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Search Linux Term
uses: actions-ecosystem/action-regex-match@v2
id: regex-match
with:
text: ${{ github.event.issue.body }}
regex: '\# Operating System(\s*)Linux'
flags: m
- name: Add Linux label
uses: actions-ecosystem/action-add-labels@v1
if: ${{ steps.regex-match.outputs.match != '' }}
with:
github_token: ${{ secrets.GH_TOKEN }}
labels: linux
-35
View File
@@ -1,35 +0,0 @@
# This workflow will...
name: Release Linux
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
on:
workflow_dispatch:
jobs:
release:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
cache: "npm"
- run: npm ci
- run: npm run publish:linux
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: release
path: release
-1
View File
@@ -18,7 +18,6 @@ We'd also love PRs. If you're thinking of a large PR, we advise opening up an is
## Submitting a pull request
1. [Fork][fork] and clone the repository.
1. Install the correct NodeJS version (highly recommend installing [Volta](https://volta.sh/) for that).
1. Configure and install the dependencies: `npm install`.
1. Create a new branch following naming convention: `git checkout -b (feature|bugfix|hotfix|chore)/(short-description)(/issue-id)`.
1. Make your change, test, and make sure BSManager work fine.
+1 -1
View File
@@ -546,7 +546,7 @@
<div>
<h2>Credits</h2>
<ul>
<li><a href="https://github.com/Zagrios">Zagrios - Mathieu Gries</a> - Lead Developer & Founder.</li>
<li><a href="https://github.com/Zagrios">Zagrios</a> - Lead Developer & Founder.</li>
<li><a href="https://github.com/Iluhadesu">Iluhadesu</a> - Co-Developer & Co-Founder, Discord Bot Developer.</li>
<li><a href="https://github.com/GaetanGrd">GaetanGrd</a> - Co-Developer & Co-Founder, Documentation Lead.</li>
<li><a href="https://github.com/cheddZy">cheddZy</a> - Icon Creator.</li>

Before

Width:  |  Height:  |  Size: 442 KiB

After

Width:  |  Height:  |  Size: 442 KiB

+2 -45
View File
@@ -679,7 +679,8 @@
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/3865840827122760836",
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/ae11d195bbe41a0f244d7b4d055a0ab89f88e5bb.png",
"ReleaseDate": "1702309893",
"year": "2023"
"year": "2023",
"recommended": true
},
{
"BSVersion": "1.34.5",
@@ -743,49 +744,5 @@
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/039be718c1494f948257cd5b09f0082986484a58.png",
"ReleaseDate": "1720710529",
"year": "2024"
},
{
"BSVersion": "1.37.2",
"BSManifest": "6848299977215652352",
"OculusBinaryId": "7438773609555687",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4349999161850643744",
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/7c03af6d12fad23f1d439666d4be86b492d40003.png",
"ReleaseDate": "1722945814",
"year": "2024"
},
{
"BSVersion": "1.37.3",
"BSManifest": "5834150512217183366",
"OculusBinaryId": "7553569971409383",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/6808965920900622902",
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/a1c9a6737cc5182fca8cddc7c8088eda6dd4d82d.png",
"ReleaseDate": "1724061712",
"year": "2024"
},
{
"BSVersion": "1.37.4",
"BSManifest": "7585106640515547731",
"OculusBinaryId": "7630971613669218",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4601078012425941968",
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/23e6e6f219a56d2f9ea4a27b560c54ac1214aec3.png",
"ReleaseDate": "1725548547",
"year": "2024"
},
{
"BSVersion": "1.37.5",
"BSManifest": "3122533396458693889",
"OculusBinaryId": "7685108391588873",
"ReleaseDate": "1725628356",
"year": "2024"
},
{
"BSVersion": "1.38.0",
"BSManifest": "3482902979520746178",
"OculusBinaryId": "7834818833284494",
"ReleaseURL": "https://steamcommunity.com/games/620980/announcements/detail/4669760442595134838",
"ReleaseImg": "https://clan.akamai.steamstatic.com/images/32055887/57cfa043407c091e67b7e0a8ae86fe5590deae33.png",
"ReleaseDate": "1728399815",
"year": "2024",
"recommended": true
}
]
+1 -14
View File
@@ -1,14 +1 @@
{
"1.5.0-alpha.1":{
"htmlBody" :"<strong>BSManager continues to get better, thanks to your continued support and contributions!</strong><center>🎈🎉🎈</center><p>Note: This is an alpha release, encountering bugs is expected. Please file new issues for any bugs you find.</p><h2>Features</h2><ul><li>Added playlist support</li><li>Added Linux support (Thanks to @Insprill)</li><li>Added reset button to reset values when editing a Beat Saber version (Thanks to @Liborsaf)</li><li>Added a changelog modal</li></ul><h2>Fixes</h2><ul><li>Fixed an issue where switching between Beat Saber versions too quickly displayed mods for the wrong version</li><li>Fixed an issue where the launch window from a shortcut would never close</li><li>Advanced launch arguments are used even when the input field is closed</li><li>Fixed an issue where having many maps caused some maps to not have their information loaded</li></ul><h2>Other changes</h2><ul><li>.NET Framework is no longer required</li><li>Clear English translations for the Steam credentials popup (Thanks to @Aeywoo)</li><li>NSFW models are now blurred when browsing for models</li><li>When no mods are available for a Beat Saber version, the version number is now shown</li><li>Mods load faster</li><li>After a first full maps loading, maps load almost instantly</li><li>Improved performance in lists with lot of contents</li></ul>",
"title" : "What's new ?",
"timestamp" : 1721048400,
"version" : "1.5.0-alpha.1"
},
"1.5.0-alpha.2":{
"htmlBody" :"<strong>BSManager continues to get better, thanks to your continued support and contributions!</strong><center>🎈🎉🎈</center><p>Note: This is an alpha release, encountering bugs is expected. Please file new issues for any bugs you find.</p><h4>Alpha changes</h4><ul><li>Fixed an issue where BSIPA could not be installed if the BS version&#39;s path contained spaces #523</li><li>Fixed a crash when changing audio volume #518</li><li>Fixed an issue where playlist maps with a malformed hash were not loaded #517</li><li>Improved reliability of reading maps from a playlist file #517</li><li>Playlists in sub-folders are now loaded #514</li><li>Fixed a crash when loading an invalid playlist file #513</li></ul><h2>Features</h2><ul> <li>Added playlist support</li> <li>Added Linux support (Thanks to @Insprill)</li> <li>Added reset button to reset values when editing a Beat Saber version (Thanks to @Liborsaf)</li> <li>Added a changelog modal</li></ul><h3>Fixes</h3><ul><li>Fixed an issue where switching between Beat Saber versions too quickly displayed mods for the wrong version #472</li><li>Fixed an issue where the launch window from a shortcut would never close #485</li><li>Advanced launch arguments are used even when the input field is closed #496</li><li>Fixed an issue where having many maps caused some maps to not have their information loaded #503</li><li>Fixed an issue where downloading maps sometimes resulted in a timeout error #524</li><li>Fixed an issue where the duration of maps could be wrong in some cases #522</li></ul><h2>Other changes</h2><ul> <li>.NET Framework is no longer required</li> <li>Clear English translations for the Steam credentials popup (Thanks to @Aeywoo)</li> <li>NSFW models are now blurred when browsing for models</li> <li>When no mods are available for a Beat Saber version, the version number is now shown</li> <li>Mods load faster</li> <li>After a first full maps loading, maps load almost instantly</li> <li>Improved performance in lists with lot of contents</li></ul>",
"title" : "What's new ?",
"timestamp" : 1721665382,
"version" : "1.5.0-alpha.2"
}
}
{}
-15
View File
@@ -99,20 +99,5 @@
},
{
"username": "Austin"
},
{
"username": "Fatalution",
"type": "diamond",
"link": "https://x.com/fatalution"
},
{
"username": "Taurus Arcade",
"type": "gold"
},
{
"username": "Mozz_Zm"
},
{
"username": "clapxz"
}
]
File diff suppressed because it is too large Load Diff
+20 -142
View File
@@ -14,8 +14,7 @@
"refuse": "Refuse",
"apply": "Apply",
"copy": "Copy",
"copied": "Copied!",
"confirm": "Confirm"
"copied": "Copied!"
},
"nav-bar": {
"add-version": "Add a version",
@@ -45,16 +44,14 @@
"filters-btn": "Filters",
"dropdown": {
"export-maps": "Export maps",
"delete-maps": "Delete maps",
"delete-duplicate-maps": "Delete duplicates"
"delete-maps": "Delete maps"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "Browse maps",
"import-maps": "Import maps"
"add-maps": {
"text": "Add"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "No mods are available yet for this version of Beat Saber",
"buttons": {
"more-infos": "More info",
"install-or-update": "Install or update",
"reinstall-all": "Reinstall all"
"install-or-update": "Install or update"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "Uninstall all"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "Mods already installed",
"description": "All selected mods are already installed"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "Logging out will allow you to switch accounts on the next Beat Saber download.",
"logout": "Log out",
"logout-success": "Logout Successful",
"download-platform": {
"title": "Default Platform",
"desc": "Choose the default platform that will be used to download versions of Beat Saber.",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "Installation folder",
"description": "Change the folder that will contain all the content downloaded by BSManager.",
"description": "Change the default folder for Beat Saber versions and other upcoming features.",
"choose-folder": "Choose folder"
},
"proton-path": {
@@ -225,34 +199,6 @@
"changelogs": {
"open" : "🚀 Check Out the Changelog!",
"not-founds": "😕 Oops, No Changelog Here!"
},
"advanced": {
"title": "Advanced",
"description": "Advanced settings for BSManager.",
"hardware-acceleration": {
"title": "Hardware Acceleration",
"description": "Enable Hardware Acceleration to use your GPU and improve BSManager's performance. Turn this off if you're experiencing frame drops.",
"modal": {
"title": "Restart Needed",
"body": "Changing hardware acceleration setting will quit and re-launch BSManager. Are you sure you want to do this?",
"confirm-btn": "Yes I'm sure"
},
"error-notification": {
"message": "An error occur, unable to disable hardware acceleration."
}
},
"use-symlinks": {
"title": "Use Symlinks",
"description": "Use Symlinks instead of Junctions to link folders. Turn this on only if you really need it.",
"modal": {
"title": "Symlink Permissions",
"body": "When creating symlinks, BSManager will require administrator privileges or developer mode enabled on your system. Are you sure you want to continue?",
"confirm-btn": "Yes I'm sure"
},
"error-notification": {
"message": "An error occur, unable to change symlinks settings."
}
}
}
}
},
@@ -271,13 +217,11 @@
"errors": {
"titles": {
"operation-running": "Operation running",
"no-internet": "No internet",
"file-not-supported": "File not supported"
"no-internet": "No internet"
},
"msg": {
"operation-running": "Wait for the current operation to finish, then try again.",
"no-internet": "Check your connection and try again.",
"file-not-supported": "Only {types} files are supported."
"no-internet": "Check your connection and try again."
}
}
},
@@ -468,8 +412,7 @@
"CantEditSteam": "Unable to edit",
"CantRename": "Renaming impossible",
"VersionAlreadExist": "This version already exists",
"CantClone": "Cloning impossible",
"UnknownError": "An unknown error occurred"
"CantClone": "Cloning impossible"
},
"msg": {
"CantEditSteam": "You can't edit the Steam version. You can clone it though."
@@ -522,27 +465,6 @@
"one-click-install": {
"success": "Map installation complete",
"error": "An error occurred while installing the map"
},
"no-duplicates-maps": {
"title": "No Duplicates",
"msg": "No maps were deleted"
},
"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": {
@@ -563,15 +485,6 @@
"title": "Backup created",
"msg": "Sharing the 'UserData' folder can generate errors, in case of problems unlink the folder to restore the backup"
}
},
"linking-error": {
"title": "Error while linking folder",
"msg": {
"EPERM": "BSManager does not have the necessary permissions to link the folder.",
"EACCES": "BSManager does not have the necessary permissions to link the folder.",
"ENOSPC": "The disk is full, make some space and try again.",
"UNKNOWN_ERROR":"An unknown error has occurred while linking the folder."
}
}
},
"create-launch-shortcut": {
@@ -737,11 +650,6 @@
"title": "Keeping maps will copy all maps from the shared folder to the current version after unlinking. Maps will not be lost if this is disabled."
},
"valid-btn": "Unlink maps"
},
"delete-duplicate-maps": {
"title": "Delete maps?",
"desc": "Only the map \"{map}\" is a duplicate. Are you sure you want to delete it?",
"desc-plural": "{nb} duplicate maps have been found. Are you sure you want to delete them?"
}
},
"download-maps": {
@@ -822,20 +730,12 @@
},
"launch-as-admin": "Launch as Administrator",
"not-remind-me": "Do not remind me"
},
"ask-install-path": {
"title": "Installation folder",
"choose-folder-description": "Choose the folder that will contain all the content downloaded by BSManager. (versions, mods, maps, playlists, etc.)",
"choose-folder": "Choose folder",
"default": "Default",
"default-tooltip": "Defaults to your home folder"
}
},
"maps": {
"map-filter-panel": {
"duration": "Duration",
"nps" : "Notes Per Second",
"njs": "Note Jump Speed",
"tags": "tags",
"specificities": "general",
"requirements": "requirements",
@@ -845,7 +745,7 @@
"accuracy": "accuracy",
"balanced": "balanced",
"challenge": "challenge",
"dance-style": "dance",
"dancestyle": "dance",
"fitness": "fitness",
"speed": "speed",
"tech": "tech"
@@ -854,10 +754,10 @@
"dance": "dance",
"swing": "swing",
"nightcore": "nightcore",
"folk-acoustic": "folk & acoustic",
"kids-family": "kids & family",
"folk": "folk",
"family": "family",
"ambient": "ambient",
"funk-disco": "funk & disco",
"funk": "funk",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
@@ -867,21 +767,21 @@
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drum-and-bass": "drum & bass",
"comedy-meme": "comedy & meme",
"drumbass": "drum & bass",
"comedy": "comedy",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indie",
"techno": "techno",
"house": "house",
"video-game-soundtrack": "video game",
"tv-movie-soundtrack": "TV & film",
"alternative": "alternative",
"game": "video game",
"film": "film",
"alt": "alternative",
"dubstep": "dubstep",
"metal": "metal",
"anime": "anime",
"hip-hop-rap": "hip hop & rap",
"hiphop": "hiphop",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
@@ -913,8 +813,7 @@
"bsr-code" : "BSR code",
"download" : "Download map",
"downloading" :"Downloading map",
"cancel-download" : "Cancel download",
"hightlight-difficulty": "Highlight difficulty"
"cancel-download" : "Cancel download"
}
},
"models": {
@@ -1106,9 +1005,6 @@
}
}
},
"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.",
@@ -1223,24 +1119,6 @@
"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": {
+25 -147
View File
@@ -14,8 +14,7 @@
"refuse": "Rechazar",
"apply": "Aplicar",
"copy": "Copiar",
"copied": "¡Copiado!",
"confirm": "Confirmar"
"copied": "¡Copiado!"
},
"nav-bar": {
"add-version": "Agregar una versión",
@@ -45,16 +44,14 @@
"filters-btn": "Filtros",
"dropdown": {
"export-maps": "Exportar mapas",
"delete-maps": "Borrar mapas",
"delete-duplicate-maps": "Borrar duplicados"
"delete-maps": "Borrar mapas"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "Explorar mapas",
"import-maps": "Importar mapas"
"add-maps": {
"text": "Añadir"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "Aún no hay mods disponibles para esta versión de Beat Saber",
"buttons": {
"more-infos": "Más información",
"install-or-update": "Instalar o actualizar",
"reinstall-all": "Reinstalar todo"
"install-or-update": "Instalar o actualizar"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "Desinstalar todos"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "Mods ya instalados",
"description": "Todos los mods seleccionados ya están instalados"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "Desconectarte te permitirá cambiar de cuenta en la próxima descarga de Beat Saber.",
"logout": "Cerrar sesión",
"logout-success": "Cierre de sesión exitoso",
"download-platform": {
"title": "Plataforma predeterminada",
"desc": "Elige la plataforma predeterminada que se utilizará para descargar las versiones de Beat Saber.",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "Carpeta de instalación",
"description": "Cambiar la carpeta que contendrá todo el contenido descargado por BSManager.",
"description": "Cambia la carpeta por defecto para las versiones de Beat Saber y próximas funciones.",
"choose-folder": "Elige la carpeta"
},
"additional-content": {
@@ -220,34 +194,6 @@
"changelogs": {
"open" : "🚀 ¡Descubre lo nuevo en el Changelog!",
"not-founds": "😕 Uy, ¡Changelog no encontrado!"
},
"advanced": {
"title": "Avanzados",
"description": "Configuraciones avanzadas para BSManager.",
"hardware-acceleration": {
"title": "Aceleración de hardware",
"description": "Habilite la aceleración de hardware para usar su GPU y mejorar el rendimiento de BSManager. Desactive esta opción si experimenta caídas de fotogramas.",
"modal": {
"title": "Reinicio necesario",
"body": "Cambiar la configuración de aceleración de hardware cerrará y reiniciará BSManager. ¿Estás seguro de que quieres hacer esto?",
"confirm-btn": "Sí, estoy seguro"
},
"error-notification": {
"message": "Ocurrió un error, no se puede desactivar la aceleración de hardware."
}
},
"use-symlinks": {
"title": "Usar enlaces simbólicos",
"description": "Utilice enlaces simbólicos en lugar de uniones para enlazar carpetas. Actívelo solo si realmente lo necesita.",
"modal": {
"title": "Permisos de enlace simbólico",
"body": "Al crear enlaces simbólicos, BSManager requerirá privilegios de administrador o el modo desarrollador activado en su sistema. ¿Estás seguro de que quieres continuar?",
"confirm-btn": "Sí, estoy seguro"
},
"error-notification": {
"message": "Ocurrió un error, no se pueden cambiar los ajustes de los enlaces simbólicos."
}
}
}
}
},
@@ -266,13 +212,11 @@
"errors": {
"titles": {
"operation-running": "Operación en curso",
"no-internet": "Sin internet",
"file-not-supported": "Archivo no compatible"
"no-internet": "Sin internet"
},
"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.",
"file-not-supported": "Solo se admiten archivos {types}."
"no-internet": "Comprueba tu conexión a Internet e inténtalo de nuevo."
}
}
},
@@ -461,8 +405,7 @@
"CantEditSteam": "Imposible editar",
"CantRename": "Cambio de nombre imposible",
"VersionAlreadExist": "Esta versión ya existe",
"CantClone": "Clonación imposible",
"UnknownError": "Ocurrió un error desconocido"
"CantClone": "Clonación imposible"
},
"msg": {
"CantEditSteam": "No puedes editar la versión de Steam. Sin embargo, puedes clonarla."
@@ -515,27 +458,6 @@
"one-click-install": {
"success": "Instalación del mapa completada",
"error": "Se produjo un error durante la instalación del mapa"
},
"no-duplicates-maps": {
"title": "Sin duplicados",
"msg": "No se eliminó ninguna carta"
},
"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": {
@@ -556,15 +478,6 @@
"title": "Backup creado",
"msg": "Compartir la carpeta 'UserData' puede generar errores, en caso de problemas desvincula la carpeta para restaurar la copia de seguridad"
}
},
"linking-error": {
"title": "Error al enlazar la carpeta",
"msg": {
"EPERM": "BSManager no tiene los permisos necesarios para enlazar la carpeta.",
"EACCES": "BSManager no tiene los permisos necesarios para enlazar la carpeta.",
"ENOSPC": "El disco está lleno, libera espacio e inténtalo de nuevo.",
"UNKNOWN_ERROR": "Se ha producido un error desconocido al enlazar la carpeta."
}
}
},
"create-launch-shortcut": {
@@ -730,11 +643,6 @@
"title": "Mantener los mapas creará una copia de los mapas compartidos para la versión actual. De lo contrario, no se mantendrá ningún mapa para esta versión."
},
"valid-btn": "Desvincular mapas"
},
"delete-duplicate-maps": {
"title": "¿Borrar mapas?",
"desc": "Solo el mapa \"{map}\" está duplicado. ¿Estás seguro de que quieres eliminarlo?",
"desc-plural": "Se han encontrado {nb} mapas duplicados. ¿Estás seguro de que quieres eliminarlos?"
}
},
"download-maps": {
@@ -815,20 +723,12 @@
},
"launch-as-admin": "Iniciar como Administrador",
"not-remind-me": "No volver a recordármelo"
},
"ask-install-path": {
"title": "Carpeta de instalación",
"choose-folder-description": "Elija la carpeta que contendrá todo el contenido descargado por BSManager. (versiones, mods, mapas, listas de reproducción, etc.)",
"choose-folder": "Elige la carpeta",
"default": "Predeterminado",
"default-tooltip": "Por defecto, en su carpeta personal"
}
},
"maps": {
"map-filter-panel": {
"duration": "Duración",
"nps" : "Notas Por Segundo",
"njs": "Velocidad de salto de nota",
"tags": "tags",
"specificities": "general",
"requirements": "requisitos",
@@ -838,19 +738,19 @@
"accuracy": "precisión",
"balanced": "equilibrado",
"challenge": "desafío",
"dance-style": "baile",
"dancestyle": "baile",
"fitness": "fitness",
"speed": "speed",
"tech": "tech"
},
"map-styles": {
"dance": "danza",
"dance": "baile",
"swing": "swing",
"nightcore": "nightcore",
"folk-acoustic": "folk & acústico",
"kids-family": "niños & familia",
"ambient": "ambiental",
"funk-disco": "funk & disco",
"folk": "folk",
"family": "familia",
"ambient": "ambiente",
"funk": "funk",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
@@ -860,26 +760,26 @@
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drum-and-bass": "drum & bass",
"comedy-meme": "comedia & meme",
"drumbass": "drum & bass",
"comedy": "comedia",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indie",
"techno": "techno",
"techno": "tecno",
"house": "house",
"video-game-soundtrack": "videojuego",
"tv-movie-soundtrack": "TV & cine",
"alternative": "alternativo",
"game": "videojuego",
"film": "film",
"alt": "alternativa",
"dubstep": "dubstep",
"metal": "metal",
"anime": "anime",
"hip-hop-rap": "hip hop & rap",
"hiphop": "hiphop",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
"electronic": "electrónica",
"classical-orchestral": "Clásica & Orquestal"
"electronic": "electrónico",
"classical-orchestral": "Clásico y orquestal"
},
"map-specificities": {
"automapper": "IA",
@@ -906,8 +806,7 @@
"bsr-code" : "Código BSR",
"download" : "Descargar mapa",
"downloading" :"Descargando mapa",
"cancel-download" : "Cancelar descarga",
"hightlight-difficulty": "Resaltar la dificultad"
"cancel-download" : "Cancelar descarga"
}
},
"models": {
@@ -1099,9 +998,6 @@
}
}
},
"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.",
@@ -1216,24 +1112,6 @@
"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": {
+25 -148
View File
@@ -14,8 +14,7 @@
"refuse": "Refuser",
"apply": "Appliquer",
"copy": "Copier",
"copied": "Copié !",
"confirm": "Confirmer"
"copied": "Copié !"
},
"nav-bar": {
"add-version": "Ajouter une version",
@@ -45,16 +44,14 @@
"filters-btn": "Filtres",
"dropdown": {
"export-maps": "Exporter les maps",
"delete-maps": "Supprimer les maps",
"delete-duplicate-maps": "Supprimer les doublons"
"delete-maps": "Supprimer les maps"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "Explorer les maps",
"import-maps": "Importer des maps"
"add-maps": {
"text": "Ajouter"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "Aucun mod n'est encore disponible pour cette version de Beat Saber",
"buttons": {
"more-infos": "Plus d'infos",
"install-or-update": "Installer ou mettre à jour",
"reinstall-all": "Tout réinstaller"
"install-or-update": "Installer ou mettre à jour"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "Tout désinstaller"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "Mods déjà installées",
"description": "Tous les mods séléctionnées sont déjà installées"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "Te déconnecter te permettra de changer de compte au prochain téléchargement de Beat Saber.",
"logout": "Déconnexion",
"logout-success": "Déconnexion réussie",
"download-platform": {
"title": "Plateforme par défaut",
"desc": "Choisi la plateforme par défaut qui sera utilisée pour télécharger les versions de Beat Saber.",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "Dossier d'installation",
"description": "Changer le dossier qui contiendra tout le contenu téléchargé par BSManager.",
"description": "Change le dossier par défaut pour les versions de Beat Saber et d'autres fonctionnalités à venir.",
"choose-folder": "Choisir un dossier"
},
"additional-content": {
@@ -220,34 +194,6 @@
"changelogs": {
"open" : "🚀 Jetez un œil au Changelog !",
"not-founds": "😕 Oups, Changelog introuvable !"
},
"advanced": {
"title": "Avancés",
"description": "Paramètres avancés pour BSManager.",
"hardware-acceleration": {
"title": "Accélération matérielle",
"description": "Activez l'accélération matérielle pour utiliser votre GPU et améliorer les performances de BSManager. Désactivez cette option si vous rencontrez des chutes d'IPS.",
"modal": {
"title": "Redémarrage nécessaire",
"body": "Changer le paramètre d'accélération matérielle va quitter et relancer BSManager. Êtes-vous sûr de vouloir continuer ?",
"confirm-btn": "Oui, je suis sûr"
},
"error-notification": {
"message": "Une erreur s'est produite, impossible de désactiver l'accélération matérielle."
}
},
"use-symlinks": {
"title": "Utiliser des liens symboliques",
"description": "Utilisez des liens symboliques au lieu de jonctions pour lier des dossiers. Activez cette option uniquement si vous en avez besoin.",
"modal": {
"title": "Permissions des liens symboliques",
"body": "Lors de la création de liens symboliques, BSManager nécessitera des privilèges administrateur ou le mode développeur activé sur votre système. Êtes-vous sûr de vouloir continuer ?",
"confirm-btn": "Oui, je suis sûr"
},
"error-notification": {
"message": "Une erreur s'est produite, impossible de changer les paramètres des liens symboliques."
}
}
}
}
},
@@ -266,13 +212,11 @@
"errors": {
"titles": {
"operation-running": "Opération en cours",
"no-internet": "Pas d'accès Internet",
"file-not-supported": "Fichier non supporté"
"no-internet": "Pas d'accès Internet"
},
"msg": {
"operation-running": "Attends la fin de l'opération en cours puis recommence.",
"no-internet": "Vérifie ta connexion internet et ressaye.",
"file-not-supported": "Seuls les fichiers {types} sont pris en charge."
"no-internet": "Vérifie ta connexion internet et ressaye."
}
}
},
@@ -461,8 +405,7 @@
"CantEditSteam": "Modification impossible",
"CantRename": "Renommage impossible",
"VersionAlreadExist": "Cette version existe déjà",
"CantClone": "Clonage impossible",
"UnknownError": "Une erreur inconnue s'est produite"
"CantClone": "Clonage impossible"
},
"msg": {
"CantEditSteam": "Tu ne peux pas modifier la version Steam, cependant tu peux la cloner."
@@ -515,27 +458,6 @@
"one-click-install": {
"success": "Installation de la map terminée",
"error": "Une erreur s'est produite lors de l'installation de la map"
},
"no-duplicates-maps": {
"title": "Pas de doublons",
"msg": "Aucune carte n'a été supprimée"
},
"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": {
@@ -556,17 +478,7 @@
"title": "Sauvegarde créée",
"msg": "Le partage du dossier 'UserData' peut générer des erreurs, en cas de soucis déliez le dossier pour restaurer la sauvegarde"
}
},
"linking-error": {
"title": "Erreur lors de la liaison du dossier",
"msg": {
"EPERM": "BSManager n'a pas les autorisations nécessaires pour lier le dossier.",
"EACCES": "BSManager n'a pas les autorisations nécessaires pour lier le dossier.",
"ENOSPC": "Le disque est plein, faites de la place et réessayez.",
"UNKNOWN_ERROR": "Une erreur inconnue est survenue lors de la liaison du dossier."
}
}
},
"create-launch-shortcut": {
"success": {
@@ -731,11 +643,6 @@
"title": "Conserver les maps créera une copie des maps partagées pour la version actuelle. Dans le cas contraire, aucune map ne sera conservée pour cette version."
},
"valid-btn": "Délier les maps"
},
"delete-duplicate-maps": {
"title": "Supprimer les maps ?",
"desc": "Seule la map \"{map}\" est en double. Es-tu sûr de vouloir la supprimer ?",
"desc-plural": "{nb} maps en double ont été trouvées. Es-tu sûr de vouloir les supprimer ?"
}
},
"download-maps": {
@@ -816,20 +723,12 @@
},
"launch-as-admin": "Lancer en administateur",
"not-remind-me": "Ne plus me rappeler"
},
"ask-install-path": {
"title": "Dossier d'installation",
"choose-folder-description": "Choisissez le dossier qui contiendra tout le contenu téléchargé par BSManager. (versions, mods, cartes, playlists, etc.)",
"choose-folder": "Choisir un dossier",
"default": "Par défaut",
"default-tooltip": "Par défaut, dans votre dossier personnel"
}
},
"maps": {
"map-filter-panel": {
"duration": "Durée",
"nps" : "Notes Par Seconde",
"njs": "Vitesse de saut des notes",
"tags": "tags",
"specificities": "général",
"requirements": "requis",
@@ -839,48 +738,48 @@
"accuracy": "précision",
"balanced": "équilibrée",
"challenge": "challenge",
"dance-style": "dance",
"dancestyle": "dance",
"fitness": "fitness",
"speed": "vitesse",
"tech": "tech"
},
"map-styles": {
"dance": "danse",
"dance": "dance",
"swing": "swing",
"nightcore": "nightcore",
"folk-acoustic": "folk & acoustique",
"kids-family": "enfants & famille",
"ambient": "ambiant",
"funk-disco": "funk & disco",
"folk": "folk",
"family": "famille",
"ambient": "ambiante",
"funk": "funk",
"jazz": "jazz",
"soul": "soul",
"speedcore": "speedcore",
"punk": "punk",
"rb": "r&b",
"holiday": "vacances",
"holiday": "vacance",
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drum-and-bass": "drum & bass",
"comedy-meme": "comédie & meme",
"drumbass": "drum & bass",
"comedy": "comédie",
"instrumental": "instrumental",
"hardcore": "hardcore",
"k-pop": "k-pop",
"indie": "indie",
"indie": "indé",
"techno": "techno",
"house": "house",
"video-game-soundtrack": "jeu vidéo",
"tv-movie-soundtrack": "TV & film",
"alternative": "alternatif",
"game": "jeu vidéo",
"film": "film",
"alt": "alternative",
"dubstep": "dubstep",
"metal": "métal",
"metal": "metal",
"anime": "anime",
"hip-hop-rap": "hip hop & rap",
"hiphop": "hiphop",
"j-pop": "j-pop",
"rock": "rock",
"pop": "pop",
"electronic": "électronique",
"classical-orchestral": "Classique & Orchestral"
"classical-orchestral": "classique & orchestrale"
},
"map-specificities": {
"automapper": "IA",
@@ -1100,9 +999,6 @@
}
}
},
"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.",
@@ -1217,26 +1113,7 @@
"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"],
+27 -149
View File
@@ -14,8 +14,7 @@
"refuse": "拒否する",
"apply": "適用",
"copy": "コピー",
"copied": "コピー済み!",
"confirm": "確認"
"copied": "コピー済み!"
},
"nav-bar": {
"add-version": "バージョンを追加",
@@ -45,16 +44,14 @@
"filters-btn": "絞り込み",
"dropdown": {
"export-maps": "マップをエクスポート",
"delete-maps": "マップを削除",
"delete-duplicate-maps": " 重複を削除"
"delete-maps": "マップを削除"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "マップを閲覧",
"import-maps": "マップをインポート"
"add-maps": {
"text": "追加"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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\" ファイルをここにドロップしてインポート"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "このバージョンで使用できるMODはまだありません。",
"buttons": {
"more-infos": "詳細情報",
"install-or-update": "インストールとアップデート",
"reinstall-all": "すべて再インストール"
"install-or-update": "インストールとアップデート"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "全てアンインストールする"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "すでにインストール済みのMOD",
"description": "選択したすべてのMODはすでにインストールされています"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "ログアウトすると、次のBeat Saberダウンロード時にアカウントを切り替えることができます。",
"logout": "ログアウト",
"logout-success": "ログアウト成功",
"download-platform": {
"title": "デフォルトプラットフォーム",
"desc": "Beat Saberのダウンロードに使用するデフォルトのプラットフォームを選択します。",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "インストールフォルダー",
"description": "BSManager によってダウンロードされたすべてのコンテンツを含むフォルダを変更します。",
"description": "BeatSaberのバージョンとその他の今後の機能を入れるフォルダを変更します。",
"choose-folder": "フォルダーを選択"
},
"additional-content": {
@@ -220,34 +194,6 @@
"changelogs": {
"open" : "🚀 チェンジログを覗いてみて!",
"not-founds": "😕 あれ、チェンジログが見当たらない!"
},
"advanced": {
"title": "高度な設定",
"description": "BSManagerの高度な設定。",
"hardware-acceleration": {
"title": "ハードウェアアクセラレーション",
"description": "ハードウェアアクセラレーションを有効にしてGPUを使用し、BSManagerのパフォーマンスを向上させます。フレームドロップが発生している場合は、これをオフにしてください。",
"modal": {
"title": "再起動が必要",
"body": "ハードウェアアクセラレーションの設定を変更すると、BSManagerが終了して再起動します。本当に続行しますか?",
"confirm-btn": "はい、確かです"
},
"error-notification": {
"message": "エラーが発生しました。ハードウェアアクセラレーションを無効にできません。"
}
},
"use-symlinks": {
"title": "シンボリックリンクを使用",
"description": "フォルダをリンクするためにジャンクションの代わりにシンボリックリンクを使用します。本当に必要な場合にのみこれをオンにしてください。",
"modal": {
"title": "シンボリックリンクの権限",
"body": "シンボリックリンクを作成する際、BSManagerは管理者権限またはシステムで有効になっている開発者モードを必要とします。本当に続行しますか?",
"confirm-btn": "はい、確かです"
},
"error-notification": {
"message": "エラーが発生しました。シンボリックリンク設定を変更できません。"
}
}
}
}
},
@@ -266,13 +212,11 @@
"errors": {
"titles": {
"operation-running": "稼働中",
"no-internet": "インターネット接続がありません",
"file-not-supported": "ファイルはサポートされていません"
"no-internet": "インターネット接続がありません"
},
"msg": {
"operation-running": "現在の処理が終了するのを待ってから、もう一度試してください。",
"no-internet": "インターネット接続を確認し、もう一度お試しください。",
"file-not-supported": "{types}ファイルのみがサポートされています。"
"no-internet": "インターネット接続を確認し、もう一度お試しください。"
}
}
},
@@ -461,8 +405,7 @@
"CantEditSteam": "編集不可",
"CantRename": "改名不可能",
"VersionAlreadExist": "このバージョンは既に存在しています!",
"CantClone": "クローン作成不可",
"UnknownError": "不明なエラーが発生しました"
"CantClone": "クローン作成不可"
},
"msg": {
"CantEditSteam": "Steam版は編集できませんがクローンを作ることは可能です。"
@@ -515,27 +458,6 @@
"one-click-install": {
"success": "マップのインストール完了",
"error": "マップのインストール中にエラーが発生しました"
},
"no-duplicates-maps": {
"title": "重複なし",
"msg": "マップは削除されませんでした"
},
"duplicates-maps-deleted": {
"title": "重複削除",
"msg": "重複が削除されました"
},
"import-map": {
"titles": {
"success": "マップのインポートが完了しました",
"error": "マップのインポート中にエラーが発生しました"
},
"msgs": {
"success": "マップが正常にインポートされました。",
"some-success": "一部のマップが正常にインポートされました。",
"only-accept-zip": "zipファイルのみがサポートされています。",
"invalid-zip": "zipファイルにマップが含まれていません。",
"unknown": "不明なエラーが発生しました。"
}
}
},
"playlists": {
@@ -556,15 +478,6 @@
"title": "バックアップを作成しました",
"msg": "'UserData'フォルダを共有するとエラーが発生する可能性があります。問題が発生した場合は、フォルダをリンク解除してバックアップを復元してください。"
}
},
"linking-error": {
"title": "フォルダのリンク中にエラーが発生しました",
"msg": {
"EPERM": "BSManagerにはフォルダをリンクするための必要な権限がありません。",
"EACCES": "BSManagerにはフォルダをリンクするための必要な権限がありません。",
"ENOSPC": "ディスクがいっぱいです。空き容量を作ってもう一度試してください。",
"UNKNOWN_ERROR": "フォルダをリンク中に不明なエラーが発生しました。"
}
}
},
"create-launch-shortcut": {
@@ -730,11 +643,6 @@
"title": "マップを保持は、リンクを解除した後、共有フォルダから現在のバージョンにすべてのマップをコピーします。これを無効をしても共有マップは失われません。"
},
"valid-btn": "マップのリンクを解除"
},
"delete-duplicate-maps": {
"title": "マップを削除しますか?",
"desc": "マップ「{map}」のみが重複しています。削除してもよろしいですか?",
"desc-plural": "{nb}個の重複したマップが見つかりました。削除してもよろしいですか?"
}
},
"download-maps": {
@@ -815,20 +723,12 @@
},
"launch-as-admin": "管理者として起動",
"not-remind-me": "Больше не напоминать"
},
"ask-install-path": {
"title": "インストールフォルダー",
"choose-folder-description": "BSManager によってダウンロードされたすべてのコンテンツ (バージョン、MOD、マップ、プレイリストなど) を含むフォルダを選択してください。",
"choose-folder": "フォルダーを選択",
"default": "デフォルト",
"default-tooltip": "デフォルトでは、ホームフォルダに設定されます"
}
},
"maps": {
"map-filter-panel": {
"duration": "尺",
"nps" : "秒間ノート数",
"njs": "ノートジャンプ速度",
"tags": "タグ",
"specificities": "一般",
"requirements": "要Mod",
@@ -838,7 +738,7 @@
"accuracy": "正確",
"balanced": "バランス",
"challenge": "挑戦",
"dance-style": "ダンス",
"dancestyle": "ダンス",
"fitness": "フットネス",
"speed": "スピード",
"tech": "技術的"
@@ -847,39 +747,39 @@
"dance": "ダンス",
"swing": "スウィング",
"nightcore": "ナイトコア",
"folk-acoustic": "フォーク & アコースティック",
"kids-family": "キッズ & ファミリー",
"folk": "フォーク",
"family": "ファミリー",
"ambient": "アンビエント",
"funk-disco": "ファンク & ディスコ",
"funk": "ファンク",
"jazz": "ジャズ",
"soul": "ソウル",
"speedcore": "スピードコア",
"speedcore": "スピードコア ",
"punk": "パンク",
"rb": "r&b",
"holiday": "ホリデー",
"holiday": "休日",
"vocaloid": "ボーカロイド",
"j-rock": "J-ロック",
"j-rock": "j-rock",
"trance": "トランス",
"drum-and-bass": "ドラム & ベース",
"comedy-meme": "コメディ & ミーム",
"instrumental": "インストゥルメンタル",
"drumbass": "ドラムベース",
"comedy": "コメディ",
"instrumental": "器楽",
"hardcore": "ハードコア",
"k-pop": "k-pop",
"indie": "インディー",
"techno": "テクノ",
"house": "ハウス",
"video-game-soundtrack": "ゲーム音楽",
"tv-movie-soundtrack": "TV & 映画",
"alternative": "オルタナティ",
"house": "",
"game": "ビデオゲーム",
"film": "映画音楽",
"alt": "オルタナティ",
"dubstep": "ダブステップ",
"metal": "メタル",
"anime": "アニメ",
"hip-hop-rap": "ヒップホップ & ラップ",
"j-pop": "J-ポップ",
"hiphop": "ヒップップ",
"j-pop": "j-pop",
"rock": "ロック",
"pop": "ポップ",
"electronic": "エレクトロニック",
"classical-orchestral": "クラシック & オーケストラ"
"classical-orchestral": "Classical & Orchestral"
},
"map-specificities": {
"automapper": "AI",
@@ -906,8 +806,7 @@
"bsr-code" : "BSRコード",
"download" : "マップをダウンロード",
"downloading" :"マップをダウンロード中",
"cancel-download" : "ダウンロードをキャンセル",
"hightlight-difficulty": "難易度をハイライト"
"cancel-download" : "ダウンロードをキャンセル"
}
},
"models": {
@@ -1099,9 +998,6 @@
}
}
},
"drop-zone": {
"or-browse-files": "またはファイルを参照"
},
"playlist": {
"error-playlist-creation-title": "プレイリストの作成エラー",
"error-playlist-creation-desc": "プレイリストの作成中にエラーが発生しました。",
@@ -1216,24 +1112,6 @@
"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": {
+28 -150
View File
@@ -14,8 +14,7 @@
"refuse": "Отказаться",
"apply": "Применить",
"copy": "Скопировать",
"copied": "Скопировано!",
"confirm": "Подтвердить"
"copied": "Скопировано!"
},
"nav-bar": {
"add-version": "Добавить версию игры",
@@ -45,16 +44,14 @@
"filters-btn": "Фильтр",
"dropdown": {
"export-maps": "Экспорт карт",
"delete-maps": "Удалить карты",
"delete-duplicate-maps": "Удалить дубликаты"
"delete-maps": "Удалить карты"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "Просмотр карт",
"import-maps": "Импорт карт"
"add-maps": {
"text": "Добавить"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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\" для их импорта"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "Не найдены моды для этой версии Beat Saber",
"buttons": {
"more-infos": "Подробнее",
"install-or-update": "Установить или обновить",
"reinstall-all": "Переустановить все"
"install-or-update": "Установить или обновить"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "Удалить всё"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "Моды уже установлены",
"description": "Все выбранные моды уже установлены"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "Выход позволит вам сменить учетную запись при следующей загрузке Beat Saber.",
"logout": "Выйти",
"logout-success": "Выход выполнен успешно",
"download-platform": {
"title": "Основная платформа",
"desc": "Выберите основную платформу, которая будет использоваться для скачивания версий Beat Saber.",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "Папка установок",
"description": "Изменить папку, которая будет содержать весь контент, загруженный BSManager.",
"description": "Измените стандартную папку, где будут версии Beat Saber и прочее.",
"choose-folder": "Изменить папку"
},
"additional-content": {
@@ -220,34 +194,6 @@
"changelogs": {
"open" : "🚀 Изучаем Changelog!",
"not-founds": "😕 Ой, Changelog нигде не видно!"
},
"advanced": {
"title": "Дополнительные",
"description": "Дополнительные настройки для BSManager.",
"hardware-acceleration": {
"title": "Аппаратное ускорение",
"description": "Включите аппаратное ускорение, чтобы использовать ваш GPU и улучшить производительность BSManager. Отключите эту опцию, если у вас возникают пропуски кадров.",
"modal": {
"title": "Требуется перезагрузка",
"body": "Изменение настройки аппаратного ускорения приведет к завершению и перезапуску BSManager. Вы уверены, что хотите это сделать?",
"confirm-btn": "Да, я уверен"
},
"error-notification": {
"message": "Произошла ошибка, невозможно отключить аппаратное ускорение."
}
},
"use-symlinks": {
"title": "Использовать символические ссылки",
"description": "Используйте символические ссылки вместо соединений для связывания папок. Включайте эту опцию только если это действительно необходимо.",
"modal": {
"title": "Разрешения для символических ссылок",
"body": "При создании символических ссылок BSManager потребуется права администратора или включенный режим разработчика на вашем устройстве. Вы уверены, что хотите продолжить?",
"confirm-btn": "Да, я уверен"
},
"error-notification": {
"message": "Произошла ошибка, невозможно изменить настройки символических ссылок."
}
}
}
}
},
@@ -266,13 +212,11 @@
"errors": {
"titles": {
"operation-running": "Операция в процессе",
"no-internet": "Нет интернета",
"file-not-supported": "Файл не поддерживается"
"no-internet": "Нет интернета"
},
"msg": {
"operation-running": "Дождитесь завершения операции, потом попробуйте снова.",
"no-internet": "Проверьте своё соединение и попробуйте снова.",
"file-not-supported": "Поддерживаются только файлы {types}."
"no-internet": "Проверьте своё соединение и попробуйте снова."
}
}
},
@@ -461,8 +405,7 @@
"CantEditSteam": "Не удалось изменить",
"CantRename": "Переименование невозможно",
"VersionAlreadExist": "Эта версия уже добавлена",
"CantClone": "Клонирование невозможно",
"UnknownError": "Произошла неизвестная ошибка"
"CantClone": "Клонирование невозможно"
},
"msg": {
"CantEditSteam": "Вы не можете изменить версию из Steam, но вы можете её клонировать."
@@ -515,27 +458,6 @@
"one-click-install": {
"success": "Карта установлена",
"error": "Ошибка установки карты"
},
"no-duplicates-maps": {
"title": "Нет дубликатов",
"msg": "Ни одна карта не была удалена"
},
"duplicates-maps-deleted": {
"title": "Дубликаты удалены",
"msg": "Дубликаты были удалены"
},
"import-map": {
"titles": {
"success": "Импорт карт завершен",
"error": "Произошла ошибка при импорте карт"
},
"msgs": {
"success": "Карты были успешно импортированы.",
"some-success": "Некоторые карты успешно импортированы.",
"only-accept-zip": "Поддерживаются только zip-файлы.",
"invalid-zip": "В zip-файле(ах) нет карт.",
"unknown": "Произошла неизвестная ошибка."
}
}
},
"playlists": {
@@ -556,15 +478,6 @@
"title": "Бэкап создан",
"msg": "Общая папка 'UserData' может создавать ошибки. В случае проблем отвяжите папку, чтобы восстановить бэкап."
}
},
"linking-error": {
"title": "Ошибка при связывании папки",
"msg": {
"EPERM": "BSManager не имеет необходимых прав для связывания папки.",
"EACCES": "BSManager не имеет необходимых прав для связывания папки.",
"ENOSPC": "Диск заполнен, освободите место и попробуйте снова.",
"UNKNOWN_ERROR": "Произошла неизвестная ошибка при связывании папки."
}
}
},
"create-launch-shortcut": {
@@ -730,11 +643,6 @@
"title": "Общие карты будут скопированы в папку карт этой версии. Карты не будут потеряны, если это не выбрано."
},
"valid-btn": "Отвязать карты"
},
"delete-duplicate-maps": {
"title": "Удалить карту?",
"desc": "Только карта \"{map}\" является дубликатом. Вы уверены, что хотите ее удалить?",
"desc-plural": "Найдено {nb} дубликатов карт. Вы уверены, что хотите их удалить?"
}
},
"download-maps": {
@@ -814,20 +722,12 @@
"info-3": "Обратите внимание, что не рекомендуется предоставлять права администратора Steam, так как это также влияет на установленные игры и моды, представляя угрозу безопасности."
},
"launch-as-admin": "Запустить от имени администратора"
},
"ask-install-path": {
"title": "Папка установок",
"choose-folder-description": "Выберите папку, которая будет содержать весь контент, загруженный BSManager. (версии, моды, карты, плейлисты и т.д.)",
"choose-folder": "Изменить папку",
"default": "По умолчанию",
"default-tooltip": "По умолчанию в вашей домашней папке"
}
},
"maps": {
"map-filter-panel": {
"duration": "Длительность",
"nps" : "Нот в Секунду",
"njs": "Скорость прыжка нот",
"tags": "тэги",
"specificities": "основное",
"requirements": "требуемые моды",
@@ -837,7 +737,7 @@
"accuracy": "точность",
"balanced": "баланс",
"challenge": "испытание",
"dance-style": "танец",
"dancestyle": "танец",
"fitness": "фитнес",
"speed": "скорость",
"tech": "техника"
@@ -846,36 +746,36 @@
"dance": "танец",
"swing": "свинг",
"nightcore": "найткор",
"folk-acoustic": "фолк и акустика",
"kids-family": "дети и семья",
"folk": "народные",
"family": "семейные",
"ambient": "эмбиент",
"funk-disco": "фанк и диско",
"funk": "фанк",
"jazz": "джаз",
"soul": "соул",
"speedcore": "спидкор",
"punk": "панк",
"rb": "r&b",
"holiday": "праздники",
"vocaloid": "вокалоид",
"rb": "ар-н-би",
"holiday": "праздник",
"vocaloid": "вокалоиды",
"j-rock": "джей-рок",
"trance": "транс",
"drum-and-bass": "драм-н-бейс",
"comedy-meme": "комедия и мемы",
"instrumental": "инструментал",
"drumbass": "драм-н-бейс",
"comedy": "комедия",
"instrumental": "инструментальные",
"hardcore": "хардкор",
"k-pop": "k-pop",
"k-pop": "кей-поп",
"indie": "инди",
"techno": "техно",
"house": "хаус",
"video-game-soundtrack": "видеоигра",
"tv-movie-soundtrack": "ТВ и кино",
"alternative": "альтернатива",
"dubstep": "дабстеп",
"game": "видеоигры",
"film": "фильмы",
"alt": "альтернативные",
"dubstep": "дапстеп",
"metal": "метал",
"anime": "аниме",
"hip-hop-rap": "хип-хоп и рэп",
"j-pop": "j-pop",
"rock": "рок",
"hiphop": "хипхоп",
"j-pop": "джей-поп",
"rock": "прк",
"pop": "поп",
"electronic": "электроника",
"classical-orchestral": "Классика и Оркестр"
@@ -905,8 +805,7 @@
"bsr-code" : "Код BSR",
"download" : "Скачать карту",
"downloading" :"Загрузка карты",
"cancel-download" : "Отменить загрузку",
"hightlight-difficulty": "Выделить сложность"
"cancel-download" : "Отменить загрузку"
}
},
"models": {
@@ -1098,9 +997,6 @@
}
}
},
"drop-zone": {
"or-browse-files": "Или просмотреть файлы"
},
"playlist": {
"error-playlist-creation-title": "Ошибка при создании плейлиста",
"error-playlist-creation-desc": "Произошла ошибка при создании плейлиста.",
@@ -1215,24 +1111,6 @@
"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": {
+47 -169
View File
@@ -14,8 +14,7 @@
"refuse": "拒絕",
"apply": "應用",
"copy": "複製",
"copied": "已複製!",
"confirm": "確認"
"copied": "已複製!"
},
"nav-bar": {
"add-version": "新增版本",
@@ -45,16 +44,14 @@
"filters-btn": "篩選",
"dropdown": {
"export-maps": "導出譜面",
"delete-maps": "刪除譜面",
"delete-duplicate-maps": "刪除重複項"
"delete-maps": "刪除譜面"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "瀏覽地圖",
"import-maps": "匯入地圖"
"add-maps": {
"text": "新增"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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\" 檔案拖曳到這裡進行匯入"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "該版本 BeatSaber 暫無可用 Mod",
"buttons": {
"more-infos": "更多資訊",
"install-or-update": "安裝或更新",
"reinstall-all": "重新安裝全部"
"install-or-update": "安裝或更新"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "全部移除"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "模組已安裝",
"description": "所有選中的模組已經安裝"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "登出後,您可以在下一次下載 Beat Saber 時切換帳戶。",
"logout": "登出",
"logout-success": "登出成功",
"download-platform": {
"title": ",預設平台",
"desc": "選擇要下載 BeatSaber 的預設平台。",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "安裝文件夾",
"description": "更改將包含 BSManager 下載的所有內容的文件夾",
"description": "為 BeatSaber 不同版本及未來其他特性修改預設文件夾",
"choose-folder": "選擇文件夾"
},
"additional-content": {
@@ -220,34 +194,6 @@
"changelogs": {
"open" : "🚀 探索更新日誌!",
"not-founds": "😕 噢,找不到更新日誌!"
},
"advanced": {
"title": "高级设置",
"description": "BSManager的高级设置。",
"hardware-acceleration": {
"title": "硬體加速",
"description": "啟用硬體加速以使用您的GPU並提高BSManager的性能。如果您遇到幀丟失,請關閉此功能。",
"modal": {
"title": "需要重啟",
"body": "更改硬體加速設置將退出並重新啟動BSManager。您確定要這樣做嗎?",
"confirm-btn": "是的,我確定"
},
"error-notification": {
"message": "發生錯誤,無法禁用硬體加速。"
}
},
"use-symlinks": {
"title": "使用符號鏈接",
"description": "使用符號鏈接而不是連接來鏈接文件夾。僅在確實需要時才啟用此功能。",
"modal": {
"title": "符號鏈接權限",
"body": "創建符號鏈接時,BSManager將需要管理員權限或啟用開發者模式。您確定要繼續嗎?",
"confirm-btn": "是的,我確定"
},
"error-notification": {
"message": "發生錯誤,無法更改符號鏈接設置。"
}
}
}
}
},
@@ -266,13 +212,11 @@
"errors": {
"titles": {
"operation-running": "操作進行中",
"no-internet": "無網路",
"file-not-supported": "文件不受支持"
"no-internet": "無網路"
},
"msg": {
"operation-running": "等待當前操作完成,然後重試。",
"no-internet": "檢查你的連接並重試。",
"file-not-supported": "僅支援{types}檔案。"
"no-internet": "檢查你的連接並重試。"
}
}
},
@@ -461,8 +405,7 @@
"CantEditSteam": "無法編輯",
"CantRename": "無法重命名",
"VersionAlreadExist": "該版本已存在",
"CantClone": "無法複製",
"UnknownError": "發生了未知錯誤"
"CantClone": "無法複製"
},
"msg": {
"CantEditSteam": "你不能編輯 Steam 版本。不過你可以複製它。"
@@ -515,27 +458,6 @@
"one-click-install": {
"success": "譜面安裝完成",
"error": "安裝譜面時發生錯誤"
},
"no-duplicates-maps": {
"title": "沒有重複",
"msg": "沒有刪除地圖"
},
"duplicates-maps-deleted": {
"title": "重複已刪除",
"msg": "重複已刪除"
},
"import-map": {
"titles": {
"success": "地圖匯入完成",
"error": "地圖匯入時發生錯誤"
},
"msgs": {
"success": "地圖已成功匯入。",
"some-success": "部分地圖已成功匯入。",
"only-accept-zip": "僅支援zip檔案。",
"invalid-zip": "zip檔案中沒有地圖。",
"unknown": "發生了未知錯誤。"
}
}
},
"playlists": {
@@ -556,15 +478,6 @@
"title": "備份已創建",
"msg": "共享 “UserData” 文件夾可能會產生錯誤,如果出現問題,請取消關聯該文件夾以恢復備份"
}
},
"linking-error": {
"title": "連結文件夾時出錯",
"msg": {
"EPERM": "BSManager沒有必要的權限來連結文件夾。",
"EACCES": "BSManager沒有必要的權限來連結文件夾。",
"ENOSPC": "磁碟已滿,請騰出空間後再試。",
"UNKNOWN_ERROR": "連結文件夾時發生未知錯誤。"
}
}
},
"create-launch-shortcut": {
@@ -730,11 +643,6 @@
"title": "保留譜面將會在取消關聯後把共享文件夾的所有譜面複製到當前版本。如果此項被禁用,譜面也不會遺失。"
},
"valid-btn": "取消關聯譜面"
},
"delete-duplicate-maps": {
"title": "刪除地圖",
"desc": "只有地圖「{map}」是重複的。你確定要刪除它嗎?",
"desc-plural": "發現了 {nb} 個重複的地圖。你確定要刪除它們嗎?"
}
},
"download-maps": {
@@ -815,20 +723,12 @@
},
"launch-as-admin": "以管理員身份啟動",
"not-remind-me": "不再提醒我"
},
"ask-install-path": {
"title": "安裝文件夾",
"choose-folder-description": "選擇將包含 BSManager 下載的所有內容的文件夾。(版本、mod、地圖、播放列表等)",
"choose-folder": "選擇文件夾",
"default": "預設",
"default-tooltip": "預設為您的主資料夾"
}
},
"maps": {
"map-filter-panel": {
"duration": "時長",
"nps" : "每秒音符數",
"njs": "音符跳躍速度",
"tags": "標籤",
"specificities": "general",
"requirements": "要求",
@@ -838,48 +738,48 @@
"accuracy": "精確度",
"balanced": "平衡",
"challenge": "挑戰",
"dance-style": "舞蹈",
"dancestyle": "舞蹈",
"fitness": "健身",
"speed": "速度",
"tech": "技術"
},
"map-styles": {
"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": "古典 & 管弦樂"
"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"
},
"map-specificities": {
"automapper": "AI",
@@ -906,8 +806,7 @@
"bsr-code" : "BSR代碼",
"download" : "下載地圖",
"downloading" :"正在下載地圖",
"cancel-download" : "取消下載",
"hightlight-difficulty": "突出顯示難度"
"cancel-download" : "取消下載"
}
},
"models": {
@@ -1099,9 +998,6 @@
}
}
},
"drop-zone": {
"or-browse-files": "或瀏覽檔案"
},
"playlist": {
"error-playlist-creation-title": "建立播放清單時發生錯誤",
"error-playlist-creation-desc": "建立播放清單時發生錯誤。",
@@ -1216,24 +1112,6 @@
"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": {
+45 -167
View File
@@ -14,8 +14,7 @@
"refuse": "拒绝",
"apply": "应用",
"copy": "复制",
"copied": "已复制!",
"confirm": "确认"
"copied": "已复制!"
},
"nav-bar": {
"add-version": "添加版本",
@@ -45,16 +44,14 @@
"filters-btn": "筛选",
"dropdown": {
"export-maps": "导出谱面",
"delete-maps": "删除谱面",
"delete-duplicate-maps": "删除重复项"
"delete-maps": "删除谱面"
}
},
"tabs": {
"maps": {
"actions": {
"drop-down": {
"browse-maps": "浏览地图",
"import-maps": "导入地图"
"add-maps": {
"text": "添加"
},
"link-maps": {
"tooltips": {
@@ -66,21 +63,6 @@
"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\" 文件拖放到这里进行导入"
}
}
}
@@ -91,8 +73,7 @@
"mods-not-available": "该版本 BeatSaber 暂无可用 Mod",
"buttons": {
"more-infos": "更多信息",
"install-or-update": "安装或更新",
"reinstall-all": "重新安装全部"
"install-or-update": "安装或更新"
},
"mods-grid": {
"header-bar": {
@@ -104,12 +85,6 @@
"uninstall-all": "全部卸载"
}
}
},
"notifications": {
"all-mods-already-installed": {
"title": "模组已安装",
"description": "所有选中的模组已经安装"
}
}
},
"dropdown": {
@@ -137,7 +112,6 @@
"title": "Steam & Oculus",
"description": "登出后,您可以在下一次下载 Beat Saber 时切换帐户。",
"logout": "登出",
"logout-success": "注销成功",
"download-platform": {
"title": ",默认平台",
"desc": "选择要下载 BeatSaber 的默认平台。",
@@ -157,7 +131,7 @@
},
"installation-folder": {
"title": "安装文件夹",
"description": "更改将包含 BSManager 下载的所有内容的文件夹",
"description": "为 BeatSaber 不同版本及未来其他特性修改默认文件夹",
"choose-folder": "选择文件夹"
},
"additional-content": {
@@ -220,34 +194,6 @@
"changelogs": {
"open" : "🚀 探索更新日志!",
"not-founds": "😕 哎呀,找不到更新日志!"
},
"advanced": {
"title": "高级设置",
"description": "BSManager的高级设置。",
"hardware-acceleration": {
"title": "硬件加速",
"description": "启用硬件加速以使用您的GPU并提高BSManager的性能。如果您遇到帧丢失,请关闭此功能。",
"modal": {
"title": "需要重启",
"body": "更改硬件加速设置将退出并重新启动BSManager。您确定要这样做吗?",
"confirm-btn": "是的,我确定"
},
"error-notification": {
"message": "发生错误,无法禁用硬件加速。"
}
},
"use-symlinks": {
"title": "使用符号链接",
"description": "使用符号链接而不是联接来链接文件夹。仅在确实需要时才启用此功能。",
"modal": {
"title": "符号链接权限",
"body": "创建符号链接时,BSManager将需要管理员权限或启用开发者模式。您确定要继续吗?",
"confirm-btn": "是的,我确定"
},
"error-notification": {
"message": "发生错误,无法更改符号链接设置。"
}
}
}
}
},
@@ -266,13 +212,11 @@
"errors": {
"titles": {
"operation-running": "操作进行中",
"no-internet": "无网络",
"file-not-supported": "文件不受支持"
"no-internet": "无网络"
},
"msg": {
"operation-running": "等待当前操作完成,然后重试。",
"no-internet": "检查你的连接并重试。",
"file-not-supported": "仅支持{types}文件。"
"no-internet": "检查你的连接并重试。"
}
}
},
@@ -461,8 +405,7 @@
"CantEditSteam": "无法编辑",
"CantRename": "无法重命名",
"VersionAlreadExist": "该版本已存在",
"CantClone": "无法克隆",
"UnknownError": "发生了未知错误"
"CantClone": "无法克隆"
},
"msg": {
"CantEditSteam": "你不能编辑 Steam 版本。不过你可以克隆它。"
@@ -515,27 +458,6 @@
"one-click-install": {
"success": "谱面安装完成",
"error": "安装谱面时发生错误"
},
"no-duplicates-maps": {
"title": "没有重复",
"msg": "没有删除地图"
},
"duplicates-maps-deleted": {
"title": "重复已删除",
"msg": "重复已删除"
},
"import-map": {
"titles": {
"success": "地图导入完成",
"error": "导入地图时发生错误"
},
"msgs": {
"success": "地图已成功导入。",
"some-success": "部分地图已成功导入。",
"only-accept-zip": "仅支持zip文件。",
"invalid-zip": "zip文件中没有地图。",
"unknown": "发生了未知错误。"
}
}
},
"playlists": {
@@ -556,15 +478,6 @@
"title": "备份已创建",
"msg": "共享 “UserData” 文件夹可能会产生错误,如果出现问题,请取消关联该文件夹以恢复备份"
}
},
"linking-error": {
"title": "链接文件夹时出错",
"msg": {
"EPERM": "BSManager没有必要的权限来链接文件夹。",
"EACCES": "BSManager没有必要的权限来链接文件夹。",
"ENOSPC": "磁盘已满,请腾出空间后再试。",
"UNKNOWN_ERROR": "链接文件夹时发生未知错误。"
}
}
},
"create-launch-shortcut": {
@@ -730,11 +643,6 @@
"title": "保留谱面将会在取消关联后把共享文件夹的所有谱面复制到当前版本。如果此项被禁用,谱面也不会丢失。"
},
"valid-btn": "取消关联谱面"
},
"delete-duplicate-maps": {
"title": "删除地图",
"desc": "只有地图 \"{map}\" 是重复的。你确定要删除它吗?",
"desc-plural": "发现了 {nb} 个重复的地图。你确定要删除它们吗?"
}
},
"download-maps": {
@@ -815,20 +723,12 @@
},
"launch-as-admin": "以管理员身份启动",
"not-remind-me": "不再提醒我"
},
"ask-install-path": {
"title": "安装文件夹",
"choose-folder-description": "选择将包含 BSManager 下载的所有内容的文件夹。(版本、mod、地图、播放列表等)",
"choose-folder": "选择文件夹",
"default": "默认",
"default-tooltip": "默认为您的主文件夹"
}
},
"maps": {
"map-filter-panel": {
"duration": "时长",
"nps" : "每秒音符数",
"njs": "音符跳跃速度",
"tags": "标签",
"specificities": "general",
"requirements": "要求",
@@ -838,48 +738,48 @@
"accuracy": "精确度",
"balanced": "平衡",
"challenge": "挑战",
"dance-style": "舞蹈",
"dancestyle": "舞蹈",
"fitness": "健身",
"speed": "速度",
"tech": "技术"
},
"map-styles": {
"dance": "舞蹈",
"swing": "摇摆",
"nightcore": "夜核",
"folk-acoustic": "民谣 & 原声",
"kids-family": "儿童 & 家庭",
"ambient": "氛围音乐",
"funk-disco": "放克 & 迪斯科",
"jazz": "爵士",
"soul": "灵魂",
"speedcore": "极速核",
"punk": "朋克",
"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": "假日",
"vocaloid": "声库",
"j-rock": "日式摇滚",
"trance": "迷幻",
"drum-and-bass": "鼓 & 贝斯",
"comedy-meme": "喜剧 & 表情包",
"instrumental": "纯音乐",
"hardcore": "硬核",
"holiday": "holiday",
"vocaloid": "vocaloid",
"j-rock": "j-rock",
"trance": "trance",
"drumbass": "drum & bass",
"comedy": "comedy",
"instrumental": "instrumental",
"hardcore": "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": "古典 & 管弦乐"
"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"
},
"map-specificities": {
"automapper": "AI",
@@ -906,8 +806,7 @@
"bsr-code" : "BSR代码",
"download" : "下载地图",
"downloading" :"正在下载地图",
"cancel-download" : "取消下载",
"hightlight-difficulty": "突出显示难度"
"cancel-download" : "取消下载"
}
},
"models": {
@@ -1099,9 +998,6 @@
}
}
},
"drop-zone": {
"or-browse-files": "或浏览文件"
},
"playlist": {
"error-playlist-creation-title": "创建播放列表时出错",
"error-playlist-creation-desc": "创建播放列表时发生错误。",
@@ -1216,24 +1112,6 @@
"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": {
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

@@ -1,21 +0,0 @@
If you encounter a keyboard shortcut conflict between AMD Software and Oculus Rift, you can either disable or modify the conflicting shortcut.
_Special thanks to_ `𝔽𝕪𝕟𝕟 (fynn07)` _for its contribution with information and screenshots_
### __Follow these steps to disable the shortcut in AMD Software:__
# Step 1 - Access Settings:
- Open AMD Software and click on the gear icon in the top right corner to go to settings.
![image](https://github.com/Zagrios/bs-manager/assets/40648115/2762f59c-f12a-46ba-80fa-9bf79beb64a9)
# Step 2 - Modify Keyboard Shortcuts:
- In the settings menu, select the "Hotkey" tab.
![image](https://github.com/Zagrios/bs-manager/assets/40648115/262d4fcc-529f-4c4c-9b87-e2da21df1b95)
# Step 3 -Disabling Shortcuts:
- Turn off the "Use Hotkeys" option to prevent any conflicts with Oculus Rift.
![image](https://github.com/Zagrios/bs-manager/assets/40648115/756018ad-7de3-469c-9f40-0082be007805)
# Step 3 alt - Modify the Shortcut:
- If you prefer to keep using keyboard shortcuts, consider changing the specific conflicting shortcut (e.g., "Ctrl + Shift + I") to another combination that is less likely to interfere.
️ _**Keep in mind you can't change the full keybind!**_
_**You can only Change the "i" to an different Button!**_
-14
View File
@@ -1,14 +0,0 @@
Welcome to the bs-manager wiki! (wip c:)
to get you oculus token check here : https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token
---
This Wiki accepts contributions!
To contribute to the wiki:
- Fork the project
- Add/Edit `.md` files in the `docs/wiki/` folder
- Create a Pull Request
- Once the PR is merged (after review), it will be present in the repository's wiki
@@ -1,30 +0,0 @@
### Important
Your token is a confidential piece of information. Possession of this token allows individuals to download applications, send messages, among other actions, under your identity.
However, you might wonder why it is necessary to provide this token to BSManager. The reason is that BSManager requires the token to continue the download with Oculus. Once you've input the token, it is used exclusively to communicate with Oculus servers to verify that you are the rightful owner of the game.
## Step 1 - Install and log into the Oculus Rift app
- Get the Oculus Rift app setup from the [Meta website](https://www.oculus.com/rift/setup/)
- Install the Oculus Rift app
**If you bought Beat Saber from the Quest store, it won't appear in your Rift library by default. To download it with BSManager, first claim it from its store page**
## Step 2 - Open developer tools
- Open Oculus app
- Open the developer tools by pressing <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>i</kbd>.
## Step 3 - Copy your Token
In the developer tools :
- Open the `Network` tab ***(1)***
- Filter for `graph` ***(2)***
- Click on the first request ***(3)***
- Open the `Headers` tab ***(4)***
- Scroll to the bottom to locate your token, it should start with `FRL` ***(5)***
- Select the token using your mouse and press <kbd>Ctrl</kbd> + <kbd>c</kbd> to copy it
![image](https://github.com/Zagrios/bs-manager/assets/40181755/b46be623-cefe-4349-bace-70a4109685a3)
# Known bugs
- [Nothing opens when I press `Ctrl`+`Shift`+`i`.](https://github.com/Zagrios/bs-manager/wiki/Nothing-opens-when-I-press-%60Ctrl%60%E2%80%90%60Shift%60%E2%80%90%60i%60.)
@@ -1,4 +0,0 @@
If nothing opens when you press `Ctrl` + `Shift` + `I`, it's possible that the keyboard shortcut to open the development tools is being used by another application.

Here's a non-exhaustive list of applications that have caused a problem and their suggested solutions:
- [AMD software](https://github.com/Zagrios/bs-manager/wiki/AMD-Sofware-%E2%80%90-Nothing-opens-when-I-press-%60Ctrl%60%E2%80%90%60Shift%60%E2%80%90%60I%60)
+1306 -927
View File
File diff suppressed because it is too large Load Diff
+37 -38
View File
@@ -1,26 +1,24 @@
{
"name": "bs-manager",
"description": "Manage maps, mods and more for Beat Saber",
"main": "./.erb/dll/main.bundle.dev.js",
"version": "1.5.0-alpha.4",
"main": "./src/main/main.ts",
"version": "1.5.0",
"scripts": {
"build-rust-scripts": "ts-node ./.erb/scripts/build-rust-scripts.js",
"build-rust-scripts": "node -r esbuild-register ./.erb/scripts/build-rust-scripts.js",
"build": "concurrently \"npm run build:main\" \"npm run build:renderer\"",
"build:dll": "cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"build:main": "cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"postinstall": "node -r esbuild-register .erb/scripts/check-native-dep.js && electron-builder install-app-deps && npm run build:dll",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"prestart": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.dev.ts",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && npm run prestart && npm run start:renderer",
"start:main": "concurrently -k \"cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --watch --config ./.erb/configs/webpack.config.main.dev.ts\" \"electronmon .\"",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"package": "node -r esbuild-register ./.erb/scripts/clean.js dist && npm run build && electron-builder build --publish never && npm run build:dll",
"start": "node -r esbuild-register ./.erb/scripts/check-port-in-use.js && npm run start:renderer",
"start:main": "cross-env NODE_ENV=development NODE_OPTIONS=\"--loader esbuild-register/loader -r esbuild-register\" electronmon .",
"start:preload": "cross-env NODE_ENV=development webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest",
"publish": "npm run build && electron-builder -c.win.certificateSha1=2164d6a7d641ecf6ad57852f665a518ca2bf960f --publish always --win --x64",
"publish:linux": "npm run build && electron-builder --publish always --linux --x64"
"publish": "npm run build && electron-builder -c.win.certificateSha1=842a817a51e2a1d360fcd62f54bf5f9193e919e1 --publish always --win --x64"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -38,6 +36,7 @@
},
"build": {
"extraResources": [
"./assets/favicon.ico",
"./assets/jsons/bs-versions.json",
"./assets/jsons/patreons.json",
"./assets/proto/song_details_cache_v1.proto"
@@ -53,14 +52,12 @@
"afterSign": ".erb/scripts/notarize.js",
"afterPack": ".erb/scripts/after-pack.js",
"win": {
"signingHashAlgorithms": ["sha256"],
"target": [
"nsis",
"nsis-web"
],
"icon": "./build/icons/win/favicon.ico",
"icon": "assets/favicon.ico",
"extraResources": [
"./build/icons/win",
"./assets/scripts/*.exe"
]
},
@@ -68,10 +65,8 @@
"target": [
"AppImage"
],
"icon": "./build/icons/png",
"category": "Utility;Game;",
"category": "Development",
"extraResources": [
"./build/icons/png",
"./assets/scripts/DepotDownloader"
]
},
@@ -184,10 +179,12 @@
"css-loader": "^6.10.0",
"css-minimizer-webpack-plugin": "^6.0.0",
"detect-port": "^1.5.1",
"electron": "^32.1.2",
"electron-builder": "^24.13.3",
"electron": "^28.2.4",
"electron-builder": "^24.12.0",
"electron-devtools-installer": "^3.2.0",
"electronmon": "^2.0.2",
"esbuild": "^0.20.1",
"esbuild-register": "^3.5.0",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.1.0",
@@ -210,13 +207,15 @@
"postcss": "^8.4.33",
"postcss-loader": "^8.1.0",
"prettier": "^3.2.4",
"ps-scrollbar-tailwind": "0.0.1",
"react-refresh": "^0.14.0",
"react-test-renderer": "^18.2.0",
"rimraf": "^5.0.5",
"sass": "^1.70.0",
"sass-loader": "^14.1.0",
"style-loader": "^3.3.4",
"tailwindcss": "^3.4.12",
"tailwind-scrollbar": "^3.1.0",
"tailwindcss": "^3.4.1",
"terser-webpack-plugin": "^5.3.10",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1",
@@ -234,7 +233,7 @@
"@nextui-org/react": "^2.3.6",
"@node-steam/vdf": "^2.2.0",
"@tippyjs/react": "^4.2.6",
"archiver": "^7.0.1",
"archiver": "^6.0.1",
"clsx": "^2.1.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
@@ -244,39 +243,39 @@
"electron-debug": "^3.2.0",
"electron-log": "^4.4.8",
"electron-store": "^8.1.0",
"electron-updater": "^6.3.4",
"electron-updater": "^6.1.8",
"fast-deep-equal": "^3.1.3",
"format-duration": "^3.0.2",
"framer-motion": "^11.2.6",
"fs-extra": "^11.2.0",
"got": "^14.4.2",
"got": "^14.2.0",
"history": "^5.3.0",
"is-elevated": "^4.0.0",
"jszip": "^3.10.1",
"md5-file": "^5.0.0",
"node-abi": "^3.65.0",
"node-abi": "^3.56.0",
"node-fetch": "^3.3.2",
"node-stream-zip": "^1.15.0",
"pako": "^2.1.0",
"protobufjs": "^7.4.0",
"qrcode.react": "^4.0.1",
"protobufjs": "^7.3.0",
"qrcode.react": "^3.1.0",
"query-process": "^0.0.3",
"react": "^18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-range": "^1.10.0",
"react-router-dom": "^6.24.1",
"react-virtualized-auto-sizer": "^1.0.24",
"react-range": "^1.8.14",
"react-router-dom": "^6.22.1",
"react-virtualized-auto-sizer": "^1.0.23",
"react-window": "^1.8.10",
"recursive-readdir": "^2.2.3",
"rfdc": "^1.4.1",
"rfdc": "^1.3.1",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"semver": "^7.6.3",
"semver": "^7.6.0",
"serialize-error": "^11.0.3",
"striptags": "^4.0.0-alpha.4",
"tailwind-merge": "^2.5.2",
"tailwind-merge": "^2.3.0",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss-scoped-groups": "^2.0.0",
"tippy.js": "^6.3.7",
"to-ico": "^1.1.5",
@@ -314,13 +313,13 @@
},
"electronmon": {
"patterns": [
"!**/**",
"src/main/**",
".erb/dll/**"
"!src/__tests__/**",
"!release/**",
"!assets/**"
],
"logLevel": "quiet"
},
"volta": {
"node": "20.17.0"
"node": "20.11.0"
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bs-manager",
"version": "1.5.0-alpha.4",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bs-manager",
"version": "1.5.0-alpha.4",
"version": "1.5.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "bs-manager",
"version": "1.5.0-alpha.4",
"version": "1.5.0",
"description": "BSManager",
"main": "./dist/main/main.js",
"author": {
@@ -9,9 +9,9 @@
"url": "https://github.com/Zagrios/bs-manager"
},
"scripts": {
"rebuild": "node -r ts-node/register ../../.erb/scripts/electron-rebuild.js",
"link-modules": "node -r ts-node/register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run rebuild && npm run link-modules"
"electron-rebuild": "node -r esbuild-register ../../.erb/scripts/electron-rebuild.js",
"link-modules": "node -r esbuild-register ../../.erb/scripts/link-modules.ts",
"postinstall": "npm run electron-rebuild && npm run link-modules"
},
"dependencies": {
"@resvg/resvg-js": "2.6.2",
+17 -21
View File
@@ -1,4 +1,4 @@
import { CopyOptions, MoveOptions, copy, createReadStream, ensureDir, move, pathExists, pathExistsSync, realpath, stat, symlink } from "fs-extra";
import { CopyOptions, copy, createReadStream, ensureDir, move, pathExists, pathExistsSync, realpath, stat, symlink } from "fs-extra";
import { access, mkdir, rm, readdir, unlink, lstat, readlink } from "fs/promises";
import path from "path";
import { Observable, concatMap, from } from "rxjs";
@@ -7,8 +7,6 @@ import { BsmException } from "shared/models/bsm-exception.model";
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 {
@@ -80,37 +78,36 @@ export async function getFilesInFolder(folderPath: string): Promise<string[]> {
return dirEntries.filter(entry => entry.isFile()).map(file => path.join(folderPath, file.name));
}
export function moveFolderContent(src: string, dest: string, option?: MoveOptions): Observable<Progression> {
export function moveFolderContent(src: string, dest: string): Observable<Progression> {
const progress: Progression = { current: 0, total: 0 };
return new Observable<Progression>(subscriber => {
subscriber.next(progress);
(async () => {
const srcExist = await pathExists(src);
const srcExist = await pathExist(src);
if (!srcExist) {
return subscriber.complete();
}
await ensureFolderExist(dest);
ensureFolderExist(dest);
const files = await readdir(src, { encoding: "utf-8", withFileTypes: true });
const files = await readdir(src, { encoding: "utf-8" });
progress.total = files.length;
for(const file of files){
const srcFullPath = path.join(src, file.name);
const destFullPath = path.join(dest, file.name);
const srcChilds = file.isDirectory() ? await readdir(srcFullPath, { encoding: "utf-8", recursive: true }) : [];
const allChildsAlreadyExist = srcChilds.every(child => pathExistsSync(path.join(destFullPath, child)));
if(file.isFile() || !allChildsAlreadyExist){
await move(srcFullPath, destFullPath, option);
const promises = files.map(async file => {
const srcFullPath = path.join(src, file);
const destFullPath = path.join(dest, file);
if (await pathExist(destFullPath)) {
progress.current++;
return subscriber.next(progress);
}
await move(srcFullPath, destFullPath);
progress.current++;
subscriber.next(progress);
}
})().catch(err => subscriber.error(CustomError.fromError(err, err?.code))).finally(() => subscriber.complete());
});
Promise.allSettled(promises).then(() => subscriber.complete());
})();
});
}
@@ -154,7 +151,7 @@ export async function copyDirectoryWithJunctions(src: string, dest: string, opti
const symlinkTarget = await readlink(sourcePath);
const relativePath = path.relative(src, symlinkTarget);
const newTarget = path.join(dest, relativePath);
await symlink(newTarget, destinationPath, "junction"); // Only junction to avoid right issues while copying content of BSManager folder
await symlink(newTarget, destinationPath, "junction");
}
}
}
@@ -280,5 +277,4 @@ export interface Progression<T = unknown, D = unknown> {
diff?: number;
data?: T;
extra?: D;
lastError?: ErrorObject;
}
-27
View File
@@ -1,27 +0,0 @@
import { pathExistsSync } from "fs-extra";
import { from, of } from "rxjs";
import { InstallationLocationService } from "main/services/installation-location.service";
import { IpcService } from "main/services/ipc.service";
const ipc = IpcService.getInstance();
ipc.on("bs-installer.folder-exists", (_, reply) => {
const service = InstallationLocationService.getInstance();
reply(of(pathExistsSync(service.installationDirectory())));
});
ipc.on("bs-installer.default-install-path", (_, reply) => {
const service = InstallationLocationService.getInstance();
reply(of(service.defaultInstallationDirectory()));
});
ipc.on("bs-installer.install-path", (_, reply) => {
const service = InstallationLocationService.getInstance();
reply(of(service.installationDirectory()));
});
ipc.on("bs-installer.set-install-path", (args, reply) => {
const service = InstallationLocationService.getInstance();
reply(from(service.setInstallationDirectory(args.path, args.move)));
});
+1 -11
View File
@@ -22,21 +22,11 @@ ipc.on("export-maps", async (args, reply) => {
reply(await maps.exportMaps(args.version, args.maps, args.outPath));
});
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) => {
ipc.on("download-map", async (args, reply) => {
const maps = LocalMapsManagerService.getInstance();
reply(from(maps.downloadMap(args.map, args.version)));
});
ipc.on("last-downloaded-map", (_, reply) => {
const maps = LocalMapsManagerService.getInstance();
reply(maps.lastDownloadedMap$);
})
ipc.on("one-click-install-map", (args, reply) => {
const maps = LocalMapsManagerService.getInstance();
reply(from(maps.oneClickDownloadMap(args)))
+12 -6
View File
@@ -1,12 +1,13 @@
import { BsModsManagerService } from "../services/mods/bs-mods-manager.service";
import { IpcService } from "../services/ipc.service";
import { from } from "rxjs";
import { BeatModsApiService } from "main/services/mods/beat-mods-api.service";
const ipc = IpcService.getInstance();
ipc.on("get-available-mods", (args, reply) => {
const modsManager = BsModsManagerService.getInstance();
reply(from(modsManager.getAvailableMods(args)));
ipc.on("get-version-mods", (args, reply) => {
const beatMods = BeatModsApiService.getInstance();
reply(from(beatMods.getVersionMods(args)));
});
ipc.on("get-installed-mods", (args, reply) => {
@@ -14,17 +15,22 @@ ipc.on("get-installed-mods", (args, reply) => {
reply(from(modsManager.getInstalledMods(args)));
});
ipc.on("get-version-aliases", (_, reply) => {
const beatmods = BeatModsApiService.getInstance();
reply(from(beatmods.getVersionAliases()));
});
ipc.on("install-mods", (args, reply) => {
const modsManager = BsModsManagerService.getInstance();
reply(modsManager.installMods(args.mods, args.version));
reply(from(modsManager.installMods(args.mods, args.version)));
});
ipc.on("uninstall-mods", (args, reply) => {
const modsManager = BsModsManagerService.getInstance();
reply(modsManager.uninstallMods(args.mods, args.version));
reply(from(modsManager.uninstallMods(args.mods, args.version)));
});
ipc.on("uninstall-all-mods", (args, reply) => {
const modsManager = BsModsManagerService.getInstance();
reply(modsManager.uninstallAllMods(args));
reply(from(modsManager.uninstallAllMods(args)));
});
+5 -6
View File
@@ -3,6 +3,8 @@ 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();
@@ -29,8 +31,10 @@ 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({
bplistSource: args.downloadSource,
bpListUrl: downloadUrl,
version: args.version,
ignoreSongsHashs: args.ignoreSongsHashs,
dest: args.dest
@@ -59,11 +63,6 @@ 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();
@@ -1,7 +1,8 @@
import { BsOculusDownloaderService } from "../../services/bs-version-download/bs-oculus-downloader.service";
import { BsSteamDownloaderService } from "../../services/bs-version-download/bs-steam-downloader.service";
import { InstallationLocationService } from "../../services/installation-location.service";
import { IpcService } from "../../services/ipc.service";
import { of } from "rxjs";
import { from, of } from "rxjs";
import { BSLocalVersionService } from "../../services/bs-local-version.service";
const ipc = IpcService.getInstance();
@@ -13,6 +14,16 @@ ipc.on("import-version", (args, reply) => {
// #region Steam
ipc.on("bs-download.installation-folder", (_, reply) => {
const installLocation = InstallationLocationService.getInstance();
reply(from(installLocation.installationDirectory()));
});
ipc.on("bs-download.set-installation-folder", (args, reply) => {
const installerService = InstallationLocationService.getInstance();
reply(from(installerService.setInstallationDirectory(args)));
});
ipc.on("auto-download-bs-version", (args, reply) => {
const bsInstaller = BsSteamDownloaderService.getInstance();
reply(bsInstaller.autoDownloadBsVersion(args));
-2
View File
@@ -1,5 +1,4 @@
import "./os-controls-ipcs";
import "./bs-installer-ipcs.ts";
import "./bs-launcher-ipcs";
import "./bs-version-ipcs";
import "./bs-uninstall-ipcs";
@@ -13,4 +12,3 @@ import "./bs-playlist-ipcs";
import "./model-saber.ipcs";
import "./bs-model-ipcs";
import "./bs-version-download/bs-download-ipcs";
import "./static-configuration.ipcs";
+1 -1
View File
@@ -6,7 +6,7 @@ const ipc = IpcService.getInstance();
ipc.on("download-update", (_, reply) => {
const updaterService = AutoUpdaterService.getInstance();
reply(updaterService.downloadUpdate());
reply(from(updaterService.downloadUpdate()));
});
ipc.on("check-update", (_, reply) => {
+1 -16
View File
@@ -3,7 +3,6 @@ import { NotificationService } from "../services/notification.service";
import { IpcService } from "../services/ipc.service";
import { from, of } from "rxjs";
import { readFileSync } from "fs-extra";
import log from "electron-log";
// TODO IMPROVE WINDOW CONTROL BY USING WINDOW SERVICE
@@ -13,16 +12,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 ?? "" })));
});
ipc.on("choose-file", async (args, reply) => {
ipc.on<string>("choose-file", async (args, reply) => {
reply(from(dialog.showOpenDialog({ properties: ["openFile"], defaultPath: args ?? "" })));
});
@@ -68,12 +62,3 @@ ipc.on("choose-image", (args, reply) => {
return res.filePaths;
})));
});
ipc.on("restart-app", (_, reply) => {
log.info("App was requested to restart");
reply(of()); // Reply before restarting to avoid any issue
app.relaunch();
app.quit();
});
@@ -1,14 +0,0 @@
import { of } from "rxjs";
import { IpcService } from "../services/ipc.service";
import { StaticConfigurationService } from "../services/static-configuration.service";
const ipc = IpcService.getInstance();
const staticConfig = StaticConfigurationService.getInstance();
ipc.on("static-configuration.get", (args, reply) => {
reply(of(staticConfig.get(args)));
});
ipc.on("static-configuration.set", (args, reply) => {
reply(of(staticConfig.set(args.key, args.value)));
});
+7 -139
View File
@@ -23,29 +23,16 @@ import { LivShortcut } from "./services/liv/liv-shortcut.service";
import { SteamLauncherService } from "./services/bs-launcher/steam-launcher.service";
import { FileAssociationService } from "./services/file-association.service";
import { SongDetailsCacheService } from "./services/additional-content/maps/song-details-cache.service";
import { readdirSync, statSync, unlinkSync } from "fs-extra";
import { StaticConfigurationService } from "./services/static-configuration.service";
const isDebug = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true";
const staticConfig = StaticConfigurationService.getInstance();
export const filterStrings = new Set<string>();
export const filterPatterns = new Set<RegExp>();
// Filter all occulus tokens
filterPatterns.add(/FRL\S{10,}/g);
initLogger();
deleteOlestLogs();
deleteOldLogs();
staticConfig.take("disable-hadware-acceleration", disabled => {
if(disabled === true){ // strictly check for true
log.info("Disabling hardware acceleration");
app.disableHardwareAcceleration();
}
});
log.transports.file.level = "info";
log.transports.file.resolvePath = () => {
const now = new Date();
return path.join(app.getPath("logs"), `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}-v${app.getVersion()}.log`);
};
log.catchErrors();
if (process.env.NODE_ENV === "production") {
const sourceMapSupport = require("source-map-support");
@@ -117,7 +104,7 @@ if (!gotTheLock) {
app.whenReady().then(() => {
// C:\\Users\\Mathieu\\Desktop\\BSManager\\BSInstances\\My Version\\UserData\\SongDetailsCache.proto
app.setAppUserModelId(APP_NAME);
@@ -141,124 +128,5 @@ if (!gotTheLock) {
log.error(args?.args);
});
ipcMain.on("add-filter-string", (_, args: IpcRequest<string>) => {
filterStrings.add(args?.args);
});
ipcMain.on("add-filter-pattern", (_, args: IpcRequest<string>) => {
filterPatterns.add(new RegExp(args?.args));
});
}).catch(log.error);
}
function initLogger(){
log.transports.file.level = "info";
log.transports.file.resolvePath = () => {
const now = new Date();
return path.join(app.getPath("logs"), `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}-v${app.getVersion()}.log`);
};
log.hooks.push((message) => {
const filterMessage = (filter: string|RegExp, ...param: unknown[]): unknown[] => {
return param.map(data => {
if(typeof data === "string"){
return data.replaceAll(filter, "****");
}
if(data instanceof Error){
data.message = data.message?.replaceAll(filter, "****");
data.stack = data.stack?.replaceAll(filter, "****");
}
if(data instanceof Array){
return filterMessage(filter, ...data);
}
return data;
});
}
filterStrings.forEach(filter => {
if(filter && message.data.length){
message.data = filterMessage(filter, ...message.data);
}
});
filterPatterns.forEach(filter => {
if(filter && message.data.length){
message.data = filterMessage(filter, ...message.data);
}
});
return message;
});
log.catchErrors();
}
function getLogFilesEntries() {
try {
const logsFolder = app.getPath("logs");
let logs = readdirSync(logsFolder, { withFileTypes: true });
logs = logs.filter(file => file.isFile() && path.extname(file.name) === ".log");
logs.sort((a, b) => {
const aStat = statSync(path.join(logsFolder, a.name));
const bStat = statSync(path.join(logsFolder, b.name));
return bStat.mtime.getTime() - aStat.mtime.getTime();
});
return logs.map(file => {
const filePath = path.join(logsFolder, file.name);
const stat = statSync(filePath);
return {
path: filePath,
name: file.name,
stats: stat
};
});
} catch (err) {
log.error('Error while retrieving log files entries:', err);
return [];
}
}
// keep only the last 5 logs
function deleteOldLogs(): void{
try {
let logs = getLogFilesEntries();
logs = logs.slice(5);
logs.forEach(file => {
try {
unlinkSync(file.path);
log.info(`Deleted log file: ${file.path}`);
} catch (err) {
log.error(`Error deleting file ${file.path}:`, err);
}
});
} catch (err) {
log.error("Error while deleting old logs:", err);
}
}
// Temporary function to delete logs before 2024-07-31
function deleteOlestLogs(): void{
// delete all logs before 2024-07-31
const date = new Date(2024, 6, 31); // month is 0-based
const logs = getLogFilesEntries().filter(file => file.stats.mtime.getTime() < date.getTime());
logs.forEach(file => {
try {
unlinkSync(file.path);
log.info(`Deleted log file: ${file.path}`);
} catch (err) {
log.error(`Error deleting file ${file.path}:`, err);
}
});
}
+3 -7
View File
@@ -1,4 +1,4 @@
import { pathExistsSync, readFileSync, writeFileSync } from "fs-extra";
import { writeFileSync } from "fs-extra";
import { tryit } from "shared/helpers/error.helpers";
import log from "electron-log";
import { Subject, debounceTime } from "rxjs";
@@ -22,13 +22,9 @@ export class JsonCache<T = unknown> {
private load(): void {
try {
if(pathExistsSync(this.jsonPath)){
this._cache = JSON.parse(readFileSync(this.jsonPath).toString());
} else {
log.warn("File cache not exist yet", this.jsonPath);
}
this._cache = require(this.jsonPath);
} catch (error) {
log.warn("Failed to load cache file", this.jsonPath, error);
log.warn("Failed to load cache or file cache not exist yet", this.jsonPath, error);
} finally {
this._cache ??= {};
}
+1 -7
View File
@@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer, IpcRendererEvent, webUtils } from "electron";
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import { ProviderPlatform } from "shared/models/provider-platform.enum";
const sep = process.platform === ProviderPlatform.WINDOWS ? "\\" : "/";
@@ -24,14 +24,8 @@ contextBridge.exposeInMainWorld("electron", {
},
path: {
sep,
basename: (path: string): string => {
return !path ? "" : path.split(sep).at(-1);
},
join: (...args: string[]): string => {
return args.join(sep);
}
},
webUtils: {
getPathForFile: webUtils.getPathForFile
}
});
@@ -7,26 +7,22 @@ import { RequestService } from "../request.service";
import { LocalMapsManagerService } from "./maps/local-maps-manager.service";
import log from "electron-log";
import { WindowManagerService } from "../window-manager.service";
import { BPList, DownloadPlaylistProgressionData, PlaylistSong } from "shared/models/playlists/playlist.interface";
import { readFileSync, Stats } from "fs";
import { BPList, DownloadPlaylistProgressionData } from "shared/models/playlists/playlist.interface";
import { readFileSync } from "fs";
import { BeatSaverService } from "../thrid-party/beat-saver/beat-saver.service";
import { copy, ensureDir, pathExists, pathExistsSync, realpath, writeFileSync } from "fs-extra";
import { copy, ensureDir, pathExists, pathExistsSync, readdirSync, realpath, writeFileSync } from "fs-extra";
import { Progression, getUniqueFileNamePath, unlinkPath } from "../../helpers/fs.helpers";
import { FileAssociationService } from "../file-association.service";
import { SongDetailsCacheService } from "./maps/song-details-cache.service";
import { sToMs } from "shared/helpers/time.helpers";
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";
import { Archive } from "main/models/archive.class";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
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,7 +38,6 @@ export class LocalPlaylistsManagerService {
private readonly DEEP_LINKS = {
BeatSaver: "bsplaylist",
};
private readonly PLAYLIST_FILETYPES = [".bplist", ".json"];
private readonly versions: BSLocalVersionService;
private readonly maps: LocalMapsManagerService;
@@ -52,6 +47,7 @@ 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,6 +59,7 @@ export class LocalPlaylistsManagerService {
this.windows = WindowManagerService.getInstance();
this.bsaver = BeatSaverService.getInstance();
this.songDetails = SongDetailsCacheService.getInstance();
this.songCache = SongCacheService.getInstance();
this.bsmFs = InstallationLocationService.getInstance();
@@ -88,15 +85,11 @@ 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) && this.acceptPlaylistFiletype(opt.dest)) { return opt.dest; }
if(opt.dest && path.isAbsolute(opt.dest) && path.extname(opt.dest) === ".bplist") { return opt.dest; }
const playlistFolder = await this.getPlaylistsFolder(opt.version);
const playlistPath = path.join(playlistFolder, `${sanitize(opt.bpList.playlistTitle)}.bplist`);
return getUniqueFileNamePath(playlistPath);
@@ -114,14 +107,14 @@ export class LocalPlaylistsManagerService {
}
private async installBPListFile(opt: {
bplistSource: string,
bslistSource: string,
version?: BSVersion,
dest?: string
}): Promise<{path: string, localBPList: LocalBPList}> {
const bplist = await this.readPlaylistFromSource(opt.bplistSource);
const bplist = await this.readPlaylistFromSource(opt.bslistSource);
const dest = await (async () => {
if(opt.dest && path.isAbsolute(opt.dest) && this.acceptPlaylistFiletype(opt.dest)) { return opt.dest; }
if(opt.dest && path.isAbsolute(opt.dest) && path.extname(opt.dest) === ".bplist") { return opt.dest; }
const playlistFolder = await this.getPlaylistsFolder(opt.version);
return path.join(playlistFolder, `${sanitize(bplist.playlistTitle)}.bplist`);
})();
@@ -138,27 +131,10 @@ export class LocalPlaylistsManagerService {
const isLocalFile = await pathExists(source).catch(e => { log.error(e); return false; });
if(!isLocalFile && !isValidUrl(source)) {
throw new CustomError(`Invalid source (${source})`, "INVALID_SOURCE");
throw new Error(`Invalid source ${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 CustomError(`Invalid playlist file (${source})`, "INVALID_PLAYLIST_FILE");
}
bpList.songs = (bpList.songs ?? []).map(s => s.hash ? (
{ ...s, hash: findHashInString(s.hash) ?? s.hash }
) : s).filter(Boolean);
const bpList: BPList = isLocalFile ? JSON.parse(readFileSync(source).toString()) : await this.request.getJSON<BPList>(source);
return bpList;
}
@@ -179,22 +155,13 @@ export class LocalPlaylistsManagerService {
throw new Error(`Playlists folder not found ${folerPath}`);
}
const ignoreFunc = (file: string, stats: Stats): boolean => {
if(stats.isFile() && !this.acceptPlaylistFiletype(file)) { return true; }
return false;
}
const playlists = readdirSync(folerPath).filter(file => path.extname(file) === ".bplist");
progress.total = playlists.length;
const playlistPaths = (await recursiveReadDir(folerPath, [ignoreFunc]));
for (const playlist of playlists) {
const playlistPath = path.join(folerPath, playlist);
const bpList = await this.readPlaylistFromSource(playlistPath);
progress.total = playlistPaths.length;
for (const playlistPath of playlistPaths) {
const {result: bpList, error} = await tryit(() => this.readPlaylistFromSource(playlistPath));
if(error) {
log.error(error);
continue;
}
const localBpList: LocalBPList = { ...bpList, path: playlistPath };
bpLists.push(localBpList);
@@ -208,25 +175,6 @@ export class LocalPlaylistsManagerService {
});
}
private getSongDetailsFromPlaylistSong(song: PlaylistSong): SongDetails | undefined {
let songDetails: SongDetails;
const songHash = findHashInString(song.hash);
if(songHash){
songDetails = this.songDetails.getSongDetails(song.hash);
}
if(song.key && !songDetails){
songDetails = this.songDetails.getSongDetailsById(song.key);
}
const levelIdHash = findHashInString(song.levelid);
if(levelIdHash && !songDetails){
songDetails = this.songDetails.getSongDetails(levelIdHash);
}
return songDetails;
}
public getLocalBPListDetails(localBPList: LocalBPList): LocalBPListsDetails {
const tryExtractPlaylistId = (url: string) => {
@@ -237,28 +185,27 @@ export class LocalPlaylistsManagerService {
const bpListDetails: LocalBPListsDetails = {
...localBPList,
duration: 0,
nbMaps: localBPList.songs?.length ?? 0,
id: localBPList.customData?.syncURL ? tryExtractPlaylistId(localBPList.customData.syncURL) : undefined
}
const mappers = new Set<number>();
const songsDetails = localBPList.songs?.map(s => {
if(s.hash){
return this.songDetails.getSongDetails(s.hash);
}
if(s.key){
return this.songDetails.getSongDetailsById(s.key);
}
return undefined;
}).filter(Boolean);
for(const song of localBPList.songs){
const songDetails = this.getSongDetailsFromPlaylistSong(song);
if(!songDetails) { continue; }
bpListDetails.duration += songDetails?.duration ? +songDetails.duration : 0;
mappers.add(songDetails.uploader?.id);
bpListDetails.minNps = Math.min(bpListDetails?.minNps ?? 0, Math.min(...songDetails.difficulties?.map(d => d?.nps || 0) ?? [0]));
bpListDetails.maxNps = Math.max(bpListDetails?.maxNps ?? 0, Math.max(...songDetails.difficulties?.map(d => d?.nps || 0) ?? [0]));
song.songDetails = songDetails;
if(songsDetails?.length){
bpListDetails.duration = songsDetails.reduce((acc, song) => acc + song.duration, 0);
bpListDetails.nbMappers = new Set(songsDetails.map(s => s.uploader.id)).size;
bpListDetails.minNps = Math.min(...songsDetails.map(s => Math.min(...s.difficulties.map(d => d.nps || 0))));
bpListDetails.maxNps = Math.max(...songsDetails.map(s => Math.max(...s.difficulties.map(d => d.nps || 0))));
}
bpListDetails.nbMappers = mappers.size;
return bpListDetails;
}
@@ -314,28 +261,7 @@ export class LocalPlaylistsManagerService {
continue;
}
const mapDetail = await (async () => {
let mapDetail: BsvMapDetail;
const mapHash = findHashInString(song?.hash);
if(mapHash) {
mapDetail = (await this.bsaver.getMapDetailsFromHashs([findHashInString(mapHash)])).at(0);
}
if(song.key && !mapDetail) {
mapDetail = await this.bsaver.getMapDetailsById(song.key);
}
const levelIdHash = findHashInString(song?.levelid);
if(levelIdHash && !mapDetail) {
mapDetail = (await this.bsaver.getMapDetailsFromHashs([levelIdHash])).at(0);
}
return mapDetail;
})().catch(e => {
log.error(e);
return undefined as BsvMapDetail;
});
const [ mapDetail ] = await this.bsaver.getMapDetailsFromHashs([song.hash]);
if(!mapDetail) {
continue;
@@ -359,8 +285,8 @@ export class LocalPlaylistsManagerService {
});
}
public downloadPlaylist({ bplistSource, version, ignoreSongsHashs = [], dest }: {
bplistSource: string,
public downloadPlaylist({ bpListUrl, version, ignoreSongsHashs = [], dest }: {
bpListUrl: string,
version?: BSVersion
ignoreSongsHashs?: string[]
dest?: string
@@ -371,7 +297,7 @@ export class LocalPlaylistsManagerService {
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
(async () => {
const { localBPList } = await this.installBPListFile({ bplistSource, version, dest });
const { localBPList } = await this.installBPListFile({ bslistSource: bpListUrl, version, dest });
await lastValueFrom(this.downloadPlaylistSongs(localBPList, ignoreSongsHashs, version).pipe(
tap({ next: p => obs.next(p) }),
@@ -440,56 +366,13 @@ export class LocalPlaylistsManagerService {
return archive.finalize();
}
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>> {
public oneClickInstallPlaylist(bpListUrl: string): Observable<Progression<DownloadPlaylistProgressionData>> {
return new Observable<Progression<DownloadPlaylistProgressionData>>(obs => {
(async () => {
const versions = await this.versions.getInstalledVersions();
const download$ = this.downloadPlaylist({ bplistSource: bplistUrl, version: versions.pop() }).pipe(tap({
const download$ = this.downloadPlaylist({ bpListUrl, version: versions.pop() }).pipe(tap({
next: progress => obs.next(progress),
error: err => obs.error(err),
}));
@@ -501,7 +384,7 @@ export class LocalPlaylistsManagerService {
const realSourceMapsFolder = await realpath(path.dirname(downloadedMaps[0].path));
for (const version of versions) {
await this.installBPListFile({ bplistSource: playlist.path, version});
await this.installBPListFile({ bslistSource: 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 } from "shared/models/maps";
import { BsvMapDetail, RawMapInfoData } 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, { BinaryLike } from "crypto";
import crypto from "crypto";
import { lstatSync } from "fs";
import { copy, createReadStream, ensureDir, pathExists, pathExistsSync, realpath, unlink } from "fs-extra";
import StreamZip from "node-stream-zip";
@@ -14,7 +14,7 @@ import sanitize from "sanitize-filename";
import { DeepLinkService } from "../../deep-link.service";
import log from "electron-log";
import { WindowManagerService } from "../../window-manager.service";
import { Observable, Subject, lastValueFrom } from "rxjs";
import { Observable, lastValueFrom } from "rxjs";
import { Archive } from "../../../models/archive.class";
import { Progression, deleteFolder, ensureFolderExist, getFilesInFolder, getFoldersInFolder, pathExist } from "../../../helpers/fs.helpers";
import { readFile } from "fs/promises";
@@ -23,13 +23,10 @@ import { allSettled } from "../../../../shared/helpers/promise.helpers";
import { splitIntoChunk } from "../../../../shared/helpers/array.helpers";
import { SongDetailsCacheService } from "./song-details-cache.service";
import { SongCacheService } from "./song-cache.service";
import { IpcService } from "../../ipc.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;
@@ -60,8 +57,7 @@ export class LocalMapsManagerService {
private readonly linker: FolderLinkerService;
private readonly songDetailsCache: SongDetailsCacheService;
private readonly songCache: SongCacheService;
private readonly _lastDownloadedMap = new Subject<{ map: BsmLocalMap, version?: BSVersion }>();
private readonly ipc: IpcService;
private constructor() {
this.localVersion = BSLocalVersionService.getInstance();
@@ -73,6 +69,7 @@ export class LocalMapsManagerService {
this.linker = FolderLinkerService.getInstance();
this.songDetailsCache = SongDetailsCacheService.getInstance();
this.songCache = SongCacheService.getInstance();
this.ipc = IpcService.getInstance();
const handleOneClick = (mapId: string, isHash = false) => {
this.windows.openWindow(`oneclick-download-map.html?mapId=${mapId}&isHash=${isHash}`);
@@ -101,35 +98,24 @@ export class LocalMapsManagerService {
}
private async computeMapHash(mapPath: string, rawInfoString: string): Promise<string> {
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 mapRawInfo: RawMapInfoData = JSON.parse(rawInfoString);
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 as BinaryLike));
stream.on("data", data => shasum.update(data));
stream.on("error", reject);
stream.on("close", resolve);
});
};
for (const diff of mapInfo.difficulties) {
if(diff.beatmapFilename){
const diffFilePath = path.join(mapPath, diff.beatmapFilename);
for (const set of mapRawInfo._difficultyBeatmapSets) {
for (const diff of set._difficultyBeatmaps) {
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");
@@ -137,16 +123,16 @@ export class LocalMapsManagerService {
public async loadMapInfoFromPath(mapPath: string): Promise<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 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 cachedMapInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath));
const cachedInfos = this.songCache.getMapInfoFromDirname(path.basename(mapPath));
if (cachedMapInfos) {
return getUrlsAndReturn(cachedMapInfos.mapInfo, cachedMapInfos.hash, mapPath);
if (cachedInfos) {
return getUrlsAndReturn(cachedInfos.rawInfo, cachedInfos.hash, mapPath);
}
const files = await getFilesInFolder(mapPath);
@@ -157,16 +143,10 @@ export class LocalMapsManagerService {
}
const rawInfoString = await readFile(infoFile, { encoding: "utf-8" });
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 rawInfo: RawMapInfoData = JSON.parse(rawInfoString);
const hash = await this.computeMapHash(mapPath, rawInfoString);
return getUrlsAndReturn(mapInfo, hash, mapPath);
return getUrlsAndReturn(rawInfo, hash, mapPath);
}
private async downloadMapZip(zipUrl: string): Promise<{ zip: StreamZip.StreamZipAsync; zipPath: string }> {
@@ -214,7 +194,7 @@ export class LocalMapsManagerService {
return null;
}
this.songCache.setMapInfoFromDirname(path.basename(mapPath), { mapInfo: mapInfo.mapInfo, hash: mapInfo.hash });
this.songCache.setMapInfoFromDirname(path.basename(mapPath), { rawInfo: mapInfo.rawInfo, hash: mapInfo.hash });
progression.loaded++;
observer.next(progression);
@@ -288,8 +268,8 @@ export class LocalMapsManagerService {
const mapsPaths = await getFoldersInFolder(versionMapsPath);
for (const mapPath of mapsPaths) {
const { result: mapInfo } = await tryit(() => this.loadMapInfoFromPath(mapPath));
if (mapInfo && hashs.includes(mapInfo.hash)) {
const mapInfo = await this.loadMapInfoFromPath(mapPath);
if (hashs.includes(mapInfo.hash)) {
await deleteFolder(mapPath);
this.songCache.deleteMapInfoFromDirname(path.basename(mapPath));
progress.current++;
@@ -317,8 +297,8 @@ export class LocalMapsManagerService {
const mapsPaths = await getFoldersInFolder(versionMapsPath);
for (const mapPath of mapsPaths) {
const { result: mapInfo } = await tryit(() => this.loadMapInfoFromPath(mapPath));
if (mapInfo?.hash === hash) {
const mapInfo = await this.loadMapInfoFromPath(mapPath);
if (mapInfo.hash === hash) {
return mapInfo;
}
}
@@ -326,111 +306,6 @@ 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) {
@@ -469,7 +344,7 @@ export class LocalMapsManagerService {
const localMap = await this.loadMapInfoFromPath(mapPath);
localMap.songDetails = this.songDetailsCache.getSongDetails(localMap.hash);
this._lastDownloadedMap.next({ map: localMap, version });
this.ipc.send<{map: BsmLocalMap, version?: BSVersion}>("map-downloaded", this.windows.getWindows("index.html").at(0), { map: localMap, version });
return localMap;
}
@@ -517,8 +392,4 @@ export class LocalMapsManagerService {
public isDeepLinksEnabled(): boolean {
return Array.from(Object.values(this.DEEP_LINKS)).every(link => this.deepLink.isDeepLinkRegistered(link));
}
public get lastDownloadedMap$(): Observable<{ map: BsmLocalMap, version?: BSVersion }> {
return this._lastDownloadedMap.asObservable();
}
}
@@ -1,7 +1,7 @@
import { CACHE_PATH } from "main/constants";
import { JsonCache } from "main/models/json-cache.class";
import path from "path";
import { MapInfo } from "shared/models/maps/info/map-info.model";
import { RawMapInfoData } from "shared/models/maps";
export class SongCacheService {
@@ -14,35 +14,34 @@ export class SongCacheService {
return SongCacheService.instance;
}
private readonly MAPS_INFO_CACHE_PATH = path.join(CACHE_PATH, "map-info-cache.json");
private readonly RAW_INFOS_CACHE_PATH = path.join(CACHE_PATH, "song-raw-info-cache.json");
private readonly mapsInfoCache: JsonCache<CachedMapInfoWithHash>;
private readonly rawInfosCache: JsonCache<CachedRawInfoWithHash>;
private constructor(){
console.log(this.MAPS_INFO_CACHE_PATH);
this.mapsInfoCache = new JsonCache(this.MAPS_INFO_CACHE_PATH);
this.rawInfosCache = new JsonCache(this.RAW_INFOS_CACHE_PATH);
}
public getMapInfoFromDirname(dirname: string): CachedMapInfoWithHash {
return this.mapsInfoCache.get(dirname);
public getMapInfoFromDirname(dirname: string): CachedRawInfoWithHash {
return this.rawInfosCache.get(dirname);
}
public getMapInfoFromHash(hash: string): { dirname: string, info: CachedMapInfoWithHash } | undefined {
const res = Object.entries(this.mapsInfoCache.cache).find(([, info]) => info.hash === hash);
public getMapInfoFromHash(hash: string): { dirname: string, info: CachedRawInfoWithHash } | undefined {
const res = Object.entries(this.rawInfosCache.cache).find(([, info]) => info.hash === hash);
return res ? { dirname: res[0], info: res[1] } : undefined;
}
public setMapInfoFromDirname(dirname: string, info: CachedMapInfoWithHash): void {
this.mapsInfoCache.set(dirname, info);
public setMapInfoFromDirname(dirname: string, info: CachedRawInfoWithHash): void {
this.rawInfosCache.set(dirname, info);
}
public deleteMapInfoFromDirname(dirname: string): void {
this.mapsInfoCache.delete(dirname);
this.rawInfosCache.delete(dirname);
}
}
export type CachedMapInfoWithHash = {
export type CachedRawInfoWithHash = {
hash: string;
mapInfo: MapInfo;
rawInfo: RawMapInfoData;
};
@@ -1,6 +1,7 @@
import path from "path";
import { ensureDirSync, existsSync, readFile, writeFile } from "fs-extra";
import { BehaviorSubject, Observable, catchError, filter, lastValueFrom, of, take, timeout } from "rxjs";
import { ConfigurationService } from "../../configuration.service";
import { RequestService } from "../../request.service";
import { tryit } from "shared/helpers/error.helpers";
import { CACHE_PATH, HTTP_STATUS_CODES } from "main/constants";
@@ -11,7 +12,6 @@ import { SongDetails } from "shared/models/maps/song-details-cache/song-details-
import { inflate } from "pako";
import { RawSongDetailsCache } from "shared/models/maps/song-details-cache/raw-song-details-cache.model";
import { RawSongDetailsDeserializer } from "shared/models/maps/song-details-cache/raw-song-details-deserializer.class";
import { StaticConfigurationService } from "main/services/static-configuration.service";
export class SongDetailsCacheService {
@@ -32,7 +32,7 @@ export class SongDetailsCacheService {
private readonly PROTO_CACHE_PATH = path.join(CACHE_PATH, "song-details-cache");
private readonly etagKey = "song-details-cache-etag";
private readonly staticConfig: StaticConfigurationService;
private readonly config: ConfigurationService;
private readonly request: RequestService;
private readonly utils: UtilsService;
@@ -41,7 +41,7 @@ export class SongDetailsCacheService {
private readonly _loaded$ = new BehaviorSubject<boolean>(null);
private constructor(){
this.staticConfig = StaticConfigurationService.getInstance();
this.config = ConfigurationService.getInstance();
this.request = RequestService.getInstance();
this.utils = UtilsService.getInstance();
this.loadCache()
@@ -49,10 +49,10 @@ export class SongDetailsCacheService {
private async loadCache(): Promise<void> {
const protoCacheExists = existsSync(this.PROTO_CACHE_PATH);
const etag = protoCacheExists ? this.staticConfig.get(this.etagKey) : null;
const etag = protoCacheExists ? this.config.get<string>(this.etagKey) : null;
await this.downloadCacheFile(etag).then(etag => {
this.staticConfig.set(this.etagKey, etag);
this.config.set(this.etagKey, etag);
log.info("SongDetailsCache downloaded");
this.songDetailsIdIndex = this.createIdIndex(this.songDetailsCache);
log.info("SongDetailsIdIndex created");
+29 -37
View File
@@ -1,12 +1,13 @@
import { autoUpdater, CancellationToken, ProgressInfo } from "electron-updater";
import { autoUpdater } from "electron-updater";
import log from "electron-log";
import { UtilsService } from "./utils.service";
import { gt } from "semver";
import { Progression } from "main/helpers/fs.helpers";
import { Observable } from "rxjs";
export class AutoUpdaterService {
private static instance: AutoUpdaterService;
private readonly utilsService: UtilsService;
public static getInstance(): AutoUpdaterService {
if (!AutoUpdaterService.instance) {
AutoUpdaterService.instance = new AutoUpdaterService();
@@ -17,46 +18,37 @@ export class AutoUpdaterService {
constructor() {
autoUpdater.logger = log;
autoUpdater.autoDownload = false;
this.utilsService = UtilsService.getInstance();
}
public isUpdateAvailable(): Promise<boolean> {
return autoUpdater.checkForUpdates().then(info => {
return !!info?.updateInfo && gt(info.updateInfo.version, autoUpdater.currentVersion.version);
}).catch(() => false);
}
public downloadUpdate(): Observable<Progression> {
return new Observable<Progression>(observer => {
observer.next({ current: 0, total: 0 });
const progressListener = (progress: ProgressInfo) => {
observer.next({ current: progress.transferred, total: progress.total });
};
const downloadedListener = () => {
observer.next({ current: 100, total: 100 });
};
autoUpdater.addListener("download-progress", progressListener);
autoUpdater.addListener("update-downloaded", downloadedListener);
const cancelToken = new CancellationToken();
autoUpdater.downloadUpdate(cancelToken)
.catch(err => observer.error(err))
.finally(() => observer.complete());
return () => {
cancelToken.cancel();
autoUpdater.removeListener("download-progress", progressListener);
autoUpdater.removeListener("update-downloaded", downloadedListener);
}
return new Promise(resolve => {
autoUpdater
.checkForUpdates()
.then(info => {
const needUpdate = (() => {
if (!info?.updateInfo) {
return false;
}
return gt(info.updateInfo.version, autoUpdater.currentVersion.version);
})();
resolve(needUpdate);
})
.catch(() => resolve(false));
});
}
public downloadUpdate(): Promise<boolean> {
autoUpdater.removeAllListeners("download-progress");
autoUpdater.addListener("download-progress", info => {
this.utilsService.ipcSend("update-download-progress", { success: true, data: info.percent });
});
return autoUpdater.downloadUpdate().then(res => !!res && !!res.length);
}
public quitAndInstall() {
log.info("Quit and install");
return autoUpdater.quitAndInstall();
autoUpdater.quitAndInstall();
}
}
@@ -140,8 +140,6 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
"STEAM_COMPAT_INSTALL_PATH": bsFolderPath,
"STEAM_COMPAT_CLIENT_INSTALL_PATH": steamPath,
"STEAM_COMPAT_APP_ID": BS_APP_ID,
// Run game in steam environment; fixes #585 for unicode song titles
"SteamEnv": "1",
// Uncomment these to create a proton log file in the Beat Saber install directory.
// "PROTON_LOG": 1,
// "PROTON_LOG_DIR": bsFolderPath,
+43 -46
View File
@@ -6,13 +6,14 @@ import { BS_APP_ID, OCULUS_BS_BACKUP_DIR, OCULUS_BS_DIR } from "../constants";
import path from "path";
import { ConfigurationService } from "./configuration.service";
import { lstat, rename } from "fs/promises";
import { BsmException } from "shared/models/bsm-exception.model";
import log from "electron-log";
import { OculusService } from "./oculus.service";
import { DownloadLinkType } from "shared/models/mods";
import sanitize from "sanitize-filename";
import { Progression, copyDirectoryWithJunctions, deleteFolder, ensurePathNotAlreadyExist, getFoldersInFolder, rxCopy } from "../helpers/fs.helpers";
import { FolderLinkerService } from "./folder-linker.service";
import { ReadStream, createReadStream, pathExists, pathExistsSync, readFile, writeFile } from "fs-extra";
import { ReadStream, createReadStream, pathExists, readFile, writeFile } from "fs-extra";
import readline from "readline";
import { Observable, Subject, catchError, finalize, from, map, switchMap, throwError } from "rxjs";
import { BsStore } from "../../shared/models/bs-store.enum";
@@ -305,58 +306,54 @@ export class BSLocalVersionService {
.catch(() => { return false; })
}
public async editVersion(version: BSVersion, name: string, color: string): Promise<BSVersion>{
if(version.steam || version.oculus){ throw new CustomError("Do not edit official Beat Saber versions", "CantEditSteam") }
const oldPath = await this.getVersionPath(version);
const editedVersion: BSVersion = version.BSVersion === name
? {...version, name: undefined, color}
: {...version, name: sanitize(name), color};
const newPath = await this.getVersionPath(editedVersion);
public async editVersion(version: BSVersion, name: string, color: string): Promise<BSVersion>{
if(version.steam || version.oculus){ throw {title: "CantEditSteam", message: "CantEditSteam"} as BsmException; }
const oldPath = await this.getVersionPath(version);
const editedVersion: BSVersion = version.BSVersion === name
? {...version, name: undefined, color}
: {...version, name: sanitize(name), color};
const newPath = await this.getVersionPath(editedVersion);
if(oldPath === newPath){
this.deleteCustomVersion(version);
this.addCustomVersion(editedVersion);
return editedVersion;
}
if(oldPath === newPath){
this.deleteCustomVersion(version);
this.addCustomVersion(editedVersion);
return editedVersion;
}
if(pathExistsSync(newPath)){
throw new CustomError("Unable to edit the version, path already exist", "VersionAlreadExist");
}
if((await pathExists(newPath)) && newPath === oldPath){ throw {title: "VersionAlreadExist"} as BsmException; }
return rename(oldPath, newPath).then(() => {
this.deleteCustomVersion(version);
this.addCustomVersion(editedVersion);
return editedVersion;
}).catch((err: Error) => {
log.error("edit version error", err, version, name, color);
throw CustomError.fromError(err, "CantRename");
});
}
return rename(oldPath, newPath).then(() => {
this.deleteCustomVersion(version);
this.addCustomVersion(editedVersion);
return editedVersion;
}).catch((err: Error) => {
log.error("edit version error", err, version, name, color);
throw {title: "CantRename", ...err} as BsmException;
});
}
public async cloneVersion(version: BSVersion, name: string, color: string): Promise<BSVersion>{
const originPath = await this.getVersionPath(version);
const cloneVersion: BSVersion = version.BSVersion === name
? {...version, name: undefined, color, steam: false, oculus: false}
: {...version, name: sanitize(name), color, steam: false, oculus: false};
const newPath = await this.getVersionPath(cloneVersion);
public async cloneVersion(version: BSVersion, name: string, color: string): Promise<BSVersion>{
const originPath = await this.getVersionPath(version);
const cloneVersion: BSVersion = version.BSVersion === name
? {...version, name: undefined, color, steam: false, oculus: false}
: {...version, name: sanitize(name), color, steam: false, oculus: false};
const newPath = await this.getVersionPath(cloneVersion);
if(pathExistsSync(newPath)){
throw new CustomError("Unable to clone the version, path already exist", "VersionAlreadExist");
}
if(originPath === newPath){
this.deleteCustomVersion(version);
this.addCustomVersion(cloneVersion);
}
if(originPath === newPath){
this.deleteCustomVersion(version);
this.addCustomVersion(cloneVersion);
}
if(await pathExists(newPath)){ throw {title: "VersionAlreadExist"} as BsmException; }
return copyDirectoryWithJunctions(originPath, newPath).then(() => {
this.addCustomVersion(cloneVersion);
return cloneVersion;
}).catch((err: Error) => {
log.error("Error occured while cloning the version", err, version, name, color);
throw CustomError.fromError(err, "CantClone");
})
}
return copyDirectoryWithJunctions(originPath, newPath).then(() => {
this.addCustomVersion(cloneVersion);
return cloneVersion;
}).catch((err: Error) => {
log.error("clone version error", err, version, name, color);
throw {title: "CantClone", ...err} as BsmException
})
}
public importVersion(opt: ImportVersionOptions): Observable<Progression<BSVersion>>{
const { fromPath, store } = opt;
@@ -12,7 +12,6 @@ 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;
@@ -97,7 +96,7 @@ export class BsSteamDownloaderService {
depotDownloader.$events().pipe(
map(event => {
if(event.type === DepotDownloaderEventType.Error){
throw new CustomError(JSON.stringify(event.data) ?? "An error occur in the DepotDownloader process", event?.subType ?? DepotDownloaderErrorEvent.Unknown, event)
throw event;
}
return event;
}),
+1 -20
View File
@@ -5,7 +5,6 @@ import { BSVersion } from "shared/bs-version.interface";
import { RequestService } from "./request.service";
import { readJSON } from "fs-extra";
import { allSettled } from "../../shared/helpers/promise.helpers";
import { StaticConfigurationService } from "./static-configuration.service";
export class BSVersionLibService {
private readonly REMOTE_BS_VERSIONS_URL: string = "https://raw.githubusercontent.com/Zagrios/bs-manager/master/assets/jsons/bs-versions.json";
@@ -15,14 +14,12 @@ export class BSVersionLibService {
private utilsService: UtilsService;
private requestService: RequestService;
private staticConfigurationService: StaticConfigurationService;
private bsVersions: BSVersion[];
private constructor() {
this.utilsService = UtilsService.getInstance();
this.requestService = RequestService.getInstance();
this.staticConfigurationService = StaticConfigurationService.getInstance();
}
public static getInstance(): BSVersionLibService {
@@ -38,28 +35,12 @@ export class BSVersionLibService {
private async getLocalVersions(): Promise<BSVersion[]> {
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
if (process.platform === "linux") {
let versions = this.staticConfigurationService.get("versions");
if (!versions) {
versions = (await readJSON(localVersionsPath)) as BSVersion[];
this.staticConfigurationService.set("versions", versions);
}
return versions;
}
return readJSON(localVersionsPath);
}
private async updateLocalVersions(versions: BSVersion[]): Promise<void> {
const localVersionsPath = path.join(this.utilsService.getAssestsJsonsPath(), this.VERSIONS_FILE);
// Do not write on readonly memory in linux when running on AppImage
if (process.platform === "linux") {
this.staticConfigurationService.set("versions", versions);
} else {
writeFileSync(localVersionsPath, JSON.stringify(versions, null, "\t"), { encoding: "utf-8", flag: "w" });
}
writeFileSync(localVersionsPath, JSON.stringify(versions, null, "\t"), { encoding: "utf-8", flag: "w" });
}
private async loadBsVersions(): Promise<BSVersion[]> {
+3 -21
View File
@@ -1,7 +1,5 @@
import ElectronStore from "electron-store";
import fs from "fs-extra";
import { InstallationLocationService } from "./installation-location.service";
import { CustomError } from "shared/models/exceptions/custom-error.class";
export class ConfigurationService {
private static instance: ConfigurationService;
@@ -15,23 +13,17 @@ export class ConfigurationService {
private readonly locations: InstallationLocationService;
private contentPath: string;
private store: ElectronStore;
private constructor() {
this.locations = InstallationLocationService.getInstance();
this.initStore(false);
this.initStore();
this.locations.onInstallLocationUpdate(() => this.initStore(true));
this.locations.onInstallLocationUpdate(() => { this.initStore() });
}
private async initStore(createFolder: boolean) {
private async initStore() {
const contentPath = this.locations.installationDirectory();
if (!createFolder && !fs.pathExistsSync(contentPath)) {
return;
}
this.contentPath = contentPath;
this.store = new ElectronStore({
cwd: contentPath,
name: "config",
@@ -40,25 +32,15 @@ export class ConfigurationService {
});
}
private checkStore(): void {
// Can be null if config.cfg does not exist or corrupted
if (!this.store) {
throw CustomError.fromError(new Error(`Can't read config.cfg on ${this.contentPath}`));
}
}
public set(key: string, value: unknown): void {
this.checkStore();
this.store.set(key, value);
}
public get<T>(key: string): T {
this.checkStore();
return this.store.get(key) as T;
}
public delete(key: string): void {
this.checkStore();
this.store.delete(key);
}
}
+6 -26
View File
@@ -4,9 +4,6 @@ import { deleteFolder, ensureFolderExist, moveFolderContent, pathExist, unlinkPa
import { lstat, symlink } from "fs/promises";
import path from "path";
import { copy, readlink } from "fs-extra";
import { lastValueFrom } from "rxjs";
import { noop } from "shared/helpers/function.helpers";
import { StaticConfigurationService } from "./static-configuration.service";
export class FolderLinkerService {
private static instance: FolderLinkerService;
@@ -19,21 +16,9 @@ export class FolderLinkerService {
}
private readonly installLocationService = InstallationLocationService.getInstance();
private readonly staticConfig: StaticConfigurationService;
private linkingType: "junction" | "symlink" = "junction";
private constructor() {
this.installLocationService = InstallationLocationService.getInstance();
this.staticConfig = StaticConfigurationService.getInstance();
this.linkingType = this.staticConfig.get("use-symlinks") === true ? "symlink" : "junction";
log.info(`Linking type is set to ${this.linkingType}`);
this.staticConfig.$watch("use-symlinks").subscribe((useSymlink) => {
this.linkingType = useSymlink === true ? "symlink" : "junction";
log.info(`Linking type set to ${this.linkingType}`);
});
}
private async sharedFolder(): Promise<string> {
@@ -64,10 +49,6 @@ export class FolderLinkerService {
});
}
private getLinkingType(): "junction" | undefined {
return this.linkingType === "junction" ? "junction" : undefined;
}
public async linkFolder(folderPath: string, options?: LinkOptions): Promise<void> {
const sharedPath = await this.getSharedFolder(folderPath, options?.intermediateFolder);
@@ -79,9 +60,7 @@ export class FolderLinkerService {
return;
}
await unlinkPath(folderPath);
log.info(`Linking ${folderPath} to ${sharedPath}; type: ${this.linkingType}`);
return symlink(sharedPath, folderPath, this.getLinkingType());
return symlink(sharedPath, folderPath, "junction");
}
await ensureFolderExist(sharedPath);
@@ -93,13 +72,12 @@ export class FolderLinkerService {
await ensureFolderExist(folderPath);
if (options?.keepContents !== false) {
await lastValueFrom(moveFolderContent(folderPath, sharedPath, { overwrite: true }));
await moveFolderContent(folderPath, sharedPath).toPromise();
}
await deleteFolder(folderPath);
log.info(`Linking ${folderPath} to ${sharedPath}; type: ${this.linkingType}`);
return symlink(sharedPath, folderPath, this.getLinkingType());
return symlink(sharedPath, folderPath, "junction");
}
public async unlinkFolder(folderPath: string, options?: UnlinkOptions): Promise<void> {
@@ -117,7 +95,9 @@ export class FolderLinkerService {
}
if (options.moveContents === true) {
return lastValueFrom(moveFolderContent(sharedPath, folderPath, { overwrite: true })).then(noop);
return moveFolderContent(sharedPath, folderPath)
.toPromise()
.then(() => {});
}
if (options?.keepContents === false) {
@@ -1,9 +1,9 @@
import path from "path";
import { app } from "electron";
import ElectronStore from "electron-store";
import { copyDirectoryWithJunctions, deleteFolder, ensureFolderExist } from "../helpers/fs.helpers";
import { tryit } from "../../shared/helpers/error.helpers";
import { pathExistsSync } from "fs-extra";
import { StaticConfigurationService } from "./static-configuration.service";
export class InstallationLocationService {
private static instance: InstallationLocationService;
@@ -23,15 +23,15 @@ export class InstallationLocationService {
private readonly STORE_INSTALLATION_PATH_KEY = "installation-folder";
private readonly staticConfig: StaticConfigurationService;
private readonly installPathConfig: ElectronStore;
private readonly updateListeners: Set<Listener> = new Set();
private _installationDirectory: string;
private constructor() {
this.staticConfig = StaticConfigurationService.getInstance();
this.installPathConfig = new ElectronStore({ watch: true });
this.staticConfig.$watch(this.STORE_INSTALLATION_PATH_KEY).subscribe(() => {
this.installPathConfig.onDidChange(this.STORE_INSTALLATION_PATH_KEY, () => {
this.triggerListeners();
});
}
@@ -40,21 +40,17 @@ export class InstallationLocationService {
this.updateListeners.forEach(listener => listener());
}
/**
* @param move - if true, move the old installation path to the path param
*/
public async setInstallationDirectory(newDir: string, move: boolean): Promise<string> {
public async setInstallationDirectory(newDir: string): Promise<string> {
newDir = path.basename(newDir) === this.INSTALLATION_FOLDER ? path.join(newDir, "..") : newDir;
const oldDir = this.installationDirectory();
if (move) {
const oldDir = this.installationDirectory();
await ensureFolderExist(oldDir);
await copyDirectoryWithJunctions(oldDir, path.join(newDir, this.INSTALLATION_FOLDER), { overwrite: true });
deleteFolder(oldDir);
}
await ensureFolderExist(oldDir);
await copyDirectoryWithJunctions(oldDir, path.join(newDir, this.INSTALLATION_FOLDER), { overwrite: true });
this._installationDirectory = newDir;
this.staticConfig.set(this.STORE_INSTALLATION_PATH_KEY, newDir);
this.installPathConfig.set(this.STORE_INSTALLATION_PATH_KEY, newDir);
deleteFolder(oldDir);
return this.installationDirectory();
}
@@ -63,12 +59,6 @@ export class InstallationLocationService {
this.updateListeners.add(fn);
}
public defaultInstallationDirectory(): string {
const { result: oldPath } = tryit(() => path.join(app.getPath("documents"), this.INSTALLATION_FOLDER));
const installationDirectory = (oldPath && pathExistsSync(oldPath)) ? app.getPath("documents") : app.getPath("home");
return path.join(installationDirectory, this.INSTALLATION_FOLDER);
}
public installationDirectory(): string {
const installParentPath = () => {
@@ -76,8 +66,8 @@ export class InstallationLocationService {
return this._installationDirectory;
}
if(this.staticConfig.has(this.STORE_INSTALLATION_PATH_KEY)) {
return this.staticConfig.get(this.STORE_INSTALLATION_PATH_KEY);
if(this.installPathConfig.has(this.STORE_INSTALLATION_PATH_KEY)) {
return this.installPathConfig.get(this.STORE_INSTALLATION_PATH_KEY) as string;
}
const { result: oldPath } = tryit(() => path.join(app.getPath("documents"), this.INSTALLATION_FOLDER));
+1 -1
View File
@@ -49,7 +49,7 @@ export class IpcService {
const sub = observable.subscribe({
next: data => this.send(channel, window, data),
error: error => {
log.error(error, error?.code, error?.data);
log.error(error, error?.code);
this.send(this.getErrorChannel(channel), window, serializeError(error));
},
complete: () => this.send(this.getCompleteChannel(channel), window)
+17 -63
View File
@@ -1,4 +1,4 @@
import { BSVersion } from "shared/bs-version.interface";
import { BSVersion, BSVersionString } from "shared/bs-version.interface";
import { Mod } from "shared/models/mods/mod.interface";
import { RequestService } from "../request.service";
@@ -12,9 +12,8 @@ export class BeatModsApiService {
private readonly BEAT_MODS_API_URL = "https://beatmods.com/api/v1/";
public readonly BEAT_MODS_URL = "https://beatmods.com";
private readonly aliasesCache = new Map<string, BSVersion[]>();
private readonly versionModsCache = new Map<string, Mod[]>();
private readonly modsHashCache = new Map<string, Mod>();
private aliasesCache: Record<BSVersionString, BSVersionString[]> = {};
private readonly versionModsCache = new Map<BSVersionString, Mod[]>();
private allModsCache: Mod[];
@@ -37,29 +36,21 @@ export class BeatModsApiService {
return `${this.BEAT_MODS_API_URL}mod`;
}
private async getVersionAlias(): Promise<Map<string, BSVersion[]>> {
if (this.aliasesCache.size) {
public async getVersionAliases(): Promise<Record<BSVersionString, BSVersionString[]>> {
if (Object.keys(this.aliasesCache).length > 0) {
return this.aliasesCache;
}
return this.requestService.getJSON<Record<string, string[]>>(this.BEAT_MODS_ALIAS).then(rawAliases => {
Object.entries(rawAliases).forEach(([key, value]) => {
this.aliasesCache.set(
key,
value.map(s => ({ BSVersion: s } as BSVersion))
);
});
return this.requestService.getJSON<Record<BSVersionString, BSVersionString[]>>(this.BEAT_MODS_ALIAS).then(rawAliases => {
this.aliasesCache = rawAliases;
return this.aliasesCache;
});
}
private async getAliasOfVersion(version: BSVersion): Promise<BSVersion> {
return this.getVersionAlias().then(aliases => {
if (Array.from(aliases.keys()).some(k => k === version.BSVersion)) {
return version;
}
const alias = Array.from(aliases.entries()).find(([, value]) => value.find(v => v.BSVersion === version.BSVersion))?.[0];
return { BSVersion: alias } as BSVersion;
});
private async getAliasOfVersion(version: BSVersionString): Promise<BSVersionString> {
return this.getVersionAliases().then(aliases => (
aliases[version] ? version : Object.keys(aliases).find(k => aliases[k as BSVersionString].some(v => v === version)) as BSVersionString
));
}
private asignDependencies(mod: Mod, mods: Mod[]): Mod {
@@ -67,42 +58,16 @@ export class BeatModsApiService {
return mod;
}
private updateModsHashCache(mods: Mod[]): void {
if(!Array.isArray(mods)){
return;
}
for (const mod of mods) {
for (const downloads of (mod.downloads ?? [])) {
for (const hashMd5 of (downloads.hashMd5 ?? [])) {
this.modsHashCache.set(hashMd5.hash, mod);
}
}
for (const dep of (mod.dependencies ?? [])) {
for (const downloads of (dep.downloads ?? [])) {
for (const hashMd5 of (downloads.hashMd5 ?? [])) {
this.modsHashCache.set(hashMd5.hash, dep);
}
}
}
}
}
public async getVersionMods(version: BSVersion): Promise<Mod[]> {
if (this.versionModsCache.has(version.BSVersion)) {
return this.versionModsCache.get(version.BSVersion);
public async getVersionMods(version: BSVersionString): Promise<Mod[]> {
if (this.versionModsCache.has(version)) {
return this.versionModsCache.get(version);
}
const alias = await this.getAliasOfVersion(version);
return this.requestService.getJSON<Mod[]>(this.getVersionModsUrl(alias)).then(mods => {
return this.requestService.getJSON<Mod[]>(this.getVersionModsUrl({ BSVersion: alias })).then(mods => {
mods = mods.map(mod => this.asignDependencies(mod, mods));
this.versionModsCache.set(version.BSVersion, mods);
this.updateModsHashCache(mods);
this.versionModsCache.set(version, mods);
return mods;
});
}
@@ -116,15 +81,4 @@ export class BeatModsApiService {
return this.allModsCache;
});
}
public getModByHash(hash: string): Promise<Mod> {
if (this.modsHashCache.has(hash)) {
return Promise.resolve(this.modsHashCache.get(hash));
}
return this.requestService.getJSON<Mod[]>(`${this.BEAT_MODS_API_URL}mod?hash=${hash}`).then(mods => {
this.updateModsHashCache(mods);
return mods.at(0);
});
}
}
+154 -130
View File
@@ -1,32 +1,38 @@
import { BSVersion } from "shared/bs-version.interface";
import { DownloadLink, Mod } from "shared/models/mods";
import { DownloadLink, InstallModsResult, Mod, ModInstallProgression, UninstallModsResult } from "shared/models/mods";
import { BeatModsApiService } from "./beat-mods-api.service";
import { BSLocalVersionService } from "../bs-local-version.service";
import path from "path";
import { UtilsService } from "../utils.service";
import md5File from "md5-file";
import { RequestService } from "../request.service";
import { spawn } from "child_process";
import { BS_EXECUTABLE } from "../../constants";
import log from "electron-log";
import { deleteFolder, pathExist, Progression, unlinkPath } from "../../helpers/fs.helpers";
import { lastValueFrom, Observable } from "rxjs";
import { deleteFolder, pathExist, unlinkPath } from "../../helpers/fs.helpers";
import { lastValueFrom } from "rxjs";
import JSZip from "jszip";
import { extractZip } from "../../helpers/zip.helpers";
import recursiveReadDir from "recursive-readdir";
import { sToMs } from "../../../shared/helpers/time.helpers";
import { ensureDir, pathExistsSync } from "fs-extra";
import { CustomError } from "shared/models/exceptions/custom-error.class";
import { popElement } from "shared/helpers/array.helpers";
import { minToMs } from "../../../shared/helpers/time.helpers";
import { ensureDir } from "fs-extra";
export class BsModsManagerService {
private static instance: BsModsManagerService;
private readonly beatModsApi: BeatModsApiService;
private readonly bsLocalService: BSLocalVersionService;
private readonly utilsService: UtilsService;
private readonly requestService: RequestService;
private manifestMatches: Mod[];
private nbModsToInstall = 0;
private nbInstalledMods = 0;
private nbModsToUninstall = 0;
private nbUninstalledMods = 0;
public static getInstance(): BsModsManagerService {
if (!BsModsManagerService.instance) {
BsModsManagerService.instance = new BsModsManagerService();
@@ -37,25 +43,35 @@ export class BsModsManagerService {
private constructor() {
this.beatModsApi = BeatModsApiService.getInstance();
this.bsLocalService = BSLocalVersionService.getInstance();
this.utilsService = UtilsService.getInstance();
this.requestService = RequestService.getInstance();
}
private async getModFromHash(hash: string): Promise<Mod> {
const allMods = await this.beatModsApi.getAllMods();
return allMods.find(mod => {
if (mod.name.toLowerCase() === "bsipa") {
return false;
}
return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash));
});
}
const mod = await this.beatModsApi.getModByHash(hash);
if(mod?.name?.toLowerCase() === "bsipa"){
return undefined;
}
return mod;
private async getIpaFromHash(hash: string): Promise<Mod> {
const allMods = await this.beatModsApi.getAllMods();
return allMods.find(mod => {
if (mod.name.toLowerCase() !== "bsipa") {
return false;
}
return mod.downloads.some(download => download.hashMd5.some(md5 => md5.hash === hash));
});
}
private async getModsInDir(version: BSVersion, modsDir: ModsInstallFolder): Promise<Mod[]> {
const bsPath = await this.bsLocalService.getVersionPath(version);
const modsPath = path.join(bsPath, modsDir);
if (!pathExistsSync(modsPath)) {
if (!(await pathExist(modsPath))) {
return [];
}
@@ -68,32 +84,30 @@ export class BsModsManagerService {
return undefined;
}
const hash = await md5File(filePath);
const mod = await this.getModFromHash(hash);
if (!mod) {
return undefined;
}
if (ext === ".manifest") {
this.manifestMatches.push(mod);
return undefined;
}
if (filePath.toLowerCase().includes("libs")) {
const manifestIndex = this.manifestMatches.findIndex(m => m.name === mod.name);
if (manifestIndex < 0) {
if (filePath.includes("Libs")) {
if (!this.manifestMatches.some(m => m.name === mod.name)) {
return undefined;
}
this.manifestMatches.splice(manifestIndex, 1);
const modIndex = this.manifestMatches.indexOf(mod);
if (modIndex > -1) {
this.manifestMatches.splice(modIndex, 1);
}
}
return mod;
});
const mods = await Promise.all(promises);
return mods.filter(Boolean);
return mods.filter(Boolean);
}
private async getBsipaInstalled(version: BSVersion): Promise<Mod> {
@@ -103,7 +117,7 @@ export class BsModsManagerService {
return undefined;
}
const injectorMd5 = await md5File(injectorPath);
return this.beatModsApi.getModByHash(injectorMd5);
return this.getIpaFromHash(injectorMd5);
}
private async downloadZip(zipUrl: string): Promise<JSZip> {
@@ -142,23 +156,13 @@ export class BsModsManagerService {
return new Promise<boolean>(resolve => {
const cmd = process.platform === 'linux'
? `screen -dmS "BSIPA" dotnet "${ipaPath}" ${args.join(" ")}` // Must run through screen, otherwise BSIPA tries to move console cursor and crashes.
: `"${ipaPath}" ${args.join(" ")}`;
? `screen -dmS "BSIPA" dotnet ${ipaPath} ${args.join(" ")}` // Must run through screen, otherwise BSIPA tries to move console cursor and crashes.
: `start /wait /min "" "${ipaPath}" ${args.join(" ")}`;
log.info("START IPA PROCESS", cmd);
const processIPA = spawn(cmd, { cwd: versionPath, detached: true, shell: true });
const timemout = setTimeout(() => {
log.info("Ipa process timeout");
resolve(false)
}, sToMs(30));
processIPA.stderr.on("data", data => {
log.error("IPA process stderr", data.toString());
})
processIPA.once("exit", code => {
clearTimeout(timemout);
if (code === 0) {
log.info("Ipa process exist with code 0");
return resolve(true);
@@ -167,6 +171,10 @@ export class BsModsManagerService {
resolve(false);
});
setTimeout(() => {
log.info("Ipa process timeout");
resolve(false)
}, minToMs(1));
});
}
@@ -179,6 +187,7 @@ export class BsModsManagerService {
private async installMod(mod: Mod, version: BSVersion): Promise<boolean> {
log.info("INSTALL MOD", mod.name, "for version", `${version.BSVersion} - ${version.name}`);
this.utilsService.ipcSend<ModInstallProgression>("mod-installed", { success: true, data: { name: mod.name, progression: ((this.nbInstalledMods + 1) / this.nbModsToInstall) * 100 } });
const download = this.getModDownload(mod, version);
@@ -237,9 +246,37 @@ export class BsModsManagerService {
}))
: extracted;
if(res){
this.nbInstalledMods++;
}
return res;
}
private isDependency(mod: Mod, selectedMods: Mod[], availableMods: Mod[]) {
return selectedMods.some(m => {
const deps = m.dependencies.map(dep => Array.from(availableMods.values()).find(m => dep.name === m.name));
if (deps.some(depMod => depMod.name === mod.name)) {
return true;
}
return deps.some(depMod => depMod.dependencies.some(depModDep => depModDep.name === mod.name));
});
}
private async resolveDependencies(mods: Mod[], version: BSVersion): Promise<Mod[]> {
const availableMods = await this.beatModsApi.getVersionMods(version.BSVersion);
return Array.from(
new Map<string, Mod>(
availableMods.reduce((res, mod) => {
if (this.isDependency(mod, mods, availableMods)) {
res.push([mod.name, mod]);
}
return res;
}, [])
).values()
);
}
private async uninstallBSIPA(mod: Mod, version: BSVersion): Promise<void> {
const download = this.getModDownload(mod, version);
@@ -262,6 +299,9 @@ export class BsModsManagerService {
}
private async uninstallMod(mod: Mod, version: BSVersion): Promise<void> {
this.nbUninstalledMods++;
this.utilsService.ipcSend<ModInstallProgression>("mod-uninstalled", { success: true, data: { name: mod.name, progression: (this.nbUninstalledMods / this.nbModsToUninstall) * 100 } });
if (mod.name.toLowerCase() === "bsipa") {
return this.uninstallBSIPA(mod, version);
}
@@ -276,122 +316,106 @@ export class BsModsManagerService {
await Promise.all(promises);
}
public getAvailableMods(version: BSVersion): Promise<Mod[]> {
return this.beatModsApi.getVersionMods(version);
}
public async getInstalledMods(version: BSVersion): Promise<Mod[]> {
this.manifestMatches = [];
await this.beatModsApi.getAllMods();
const bsipa = await this.getBsipaInstalled(version);
return Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING), this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING), this.getModsInDir(version, ModsInstallFolder.PLUGINS), this.getModsInDir(version, ModsInstallFolder.LIBS)]).then(dirMods => {
const modsDict = new Map<string, Mod>();
const pluginsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.PLUGINS), this.getModsInDir(version, ModsInstallFolder.PLUGINS_PENDING)]);
const libsMods = await Promise.all([this.getModsInDir(version, ModsInstallFolder.LIBS), this.getModsInDir(version, ModsInstallFolder.LIBS_PENDING)]);
if (bsipa) {
modsDict.set(bsipa.name, bsipa);
}
const dirMods = pluginsMods.flat().concat(libsMods.flat());
for (const mod of dirMods.flat()) {
if (modsDict.has(mod.name)) {
continue;
}
modsDict.set(mod.name, mod);
}
const modsDict = new Map<string, Mod>();
return Array.from(modsDict.values());
});
}
public async installMods(mods: Mod[], version: BSVersion): Promise<InstallModsResult> {
if (!mods?.length) {
throw "no-mods";
}
const deps = await this.resolveDependencies(mods, version);
mods.push(...deps);
const bsipa = mods.find(mod => mod.name.toLowerCase() === "bsipa");
if (bsipa) {
mods = mods.filter(mod => mod.name.toLowerCase() !== "bsipa");
}
this.nbModsToInstall = mods.length + (bsipa && 1);
this.nbInstalledMods = 0;
if (bsipa) {
modsDict.set(bsipa.name, bsipa);
}
for (const mod of dirMods.flat()) {
if (modsDict.has(mod.name)) {
continue;
const installed = await this.installMod(bsipa, version).catch(err => {
log.error("INSTALL BSIPA", err);
return false;
});
if (!installed) {
throw "cannot-install-bsipa";
}
modsDict.set(mod.name, mod);
}
return Array.from(modsDict.values());
for (const mod of mods) {
await this.installMod(mod, version);
}
return {
nbModsToInstall: this.nbModsToInstall,
nbInstalledMods: this.nbInstalledMods,
};
}
public installMods(mods: Mod[], version: BSVersion): Observable<Progression> {
const progress = { current: 0, total: mods.length };
public async uninstallMods(mods: Mod[], version: BSVersion): Promise<UninstallModsResult> {
if (!mods?.length) {
throw "no-mods";
}
return new Observable<Progression>(obs => {
(async () => {
if (!mods?.length) {
throw CustomError.throw(new Error("No mods to install"), "no-mods", mods);
}
this.nbModsToUninstall = mods.length;
this.nbUninstalledMods = 0;
obs.next(progress);
for (const mod of mods) {
await this.uninstallMod(mod, version);
}
const bsipa = popElement(mod => mod.name.toLowerCase() === "bsipa", mods);
if(bsipa){
const bsipaInstalled = await this.installMod(bsipa, version).catch(err => {
log.error("Error while installing BSIPA", err);
});
if(!bsipaInstalled){
throw CustomError.throw(new Error("BSIPA failed to install"), "cannot-install-bsipa");
}
progress.current++;
obs.next(progress);
}
for (const mod of mods) {
await this.installMod(mod, version);
progress.current++;
obs.next(progress);
}
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
});
return {
nbModsToUninstall: this.nbModsToUninstall,
nbUninstalledMods: this.nbUninstalledMods,
};
}
public uninstallMods(mods: Mod[], version: BSVersion): Observable<Progression> {
const progress = { current: 0, total: mods.length };
public async uninstallAllMods(version: BSVersion): Promise<UninstallModsResult> {
const mods = await this.getInstalledMods(version);
return new Observable<Progression>(obs => {
(async () => {
if (!mods?.length) {
throw CustomError.throw(new Error("No mods to uninstall"), "no-mods", mods);
}
if (!mods?.length) {
throw "no-mods";
}
obs.next(progress);
this.nbModsToUninstall = mods.length;
this.nbUninstalledMods = 0;
for (const mod of mods) {
await this.uninstallMod(mod, version);
progress.current++;
obs.next(progress);
}
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
});
}
for (const mod of mods) {
await this.uninstallMod(mod, version);
}
public uninstallAllMods(version: BSVersion): Observable<Progression> {
return new Observable<Progression>(obs => {
(async () => {
const mods = await this.getInstalledMods(version).catch(err => {
log.error(err);
return [];
});
const versionPath = await this.bsLocalService.getVersionPath(version);
const progress = { current: 0, total: mods.length };
await deleteFolder(path.join(versionPath, ModsInstallFolder.PLUGINS));
await deleteFolder(path.join(versionPath, ModsInstallFolder.LIBS));
await deleteFolder(path.join(versionPath, ModsInstallFolder.IPA));
obs.next(progress);
for (const mod of mods) {
await this.uninstallMod(mod, version);
progress.current++;
obs.next(progress);
}
const versionPath = await this.bsLocalService.getVersionPath(version);
await deleteFolder(path.join(versionPath, ModsInstallFolder.PLUGINS));
await deleteFolder(path.join(versionPath, ModsInstallFolder.LIBS));
await deleteFolder(path.join(versionPath, ModsInstallFolder.IPA));
})()
.catch(err => obs.error(err))
.finally(() => obs.complete());
});
return {
nbModsToUninstall: this.nbModsToUninstall,
nbUninstalledMods: this.nbUninstalledMods,
};
}
}
+18 -22
View File
@@ -1,5 +1,5 @@
import { Agent, RequestOptions } from "https";
import { createWriteStream } from "fs";
import { Agent, RequestOptions, get } from "https";
import { createWriteStream, unlink } from "fs";
import { Progression } from "main/helpers/fs.helpers";
import { Observable, shareReplay, tap } from "rxjs";
import log from "electron-log";
@@ -8,8 +8,6 @@ import got, { Options } from "got";
import { IncomingMessage } from "http";
import { app } from "electron";
import os from "os";
import { unlinkSync } from "fs-extra";
import { tryit } from "shared/helpers/error.helpers";
export class RequestService {
private static instance: RequestService;
@@ -62,33 +60,31 @@ export class RequestService {
public downloadFile(url: string, dest: string): Observable<Progression<string>> {
return new Observable<Progression<string>>(subscriber => {
const progress: Progression<string> = { current: 0, total: 0, data: dest };
const progress: Progression<string> = { current: 0, total: 0 };
const stream = got.stream(url)
const file = createWriteStream(dest);
stream.on("downloadProgress", ({ transferred, total }) => {
progress.current = transferred;
progress.total = total;
subscriber.next(progress);
});
stream.on("error", err => {
tryit(() => unlinkSync(dest));
subscriber.error(err);
});
stream.on("end", () => {
file.on("close", () => {
progress.data = dest;
subscriber.next(progress);
subscriber.complete();
});
file.on("error", err => unlink(dest, () => subscriber.error(err)));
stream.pipe(file);
const req = get(url, this.requestOptionsFromDefaultInit(), res => {
progress.total = parseInt(res.headers?.["content-length"] || "0", 10);
return () => {
stream.destroy();
}
res.on("data", chunk => {
progress.current += chunk.length;
subscriber.next(progress);
});
res.pipe(file);
});
req.on("error", err => {
subscriber.error(err);
});
}).pipe(tap({ error: e => log.error(e, url, dest) }), shareReplay(1));
}
@@ -1,81 +0,0 @@
import ElectronStore from "electron-store";
import { Observable, Subject } from "rxjs";
import { BSVersion } from "shared/bs-version.interface";
export class StaticConfigurationService {
private static instance: StaticConfigurationService;
public static getInstance(): StaticConfigurationService {
if (!StaticConfigurationService.instance) {
StaticConfigurationService.instance = new StaticConfigurationService();
}
return StaticConfigurationService.instance;
}
private readonly store: ElectronStore;
private readonly watchers: {
[K in StaticConfigKeys]?: Subject<StaticConfigKeyValues[K]>;
} = {};
private constructor() {
this.store = new ElectronStore();
}
public has<K extends StaticConfigKeys>(key: K): boolean {
return this.store.has(key);
}
public get<K extends StaticConfigKeys>(key: K): StaticConfigKeyValues[K] {
return this.store.get<K>(key) as StaticConfigKeyValues[K];
}
public take<K extends StaticConfigKeys>(key: K, cb: (val: StaticConfigKeyValues[K]) => void): void {
cb(this.get(key));
}
public set<K extends StaticConfigKeys>(key: K, value: StaticConfigKeyValues[K]): void {
this.store.set(key, value);
if (this.watchers[key]) {
this.watchers[key].next(value); // update watchers if any
}
}
public delete<K extends StaticConfigKeys>(key: K): void {
this.store.delete(key);
}
public getStore(): ElectronStore {
return this.store;
}
public $watch<K extends StaticConfigKeys>(key: K): Observable<StaticConfigKeyValues[K]> {
if (!this.watchers[key]) {
this.watchers[key] = new Subject() as any; // avoid type error here, the essential is that it work using the function
}
return this.watchers[key];
}
}
export interface StaticConfigKeyValues {
"versions": BSVersion[];
"installation-folder": string;
"song-details-cache-etag": string;
"disable-hadware-acceleration": boolean;
"use-symlinks": boolean;
}
export type StaticConfigKeys = keyof StaticConfigKeyValues;
export type StaticConfigGetIpcRequestResponse<K extends StaticConfigKeys> = {
request: K;
response: StaticConfigKeyValues[K];
};
export type StaticConfigSetIpcRequest<K extends StaticConfigKeys> = {
request: { key: K, value: StaticConfigKeyValues[K] };
response: void;
}
+1
View File
@@ -52,6 +52,7 @@ export class SteamService {
* @returns true if the Steam process is running as administrator
*/
public async isElevated(): Promise<boolean>{
if(process.platform === "linux"){ return true; }
const steamPid = await this.getSteamPid();
if(!steamPid){ return false; }
@@ -35,14 +35,10 @@ export class BeatSaverApiService {
const enbledTagsString = filter.enabledTags ? Array.from(filter.enabledTags) : null;
const excludedTagsString = filter.excludedTags ? Array.from(filter.excludedTags).map(tag => `!${tag}`) : null;
let tags = [...(enbledTagsString ?? []), ...(excludedTagsString ?? [])].join("|");
tags = tags.length > 0 ? tags : null;
const tags = enbledTagsString || excludedTagsString ? [...enbledTagsString, excludedTagsString].join("|") : null;
const params: Record<string, string> = this.objectToStringRecord({...filter, tags});
delete params.enabledTags;
delete params.excludedTags;
return new URLSearchParams(params);
}
@@ -23,15 +23,14 @@ export class BeatSaverService {
}
public async getMapDetailsFromHashs(hashs: string[]): Promise<BsvMapDetail[]> {
const filtredHashs = hashs.map(h => h.toLowerCase()).filter(hash => !this.cachedMapsDetails.has(hash));
const filtredHashs = hashs.map(h => h.toLowerCase()).filter(hash => !Array.from(this.cachedMapsDetails.keys()).includes(hash));
const chunkHash = splitIntoChunk(filtredHashs, 50);
const mapDetails = hashs.reduce((acc, hash) => {
const detail = this.cachedMapsDetails.get(hash.toLowerCase());
if (detail) {
acc.push(detail);
const mapDetails = Array.from(this.cachedMapsDetails.entries()).reduce((res, [hash, details]) => {
if (hashs.includes(hash)) {
res.push(details);
}
return acc;
return res;
}, [] as BsvMapDetail[]);
await Promise.allSettled(
+10 -9
View File
@@ -1,17 +1,14 @@
import path from "path";
import { app } from "electron";
import { app, BrowserWindow } from "electron";
import { IpcResponse } from "shared/models/ipc";
import log from "electron-log";
// TODO : REFACTOR
export class UtilsService {
private static instance: UtilsService;
private assetsPath: string = app.isPackaged
? path.join(process.resourcesPath, "assets")
: path.join(path.dirname(path.dirname(__dirname)), "assets");
private readonly buildPath: string = app.isPackaged
? path.join(process.resourcesPath, "build")
: path.join(path.dirname(path.dirname(__dirname)), "build");
private assetsPath: string = app.isPackaged ? path.join(process.resourcesPath, "assets") : path.join(__dirname, "../../../assets");
private constructor() {}
@@ -39,7 +36,11 @@ export class UtilsService {
return path.join(app.getPath("temp"), app.getName());
}
public getBuildPath(filepath: string): string {
return path.join(this.buildPath, filepath);
public ipcSend<T = unknown>(channel: string, response: IpcResponse<T>): void {
try {
BrowserWindow.getAllWindows().forEach(window => window?.webContents?.send(channel, response));
} catch (error) {
log.error(error);
}
}
}
@@ -1,6 +1,6 @@
import { getFoldersInFolder } from "../helpers/fs.helpers";
import path from "path";
import { VersionLinkerAction, VersionUnlinkFolderAction } from "renderer/services/version-folder-linker.service";
import { VersionLinkerAction, VersionLinkFolderAction, VersionUnlinkFolderAction } from "renderer/services/version-folder-linker.service";
import { BSVersion } from "shared/bs-version.interface";
import { LocalMapsManagerService } from "./additional-content/maps/local-maps-manager.service";
import { BSLocalVersionService } from "./bs-local-version.service";
@@ -58,14 +58,17 @@ export class VersionFolderLinkerService {
return path.join(parentPath, relativePath);
}
public async linkVersionFolder(action: VersionLinkerAction): Promise<void> {
public async linkVersionFolder(action: VersionLinkerAction): Promise<boolean> {
action.options = this.specialFolderOption(action.relativeFolder, action.options);
const versionPath = await this.localVersion.getVersionPath(action.version);
const folderPath = this.relativeToFullPath(versionPath, action.relativeFolder);
return this.folderLinker.linkFolder(folderPath, action.options)
return this.folderLinker
.linkFolder(folderPath, action.options)
.catch(() => false)
.then(() => true);
}
public async unlinkVersionFolder(action: VersionUnlinkFolderAction): Promise<void> {
public async unlinkVersionFolder(action: VersionUnlinkFolderAction): Promise<boolean> {
action.options = this.specialFolderOption(action.relativeFolder, action.options);
const versionPath = await this.localVersion.getVersionPath(action.version);
@@ -73,10 +76,13 @@ export class VersionFolderLinkerService {
action.options.moveContents = !(await this.isOtherVersionHaveFolderLinked(action.relativeFolder, folderPath));
return this.folderLinker.unlinkFolder(folderPath, action.options);
return this.folderLinker
.unlinkFolder(folderPath, action.options)
.catch(() => false)
.then(() => true);
}
public doAction(action: VersionLinkerAction): Promise<void> {
public async doAction(action: VersionLinkerAction): Promise<boolean> {
if (action.type === "link") {
return this.linkVersionFolder(action);
}
@@ -117,7 +123,7 @@ export class VersionFolderLinkerService {
for (const version of versions) {
const linkedFolders = await this.getLinkedFolders(version, { relative: true, ignoreSymlinkTargetError: true });
const actions = linkedFolders.map(folder => ({ type: "link", version, relativeFolder: folder } as VersionLinkerAction));
const actions = linkedFolders.map(folder => ({ type: "link", version, relativeFolder: folder } as VersionLinkFolderAction));
await Promise.all(actions.map(action => this.doAction(action)));
}
}
+2 -4
View File
@@ -9,7 +9,7 @@ import { isValidUrl } from "../../shared/helpers/url.helpers";
export class WindowManagerService {
private static instance: WindowManagerService;
private readonly PRELOAD_PATH = app.isPackaged ? path.join(__dirname, "preload.js") : path.join(__dirname, "../../.erb/dll/preload.js");
private readonly PRELOAD_PATH = app.isPackaged ? path.join(__dirname, "preload.js") : path.join(__dirname, "../../../.erb/dll/preload.js");
private readonly IS_DEBUG = process.env.NODE_ENV === "development" || process.env.DEBUG_PROD === "true"
private readonly utilsService: UtilsService = UtilsService.getInstance();
@@ -25,9 +25,7 @@ export class WindowManagerService {
private readonly baseWindowOption: BrowserWindowConstructorOptions = {
title: APP_NAME,
icon: process.platform === "linux"
? this.utilsService.getBuildPath(path.join("icons", "png", "256x256.png"))
: this.utilsService.getBuildPath(path.join("icons", "win", "favicon.ico")),
icon: this.utilsService.getAssetsPath("favicon.ico"),
show: false,
frame: false,
titleBarOverlay: false,
@@ -1,4 +1,4 @@
import { BSVersion } from "shared/bs-version.interface";
import { BSVersion, BSVersionString } from "shared/bs-version.interface";
import { useState, memo, ComponentProps } from "react";
import defaultImage from "../../../../assets/images/default-version-img.jpg";
import dateFormat from "dateformat";
@@ -9,19 +9,33 @@ import { GlowEffect } from "../shared/glow-effect.component";
import equal from "fast-deep-equal";
import { SteamIcon } from "../svgs/icons/steam-icon.component";
import { useConstant } from "renderer/hooks/use-constant.hook";
import { CpuIcon } from "../svgs/icons/cpu-icon.component";
import Tippy from "@tippyjs/react";
import { useService } from "renderer/hooks/use-service.hook";
import { ModalService } from "renderer/services/modale.service";
import { AvailableModsModal } from "../modal/modal-types/available-mods-modal.component";
type Props = {
version: BSVersion;
selected: boolean;
beatModsVersionAlias?: BSVersionString;
onClick: ComponentProps<"li">["onClick"];
}
export const AvailableVersionItem = memo(function AvailableVersionItem({version, selected, onClick}: Props) {
export const AvailableVersionItem = memo(function AvailableVersionItem({version, selected, beatModsVersionAlias, onClick}: Props) {
const t = useTranslation();
const modal = useService(ModalService);
const [hovered, setHovered] = useState(false);
const formatedDate = useConstant(() => dateFormat(+version.ReleaseDate * 1000, "ddd. d mmm yyyy"));
const openAvailableMods = () => {
if(!beatModsVersionAlias) { return; }
modal.openModal(AvailableModsModal, beatModsVersionAlias);
};
return (
<motion.li className="group relative w-72 h-60 active:scale-[.98]" onClick={onClick} onHoverStart={() => setHovered(true)} onHoverEnd={() => setHovered(false)}>
<GlowEffect visible={hovered || selected} className="absolute" />
@@ -36,12 +50,22 @@ export const AvailableVersionItem = memo(function AvailableVersionItem({version,
<h2 className="block text-xl font-bold text-white tracking-wider">{version.BSVersion}</h2>
<span className="text-sm text-gray-700 dark:text-gray-400">{formatedDate}</span>
</div>
{version.ReleaseURL && (
<a href={version.ReleaseURL} target="_blank" className="flex flex-row justify-between items-center rounded-full bg-black bg-opacity-30 text-white pb-px overflow-hidden hover:bg-opacity-50" tabIndex={-1}>
<SteamIcon className="w-[25px] h-[25px] transition-transform group-hover:rotate-[-360deg] duration-300" />
<span className="relative -left-px text-sm w-fit max-w-0 text-center overflow-hidden h-full whitespace-nowrap pb-[3px] transition-all group-hover:max-w-[200px] group-hover:px-1 duration-300">{t("pages.available-versions.steam-release")}</span>
</a>
)}
<div className="flex items-end gap-2">
{version.ReleaseURL && (
<a href={version.ReleaseURL} target="_blank" className="flex flex-row justify-between items-center rounded-full bg-black bg-opacity-30 text-white pb-px overflow-hidden hover:bg-opacity-50" tabIndex={-1}>
<SteamIcon className="size-[25px] transition-transform group-hover:rotate-[-360deg] duration-300" />
<span className="relative -left-px text-sm w-fit max-w-0 text-center overflow-hidden h-full whitespace-nowrap pb-[3px] transition-all group-hover:max-w-[200px] group-hover:px-1 duration-300">{t("pages.available-versions.steam-release")}</span>
</a>
)}
{beatModsVersionAlias && (
<Tippy content="Voir les mods disponnible" placement="top" theme="default">
<button className="size-[25px] rounded-full bg-black bg-opacity-30 p-[3px] hover:bg-opacity-50" type="button" onClickCapture={openAvailableMods}>
<CpuIcon className="text-gray-200"/>
</button>
</Tippy>
)}
</div>
</div>
</div>
</motion.li>
@@ -1,14 +1,15 @@
import { useContext } from "react";
import { AvailableVersionItem } from "./available-version-item.component";
import { BSVersion } from "shared/bs-version.interface";
import { BSVersion, BSVersionString } from "shared/bs-version.interface";
import { AvailableVersionsContext } from "renderer/pages/available-versions-list.components";
import equal from "fast-deep-equal";
type Props = {
versions: BSVersion[]
versions: BSVersion[],
versionModAliases?: Record<BSVersionString, BSVersionString[]>
}
export function AvailableVersionsSlide({ versions }: Props) {
export function AvailableVersionsSlide({ versions, versionModAliases }: Props) {
const context = useContext(AvailableVersionsContext);
@@ -28,10 +29,16 @@ export function AvailableVersionsSlide({ versions }: Props) {
return copy;
}
const getVersionModAlias = (version: BSVersion): BSVersionString => {
if(!versionModAliases) { return; }
if(versionModAliases[version.BSVersion]) { return version.BSVersion; }
return Object.keys(versionModAliases).find(k => versionModAliases[k as BSVersionString].some(v => v === version.BSVersion)) as BSVersionString;
}
return (
<ol className="w-full flex items-start justify-center gap-6 shrink-0 content-start flex-wrap px-3.5 py-4 overflow-x-hidden overflow-y-scroll scrollbar-default">
{getVersions().map(version => (
<AvailableVersionItem key={version.BSManifest} version={version} selected={equal(version, context.selectedVersion)} onClick={() => setSelectedVersion(version)}/>
<AvailableVersionItem key={version.BSManifest} version={version} selected={equal(version, context.selectedVersion)} onClick={() => setSelectedVersion(version)} beatModsVersionAlias={getVersionModAlias(version)}/>
))}
</ol>
);
@@ -4,10 +4,13 @@ import { AvailableVersionsSlide } from "./available-versions-slide.component";
import { TabNavBar } from "../shared/tab-nav-bar.component";
import { useService } from "renderer/hooks/use-service.hook";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { BeatModsService } from "renderer/services/thrird-partys/beat-mods.service";
export function AvailableVersionsSlider() {
const versionManagerService = useService(BSVersionManagerService);
const beatMods = useService(BeatModsService);
const versionModAliases = useObservable(() => beatMods.getVersionAliases(), undefined);
const availableVersions = useObservable(() => versionManagerService.availableVersions$);
const [yearIndex, setYearIndex] = useState(0);
@@ -29,7 +32,7 @@ export function AvailableVersionsSlider() {
<TabNavBar tabIndex={yearIndex} tabsText={availableYears} onTabChange={setSelectedYear} />
<ol className="w-full min-h-0 flex transition-transform duration-300" style={{ transform: `translate(${-(yearIndex * 100)}%, 0)` }}>
{availableYears.map(year => (
<AvailableVersionsSlide key={year} versions={getVersionOfYear(year)} />
<AvailableVersionsSlide key={year} versions={getVersionOfYear(year)} versionModAliases={versionModAliases} />
))}
</ol>
</div>
@@ -1,6 +1,6 @@
import { createContext, useRef, useState } from "react";
import { BSVersion } from "shared/bs-version.interface";
import { LocalMapsListPanel, LocalMapsListPanelRef } from "./maps/local-maps-list-panel.component";
import { LocalMapsListPanel } from "./maps/local-maps-list-panel.component";
import { BsmDropdownButton, DropDownItem } from "../shared/bsm-dropdown-button.component";
import { FilterPanel } from "./maps/filter-panel.component";
import { MapFilter } from "shared/models/maps/beat-saver.model";
@@ -10,10 +10,11 @@ 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, lastValueFrom, of } from "rxjs";
import { BehaviorSubject, 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";
@@ -22,8 +23,6 @@ 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;
@@ -47,9 +46,6 @@ 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));
@@ -60,7 +56,7 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
setPlaylists: playlists$.next.bind(playlists$),
}));
const mapsRef = useRef<LocalMapsListPanelRef>();
const mapsRef = useRef<any>();
const playlistsRef = useRef<LocalPlaylistsListRef>();
const [mapFilter, setMapFilter] = useState<MapFilter>({});
@@ -77,16 +73,11 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
return playlistsManager.$playlistsFolderLinkState(version);
}, FolderLinkState.Unlinked, [version]);
const handleBrowse = () => {
const handleAddClick = () => {
switch (tabIndex) {
case 0:
mapsDownloader.openDownloadMapModal(version, maps$.value);
return;
case 1:
playlistsDownloader.openDownloadPlaylistModal(version, playlists$, maps$);
return;
default:
return noop();
case 0: return mapsDownloader.openDownloadMapModal(version, maps$.value);
case 1: return playlistsDownloader.openDownloadPlaylistModal(version, playlists$, maps$);
default: return noop();
}
}
@@ -110,41 +101,16 @@ 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 [
{ icon: "export", text: "pages.version-viewer.maps.search-bar.dropdown.export-maps", onClick: () => mapsRef.current.exportMaps?.() },
{ icon: "trash", text: "pages.version-viewer.maps.search-bar.dropdown.delete-maps", onClick: () => mapsRef.current.deleteMaps?.() },
{ icon: "clean", text: "pages.version-viewer.maps.search-bar.dropdown.delete-duplicate-maps", onClick: () => mapsRef.current.removeDuplicates?.() },
];
}
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?.() },
@@ -152,22 +118,15 @@ 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">
<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"
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<nav className="w-full shrink-0 flex h-9 justify-center px-40 gap-2 text-main-color-1 dark:text-white">
<BsmButton
className="flex items-center justify-center w-fit rounded-full px-2 py-1 font-bold whitespace-nowrap"
icon="add"
buttonColor="primary"
text="misc.add"
align="center"
menuTranslationY="6px"
typeColor="primary"
withBar={false}
items={addDropDownItems}
onClick={handleAddClick}
/>
<div className="h-full rounded-full bg-light-main-color-2 dark:bg-main-color-2 grow p-[6px]">
<input
@@ -215,39 +174,10 @@ export function MapsPlaylistsPanel({ version, isActive }: Props) {
]}
>
<InstalledMapsContext.Provider value={mapsContextValue}>
<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>
<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}/>
</InstalledMapsContext.Provider>
</BsContentTabPanel>
</>
</div>
);
}
@@ -2,7 +2,8 @@ import { BsvMapDetail, MapFilter, MapRequirement, MapSpecificity, MapStyle, MapT
import { motion } from "framer-motion";
import { MutableRefObject, useEffect, useRef, useState } from "react";
import { BsmCheckbox } from "../../shared/bsm-checkbox.component";
import { minToS, sToMs } from "../../../../shared/helpers/time.helpers";
import { minToS } from "../../../../shared/helpers/time.helpers";
import dateFormat from "dateformat";
import { BsmRange } from "../../shared/bsm-range.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { MAP_DIFFICULTIES_COLORS } from "shared/models/maps/difficulties-colors";
@@ -12,8 +13,6 @@ import clone from "rfdc";
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;
@@ -61,8 +60,9 @@ export function FilterPanel({ className, ref, playlist = false, filter, localDat
if (sec === MAX_DURATION) {
return "∞";
}
const ms = sToMs(sec);
return formatDuration(ms, { leading: true });
const date = new Date(0);
date.setSeconds(sec);
return sec > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss");
})();
return renderLabel(textValue, sec === MAX_DURATION);
@@ -303,10 +303,9 @@ function isFitVerified(filter: MapFilter, verified: boolean): boolean {
return verified;
}
function isFitSearch(search: string, {songName, songAuthorName, levelMappers}: {songName: MapInfo["songName"], songAuthorName: MapInfo["songAuthorName"], levelMappers: MapInfo["levelMappers"]}): boolean {
function isFitSearch(search: string, {songName, songAuthorName, levelAuthorName}: {songName: string, songAuthorName: string, levelAuthorName: string}): boolean {
if (!search) { return true; }
if(levelMappers?.some(mapper => mapper?.toLowerCase().includes(search.toLowerCase()))) { return true; }
return songName?.toLowerCase().includes(search.toLowerCase()) || songAuthorName?.toLowerCase().includes(search.toLowerCase());
return songName?.toLowerCase().includes(search.toLowerCase()) || songAuthorName?.toLowerCase().includes(search.toLowerCase()) || levelAuthorName?.toLowerCase().includes(search.toLowerCase());
}
export const isLocalMapFitMapFilter = ({filter, map, search}: { filter: MapFilter, map: BsmLocalMap, search: string }): boolean => {
@@ -325,7 +324,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.mapInfo?.songName, songAuthorName: map.mapInfo?.songAuthorName, levelMappers: map.mapInfo?.levelMappers})) { return false; }
if (!isFitSearch(search, {songName: map.rawInfo?._songName, songAuthorName: map.rawInfo?._songAuthorName, levelAuthorName: map.rawInfo?._levelAuthorName})) { return false; }
return true;
};
@@ -346,7 +345,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, levelMappers: [map.metadata.levelAuthorName]})) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.metadata.songAuthorName, levelAuthorName: map.metadata.levelAuthorName})) { return false; }
return true;
};
@@ -366,12 +365,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, levelMappers: [map.uploader.name]})) { return false; }
if (!isFitSearch(search, {songName: map.name, songAuthorName: map.uploader.name, levelAuthorName: 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)?.mapInfo) { return isLocalMapFitMapFilter({filter, map: (map as BsmLocalMap), search}); }
if ((map as BsmLocalMap)?.rawInfo) { 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;
@@ -2,10 +2,10 @@ import { MapsManagerService } from "renderer/services/maps-manager.service";
import { BSVersion } from "shared/bs-version.interface";
import { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useState } from "react";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import { Subscription, BehaviorSubject, lastValueFrom } from "rxjs";
import { Subscription, BehaviorSubject } from "rxjs";
import { MapFilter } from "shared/models/maps/beat-saver.model";
import { MapsDownloaderService } from "renderer/services/maps-downloader.service";
import { bufferTime, last, tap } from "rxjs/operators";
import { 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";
@@ -34,13 +34,7 @@ type Props = {
isActive?: boolean;
};
export type LocalMapsListPanelRef = {
deleteMaps: () => void;
exportMaps: () => void;
removeDuplicates: () => void;
}
export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ version, className, filter, search, linkedState, isActive }, forwardRef) => {
export const LocalMapsListPanel = forwardRef<unknown, Props>(({ version, className, filter, search, linkedState, isActive }, forwardRef) => {
const mapsManager = useService(MapsManagerService);
const mapsDownloader = useService(MapsDownloaderService);
@@ -69,12 +63,6 @@ export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ ve
},
exportMaps() {
mapsManager.exportMaps(version, selectedMaps);
},
removeDuplicates() {
return lastValueFrom(mapsManager.deleteDuplicateMaps(maps$.value)).then(res => {
if(!res?.current){ return; }
loadMaps();
}).catch(noop);
}
}),[selectedMaps, maps, version]);
@@ -88,8 +76,6 @@ export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ ve
return noop;
}, [linkedState]);
useOnUpdate(() => setSelectedMaps([]), [version]); // Clear selected maps when version changes
useEffect(() => {
if (isActiveOnce) {
loadMaps();
@@ -99,32 +85,25 @@ export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ ve
setMaps(null);
loadPercent$.next(0);
subs.forEach(s => s.unsubscribe());
mapsDownloader.removeOnMapDownloadedListener(loadMaps);
};
}, [isActiveOnce, version, linked]);
useEffect(() => {
const subs: Subscription[] = [];
if(isActiveOnce){
subs.push(mapsDownloader.lastDownloadedMap$.subscribe({ next: ({map, version: targetVersion}) => {
mapsDownloader.addOnMapDownloadedListener((map, 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]);
}}));
setMaps((maps ? [map, ...maps] : [map]));
});
}
return () => {
subs.forEach(s => s.unsubscribe());
mapsDownloader.removeOnMapDownloadedListener(loadMaps);
}
}, [isActiveOnce, version])
}, [isActiveOnce, version, maps])
const loadMaps = () => {
setMaps(null);
@@ -181,18 +160,17 @@ export const LocalMapsListPanel = forwardRef<LocalMapsListPanelRef, Props>(({ ve
<MapItem
key={map.path}
hash={map.hash}
title={map.mapInfo.songName}
title={map.rawInfo._songName}
coverUrl={map.coverUrl}
songUrl={map.songUrl}
autor={map.mapInfo.levelMappers.at(0)}
songAutor={map.mapInfo.songAuthorName}
bpm={map.mapInfo.beatsPerMinute}
autor={map.rawInfo._levelAuthorName}
songAutor={map.rawInfo._songAuthorName}
bpm={map.rawInfo._beatsPerMinute}
duration={map.songDetails?.duration}
selected={renderableMap.selected}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ mapInfo: map.mapInfo, songDetails: map.songDetails })}
diffs={MapItemComponentPropsMapper.extractMapDiffs({ rawMapInfo: map.rawInfo, 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}
@@ -4,7 +4,7 @@ import { BsmLink } from "../../shared/bsm-link.component";
import { BsmIcon } from "../../svgs/bsm-icon.component";
import { BsmButton } from "../../shared/bsm-button.component";
import { AnimatePresence, motion } from "framer-motion";
import { useState, Fragment, useRef, useMemo } from "react";
import { useState, Fragment, useRef } from "react";
import { LinkOpenerService } from "renderer/services/link-opener.service";
import dateFormat from "dateformat";
import { AudioPlayerService } from "renderer/services/audio-player.service";
@@ -30,10 +30,6 @@ import { BsmCheckbox } from "renderer/components/shared/bsm-checkbox.component";
import { BPListDifficulty } from "shared/models/playlists/playlist.interface";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import { cn } from "renderer/helpers/css-class.helpers";
import { sToMs } from "shared/helpers/time.helpers";
import formatDuration from "format-duration";
import { NpsIcon } from "renderer/components/svgs/icons/nps-icon.component";
import { SpeedIcon } from "renderer/components/svgs/icons/speed-icon.component";
export type MapItemComponentProps<T = unknown> = {
hash: string;
@@ -110,13 +106,14 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
return dateFormat(date, "d mmm yyyy");
});
const durationText = useMemo(() => {
const durationText = (() => {
if (!duration) {
return null;
}
const durationMs = sToMs(duration);
return formatDuration(durationMs, { leading: true });
}, [duration]);
const date = new Date(0);
date.setSeconds(duration);
return duration > 3600 ? dateFormat(date, "h:MM:ss") : dateFormat(date, "MM:ss");
})();
const parseDiffLabel = (diffLabel: string) => {
if (MAP_DIFFICULTIES.includes(diffLabel as SongDiffName)) {
@@ -210,8 +207,8 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
<motion.ul key={hash} className="absolute top-[calc(100%-10px)] w-full h-fit max-h-[200%] pt-4 pb-2 px-2 overflow-y-scroll bg-light-main-color-3 dark:bg-main-color-3 text-main-color-1 dark:text-current brightness-125 rounded-md flex flex-col gap-3 scrollbar-default shadow-sm shadow-black" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} onHoverStart={diffsPanelHoverStart} onHoverEnd={diffsPanelHoverEnd}>
{Array.from(diffs.entries()).map(([charac, diffSet]) => (
<ol key={charac} className="flex flex-col w-full gap-1">
{diffSet.map(({ name, libelle, stars, nps, njs }) => (
<li key={`${name}${libelle}${stars}`} className="w-full h-[1.15rem] flex items-center gap-1">
{diffSet.map(({ name, libelle, stars }) => (
<li key={`${name}${libelle}${stars}`} className="w-full h-4 flex items-center gap-1">
{onHighlightedDiffsChange && (
<Tippy content={t("maps.map-item.hightlight-difficulty")} placement="top" theme="default">
<div className="h-full aspect-square">
@@ -220,38 +217,9 @@ export function MapItemComponent <T = unknown>({ hash, title, autor, songAutor,
</Tippy>
)}
<BsmIcon className="h-full w-fit p-px shrink-0" icon={charac} />
<div className="h-full px-2 shrink-0 flex items-center text-sm font-bold bg-current rounded-full" style={{ color: MAP_DIFFICULTIES_COLORS[name] }}>
{(() => {
if(stars){
return (
<div className="h-full brightness-[.15] flex justify-center items-center">
<span className="pb-0.5"> {stars.toFixed(2)}</span>
</div>
);
}
if(nps){
return (
<div className="h-full brightness-[.15] flex justify-center items-center gap-1" title={t("maps.map-filter-panel.nps")}>
<NpsIcon className="h-full py-px"/>
<span className="pb-0.5">{nps.toFixed(2)}</span>
</div>
);
}
if(njs){
return (
<p className="h-full brightness-[.15] flex justify-center items-center gap-1" title={t("maps.map-filter-panel.njs")}>
<SpeedIcon className="h-full py-px"/>
<span className="pb-0.5">{njs.toFixed(2)}</span>
</p>
);
}
return (
<div className="h-full brightness-[.15] flex justify-center items-center">
<span className="pb-0.5 capitalize">{parseDiffLabel(name)}</span>
</div>
);
})()}
</div>
<span className="h-full px-2 flex items-center text-xs font-bold bg-current rounded-full" style={{ color: MAP_DIFFICULTIES_COLORS[name] }}>
{stars ? <span className="h-full block brightness-[.25]"> {stars.toFixed(2)}</span> : <span className="h-full brightness-[.25] leading-4 pb-[2px] capitalize">{parseDiffLabel(name)}</span>}
</span>
<span className={cn("text-sm leading-4 pb-[2px] line-clamp-1", isDiffHightlighted({name, characteristic: charac}) && "text-yellow-400")}>{parseDiffLabel(libelle)}</span>
</li>
))}
@@ -3,9 +3,9 @@ import { Dispatch, SetStateAction, useState } from "react";
import { BsmRange } from "renderer/components/shared/bsm-range.component";
import { cn } from "renderer/helpers/css-class.helpers"
import { useTranslation } from "renderer/hooks/use-translation.hook";
import dateFormat from "dateformat";
import { hourToS, sToMs } from "shared/helpers/time.helpers";
import { useOnUpdate } from "renderer/hooks/use-on-update.hook";
import formatDuration from "format-duration";
type Props = {
className?: string;
@@ -81,8 +81,7 @@ export function LocalPlaylistFilterPanel({ className, filter, onChange }: Props)
return "∞";
}
const ms = sToMs(sec);
return formatDuration(ms, { leading: true });
return sec > 3600 ? dateFormat(sToMs(sec), "h:MM:ss") : dateFormat(sToMs(sec), "MM:ss");
})();
return renderLabel(textValue, sec === MAX_DURATION);
@@ -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, Subscription, bufferTime, combineLatest, distinctUntilChanged, filter, finalize, lastValueFrom, map, tap } from "rxjs";
import { BehaviorSubject, Observable, 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: (playlists?: LocalBPList[]) => Promise<void>;
syncPlaylists: () => Promise<void>;
deletePlaylists: () => Promise<void>;
exportPlaylists: () => Promise<void>;
}
@@ -111,9 +111,9 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
}
},
syncPlaylists: async (playlists?: LocalBPList[]) => {
syncPlaylists: async () => {
if(!isOnline){ return; }
const toSync = playlists ?? (selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value);
const toSync = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value;
if(!toSync.length){ return; }
const modalRes = await modals.openModal(SyncPlaylistModal, { data: toSync });
@@ -151,7 +151,7 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
const obs$ = playlistService.exportPlaylists({ version, bpLists: toExport, dest: folderRes.filePaths.at(0), playlistsMaps: mapsToExport });
progess.show(obs$);
progess.show(obs$, true);
const { error } = await tryit(() => lastValueFrom(obs$));
@@ -163,7 +163,7 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
notification.notifySuccess({ title: "playlist.playlists-exported-title", desc: exportMaps ? "playlist.playlists-exported-desc" : "playlist.playlists-with-maps-exported-desc", duration: 5000 });
progess.hide();
progess.hide(true);
},
deletePlaylists: () => {
const toDelete = selectedPlaylists$.value?.length ? selectedPlaylists$.value : playlists$.value;
@@ -183,8 +183,6 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
return lastValueFrom(obs);
}
useOnUpdate(() => selectedPlaylists$.next([]), [version]);
useOnUpdate(() => {
if(!isActiveOnce){ return noop(); }
@@ -211,22 +209,10 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
setPlaylists(newPlaylist);
}
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]);
}
}));
const sub = playlistDownloader.currentDownload$.pipe(filter(download => download?.downloaded && equal(download?.info.version, version))).subscribe(download => onPlaylistDownloadedCB(download.downloaded));
return () => {
subs.forEach(s => s.unsubscribe());
sub.unsubscribe();
}
}, [isActiveOnce, version, linked]);
@@ -259,7 +245,7 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
if(exitCode !== ModalExitCode.COMPLETED){ return; }
const progess$ = new BehaviorSubject<ProgressionInterface>({ progression: 0 });
progess.show(progess$)
progess.show(progess$, true)
for(const [i, bpList] of enumerate(bpLists)){
@@ -268,7 +254,7 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
if(error){
logRenderError("Error occured while deleting playlist", error);
notification.notifyError({ title: "playlist.playlist-delete-error-title", desc: "playlist.playlist-delete-error-desc" });
progess.hide();
progess.hide(true);
return;
}
@@ -282,7 +268,7 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
notification.notifySuccess({ title: "playlist.playlists-deleted-title", desc: "playlist.playlists-deleted-desc", duration: 5000 });
progess.hide();
progess.hide(true);
};
const openPlaylistDetails = (playlistPath: string) => {
@@ -296,9 +282,8 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
let map: BsmLocalMap;
if(playlistSong.hash || playlistSong?.songDetails?.hash){
const hash = (playlistSong.hash || playlistSong.songDetails.hash).toLowerCase();
map = maps.find(m => m.hash.toLowerCase() === hash);
if(playlistSong.hash){
map = maps.find(m => m.hash.toLowerCase() === playlistSong.hash.toLowerCase());
}
else if(playlistSong.key){
map = maps.find(m => m?.songDetails?.id === playlistSong.key);
@@ -394,8 +379,8 @@ export const LocalPlaylistsListPanel = forwardRef<LocalPlaylistsListRef, Props>(
if(!playlists){ return []; }
return playlists.filter(p => {
if(!p.playlistTitle?.toLowerCase().includes(search.toLowerCase())){ return false; }
if(!p.playlistAuthor?.toLowerCase().includes(search.toLowerCase())){ return false; }
if(!p.playlistTitle.toLowerCase().includes(search.toLowerCase())){ return false; }
if(!p.playlistAuthor.toLowerCase().includes(search.toLowerCase())){ return false; }
if(typeof p.nbMaps === "number" && (typeof playlistFiler?.minNbMaps === "number" || typeof playlistFiler?.maxNbMaps === "number")){
if(playlistFiler?.minNbMaps && p.nbMaps < playlistFiler.minNbMaps){ return false; }
@@ -5,6 +5,7 @@ import { ClockIcon } from 'renderer/components/svgs/icons/clock-icon.component';
import { MapIcon } from 'renderer/components/svgs/icons/map-icon.component';
import { PersonIcon } from 'renderer/components/svgs/icons/person-icon.component';
import { useThemeColor } from 'renderer/hooks/use-theme-color.hook';
import dateFormat from 'dateformat';
import { NpsIcon } from 'renderer/components/svgs/icons/nps-icon.component';
import { GlowEffect } from 'renderer/components/shared/glow-effect.component';
import { memo, useState } from 'react';
@@ -17,8 +18,6 @@ import { BsmBasicSpinner } from 'renderer/components/shared/bsm-basic-spinner/bs
import defaultImage from "../../../../../assets/images/default-version-img.jpg";
import equal from 'fast-deep-equal';
import { useTranslation } from 'renderer/hooks/use-translation.hook';
import { sToMs } from 'shared/helpers/time.helpers';
import formatDuration from 'format-duration';
export type PlaylistItemComponentProps = {
title?: string;
@@ -83,8 +82,7 @@ export const PlaylistItem = memo(({ title,
if (!duration) {
return null;
}
const durationMs = sToMs(duration);
return formatDuration(durationMs, { leading: true });
return duration > 3600 ? dateFormat(duration * 1000, "h:MM:ss") : dateFormat(duration * 1000, "MM:ss");
})();
return (
@@ -1,42 +0,0 @@
import { BsmButton, BsmButtonType } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { cn } from "renderer/helpers/css-class.helpers";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
type BasicModalOptions = {
title: string;
image: string;
body?: string;
buttons?: {
id: string;
text: string;
type: BsmButtonType,
isCancel?: boolean;
}[];
buttonsLayout?: "row" | "column";
};
export const BasicModal: ModalComponent<BasicModalOptions["buttons"][0]["id"], BasicModalOptions> = ({ resolver, options: {
data: { title, image, body, buttons, buttonsLayout = "column" }
} }) => {
const t = useTranslation();
const handleClick = (button: BasicModalOptions["buttons"][0]) => {
resolver({ exitCode: button.isCancel ? ModalExitCode.CANCELED : ModalExitCode.COMPLETED, data: button.id });
}
return (
<form className="text-gray-900 dark:text-white">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t(title)}</h1>
<BsmImage className="mx-auto h-24" image={image} />
{ body && <p className="w-full">{t(body)}</p> }
<div className={cn("grid gap-2 mt-4")} style={{ gridAutoFlow: buttonsLayout, ...(buttonsLayout === "row" ? { gridTemplateRows: `repeat(${buttons.length}, 1fr)` } : { gridTemplateColumns: `repeat(${buttons.length}, 1fr)` }) }}>
{buttons.map(button => (
<BsmButton key={button.id} typeColor={button.type} className="h-8 rounded-md text-center flex justify-center items-center" onClick={() => handleClick(button)} withBar={false} text={button.text} />
))}
</div>
</form>
);
};
@@ -1,109 +0,0 @@
import { lastValueFrom } from "rxjs";
import { useEffect, useState } from "react";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { useService } from "renderer/hooks/use-service.hook";
import Tippy from "@tippyjs/react";
import { IpcService } from "renderer/services/ipc.service";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { BsmButton } from "renderer/components/shared/bsm-button.component";
export const AskInstallPathModal: ModalComponent<{ installPath: string }, {}> = ({ resolver }) => {
const t = useTranslation();
const ipcService = useService(IpcService);
const [installPath, setInstallPath] = useState("");
const [installFolder, setInstallFolder] = useState("");
const [defaultInstallPath, setDefaultInstallPath] = useState("");
useEffect(() => {
lastValueFrom(ipcService.sendV2("bs-installer.default-install-path"))
.then(defaultPath => {
setInstallPath(defaultPath);
setDefaultInstallPath(defaultPath);
setInstallFolder(window.electron.path.basename(defaultPath));
});
}, []);
const selectInstallPath = async () => {
const response = await lastValueFrom(ipcService.sendV2("choose-folder"));
if (response.canceled || !response.filePaths?.length) {
return;
}
const path = response.filePaths[0];
setInstallPath(
window.electron.path.basename(path) === installFolder ?
path :
window.electron.path.join(response.filePaths[0], installFolder)
);
}
const onDefaultButtonPressed = () => {
setInstallPath(defaultInstallPath);
}
const onConfirmButtonPressed = () => {
resolver({
data: { installPath },
exitCode: ModalExitCode.COMPLETED
});
}
return (
<form
className="max-w-xl w-max"
onSubmit={event => {
event.preventDefault();
onConfirmButtonPressed();
}}>
<h1 className="tracking-wide w-full uppercase text-3xl text-center">
{t("modals.ask-install-path.title")}
</h1>
<p className="py-3">
{t("modals.ask-install-path.choose-folder-description")}
</p>
<div className="relative rounded-md pl-2 py-1 mb-3 flex items-center justify-between gap-1 w-full h-8 bg-light-main-color- dark:bg-main-color-1">
<span className="text-ellipsis overflow-hidden min-w-0 text-nowrap text-left cursor-help" title={installPath} style={{ direction: "rtl" }}>
{installPath}
</span>
<BsmButton
onClick={selectInstallPath}
className="shrink-0 whitespace-nowrap mr-2 px-2 font-bold italic text-sm rounded-md"
text="modals.ask-install-path.choose-folder"
withBar={false}
/>
</div>
<div className="h-8 grid grid-flow-col grid-cols-2 gap-2">
<Tippy
content={t("modals.ask-install-path.default-tooltip")}
theme="default"
delay={[300, 0]}
arrow={false}
placement="bottom"
>
<BsmButton
typeColor="cancel"
className="rounded-md text-center transition-all flex items-center justify-center"
onClick={onDefaultButtonPressed}
withBar={false}
text="modals.ask-install-path.default"
/>
</Tippy>
<BsmButton
typeColor="primary"
className="rounded-md text-center transition-all"
type="submit"
withBar={false}
text="misc.confirm"
/>
</div>
</form>
)
}
@@ -0,0 +1,19 @@
import { ModsGrid, modsArrayToCategoryMap } from "renderer/components/version-viewer/slides/mods/mods-grid.component";
import { useObservable } from "renderer/hooks/use-observable.hook";
import { useService } from "renderer/hooks/use-service.hook"
import { ModalComponent } from "renderer/services/modale.service"
import { BeatModsService } from "renderer/services/thrird-partys/beat-mods.service";
import { BSVersionString } from "shared/bs-version.interface"
export const AvailableModsModal: ModalComponent<void, BSVersionString> = ({ data }) => {
const beatMods = useService(BeatModsService);
const availableMods = useObservable(() => beatMods.getVersionMods(data), undefined);
return (
<div className="h-[85vh] overflow-y-scroll">
{availableMods && <ModsGrid modsMap={modsArrayToCategoryMap(availableMods)} />}
</div>
)
}
@@ -30,7 +30,7 @@ export const EnterMetaTokenModal: ModalComponent<string> = ({resolver}) => {
const cancel = () => {
resolver({exitCode: ModalExitCode.CANCELED});
}
return (
<form className="flex flex-col w-80 gap-4">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t("modals.enter-meta-token.title")}</h1>
@@ -205,7 +205,7 @@ const PasswordInput = ({onChange, value}: {onChange: (value : {password: string,
<input className="grow px-1 py-[2px] outline-none bg-transparent" onChange={e => onChange({password: e.target.value, valid: isPasswordValid(e.target.value)})} value={value} type={showPassword ? "text" : "password"} name="password" id="password" placeholder={t("modals.enter-meta-token.body.password")} />
<BsmButton className="shrink-0 m-1 rounded-md p-0.5 !bg-light-main-color-3 dark:!bg-main-color-3" icon={showPassword ? "eye-cross" : "eye"} withBar={false} onClick={() => setShowPassword(prev => !prev)} />
</div>
</>
</>
)
}
@@ -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 { catchError, Observable, of } from "rxjs";
import { Observable } 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$.pipe(catchError(() => of(null))));
const qrCodeUrl = useObservable(() => data.qrCode$);
const t = useTranslation();
useEffect(() => {
@@ -14,13 +14,12 @@ export const ChangelogModal: ModalComponent<void, ChangelogVersion> = ({ options
const openTwitter = () => linkOpener.open("https://twitter.com/BSManager_");
const openSupportPage = () => linkOpener.open("https://www.patreon.com/bsmanager");
const openDiscord = () => linkOpener.open("https://discord.gg/uSqbHVpKdV");
const openWebSite = () => linkOpener.open("https://bsmanager.io/");
const date = changelog?.timestamp ? new Date(changelog.timestamp * 1000).toLocaleDateString() : '';
return (
<form className="w-[350px] text-gray-800 dark:text-gray-200 h-[70vh] flex flex-col justify-between">
<h1 className=" p-4 pt-1 text-3xl uppercase tracking-wide w-full text-center text-gray-800 dark:text-gray-200 font-bold">{changelog?.title}</h1>
<div className=" overflow-y-scroll h-full content grow scrollbar-default" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(changelog?.htmlBody) }}/>
<div className=" overflow-y-scroll h-full content grow" dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(changelog?.htmlBody) }}/>
<span className="block w-[100%] mx-auto mt-0 mb-4 h-1 rounded-full bg-main-color-1" />
<div className="flex flex-row justify-between">
<div className="my-auto flex flex-col text-sm">
@@ -40,9 +39,6 @@ export const ChangelogModal: ModalComponent<void, ChangelogVersion> = ({ options
<Tippy content="Patreon" placement="top" className="font-bold !bg-neutral-900" duration={[200, 0]} arrow={false}>
<div><BsmButton onClick={openSupportPage} className="rounded-md p-1 w-7 h-7 " icon="patreon" withBar={false} iconColor="#fff" color="#000"/></div>
</Tippy>
<Tippy content="Web Site" placement="top" className="font-bold !bg-neutral-900" duration={[200, 0]} arrow={false}>
<div><BsmButton onClick={openWebSite} className="rounded-md p-1 w-7 h-7 " icon="web-site" withBar={false} iconColor="#fff" color="#000"/></div>
</Tippy>
</div>
</div>
</form>
@@ -1,29 +0,0 @@
import { BsmButton } from "renderer/components/shared/bsm-button.component";
import { BsmImage } from "renderer/components/shared/bsm-image.component";
import { useTranslation } from "renderer/hooks/use-translation.hook";
import { ModalComponent, ModalExitCode } from "renderer/services/modale.service";
import { BsmLocalMap } from "shared/models/maps/bsm-local-map.interface";
import BeatConflict from "../../../../../assets/images/apngs/beat-conflict.png";
import { useConstant } from "renderer/hooks/use-constant.hook";
export const DeleteDuplicateMapsModal: ModalComponent<void, { maps: BsmLocalMap[] }> = ({ resolver, options: {data : { maps }}}) => {
const t = useTranslation();
const multiple = useConstant(() => maps.length > 1);
return (
<form className="text-gray-800 dark:text-gray-200 max-w-sm">
<h1 className="text-3xl uppercase tracking-wide w-full text-center">{t("modals.maps-actions.delete-duplicate-maps.title")}</h1>
<BsmImage className="mx-auto h-24" image={BeatConflict} />
<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).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" />
<BsmButton typeColor="primary" className="rounded-md flex justify-center items-center transition-all" onClick={() => resolver({ exitCode: ModalExitCode.COMPLETED })} withBar={false} text="misc.delete" />
</div>
</form>
);
};

Some files were not shown because too many files have changed in this diff Show More