diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs deleted file mode 100644 index bcff8bd91c..0000000000 --- a/.dependency-cruiser.cjs +++ /dev/null @@ -1,376 +0,0 @@ -/** @type {import("dependency-cruiser").IConfiguration} */ -module.exports = { - forbidden: [ - { - name: "no-circular", - severity: "warn", - comment: - "This dependency is part of a circular relationship. You might want to revise " + - "your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ", - from: {}, - to: { - circular: true - } - }, - { - name: "no-orphans", - comment: - "This is an orphan module - it's likely not used (anymore?). Either use it or " + - "remove it. If it's logical this module is an orphan (i.e. it's a config file), " + - "add an exception for it in your dependency-cruiser configuration. By default " + - "this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration " + - "files (.d.ts), tsconfig.json and some of the babel and webpack configs.", - severity: "warn", - from: { - orphan: true, - pathNot: [ - "(^|/)[.][^/]+[.](?:js|cjs|mjs|ts|cts|mts|json)$", // dot files - "[.]d[.]ts$", // TypeScript declaration files - "(^|/)tsconfig[.]json$", // TypeScript config - "(^|/)(?:babel|webpack)[.]config[.](?:js|cjs|mjs|ts|cts|mts|json)$" // other configs - ] - }, - to: {}, - }, - { - name: "no-deprecated-core", - comment: - "A module depends on a node core module that has been deprecated. Find an alternative - these are " + - "bound to exist - node doesn't deprecate lightly.", - severity: "warn", - from: {}, - to: { - dependencyTypes: [ - "core" - ], - path: [ - "^v8/tools/codemap$", - "^v8/tools/consarray$", - "^v8/tools/csvparser$", - "^v8/tools/logreader$", - "^v8/tools/profile_view$", - "^v8/tools/profile$", - "^v8/tools/SourceMap$", - "^v8/tools/splaytree$", - "^v8/tools/tickprocessor-driver$", - "^v8/tools/tickprocessor$", - "^node-inspect/lib/_inspect$", - "^node-inspect/lib/internal/inspect_client$", - "^node-inspect/lib/internal/inspect_repl$", - "^async_hooks$", - "^punycode$", - "^domain$", - "^constants$", - "^sys$", - "^_linklist$", - "^_stream_wrap$" - ], - } - }, - { - name: "not-to-deprecated", - comment: - "This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later " + - "version of that module, or find an alternative. Deprecated modules are a security risk.", - severity: "warn", - from: {}, - to: { - dependencyTypes: [ - "deprecated" - ] - } - }, - { - name: "no-non-package-json", - severity: "error", - comment: - "This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " + - "That's problematic as the package either (1) won't be available on live (2 - worse) will be " + - "available on live with an non-guaranteed version. Fix it by adding the package to the dependencies " + - "in your package.json.", - from: {}, - to: { - dependencyTypes: [ - "npm-no-pkg", - "npm-unknown" - ] - } - }, - { - name: "not-to-unresolvable", - comment: - "This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " + - "module: add it to your package.json. In all other cases you likely already know what to do.", - severity: "error", - from: {}, - to: { - couldNotResolve: true - } - }, - { - name: "no-duplicate-dep-types", - comment: - "Likely this module depends on an external ('npm') package that occurs more than once " + - "in your package.json i.e. bot as a devDependencies and in dependencies. This will cause " + - "maintenance problems later on.", - severity: "warn", - from: {}, - to: { - moreThanOneDependencyType: true, - // as it's pretty common to have a type import be a type only import - // _and_ (e.g.) a devDependency - don't consider type-only dependency - // types for this rule - dependencyTypesNot: ["type-only"] - } - }, - - /* rules you might want to tweak for your specific situation: */ - - { - name: "not-to-spec", - comment: - "This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. " + - "If there's something in a spec that's of use to other modules, it doesn't have that single " + - "responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.", - severity: "error", - from: {}, - to: { - path: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$" - } - }, - { - name: "not-to-dev-dep", - severity: "error", - comment: - "This module depends on an npm package from the 'devDependencies' section of your " + - "package.json. It looks like something that ships to production, though. To prevent problems " + - "with npm packages that aren't there on production declare it (only!) in the 'dependencies'" + - "section of your package.json. If this module is development only - add it to the " + - "from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration", - from: { - path: "^(src)", - pathNot: "[.](?:spec|test)[.](?:js|mjs|cjs|jsx|ts|mts|cts|tsx)$" - }, - to: { - dependencyTypes: [ - "npm-dev", - ], - // type only dependencies are not a problem as they don't end up in the - // production code or are ignored by the runtime. - dependencyTypesNot: [ - "type-only" - ], - pathNot: [ - "node_modules/@types/" - ] - } - }, - { - name: "optional-deps-used", - severity: "info", - comment: - "This module depends on an npm package that is declared as an optional dependency " + - "in your package.json. As this makes sense in limited situations only, it's flagged here. " + - "If you're using an optional dependency here by design - add an exception to your" + - "dependency-cruiser configuration.", - from: {}, - to: { - dependencyTypes: [ - "npm-optional" - ] - } - }, - { - name: "peer-deps-used", - comment: - "This module depends on an npm package that is declared as a peer dependency " + - "in your package.json. This makes sense if your package is e.g. a plugin, but in " + - "other cases - maybe not so much. If the use of a peer dependency is intentional " + - "add an exception to your dependency-cruiser configuration.", - severity: "warn", - from: {}, - to: { - dependencyTypes: [ - "npm-peer" - ] - } - } - ], - options: { - - /* Which modules not to follow further when encountered */ - doNotFollow: { - /* path: an array of regular expressions in strings to match against */ - path: ["node_modules"] - }, - - /* Which modules to exclude */ - // exclude : { - // /* path: an array of regular expressions in strings to match against */ - // path: '', - // }, - - /* Which modules to exclusively include (array of regular expressions in strings) - dependency-cruiser will skip everything not matching this pattern - */ - // includeOnly : [''], - - /* List of module systems to cruise. - When left out dependency-cruiser will fall back to the list of _all_ - module systems it knows of. It's the default because it's the safe option - It might come at a performance penalty, though. - moduleSystems: ['amd', 'cjs', 'es6', 'tsd'] - - As in practice only commonjs ('cjs') and ecmascript modules ('es6') - are widely used, you can limit the moduleSystems to those. - */ - - // moduleSystems: ['cjs', 'es6'], - - /* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/main/' - to open it on your online repo or `vscode://file/${process.cwd()}/` to - open it in visual studio code), - */ - // prefix: `vscode://file/${process.cwd()}/`, - - /* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation - true: also detect dependencies that only exist before typescript-to-javascript compilation - "specify": for each dependency identify whether it only exists before compilation or also after - */ - tsPreCompilationDeps: true, - - /* list of extensions to scan that aren't javascript or compile-to-javascript. - Empty by default. Only put extensions in here that you want to take into - account that are _not_ parsable. - */ - // extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"], - - /* if true combines the package.jsons found from the module up to the base - folder the cruise is initiated from. Useful for how (some) mono-repos - manage dependencies & dependency definitions. - */ - // combinedDependencies: false, - - /* if true leave symlinks untouched, otherwise use the realpath */ - // preserveSymlinks: false, - - /* TypeScript project file ('tsconfig.json') to use for - (1) compilation and - (2) resolution (e.g. with the paths property) - - The (optional) fileName attribute specifies which file to take (relative to - dependency-cruiser's current working directory). When not provided - defaults to './tsconfig.json'. - */ - tsConfig: { - fileName: "tsconfig.json" - }, - - /* Webpack configuration to use to get resolve options from. - - The (optional) fileName attribute specifies which file to take (relative - to dependency-cruiser's current working directory. When not provided defaults - to './webpack.conf.js'. - - The (optional) `env` and `arguments` attributes contain the parameters - to be passed if your webpack config is a function and takes them (see - webpack documentation for details) - */ - // webpackConfig: { - // fileName: 'webpack.config.js', - // env: {}, - // arguments: {} - // }, - - /* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use - for compilation - */ - // babelConfig: { - // fileName: '.babelrc', - // }, - - /* List of strings you have in use in addition to cjs/ es6 requires - & imports to declare module dependencies. Use this e.g. if you've - re-declared require, use a require-wrapper or use window.require as - a hack. - */ - // exoticRequireStrings: [], - - /* options to pass on to enhanced-resolve, the package dependency-cruiser - uses to resolve module references to disk. The values below should be - suitable for most situations - - If you use webpack: you can also set these in webpack.conf.js. The set - there will override the ones specified here. - */ - enhancedResolveOptions: { - /* What to consider as an 'exports' field in package.jsons */ - exportsFields: ["exports"], - /* List of conditions to check for in the exports field. - Only works when the 'exportsFields' array is non-empty. - */ - conditionNames: ["import", "require", "node", "default", "types"], - /* - The extensions, by default are the same as the ones dependency-cruiser - can access (run `npx depcruise --info` to see which ones that are in - _your_ environment). If that list is larger than you need you can pass - the extensions you actually use (e.g. [".js", ".jsx"]). This can speed - up module resolution, which is the most expensive step. - */ - // extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"], - /* What to consider a 'main' field in package.json */ - mainFields: ["module", "main", "types", "typings"], - /* - A list of alias fields in package.jsons - See [this specification](https://github.com/defunctzombie/package-browser-field-spec) and - the webpack [resolve.alias](https://webpack.js.org/configuration/resolve/#resolvealiasfields) - documentation - - Defaults to an empty array (= don't use alias fields). - */ - // aliasFields: ["browser"], - }, - reporterOptions: { - dot: { - /* pattern of modules that can be consolidated in the detailed - graphical dependency graph. The default pattern in this configuration - collapses everything in node_modules to one folder deep so you see - the external modules, but their innards. - */ - collapsePattern: "node_modules/(?:@[^/]+/[^/]+|[^/]+)", - - /* Options to tweak the appearance of your graph.See - https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions - for details and some examples. If you don't specify a theme - dependency-cruiser falls back to a built-in one. - */ - // theme: { - // graph: { - // /* splines: "ortho" gives straight lines, but is slow on big graphs - // splines: "true" gives bezier curves (fast, not as nice as ortho) - // */ - // splines: "true" - // }, - // } - }, - archi: { - /* pattern of modules that can be consolidated in the high level - graphical dependency graph. If you use the high level graphical - dependency graph reporter (`archi`) you probably want to tweak - this collapsePattern to your situation. - */ - collapsePattern: "^(?:packages|src|lib(s?)|app(s?)|bin|test(s?)|spec(s?))/[^/]+|node_modules/(?:@[^/]+/[^/]+|[^/]+)", - - /* Options to tweak the appearance of your graph. If you don't specify a - theme for 'archi' dependency-cruiser will use the one specified in the - dot section above and otherwise use the default one. - */ - // theme: { }, - }, - "text": { - "highlightFocused": true - }, - } - } -}; -// generated: dependency-cruiser@16.3.10 on 2024-07-31T09:50:16.819Z diff --git a/.github/security.md b/.github/security.md new file mode 100644 index 0000000000..b8773361bd --- /dev/null +++ b/.github/security.md @@ -0,0 +1,3 @@ +## Reporting a Vulnerability +If you are positive you found a vulnerability, please contact me through a direct message by [joining my Discord server](https://dc.sv443.net/) or via E-Mail ([address is on my homepage](https://sv443.net)). +Please don't open a GitHub issue or discussion thread. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a9d941e6d..4105c9500c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -19,8 +19,8 @@ jobs: strategy: matrix: - language: ["javascript"] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + language: ["javascript-typescript"] + # CodeQL supports "c-cpp", "csharp", "go", "java-kotlin", "javascript-typescript", "python", "ruby", "swift" # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed diff --git a/.gitignore b/.gitignore index 4744624f9b..246307cdb7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,4 @@ dist/*.css.map .build.json *.ignore.* -dependency-graph.svg *storybook.log diff --git a/.storybook/main.ts b/.storybook/main.ts index 6d60424263..1f7ded642a 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -13,6 +13,9 @@ const config: StorybookConfig = { name: "@storybook/html-vite", options: {}, }, + core: { + disableTelemetry: true, + }, }; export default config; diff --git a/.vscode/schemas/locales.schema.json b/.vscode/schemas/locales.schema.json new file mode 100644 index 0000000000..233f0eae01 --- /dev/null +++ b/.vscode/schemas/locales.schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "description": "All locales and translations that are available in BYTM.", + "patternProperties": { + "^[a-z]{2}-[A-Z]{2}$": { + "type": "object", + "required": ["name", "nameEnglish", "emoji", "userscriptDesc", "authors", "altLocales"], + "description": "Main locale code in the format \"ll-CC\" (language-COUNTRY).", + "properties": { + "name": { + "type": "string", + "description": "Native name and country of the language, e.g. \"Deutsch (Deutschland)\"." + }, + "nameEnglish": { + "type": "string", + "description": "English name and country of the language, e.g. \"German (Germany)\".", + "pattern": "^.+ \\(.+\\)$" + }, + "emoji": { + "type": "string", + "pattern": "^[\uD83C\uDDE6-\uDDFF\uD83C\uDDE6-\uDDFF]{4}$", + "description": "Flag emoji of the country." + }, + "userscriptDesc": { + "type": "string", + "description": "Localized userscript description (from \"description\" in package.json)." + }, + "authors": { + "type": "array", + "items": { + "type": "string" + }, + "description": "List of authors that contributed to the translation." + }, + "altLocales": { + "type": "array", + "items": { + "contains": { + "pattern": "^[a-z]{2}$" + }, + "type": "string", + "pattern": "^[a-z]{2}(-[A-Z]{2})?$" + }, + "description": "Alternative locale codes that will be redirected to this locale, e.g. for all German languages: [\"de\", \"de-AT\", \"de-CH\", \"de-LI\", \"de-LU\"]." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/.vscode/schemas/require.schema.json b/.vscode/schemas/require.schema.json new file mode 100644 index 0000000000..1f08d5321b --- /dev/null +++ b/.vscode/schemas/require.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "array", + "description": "Configuration for libraries loaded via @require (only supports UMD bundles or user libraries).", + "items": { + "type": "object", + "required": ["pkgName", "global"], + "description": "Configuration for a library (via UMD bundle or global variable).", + "properties": { + "pkgName": { + "type": "string", + "description": "Full identifier of the NPM package.", + "pattern": "^@?[a-z0-9-_/]+$" + }, + "path": { + "type": "string", + "description": "Path to the package's UMD/global bundle file, relative to the library root folder.", + "pattern": "^.+\\.(m|c)?(j|t)sx?$" + }, + "global": { + "type": "string", + "description": "Name of the global variable created in the package's UMD/global bundle.", + "pattern": "^[$_a-zA-Z][$_a-zA-Z0-9]*$" + } + }, + "additionalProperties": false + } +} diff --git a/.vscode/schemas/resources.schema.json b/.vscode/schemas/resources.schema.json new file mode 100644 index 0000000000..e3380894b8 --- /dev/null +++ b/.vscode/schemas/resources.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["resources"], + "description": "Contains all resources (images, fonts, stylesheets, etc.) used by BYTM.", + "properties": { + "alwaysExternalAssetPatterns": { + "type": "array", + "description": "List of patterns passed to `new RegExp()` that should always be considered external assets (they won't be loaded via @resource).", + "items": { + "type": "string", + "format": "regex", + "pattern": "^.+$" + } + }, + "resources": { + "type": "object", + "description": "Mapping of resource file identifiers and paths.", + "patternProperties": { + "^[a-zA-Z]([a-zA-Z0-9_]+)?-[a-zA-Z0-9-_]+$": { + "description": "Resource with a key in the format `prefix-resource_name` and a value that is either a path string or metadata object.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": ["path"], + "properties": { + "path": { + "type": "string", + "description": "Path or URL to the resource. If it starts with /, it's relative to the project root, else it's relative to the assets folder. Query strings and hashes are allowed. You may make use of the placeholders $MODE, $BRANCH, $HOST, $BUILD_NUMBER and $UID anywhere in the string." + }, + "ref": { + "type": "string", + "description": "Git Ref (branch, tag, or commit SHA) to use when fetching the resource from the CDN. Uses the branch and build number from src/constants.ts if not set. You may make use of the placeholders $MODE, $BRANCH, $HOST, $BUILD_NUMBER and $UID anywhere in the string." + }, + "integrity": { + "type": "boolean", + "default": true, + "description": "Whether to calculate a Subresource Integrity hash for the resource (works with URLs too)." + } + }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/.vscode/schemas/translation.schema.json b/.vscode/schemas/translation.schema.json new file mode 100644 index 0000000000..ac603f4773 --- /dev/null +++ b/.vscode/schemas/translation.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["meta"], + "description": "Mapping of translation keys to strings.", + "patternProperties": { + "^[a-zA-Z]([a-zA-Z0-9_-]+)?$": { + "type": "string", + "description": "Case sensitive translation key. May only contain letters, numbers, underscores, and hyphens." + } + }, + "properties": { + "meta": { + "type": "object", + "description": "Metadata about the translation file.", + "required": [ + "langName", + "langNameEnglish", + "countryName", + "authors" + ], + "properties": { + "base": { + "type": "string", + "description": "Optional base locale from which all missing translations are automatically inherited. Must be in the format \"ll-CC\" (language-country), e.g. en-US.", + "pattern": "^[a-z]{2}-[A-Z]{2}$", + "default": "en-US" + }, + "langName": { + "type": "string", + "description": "Name of the language in the language itself." + }, + "langNameEnglish": { + "type": "string", + "description": "Name of the language and the country (or locale) in English in the format \"Language (Country or Locale)\".", + "pattern": "^[\\w\\s,._\\-&]+ \\([\\w\\s,._\\-&]+\\)$" + }, + "countryName": { + "type": "string", + "description": "Name of the country in the language itself." + }, + "authors": { + "type": "array", + "description": "Name(s) of the author and contributors of the translation.", + "items": { + "type": "string", + "description": "Name of the author or contributor." + } + } + } + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fa30712b9..bd124e6648 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,25 @@ }, "editor.tabSize": 2, + "json.schemas": [ + { + "fileMatch": ["**/locales.json"], + "url": ".vscode/schemas/locales.schema.json" + }, + { + "fileMatch": ["**/require.json"], + "url": ".vscode/schemas/require.schema.json" + }, + { + "fileMatch": ["**/resources.json"], + "url": ".vscode/schemas/resources.schema.json" + }, + { + "fileMatch": ["**/translations/*-*.json"], + "url": ".vscode/schemas/translation.schema.json" + }, + ], + // requires extension: fabiospampinato.vscode-highlight "highlight.regexes": { "(TODO(\\((\\s|\\d|\\w|[,.-_+*&])+\\))?:?)": { // TODO: or TODO or TODO(xy): but not todo or todo: @@ -20,9 +39,9 @@ "overviewRulerColor": "#ed0", }, "((//\\s*|/\\*\\s*)?#region ([^\\S\\r\\n]*[\\(\\)\\w,.\\-_&+#*'\"/:]+)*)": { //#region test: (abc): - "backgroundColor": "#5df", + "backgroundColor": "#35b5d0", "color": "#000", - "overviewRulerColor": "#5df", + "overviewRulerColor": "#35b5d0", }, "(()?)": { // and or <{{BAR}}> and "backgroundColor": "#9af", diff --git a/README-summary.md b/README-summary.md index 965cfaa217..3a24ee3c75 100644 --- a/README-summary.md +++ b/README-summary.md @@ -3,12 +3,13 @@


BetterYTM

### Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™ -🇨🇳 Chinese (simpl.), 🇬🇧 English (GB), 🇺🇸 English (US), 🇫🇷 French, 🇩🇪 German, 🇮🇳 Hindi, 🇯🇵 Japanese, 🇧🇷 Portuguese, 🇪🇸 Spanish + +

Available in these languages: 🇨🇳 CN, 🇬🇧 GB, 🇺🇸 US, 🇫🇷 FR, 🇩🇪 DE, 🇮🇳 IN, 🇯🇵 JP, 🇧🇷 BR, 🇪🇸 ES

+ +--- +#### [**Features**](#features) • [**Installation**](#installation) • [**Integrations**](#integrations) • [**Plugins**](#plugins) • [**Support**](#support) • [**Privacy**](#privacy) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers) -[**Features**](#features) • [**Installation**](#installation) • [**Integrations**](#integrations) • [**Plugins**](#plugins) • [**Support**](#support) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers) - ----
@@ -24,7 +25,7 @@ All of these can be toggled and configured in the configuration menu. - Improve clickability of song titles and thumbnails when wanting to open them in a new tab - Remove the tracking parameter from URLs in the share menu - Automatically close permanent notifications -- Input / Interaction: +- Input & Interaction: - Auto-like songs and videos of your favorite creators on both YouTube and YouTube Music - Use arrow keys to skip forward or backward by a configurable amount of time - Press number keys to skip to a percentage of the currently playing song @@ -43,30 +44,17 @@ My work relies on donations, so if you like this userscript please consider [sup

## Installation: - - -Requires a userscript manager browser extension. -I really recommend Violentmonkey: [Firefox](https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/) • [Chrome](https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag) • [Edge](https://microsoftedge.microsoft.com/addons/detail/violentmonkey/eeagobfjdenkkddmbclomhiblgggliao?hl=en-GB&gl=DE) - - -Other extensions are known to be less reliable and may not work as expected, especially in isolated contexts like FireMonkey or the Brave browser. - -
- -Once you have the extension, click the install button at the top of this page! - -
+Note: by installing BetterYTM, you agree to the [license terms](./LICENSE.txt), [disclaimers](#disclaimers) and [privacy notice.](#privacy) +1. **Install a userscript manager browser extension - I really recommend Violentmonkey ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/), [Chrome](https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag), [Edge](https://microsoftedge.microsoft.com/addons/detail/violentmonkey/eeagobfjdenkkddmbclomhiblgggliao?hl=en-GB&gl=DE)).** + Other extensions are known to not work as expected, especially in isolated contexts like with FireMonkey or the Brave browser (more info here). - +2. **Then, [visit the Releases page](https://github.com/Sv443/BetterYTM/releases) and click the install button on the latest release.** + Note: the `unsafeWindow` grant is required due to limitations in some browsers (more info here). -Note: the `unsafeWindow` grant is required due to limitations in some browsers, [see this issue for more info.](https://github.com/Sv443/BetterYTM/issues/18#show_issue) -
- - -To install the latest development build [click here](https://github.com/Sv443/BetterYTM/raw/develop/dist/BetterYTM.user.js) (make sure to fully reinstall the userscript once the next release is out) - - + +If you want to install the latest (potentially unstable) development build, [look for the latest pull request](https://github.com/Sv443/BetterYTM/pulls?q=sort%3Aupdated-desc+is%3Apr+is%3Aopen) and click the install button there. +Please make sure to manually remove and reinstall the userscript once the next release is out to avoid any issues.


@@ -104,7 +92,22 @@ Currently there are no available plugins, but you can [submit an issue using the If you have any questions, issues, or feature requests, please [open an issue here.](https://github.com/Sv443/BetterYTM/issues/new/choose) You can also join my Discord server and ask your questions there or just hang out with other community members and me: -[![Discord](https://img.shields.io/discord/565933531214118942)](https://discord.gg/aBH4uRG) +[![Discord](https://img.shields.io/discord/565933531214118942)](https://dc.sv443.net/) + +

+ +## Privacy: +BetterYTM does not collect any data about you that isn't strictly necessary for its features to work. +All data is recorded and stored on your device or on the servers of the site the script runs on. + +BetterYTM makes use of external services and APIs to provide some of its features, which may collect data about you. +In every case, only the bare minimum of data is sent to these services and only when necessary. +This data includes your IP address and the ID or title and author of the video you're watching. + +Find more info about the privacy of these services here: +- [Return YouTube Dislike - Security FAQ](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md) +- [SponsorBlock - Privacy Policy](https://gist.github.com/ajayyy/aa9f8ded2b573d4f73a3ffa0ef74f796#requests-sent-to-the-server-while-using-the-extension) +- [geniURL - Privacy Policy](https://sv443.net/privacypolicy/en)

diff --git a/README.md b/README.md index a78e02fb2b..ba0b7c7615 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@


BetterYTM

### Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™ -🇨🇳 Chinese (simpl.), 🇬🇧 English (GB), 🇺🇸 English (US), 🇫🇷 French, 🇩🇪 German, 🇮🇳 Hindi, 🇯🇵 Japanese, 🇧🇷 Portuguese, 🇪🇸 Spanish + +

Available in these languages: 🇨🇳 CN, 🇬🇧 GB, 🇺🇸 US, 🇫🇷 FR, 🇩🇪 DE, 🇮🇳 IN, 🇯🇵 JP, 🇧🇷 BR, 🇪🇸 ES

+ +--- +#### [**Features**](#features) • [**Installation**](#installation) • [**Integrations**](#integrations) • [**Plugins**](#plugins) • [**Support**](#support) • [**Privacy**](#privacy) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers) -[**Features**](#features) • [**Installation**](#installation) • [**Integrations**](#integrations) • [**Plugins**](#plugins) • [**Support**](#support) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers) - ----
@@ -26,7 +27,7 @@ All of these can be toggled and configured in the configuration menu. - Improve clickability of song titles and thumbnails when wanting to open them in a new tab - Remove the tracking parameter from URLs in the share menu - Automatically close permanent notifications -- Input / Interaction: +- Input & Interaction: - Auto-like songs and videos of your favorite creators on both YouTube and YouTube Music - Use arrow keys to skip forward or backward by a configurable amount of time - Press number keys to skip to a percentage of the currently playing song @@ -40,36 +41,24 @@ All of these can be toggled and configured in the configuration menu. To toggle and configure features, after installing the userscript, click the "BetterYTM" text under the logo to open the configuration menu. If you disabled the watermark, you can still open it by clicking your avatar in the top right corner on YTM or through the left sidebar on YT. -> [!NOTE] +> [!NOTE] +> > My work relies on donations, so if you like this userscript please consider [supporting development ❤️](https://github.com/sponsors/Sv443)


## Installation: - - -Requires a userscript manager browser extension. -I really recommend Violentmonkey: [Firefox](https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/) • [Chrome](https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag) • [Edge](https://microsoftedge.microsoft.com/addons/detail/violentmonkey/eeagobfjdenkkddmbclomhiblgggliao?hl=en-GB&gl=DE) +Note: by installing BetterYTM, you agree to the [license terms](./LICENSE.txt), [disclaimers](#disclaimers) and [privacy notice.](#privacy) +1. **Install a userscript manager browser extension - I really recommend Violentmonkey ([Firefox](https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/), [Chrome](https://chrome.google.com/webstore/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag), [Edge](https://microsoftedge.microsoft.com/addons/detail/violentmonkey/eeagobfjdenkkddmbclomhiblgggliao?hl=en-GB&gl=DE)).** + Other extensions are known to not work as expected, especially in isolated contexts like with FireMonkey or the Brave browser (more info here). - -Other extensions are known to be less reliable and may not work as expected, especially in isolated contexts like FireMonkey or the Brave browser. +2. **Then, [visit the Releases page](https://github.com/Sv443/BetterYTM/releases) and click the install button on the latest release.** + Note: the `unsafeWindow` grant is required due to limitations in some browsers (more info here).
- -Once you have the extension, **[visit the Releases page](https://github.com/Sv443/BetterYTM/releases) and click the install button** on the latest release. - -
- - - -Note: the `unsafeWindow` grant is required due to limitations in some browsers, [see this issue for more info.](https://github.com/Sv443/BetterYTM/issues/18#show_issue) - - - - -To install the latest development build [click here](https://github.com/Sv443/BetterYTM/raw/develop/dist/BetterYTM.user.js) (make sure to fully reinstall the userscript once the next release is out) - - + +If you want to install the latest (potentially unstable) development build, [look for the latest pull request](https://github.com/Sv443/BetterYTM/pulls?q=sort%3Aupdated-desc+is%3Apr+is%3Aopen) and click the install button there. +Please make sure to manually remove and reinstall the userscript once the next release is out to avoid any issues.


@@ -111,6 +100,21 @@ You can also join my Discord server and ask your questions there or just hang ou

+## Privacy: +BetterYTM does not collect any data about you that isn't strictly necessary for its features to work. +All data is recorded and stored on your device or on the servers of the site the script runs on. + +BetterYTM makes use of external services and APIs to provide some of its features, which may collect data about you. +In every case, only the bare minimum of data is sent to these services and only when necessary. +This data includes your IP address and the ID or title and author of the video you're watching. + +Find more info about the privacy of these services here: +- [Return YouTube Dislike - Security FAQ](https://github.com/Anarios/return-youtube-dislike/blob/main/Docs/SECURITY-FAQ.md) +- [SponsorBlock - Privacy Policy](https://gist.github.com/ajayyy/aa9f8ded2b573d4f73a3ffa0ef74f796#requests-sent-to-the-server-while-using-the-extension) +- [geniURL - Privacy Policy](https://sv443.net/privacypolicy/en) + +

+ ### Development: This project is based on my extensive template for making a userscript with TypeScript and many modern language and convenience features. [Check it out here](https://github.com/Sv443/Userscript.ts) if you want to make your own userscripts! @@ -131,9 +135,9 @@ This userscript depends on these projects: For development dependencies, please refer to `devDependencies` in [`package.json`](./package.json) -Icons: +#### Icon attributions: - Most icons are from [Material Icons](https://fonts.google.com/icons) -- For external icon attributions, see [`assets/images/external/`](assets/images/external/README.md) +- For external icon attributions, see [`assets/images/external/README.md`](assets/images/external/README.md)

diff --git a/assets/README.md b/assets/README.md index 02c189aa92..7317e4aae9 100644 --- a/assets/README.md +++ b/assets/README.md @@ -3,22 +3,25 @@
### Images: -- PNG images and external assets are stored in `assets/images` -- SVG images are stored in `assets/icons` -- CSS files in `assets/style` -- Translations are in `assets/translations` +- Own PNG/JPG/GIF images are stored in [`assets/images`](./images) +- External images are stored in [`assets/images/external`](./images/external) +- The BYTM logo files are in [`assets/images/logo`](./images/logo) +- SVG icons are stored in [`assets/icons`](./icons) +- CSS stylesheets are in [`assets/style`](./style) +- Translations are in [`assets/translations`](./translations)
## JSON file formats: -> [!NOTE] -> Note: If a property is wrapped with square brackets (`[name]`), it means that the property is optional +> [!NOTE] +> +> Note: a property that's followed by a question mark means it is optional.
### [`locales.json`](locales.json) -This file contains a definition of the available locales and translations. -The keys of the object are the locale codes, and the values are the locale objects, with the following properties: +This file contains a definition of the available locales, which dictate the userscript header description, available locale setting values, translations and more. +The keys of the object are the locale codes (which follow the [BCP 47 standard](https://en.wikipedia.org/wiki/IETF_language_tag)), and the values are objects with the following properties (all required): | Property | Type | Description | | :--------------- | :--------- | :--------------------------------------------------------- | @@ -27,33 +30,57 @@ The keys of the object are the locale codes, and the values are the locale objec | `emoji` | `string` | The flag emoji of the locale | | `userscriptDesc` | `string` | The description of the userscript in the locale's language | | `authors` | `string[]` | The authors of the translations | +| `altLocales` | `string[]` | Alternative locales that are similar to this one |
### [`plugins.json`](plugins.json) -(Not fully implemented yet, but should still be filled out when a plugin is added) +(Not implemented yet. As soon as a plugin is added, this needs to be fleshed out) -For the structure of this array of objects, see `type PluginObj` in [`src/types.ts`](../src/types.ts) + + +
+ +### [`resources.json`](resources.json) +This file contains the resources that are loaded into the runtime through the `@resource` userscript directive. +That includes icons, images, CSS files, fonts, translations and other assets. +Configure which resources will always be fetched from the external asset source (GitHub's CDN) by editing the regexp patterns in the `alwaysExternalAssetPatterns` property. + +Inside the file is an object under the `resources` prop, whose keys are the resource names and the values are the path to the resource or a configuration object (props are listed in the table below). +The path to the resource can be relative, in which case it's resolved relative to the `assets` directory. +If it starts with a slash, it will be resolved relative to the project root (where `package.json` is). + +All values will be run through the function `resolveResourceVal()` in [`src/tools/post-build.ts`](./src/tools/post-build.ts) to replace placeholders with dynamic values. +For example, `$BRANCH` will be replaced with the branch name. Find all possible replacements in that function's declaration. + +The configuration object can have the following properties: +| Property | Type | Description | +| :------- | :------- | :----------------------- | +| `path` | `string` | The path to the resource | +| `ref?` | `string` | The GitHub ref to use for the resource, e.g. `main`, a Git tag like `v2.0.0` or a commit hash - defaults to the branch resolved in [`src/tools/post-build.ts`](./src/tools/post-build.ts) |
### [`require.json`](require.json) This file contains the npm libraries that are loaded into the runtime through the `@require` userscript directive. -It's an array of objects, which each have one of the following sets of properties: +This is done to massively reduce the bundle size and make use of the userscript manager extension's caching. +Each library will be set as an external in the [rollup configuration](../rollup.config.js) to prevent it from including it in the bundle. +The version of each package will be parsed from [`package.json`](../package.json)'s `dependencies` or `devDependencies` to ensure consistent versions across the project. -Using npm and a CDN: -| Property | Type | Description | -| :---------- | :-------- | :------------------------------------------------------------------------------------ | -| `pkgName` | `string` | The name of the npm package, as in `npm i ` | -| `path` | `string` | The path to the file that should be loaded, relative to the library root dir | -| `global` | `string` | The name of the global variable that the library exports | -| `[baseUrl]` | `string` | Base URL of the CDN to use - `https://cdn.jsdelivr.net/npm/` by default | -| `[link]` | `boolean` | Whether `npm link` is active and the library should instead be included in the bundle | - +Inside the file is an array of objects, which each have one of the following properties: + +Using npm packages through a CDN: +| Property | Type | Description | +| :--------- | :-------- | :----------------------------------------------------------------------------------------------------- | +| `pkgName` | `string` | The name of the npm package, as in `npm i pkgName` | +| `path` | `string` | The path to the file that should be loaded, relative to the library root dir | +| `global` | `string` | The name of the global variable that the library exports | +| `baseUrl?` | `string` | Base URL of the CDN to use - `https://cdn.jsdelivr.net/npm/` by default - package will be appended as `pkgName@versionFromPkgJson` | +| `link?` | `boolean` | Whether `npm link` is active and the library should be force-included in the bundle (false by default) | Using a direct URL: -| Property | Type | Description | -| :------- | :-------- | :------------------------------------------------------------------------------------ | -| `url` | `string` | URL to the file to include | -| `global` | `string` | The name of the global variable that the library exports | -| `[link]` | `boolean` | Whether `npm link` is active and the library should instead be included in the bundle | \ No newline at end of file +| Property | Type | Description | +| :------- | :-------- | :---------------------------------------------------------------------------------- | +| `url` | `string` | URL to the file to include | +| `global` | `string` | The name of the global variable that the library exports | +| `link?` | `boolean` | Whether `npm link` is active and the library should be force-included in the bundle | \ No newline at end of file diff --git a/assets/icons/speed.svg b/assets/icons/speed.svg new file mode 100644 index 0000000000..c02833f22e --- /dev/null +++ b/assets/icons/speed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/logo/logo.pdn b/assets/images/logo/logo.pdn index 3baa42a58f..e944f8a334 100644 Binary files a/assets/images/logo/logo.pdn and b/assets/images/logo/logo.pdn differ diff --git a/assets/locales.json b/assets/locales.json index 327b30007c..7080057a9e 100644 --- a/assets/locales.json +++ b/assets/locales.json @@ -1,30 +1,31 @@ { "de-DE": { "name": "Deutsch (Deutschland)", - "nameEnglish": "German", + "nameEnglish": "German (Germany)", "emoji": "🇩🇪", "userscriptDesc": "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™", "authors": ["Sv443"], - "altLocales": ["de", "de-AT", "de-CH"] + "altLocales": ["de", "de-AT", "de-CH", "de-LI", "de-LU"] }, "en-US": { "name": "English (United States)", - "nameEnglish": "English (US)", + "nameEnglish": "English (United States)", "emoji": "🇺🇸", "userscriptDesc": "Configurable layout and user experience improvements for YouTube Music™ and YouTube™", "authors": ["Sv443"], - "altLocales": ["en", "en-CA", "en-AU"] + "altLocales": ["en", "en-CA"] }, "en-GB": { "name": "English (Great Britain)", - "nameEnglish": "English (GB)", + "nameEnglish": "English (Great Britain)", "emoji": "🇬🇧", "userscriptDesc": "Configurable layout and user experience improvements for YouTube Music™ and YouTube™", - "authors": ["Sv443"] + "authors": ["Sv443"], + "altLocales": ["en-AU", "en-IE", "en-NZ", "en-ZA"] }, "es-ES": { "name": "Español (España)", - "nameEnglish": "Spanish", + "nameEnglish": "Spanish (Spain)", "emoji": "🇪🇸", "userscriptDesc": "Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™", "authors": ["Sv443"], @@ -32,7 +33,7 @@ }, "fr-FR": { "name": "Français (France)", - "nameEnglish": "French", + "nameEnglish": "French (France)", "emoji": "🇫🇷", "userscriptDesc": "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™", "authors": ["Sv443"], @@ -40,7 +41,7 @@ }, "hi-IN": { "name": "हिंदी (भारत)", - "nameEnglish": "Hindi", + "nameEnglish": "Hindi (India)", "emoji": "🇮🇳", "userscriptDesc": "YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार", "authors": ["Sv443"], @@ -48,15 +49,15 @@ }, "ja-JP": { "name": "日本語 (日本)", - "nameEnglish": "Japanese", + "nameEnglish": "Japanese (Japan)", "emoji": "🇯🇵", "userscriptDesc": "YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上", "authors": ["Sv443"], - "altLocales": ["ja", "ja-JP"] + "altLocales": ["ja"] }, "pt-BR": { "name": "Português (Brasil)", - "nameEnglish": "Portuguese", + "nameEnglish": "Portuguese (Brazil)", "emoji": "🇧🇷", "userscriptDesc": "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™", "authors": ["Sv443"], @@ -64,10 +65,10 @@ }, "zh-CN": { "name": "中文(简化,中国)", - "nameEnglish": "Chinese (simpl.)", + "nameEnglish": "Chinese (Simplified, China)", "emoji": "🇨🇳", "userscriptDesc": "YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进", "authors": ["Sv443"], - "altLocales": ["zh", "zh-TW", "zh-HK"] + "altLocales": ["zh", "zh-TW", "zh-HK", "zh-SG"] } } diff --git a/assets/resources.json b/assets/resources.json index 31618a62d8..7a17260a8b 100644 --- a/assets/resources.json +++ b/assets/resources.json @@ -1,50 +1,72 @@ { - "css-above_queue_btns": "style/aboveQueueBtns.css", - "css-anchor_improvements": "style/anchorImprovements.css", - "css-auto_like": "style/autoLike.css", - "css-fix_hdr": "style/fixHDR.css", - "css-fix_playerpage_theming": "style/fixPlayerPageTheming.css", - "css-fix_spacing": "style/fixSpacing.css", - "css-fix_sponsorblock": "style/fixSponsorBlock.css", - "css-hide_themesong_logo": "style/hideThemeSongLogo.css", - "css-show_votes": "style/showVotes.css", - "css-vol_slider_size": "style/volSliderSize.css", - "doc-changelog": { - "path": "/changelog.md", - "ref": "main" - }, - "font-cousine_ttf": "fonts/Cousine/Cousine-Regular.ttf", - "font-cousine_woff": "fonts/Cousine/Cousine-Regular.woff", - "font-cousine_woff2": "fonts/Cousine/Cousine-Regular.woff2", - "icon-advanced_mode": "icons/plus_circle_small.svg", - "icon-alert": "icons/alert.svg", - "icon-arrow_down": "icons/arrow_down.svg", - "icon-auto_like_enabled": "icons/auto_like_enabled.svg", - "icon-auto_like": "icons/auto_like.svg", - "icon-clear_list": "icons/clear_list.svg", - "icon-copy": "icons/copy.svg", - "icon-delete": "icons/delete.svg", - "icon-edit": "icons/edit.svg", - "icon-error": "icons/error.svg", - "icon-experimental": "icons/beaker_small.svg", - "icon-globe_small": "icons/globe_small.svg", - "icon-globe": "icons/globe.svg", - "icon-help": "icons/help.svg", - "icon-image_filled": "icons/image_filled.svg", - "icon-image": "icons/image.svg", - "icon-link": "icons/link.svg", - "icon-lyrics": "icons/lyrics.svg", - "icon-prompt": "icons/help.svg", - "icon-reload": "icons/refresh.svg", - "icon-restore_time": "icons/restore_time.svg", - "icon-skip_to": "icons/skip_to.svg", - "icon-spinner": "icons/spinner.svg", - "icon-upload": "icons/upload.svg", - "img-close": "images/close.png", - "img-discord": "images/external/discord.png", - "img-github": "images/external/github.png", - "img-greasyfork": "images/external/greasyfork.png", - "img-logo_dev": "images/logo/logo_dev_48.png", - "img-logo": "images/logo/logo_48.png", - "img-openuserjs": "images/external/openuserjs.png" + "alwaysExternalAssetPatterns": [ + "^doc-", + "^font-", + "^icon-", + "^img-", + "^trans-" + ], + "resources": { + "css-above_queue_btns": "style/aboveQueueBtns.css", + "css-above_queue_btns_sticky": "style/aboveQueueBtnsSticky.css", + "css-anchor_improvements": "style/anchorImprovements.css", + "css-auto_like": "style/autoLike.css", + "css-fix_hdr": "style/fixHDR.css", + "css-fix_playerpage_theming": "style/fixPlayerPageTheming.css", + "css-fix_spacing": "style/fixSpacing.css", + "css-fix_sponsorblock": "style/fixSponsorBlock.css", + "css-hide_themesong_logo": "style/hideThemeSongLogo.css", + "css-show_votes": "style/showVotes.css", + "css-vol_slider_size": "style/volSliderSize.css", + "doc-license": { + "path": "/LICENSE.txt", + "ref": "$BRANCH", + "integrity": false + }, + "doc-svg_spritesheet": "spritesheet.svg", + "font-cousine_ttf": "fonts/Cousine/Cousine-Regular.ttf", + "font-cousine_woff": "fonts/Cousine/Cousine-Regular.woff", + "font-cousine_woff2": "fonts/Cousine/Cousine-Regular.woff2", + "icon-advanced_mode": "icons/plus_circle_small.svg", + "icon-alert": "icons/alert.svg", + "icon-arrow_down": "icons/arrow_down.svg", + "icon-auto_like_enabled": "icons/auto_like_enabled.svg", + "icon-auto_like": "icons/auto_like.svg", + "icon-clear_list": "icons/clear_list.svg", + "icon-copy": "icons/copy.svg", + "icon-delete": "icons/delete.svg", + "icon-edit": "icons/edit.svg", + "icon-error": "icons/error.svg", + "icon-experimental": "icons/beaker_small.svg", + "icon-globe_small": "icons/globe_small.svg", + "icon-globe": "icons/globe.svg", + "icon-help": "icons/help.svg", + "icon-image_filled": "icons/image_filled.svg", + "icon-image": "icons/image.svg", + "icon-link": "icons/link.svg", + "icon-lyrics": "icons/lyrics.svg", + "icon-prompt": "icons/help.svg", + "icon-reload": "icons/refresh.svg", + "icon-restore_time": "icons/restore_time.svg", + "icon-skip_to": "icons/skip_to.svg", + "icon-speed": "icons/speed.svg", + "icon-spinner": "icons/spinner.svg", + "icon-upload": "icons/upload.svg", + "img-close": "images/close.png", + "img-discord": "images/external/discord.png", + "img-github": "images/external/github.png", + "img-greasyfork": "images/external/greasyfork.png", + "img-logo_dev": "images/logo/logo_dev_48.png", + "img-logo": "images/logo/logo_48.png", + "img-openuserjs": "images/external/openuserjs.png", + "trans-de-DE": "translations/de-DE.json", + "trans-en-US": "translations/en-US.json", + "trans-en-GB": "translations/en-GB.json", + "trans-es-ES": "translations/es-ES.json", + "trans-fr-FR": "translations/fr-FR.json", + "trans-hi-IN": "translations/hi-IN.json", + "trans-ja-JP": "translations/ja-JP.json", + "trans-pt-BR": "translations/pt-BR.json", + "trans-zh-CN": "translations/zh-CN.json" + } } diff --git a/assets/spritesheet.svg b/assets/spritesheet.svg new file mode 100644 index 0000000000..67620563b3 --- /dev/null +++ b/assets/spritesheet.svg @@ -0,0 +1,77 @@ + \ No newline at end of file diff --git a/assets/style/aboveQueueBtns.css b/assets/style/aboveQueueBtns.css index 69ab8704fd..9fb9189974 100644 --- a/assets/style/aboveQueueBtns.css +++ b/assets/style/aboveQueueBtns.css @@ -1,11 +1,10 @@ #side-panel ytmusic-tab-renderer ytmusic-queue-header-renderer { - position: sticky; align-items: center; top: 0; z-index: 2; padding: 16px 8px; margin: unset; - background-color: var(--bytm-themed-bg); + background-color: var(--bytm-themed-bg-col); border-bottom: 1px solid var(--ytmusic-divider); /* fallback for when the ThemeSong bg color is a gradient function instead of a color value */ backdrop-filter: blur(8px); diff --git a/assets/style/aboveQueueBtnsSticky.css b/assets/style/aboveQueueBtnsSticky.css new file mode 100644 index 0000000000..7d10f5ac41 --- /dev/null +++ b/assets/style/aboveQueueBtnsSticky.css @@ -0,0 +1,3 @@ +#side-panel ytmusic-tab-renderer ytmusic-queue-header-renderer { + position: sticky; +} diff --git a/assets/translations/README.md b/assets/translations/README.md index 9822947587..774187a356 100644 --- a/assets/translations/README.md +++ b/assets/translations/README.md @@ -16,15 +16,15 @@ To submit or edit a translation, please follow [this guide](../../contributing.m ### Translation progress: |   | Locale | Translated keys | Based on | | :----: | ------ | --------------- | :------: | -| | [`en-US`](./en-US.json) | `328` (default locale) | | -| ⚠ | [`de-DE`](./de-DE.json) | `325/328` (99.1%) | ─ | -| | [`en-GB`](./en-GB.json) | `328/328` (100%) | `en-US` | -| ⚠ | [`es-ES`](./es-ES.json) | `322/328` (98.2%) | ─ | -| ⚠ | [`fr-FR`](./fr-FR.json) | `322/328` (98.2%) | ─ | -| ⚠ | [`hi-IN`](./hi-IN.json) | `322/328` (98.2%) | ─ | -| ⚠ | [`ja-JP`](./ja-JP.json) | `322/328` (98.2%) | ─ | -| ⚠ | [`pt-BR`](./pt-BR.json) | `322/328` (98.2%) | ─ | -| ⚠ | [`zh-CN`](./zh-CN.json) | `322/328` (98.2%) | ─ | +| | [`en-US`](./en-US.json) | `0` (default locale) | | +| ‼️ | [`de-DE`](./de-DE.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`en-GB`](./en-GB.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`es-ES`](./es-ES.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`fr-FR`](./fr-FR.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`hi-IN`](./hi-IN.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`ja-JP`](./ja-JP.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`pt-BR`](./pt-BR.json) | `undefined/0` (NaN%) | ─ | +| ‼️ | [`zh-CN`](./zh-CN.json) | `undefined/0` (NaN%) | ─ | ✅ - Fully translated @@ -44,91 +44,4 @@ This means to figure out which keys are untranslated, you will need to manually
### Missing keys: - -
de-DE - 3 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | - -
- -
es-ES - 6 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | -| `feature_desc_resetEverything` | `Reset all stored data to the default values` | -| `feature_btn_resetEverything` | `Reset everything` | -| `feature_btn_resetEverything_running` | `Resetting...` | - -
- -
fr-FR - 6 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | -| `feature_desc_resetEverything` | `Reset all stored data to the default values` | -| `feature_btn_resetEverything` | `Reset everything` | -| `feature_btn_resetEverything_running` | `Resetting...` | - -
- -
hi-IN - 6 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | -| `feature_desc_resetEverything` | `Reset all stored data to the default values` | -| `feature_btn_resetEverything` | `Reset everything` | -| `feature_btn_resetEverything_running` | `Resetting...` | - -
- -
ja-JP - 6 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | -| `feature_desc_resetEverything` | `Reset all stored data to the default values` | -| `feature_btn_resetEverything` | `Reset everything` | -| `feature_btn_resetEverything_running` | `Resetting...` | - -
- -
pt-BR - 6 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | -| `feature_desc_resetEverything` | `Reset all stored data to the default values` | -| `feature_btn_resetEverything` | `Reset everything` | -| `feature_btn_resetEverything_running` | `Resetting...` | - -
- -
zh-CN - 6 missing keys (click to show)
- -| Key | English text | -| --- | ------------ | -| `reset_everything_confirm` | `Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.` | -| `open_current_lyrics` | `Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search` | -| `open_lyrics` | `Open this song's lyrics in a new tab - ctrl-click to open manual search` | -| `feature_desc_resetEverything` | `Reset all stored data to the default values` | -| `feature_btn_resetEverything` | `Reset everything` | -| `feature_btn_resetEverything_running` | `Resetting...` | - -
+No missing keys diff --git a/assets/translations/de-DE.json b/assets/translations/de-DE.json index 01e20b4fc1..46c1e70845 100644 --- a/assets/translations/de-DE.json +++ b/assets/translations/de-DE.json @@ -1,359 +1,378 @@ { - "translations": { - "config_menu_option": "%1 Einstellungen", - "config_menu_title": "%1 - Einstellungen", - "changelog_menu_title": "%1 - Änderungsprotokoll", - "export_menu_title": "%1 - Einstellungen exportieren", - "import_menu_title": "%1 - Einstellungen importieren", - "open_menu_tooltip": "%1's Einstellungen öffnen", - "close_menu_tooltip": "Klicke um das Menü zu schließen", - "reload_hint": "Bitte lade die Seite neu, um die Änderungen anzuwenden", - "reload_now": "Jetzt neu laden", - "reload_tooltip": "Seite neu laden", - "feature_requires_reload": "Die Seite muss neu geladen werden, wenn diese Funktion geändert wird", - "version_tooltip": "Version %1 (build %2) - klicken um das Änderungsprotokoll zu öffnen", - "bytm_config_export_import_title": "Einstellungen exportieren oder importieren", - "bytm_config_import_desc": "Füge die Konfiguration, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:", - "bytm_config_export_desc": "Kopiere den unten stehenden Text um deine Einstellungen zu exportieren. Warnung: kann sensible Daten enthalten.", - "export_import": "Export/Import", - "export": "Exportieren", - "export_hint": "Kopiere den folgenden Text um deine Einstellungen zu exportieren.\nWarnung: der Text kann sensible Informationen enthalten.", - "click_to_reveal": "(zum Anzeigen klicken)", - "click_to_reveal_sensitive_info": "(klicke um sensible Informationen anzuzeigen)", - "export_tooltip": "Exportiere deine aktuellen Einstellungen", - "import": "Importieren", - "import_hint": "Füge die Einstellungen, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:", - "import_tooltip": "Importiere Einstellungen, die du zuvor exportiert hast", - "start_import_tooltip": "Klicke um die Daten, die du oben eingefügt hast, zu importieren", - "import_error_invalid": "Die importierten Daten sind ungültig", - "import_error_no_format_version": "Die importierten Daten enthalten keine Format-Version", - "import_error_no_data": "Das importierte Objekt enthält keine Daten", - "import_error_wrong_format_version": "Die importierten Daten haben eine nicht unterstützte Format-Version (%1 oder niedriger erwartet, aber %2 erhalten)", - "import_success": "Die Daten wurden erfolgreich importiert", - "import_success_confirm_reload": "Die Daten wurden erfolgreich importiert.\nMöchtest du die Seite jetzt neu laden, um die Änderungen anzuwenden?", - "reset_config_confirm": "Möchtest du wirklich alle Einstellungen auf ihre Standardwerte zurücksetzen?\nDie Seite wird automatisch neu geladen.", - "copy": "Kopieren", - "copy_to_clipboard": "In die Zwischenablage kopieren", - "copy_to_clipboard_error": "Der Text konnte nicht in die Zwischenablage kopiert werden. Bitte kopiere ihn hier manuell:\n%1", - "copy_config_tooltip": "Kopiere die Einstellungen in die Zwischenablage", - "copied": "Kopiert!", - "copied_to_clipboard": "In die Zwischenablage kopiert!", - "copy_hidden": "Versteckten Wert kopieren", - "copy_hidden_tooltip": "Klicke um den versteckten Wert zu kopieren - Vorsicht, sensible Daten ⚠️", - "open_github": "Öffne %1 auf GitHub", - "open_discord": "Komm auf meinen Discord Server", - "open_greasyfork": "Öffne %1 auf GreasyFork", - "open_openuserjs": "Öffne %1 auf OpenUserJS", - "lang_changed_prompt_reload": "Die Sprache wurde geändert.\nMöchtest du die Seite jetzt neu laden, um die Änderungen zu übernehmen?", - "search_placeholder": "Suchen...", - "search_clear": "Suche löschen", + "meta": { + "langName": "Deutsch", + "langNameEnglish": "German (Germany)", + "countryName": "Deutschland", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "%1 Einstellungen", + "config_menu_title": "%1 - Einstellungen", + "changelog_menu_title": "%1 - Änderungsprotokoll", + "export_menu_title": "%1 - Einstellungen exportieren", + "import_menu_title": "%1 - Einstellungen importieren", + "open_menu_tooltip": "%1's Einstellungen öffnen", + "close_menu_tooltip": "Klicke um das Menü zu schließen", + "reload_hint": "Bitte lade die Seite neu, um die Änderungen anzuwenden", + "reload_now": "Jetzt neu laden", + "reload_tooltip": "Seite neu laden", + "feature_requires_reload": "Die Seite muss neu geladen werden, wenn diese Funktion geändert wird", + "version_tooltip": "Version %1 (build %2) - klicken um das Änderungsprotokoll zu öffnen", + "bytm_config_export_import_title": "Einstellungen exportieren oder importieren", + "bytm_config_import_desc": "Füge die Konfiguration, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:", + "bytm_config_export_desc": "Kopiere den unten stehenden Text um deine Einstellungen zu exportieren. Warnung: kann sensible Daten enthalten.", + "export_import": "Export/Import", + "export": "Exportieren", + "export_hint": "Kopiere den folgenden Text um deine Einstellungen zu exportieren.\nWarnung: der Text kann sensible Informationen enthalten.", + "click_to_reveal": "(zum Anzeigen klicken)", + "click_to_reveal_sensitive_info": "(klicke um sensible Informationen anzuzeigen)", + "export_tooltip": "Exportiere deine aktuellen Einstellungen", + "import": "Importieren", + "import_hint": "Füge die Einstellungen, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:", + "import_tooltip": "Importiere Einstellungen, die du zuvor exportiert hast", + "start_import_tooltip": "Klicke um die Daten, die du oben eingefügt hast, zu importieren", + "import_error_invalid": "Die importierten Daten sind ungültig", + "import_error_no_format_version": "Die importierten Daten enthalten keine Format-Version", + "import_error_no_data": "Das importierte Objekt enthält keine Daten", + "import_error_wrong_format_version": "Die importierten Daten haben eine nicht unterstützte Format-Version (%1 oder niedriger erwartet, aber %2 erhalten)", + "import_success": "Die Daten wurden erfolgreich importiert", + "import_success_confirm_reload": "Die Daten wurden erfolgreich importiert.\nMöchtest du die Seite jetzt neu laden, um die Änderungen anzuwenden?", + "reset_config_confirm": "Möchtest du wirklich alle Einstellungen auf ihre Standardwerte zurücksetzen?\nDie Seite wird automatisch neu geladen.", + "reset_everything_confirm": "Möchtest du wirklich alle gespeicherten Daten auf die Standardwerte zurücksetzen?\nDie Seite wird automatisch neu geladen.", + "copy": "Kopieren", + "copy_to_clipboard": "In die Zwischenablage kopieren", + "copy_to_clipboard_error": "Der Text konnte nicht in die Zwischenablage kopiert werden. Bitte kopiere ihn hier manuell:\n%1", + "copy_config_tooltip": "Kopiere die Einstellungen in die Zwischenablage", + "copied": "Kopiert!", + "copied_to_clipboard": "In die Zwischenablage kopiert!", + "copy_hidden": "Versteckten Wert kopieren", + "copy_hidden_tooltip": "Klicke um den versteckten Wert zu kopieren - Vorsicht, sensible Daten ⚠️", + "open_github": "Öffne %1 auf GitHub", + "open_discord": "Komm auf meinen Discord Server", + "open_greasyfork": "Öffne %1 auf GreasyFork", + "open_openuserjs": "Öffne %1 auf OpenUserJS", + "lang_changed_prompt_reload": "Die Sprache wurde geändert.\nMöchtest du die Seite jetzt neu laden, um die Änderungen zu übernehmen?", + "search_placeholder": "Suchen...", + "search_clear": "Suche löschen", - "reset": "Zurücksetzen", - "close": "Schließen", - "log_level_debug": "Debug (sehr viele)", - "log_level_info": "Info (nur wichtige)", - "toggled_on": "An", - "toggled_off": "Aus", - "trigger_btn_action": "Ausführen", - "trigger_btn_action_running": "Wird ausgeführt...", - "new_entry": "Neuer Eintrag", - "new_entry_tooltip": "Klicke um einen neuen Eintrag zu erstellen", - "remove_entry": "Eintrag entfernen", - "edit_entry": "Eintrag bearbeiten", - "remove_from_queue": "Aus der Wiedergabeliste entfernen", - "delete_from_list": "Aus der Liste löschen", - "couldnt_remove_from_queue": "Song konnte nicht aus der Wiedergabeliste entfernt werden", - "couldnt_delete_from_list": "Song konnte nicht aus der Liste gelöscht werden", - "clear_list": "Liste leeren", - "clear_list_confirm": "Möchtest du wirklich die Liste leeren und nur den aktuell spielenden Song behalten?", - "scroll_to_playing": "Zum aktiven Song scrollen", - "scroll_to_bottom": "Zum Ende der Wiedergabeliste scrollen", - "volume_tooltip": "Lautstärke: %1% (Sensitivität: %2%)", - "volume_shared_tooltip": "Lautstärke wird zwischen allen Tabs geteilt - deaktivieren in den Einstellungen", - "middle_click_open_tab": "Mittelklick um in einem neuen Tab zu öffnen", - "example_toast": "Beispiel-Toast", - "generic_error_toast_encountered_error_type": "%1 ist aufgetreten", - "generic_error_toast_click_for_details": "Klicke für Details", - "error": "Error", - "generic_error_dialog_message": "Ein Error ist aufgetreten.", - "generic_error_dialog_open_console_note": "Falls dieser Error öfter auftritt, öffne bitte die JavaScript-Konsole (meistens mit Strg + Shift + K) und hänge einen Screenshot der **gesamten** Fehlermeldung, die mit %1 beginnt, an einem neuen [GitHub Issue](%2) an.", - "active_mode_display": "%1 Modus", - "active_mode_tooltip-1": "%1 ist derzeit aktiv", - "active_mode_tooltip-n": "%1 sind derzeit aktiv", - "dev_mode": "Entwicklermodus", - "dev_mode_short": "Dev", - "advanced_mode": "Erweiterte Einstellungen", - "advanced_mode_short": "Erweit.", - "experimental_feature": "Experimentelle Funktion", + "reset": "Zurücksetzen", + "close": "Schließen", + "log_level_debug": "Debug (sehr viele)", + "log_level_info": "Info (nur wichtige)", + "toggled_on": "An", + "toggled_off": "Aus", + "trigger_btn_action": "Ausführen", + "trigger_btn_action_running": "Wird ausgeführt...", + "new_entry": "Neuer Eintrag", + "new_entry_tooltip": "Klicke um einen neuen Eintrag zu erstellen", + "remove_entry": "Eintrag entfernen", + "edit_entry": "Eintrag bearbeiten", + "remove_from_queue": "Aus der Wiedergabeliste entfernen", + "delete_from_list": "Aus der Liste löschen", + "couldnt_remove_from_queue": "Song konnte nicht aus der Wiedergabeliste entfernt werden", + "couldnt_delete_from_list": "Song konnte nicht aus der Liste gelöscht werden", + "clear_list": "Liste leeren", + "clear_list_confirm": "Möchtest du wirklich die Liste leeren und nur den aktuell spielenden Song behalten?", + "scroll_to_playing": "Zum aktiven Song scrollen", + "scroll_to_bottom": "Zum Ende der Wiedergabeliste scrollen", + "volume_tooltip": "Lautstärke: %1% (Sensitivität: %2%)", + "volume_shared_tooltip": "Lautstärke wird zwischen allen Tabs geteilt - deaktivieren in den Einstellungen", + "middle_click_open_tab": "Mittel-Klick um in einem neuen Tab zu öffnen", + "example_toast": "Beispiel-Toast", + "generic_error_toast_encountered_error_type": "%1 ist aufgetreten", + "generic_error_toast_click_for_details": "Klicke für Details", + "error": "Error", + "generic_error_dialog_message": "Ein Error ist aufgetreten.", + "generic_error_dialog_open_console_note": "Falls dieser Error öfter auftritt, öffne bitte die JavaScript-Konsole (meistens mit Strg + Shift + K) und hänge einen Screenshot der **gesamten** Fehlermeldung, die mit %1 beginnt, an einem neuen [GitHub Issue](%2) an.", + "active_mode_display": "%1 Modus", + "active_mode_tooltip-1": "%1 ist derzeit aktiv", + "active_mode_tooltip-n": "%1 sind derzeit aktiv", + "dev_mode": "Entwicklermodus", + "dev_mode_short": "Dev", + "advanced_mode": "Erweiterte Einstellungen", + "advanced_mode_short": "Erweit.", + "experimental_feature": "Experimentelle Funktion", - "open_lyrics_search_prompt": "Gib den Songtitel und den Künstler ein, um nach dem Songtext zu suchen:", - "lyrics_loading": "Songtext-URL wird geladen...", - "lyrics_rate_limited-1": "Du hast zu viele Anfragen gesendet.\nBitte warte ein paar Sekunden, bevor du weitere Songtexte anforderst.", - "lyrics_rate_limited-n": "Du hast zu viele Anfragen gesendet.\nBitte warte %1 Sekunden, bevor du weitere Songtexte anforderst.", - "lyrics_not_found_confirm_open_search": "Für diesen Song konnte kein Songtext gefunden werden.\nMöchtest du genius.com öffnen, um manuell danach zu suchen?", - "lyrics_not_found_click_open_search": "Es konnte kein Songtext gefunden werden - klicke um die manuelle Suche zu öffnen", - "lyrics_clear_cache_confirm_prompt-1": "Der Songtext-Cache enthält derzeit einen Eintrag.\nMöchtest du ihn löschen?", - "lyrics_clear_cache_confirm_prompt-n": "Der Songtext-Cache enthält derzeit %1 Einträge.\nMöchtest du sie wirklich löschen?", - "lyrics_clear_cache_success": "Der Songtext-Cache wurde erfolgreich gelöscht.", - "lyrics_cache_changed_clear_confirm": "Du hast Einstellungen geändert, die die Daten im Songtext-Cache beeinflussen, was Songtext-Suchen kaputt macht.\nMöchtest du den Cache jetzt löschen?", + "open_current_lyrics": "Songtext des aktuellen Songs öffnen - Shift-Klick, um in einem neuen Fenster zu öffnen oder Strg-Klick, um manuell zu suchen", + "open_lyrics": "Songtext in einem neuen Tab öffnen - Strg-Klick, um manuell zu suchen", + "open_lyrics_search_prompt": "Gib den Songtitel und den Künstler ein, um nach dem Songtext zu suchen:", + "lyrics_loading": "Songtext-URL wird geladen...", + "lyrics_rate_limited-1": "Du hast zu viele Anfragen gesendet.\nBitte warte ein paar Sekunden, bevor du weitere Songtexte anforderst.", + "lyrics_rate_limited-n": "Du hast zu viele Anfragen gesendet.\nBitte warte %1 Sekunden, bevor du weitere Songtexte anforderst.", + "lyrics_not_found_confirm_open_search": "Für diesen Song konnte kein Songtext gefunden werden.\nMöchtest du genius.com öffnen, um manuell danach zu suchen?", + "lyrics_not_found_click_open_search": "Es konnte kein Songtext gefunden werden - klicke um die manuelle Suche zu öffnen", + "lyrics_clear_cache_confirm_prompt-1": "Der Songtext-Cache enthält derzeit einen Eintrag.\nMöchtest du ihn löschen?", + "lyrics_clear_cache_confirm_prompt-n": "Der Songtext-Cache enthält derzeit %1 Einträge.\nMöchtest du sie wirklich löschen?", + "lyrics_clear_cache_success": "Der Songtext-Cache wurde erfolgreich gelöscht.", + "lyrics_cache_changed_clear_confirm": "Du hast Einstellungen geändert, die die Daten im Songtext-Cache beeinflussen, was Songtext-Suchen kaputt macht.\nMöchtest du den Cache jetzt löschen?", - "hotkey_input_click_to_change": "Zum Ändern klicken", - "hotkey_input_click_to_change_tooltip": "%1 - Derzeit gesetzt auf: %2 - Gib zum Ändern eine beliebige Tastenkombination ein. Hinweis: Einige Screenreader könnten bestimmte Tastenkombinationen blockieren.", - "hotkey_input_click_to_reset_tooltip": "Zurücksetzen auf die zuletzt gespeicherte Tastenkombination", - "hotkey_key_ctrl": "Strg", - "hotkey_key_shift": "Shift", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "Keine Tastenkombination ausgewählt", + "hotkey_input_click_to_change": "Zum Ändern klicken", + "hotkey_input_click_to_change_tooltip": "%1 - Derzeit gesetzt auf: %2 - Gib zum Ändern eine beliebige Tastenkombination ein. Hinweis: Einige Screenreader könnten bestimmte Tastenkombinationen blockieren.", + "hotkey_input_click_to_reset_tooltip": "Zurücksetzen auf die zuletzt gespeicherte Tastenkombination", + "hotkey_key_ctrl": "Strg", + "hotkey_key_shift": "Shift", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "Keine Tastenkombination ausgewählt", - "welcome_menu_title": "Willkommen bei %1!", - "config_menu": "Einstellungen", - "open_config_menu_tooltip": "Klicke um das Einstellungsmenü zu öffnen", - "open_changelog": "Änderungsprotokoll", - "open_changelog_tooltip": "Klicke um das Änderungsprotokoll zu öffnen", - "feature_help_button_tooltip": "Klicke um mehr Informationen über das folgende Feature zu erhalten: \"%1\"", - "welcome_text_line_1": "Vielen Dank für die Installation!", - "welcome_text_line_2": "Ich hoffe, du hast genauso viel Spaß mit %1 wie ich beim Erstellen hatte 😃", - "welcome_text_line_3": "Wenn dir %1 gefällt, hinterlasse bitte eine Bewertung auf %2GreasyFork%3 oder %4OpenUserJS%5", - "welcome_text_line_4": "Meine Arbeit hängt von %1Spenden%2 ab, also bitte denke darüber nach, ob du spenden möchtest ❤️", - "welcome_text_line_5": "Hast du einen Fehler gefunden oder möchtest ein Feature vorschlagen? Bitte %1öffne ein Issue auf GitHub%2", + "welcome_menu_title": "Willkommen bei %1!", + "config_menu": "Einstellungen", + "open_config_menu_tooltip": "Klicke um das Einstellungsmenü zu öffnen", + "open_changelog": "Änderungsprotokoll", + "open_changelog_tooltip": "Klicke um das Änderungsprotokoll zu öffnen", + "feature_help_button_tooltip": "Klicke um mehr Informationen über das folgende Feature zu erhalten: \"%1\"", + "welcome_text_line_1": "Vielen Dank für die Installation!", + "welcome_text_line_2": "Ich hoffe, du hast genauso viel Spaß mit %1 wie ich beim Erstellen hatte 😃", + "welcome_text_line_3": "Wenn dir %1 gefällt, hinterlasse bitte eine Bewertung auf %2GreasyFork%3 oder %4OpenUserJS%5", + "welcome_text_line_4": "Meine Arbeit hängt von %1Spenden%2 ab, also bitte denke darüber nach, ob du spenden möchtest ❤️", + "welcome_text_line_5": "Hast du einen Fehler gefunden oder möchtest ein Feature vorschlagen? Bitte %1öffne ein Issue auf GitHub%2", - "list_button_placement_queue_only": "Nur Wiedergabeliste", - "list_button_placement_everywhere": "In jeder Songliste", + "list_button_placement_queue_only": "Nur Wiedergabeliste", + "list_button_placement_everywhere": "In jeder Songliste", - "site_selection_both_sites": "Beide Seiten", - "site_selection_only_yt": "Nur YouTube", - "site_selection_only_ytm": "Nur YouTube Music", - "site_selection_none": "Keine (deaktiviert)", + "site_selection_both_sites": "Beide Seiten", + "site_selection_only_yt": "Nur YouTube", + "site_selection_only_ytm": "Nur YouTube Music", + "site_selection_none": "Keine (deaktiviert)", - "new_version_available": "Eine neue Version von %1 ist verfügbar!\nDu hast derzeit Version %2 installiert und kannst auf Version %3 aktualisieren", - "open_update_page_install_manually": "Installieren auf %1", - "disable_update_check": "Deaktiviere automatische Update-Prüfungen", - "reenable_in_config_menu": "(kann im Einstellungsmenü wieder aktiviert werden)", - "close_and_ignore_for_24h": "Schließen und für 24h ignorieren", - "close_and_ignore_until_reenabled": "Schließen und ignorieren, bis in den Einstellungen reaktiviert", - "expand_release_notes": "Klicke um die neuesten Release-Notes zu öffnen", - "collapse_release_notes": "Klicke um die neuesten Release-Notes zu schließen", - "no_updates_found": "Keine Updates gefunden.", + "new_version_available": "Eine neue Version von %1 ist verfügbar!\nDu hast derzeit Version %2 installiert und kannst auf Version %3 aktualisieren", + "open_update_page_install_manually": "Installieren auf %1", + "disable_update_check": "Deaktiviere automatische Update-Prüfungen", + "reenable_in_config_menu": "(kann im Einstellungsmenü wieder aktiviert werden)", + "close_and_ignore_for_24h": "Schließen und für 24h ignorieren", + "close_and_ignore_until_reenabled": "Schließen und ignorieren, bis in den Einstellungen reaktiviert", + "expand_release_notes": "Klicke um die neuesten Release-Notes zu öffnen", + "collapse_release_notes": "Klicke um die neuesten Release-Notes zu schließen", + "no_new_version_found": "Keine neue Version gefunden.", - "thumbnail_overlay_behavior_never": "Nie", - "thumbnail_overlay_behavior_videos_only": "Nur bei Videos", - "thumbnail_overlay_behavior_songs_only": "Nur bei Songs", - "thumbnail_overlay_behavior_always": "Immer", - "thumbnail_overlay_toggle_btn_tooltip_hide": "Deaktiviere das Thumbnail-Overlay - Mittelklick oder Shift-Klick um in einem neuen Tab zu öffnen", - "thumbnail_overlay_toggle_btn_tooltip_show": "Aktiviere das Thumbnail-Overlay - Mittelklick oder Shift-Klick um in einem neuen Tab zu öffnen", - "thumbnail_overlay_indicator_tooltip": "Das Thumbnail-Overlay ist derzeit aktiv", - "thumbnail_overlay_image_fit_crop": "Zuschneiden falls nötig", - "thumbnail_overlay_image_fit_full": "Vollständiges Bild", - "thumbnail_overlay_image_fit_stretch": "Auf Passform strecken", + "thumbnail_overlay_behavior_never": "Nie", + "thumbnail_overlay_behavior_videos_only": "Nur bei Videos", + "thumbnail_overlay_behavior_songs_only": "Nur bei Songs", + "thumbnail_overlay_behavior_always": "Immer", + "thumbnail_overlay_toggle_btn_tooltip_hide": "Deaktiviere das Thumbnail-Overlay - Mittelklick oder Shift-Klick um in einem neuen Tab zu öffnen", + "thumbnail_overlay_toggle_btn_tooltip_show": "Aktiviere das Thumbnail-Overlay - Mittelklick oder Shift-Klick um in einem neuen Tab zu öffnen", + "thumbnail_overlay_indicator_tooltip": "Das Thumbnail-Overlay ist derzeit aktiv", + "thumbnail_overlay_image_fit_crop": "Zuschneiden falls nötig", + "thumbnail_overlay_image_fit_full": "Vollständiges Bild", + "thumbnail_overlay_image_fit_stretch": "Auf Passform strecken", - "auto_like_channels_dialog_title": "Auto-gelikte Kanäle", - "auto_like_channels_dialog_desc": "Hier kannst du sehen, welche Kanäle du zum Auto-Liken eingestellt hast und sie bearbeiten, aktivieren, deaktivieren und entfernen.\nDu kannst auch manuell Einträge erstellen, obwohl es einfacher ist, einfach die Kanalseite zu besuchen und dort auf den Button zu klicken.", - "auto_like": "Auto-Like", - "auto_like_button_tooltip_enabled": "Klicke um Auto-Liken zu deaktivieren. Shift-Klick um den Management-Dialog zu öffnen.", - "auto_like_button_tooltip_disabled": "Klicke um Auto-Liken zu aktivieren. Shift-Klick um den Management-Dialog zu öffnen.", - "auto_like_channel_toggle_tooltip": "Auto-Liken für den Kanal %1 umschalten", - "add_auto_like_channel_id_prompt": "Gib die Benutzer-ID (@Name / UC...) oder die vollständige URL des Kanals ein, den du auto-liken möchtest.\nDrücke \"Abbrechen\" um zu beenden.", - "add_auto_like_channel_invalid_id": "Die eingegebene Benutzer-ID ist ungültig.\nBitte stelle sicher, dass du die gesamte Kanal-URL kopierst! Sie sollte einen Teil wie \"channel/UC...\" oder \"/@...\" enthalten", - "add_auto_like_channel_already_exists_prompt_new_name": "Ein Kanal mit dieser ID ist bereits in der Liste.\nMöchtest du den Namen ändern?", - "add_auto_like_channel_name_prompt": "Gib den Namen des Kanals ein.\nDrücke \"Abbrechen\" um zu beenden.", - "auto_like_channel_edit_name_prompt": "Gib den neuen Namen für diesen Kanal ein.\nDrücke \"Abbrechen\" um zu beenden.", - "auto_like_channel_edit_id_prompt": "Gib die neue Benutzer-ID (@Name / UC...) oder die vollständige URL für diesen Kanal ein.\nDrücke \"Abbrechen\" um zu beenden.", - "auto_like_enabled_toast": "Auto-Liken aktiviert", - "auto_like_disabled_toast": "Auto-Liken deaktiviert", - "auto_liked_a_channels_song": "Song von %1 geliked", - "auto_liked_a_channels_video": "Video von %1 geliked", - "auto_like_click_to_configure": "Klicke zum Konfigurieren", - "auto_like_export_or_import_tooltip": "Exportiere oder importiere deine auto-gelikten Kanäle", - "auto_like_export_import_title": "Auto-gelikte Kanäle exportieren oder importieren", - "auto_like_export_desc": "Kopiere den folgenden Text um deine auto-gelikten Kanäle zu exportieren.", - "auto_like_import_desc": "Füge die auto-gelikten Kanäle, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:", + "auto_like_channels_dialog_title": "Auto-gelikte Kanäle", + "auto_like_channels_dialog_desc": "Hier kannst du sehen, welche Kanäle du zum Auto-Liken eingestellt hast und sie bearbeiten, aktivieren, deaktivieren und entfernen.\nDu kannst auch manuell Einträge erstellen, obwohl es einfacher ist, einfach die Kanalseite zu besuchen und dort auf den Button zu klicken.", + "auto_like": "Auto-Like", + "auto_like_button_tooltip_enabled": "Klicke um Auto-Liken zu deaktivieren. Shift-Klick um den Management-Dialog zu öffnen.", + "auto_like_button_tooltip_disabled": "Klicke um Auto-Liken zu aktivieren. Shift-Klick um den Management-Dialog zu öffnen.", + "auto_like_channel_toggle_tooltip": "Auto-Liken für den Kanal %1 umschalten", + "add_auto_like_channel_id_prompt": "Gib die Benutzer-ID (@Name / UC...) oder die vollständige URL des Kanals ein, den du auto-liken möchtest.\nDrücke \"Abbrechen\" um zu beenden.", + "add_auto_like_channel_invalid_id": "Die eingegebene Benutzer-ID ist ungültig.\nBitte stelle sicher, dass du die gesamte Kanal-URL kopierst! Sie sollte einen Teil wie \"channel/UC...\" oder \"/@...\" enthalten", + "add_auto_like_channel_already_exists_prompt_new_name": "Ein Kanal mit dieser ID ist bereits in der Liste.\nMöchtest du den Namen ändern?", + "add_auto_like_channel_name_prompt": "Gib den Namen des Kanals ein.\nDrücke \"Abbrechen\" um zu beenden.", + "auto_like_channel_edit_name_prompt": "Gib den neuen Namen für diesen Kanal ein.\nDrücke \"Abbrechen\" um zu beenden.", + "auto_like_channel_edit_id_prompt": "Gib die neue Benutzer-ID (@Name / UC...) oder die vollständige URL für diesen Kanal ein.\nDrücke \"Abbrechen\" um zu beenden.", + "auto_like_enabled_toast": "Auto-Liken aktiviert", + "auto_like_disabled_toast": "Auto-Liken deaktiviert", + "auto_liked_a_channels_song": "Song von %1 geliked", + "auto_liked_a_channels_video": "Video von %1 geliked", + "auto_like_click_to_configure": "Klicke zum Konfigurieren", + "auto_like_export_or_import_tooltip": "Exportiere oder importiere deine auto-gelikten Kanäle", + "auto_like_export_import_title": "Auto-gelikte Kanäle exportieren oder importieren", + "auto_like_export_desc": "Kopiere den folgenden Text um deine auto-gelikten Kanäle zu exportieren.", + "auto_like_import_desc": "Füge die auto-gelikten Kanäle, die du importieren möchtest, in das Feld unten ein und klicke dann auf Importieren:", - "prompt_confirm": "Ok", - "prompt_close": "Schließen", - "prompt_cancel": "Abbrechen", + "prompt_confirm": "Ok", + "prompt_close": "Schließen", + "prompt_cancel": "Abbrechen", - "click_to_confirm_tooltip": "Klicke um zu bestätigen", - "click_to_close_tooltip": "Zum Schließen klicken", - "click_to_cancel_tooltip": "Klicke um abzubrechen", + "click_to_confirm_tooltip": "Klicke um zu bestätigen", + "click_to_close_tooltip": "Zum Schließen klicken", + "click_to_cancel_tooltip": "Klicke um abzubrechen", - "vote_label_likes-1": "%1 Like", - "vote_label_likes-n": "%1 Likes", - "vote_label_dislikes-1": "%1 Dislike", - "vote_label_dislikes-n": "%1 Dislikes", + "vote_label_likes-1": "%1 Like", + "vote_label_likes-n": "%1 Likes", + "vote_label_dislikes-1": "%1 Dislike", + "vote_label_dislikes-n": "%1 Dislikes", - "vote_ratio_disabled": "Deaktiviert", - "vote_ratio_green_red": "Grün und rot", - "vote_ratio_blue_gray": "Blau und grau", + "vote_ratio_disabled": "Deaktiviert", + "vote_ratio_green_red": "Grün und rot", + "vote_ratio_blue_gray": "Blau und grau", - "votes_format_short": "Kurz", - "votes_format_long": "Lang", + "votes_format_short": "Kurz", + "votes_format_long": "Lang", - "unit_entries-1": "Eintrag", - "unit_entries-n": "Einträge", + "unit_entries-1": "Eintrag", + "unit_entries-n": "Einträge", - "unit_days-1": "Tag", - "unit_days-n": "Tage", + "unit_days-1": "Tag", + "unit_days-n": "Tage", - "color_lightness_darker": "Dunkler", - "color_lightness_normal": "Normal", - "color_lightness_lighter": "Heller", + "color_lightness_darker": "Dunkler", + "color_lightness_normal": "Normal", + "color_lightness_lighter": "Heller", - "plugin_list_title": "Plugin-Liste", - "plugin_list_no_plugins": "Derzeit sind keine Plugins installiert.\nBesuche %1diese Seite%2 für mehr Informationen.", - "plugin_list_no_plugins_tooltip": "Derzeit sind keine Plugins installiert.", - "plugin_list_permissions_header": "Berechtigungen:", + "plugin_list_title": "Plugin-Liste", + "plugin_list_no_plugins": "Derzeit sind keine Plugins installiert.\nBesuche %1diese Seite%2 für mehr Informationen.", + "plugin_list_no_plugins_tooltip": "Derzeit sind keine Plugins installiert.", + "plugin_list_permissions_header": "Berechtigungen:", - "plugin_link_type_source": "Quellcode", - "plugin_link_type_other": "Andere / Homepage", - "plugin_link_type_bug": "Fehler melden", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "Quellcode", + "plugin_link_type_other": "Andere / Homepage", + "plugin_link_type_bug": "Fehler melden", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "Dieses Plugin kann die Feature-Konfiguration lesen", - "plugin_intent_description_WriteFeatureConfig": "Dieses Plugin kann die Feature-Konfiguration überschreiben", - "plugin_intent_description_SeeHiddenConfigValues": "Dieses Plugin kann versteckte Konfigurationswerte lesen", - "plugin_intent_description_WriteLyricsCache": "Dieses Plugin kann den Songtext-Cache überschreiben", - "plugin_intent_description_WriteTranslations": "Dieses Plugin kann neue Übersetzungen hinzufügen und bestehende überschreiben", - "plugin_intent_description_CreateModalDialogs": "Dieses Plugin kann modale Dialoge erstellen", - "plugin_intent_description_ReadAutoLikeData": "Dieses Plugin kann Auto-Like-Daten lesen", - "plugin_intent_description_WriteAutoLikeData": "Dieses Plugin kann Auto-Like-Daten überschreiben", + "plugin_intent_name_ReadFeatureConfig": "Konfiguration lesen", + "plugin_intent_description_ReadFeatureConfig": "Dieses Plugin kann die Feature-Konfiguration lesen", + "plugin_intent_name_WriteFeatureConfig": "Konfiguration überschreiben", + "plugin_intent_description_WriteFeatureConfig": "Dieses Plugin kann die Feature-Konfiguration überschreiben", + "plugin_intent_name_SeeHiddenConfigValues": "Versteckte Werte lesen", + "plugin_intent_description_SeeHiddenConfigValues": "Dieses Plugin kann versteckte Konfigurationswerte lesen", + "plugin_intent_name_WriteLyricsCache": "Songtext-Cache überschreiben", + "plugin_intent_description_WriteLyricsCache": "Dieses Plugin kann den Songtext-Cache überschreiben", + "plugin_intent_name_WriteTranslations": "Übersetzungen überschreiben", + "plugin_intent_description_WriteTranslations": "Dieses Plugin kann neue Übersetzungen hinzufügen und bestehende überschreiben", + "plugin_intent_name_CreateModalDialogs": "Modale Dialoge erzeugen", + "plugin_intent_description_CreateModalDialogs": "Dieses Plugin kann modale Dialoge erzeugen", + "plugin_intent_name_ReadAutoLikeData": "Auto-Like-Daten lesen", + "plugin_intent_description_ReadAutoLikeData": "Dieses Plugin kann Auto-Like-Daten lesen", + "plugin_intent_name_WriteAutoLikeData": "Auto-Like-Daten überschreiben", + "plugin_intent_description_WriteAutoLikeData": "Dieses Plugin kann Auto-Like-Daten überschreiben", - "plugin_validation_error_no_property": "Keine Eigenschaft '%1' vom Typ '%2'", - "plugin_validation_error_invalid_property-1": "Eigenschaft '%1' mit Wert '%2' ist ungültig. Beispielwert: %3", - "plugin_validation_error_invalid_property-n": "Eigenschaft '%1' mit Wert '%2' ist ungültig. Beispielwerte: %3", + "plugin_validation_error_no_property": "Keine Eigenschaft '%1' vom Typ '%2'", + "plugin_validation_error_invalid_property-1": "Eigenschaft '%1' mit Wert '%2' ist ungültig. Beispielwert: %3", + "plugin_validation_error_invalid_property-n": "Eigenschaft '%1' mit Wert '%2' ist ungültig. Beispielwerte: %3", - "feature_category_layout": "Layout", - "feature_category_volume": "Lautstärke", - "feature_category_songLists": "Songlisten", - "feature_category_behavior": "Verhalten", - "feature_category_input": "Eingabe", - "feature_category_lyrics": "Songtexte", - "feature_category_integrations": "Integrationen", - "feature_category_plugins": "Plugins", - "feature_category_general": "Allgemein", + "feature_category_layout": "Layout", + "feature_category_volume": "Lautstärke", + "feature_category_songLists": "Songlisten", + "feature_category_behavior": "Verhalten", + "feature_category_input": "Eingabe", + "feature_category_lyrics": "Songtexte", + "feature_category_integrations": "Integrationen", + "feature_category_plugins": "Plugins", + "feature_category_general": "Allgemein", - "feature_desc_watermarkEnabled": "Zeige ein Wasserzeichen unter dem Seitenlogo, das dieses Einstellungsmenü öffnet", - "feature_helptext_watermarkEnabled": "Wenn dies deaktiviert ist, kannst du das Einstellungsmenü immer noch öffnen, indem du die Option im Menü anklickst, das sich öffnet, wenn du auf dein Profilbild in der oberen rechten Ecke klickst.\nEs wird jedoch schwieriger sein, das Easter Egg zu finden ;)", - "feature_desc_removeShareTrackingParam": "Entferne den Tracking-Parameter \"?si\" aus der URL in den Teilen-Menüs", - "feature_helptext_removeShareTrackingParam": "Zu Analysezwecken fügt YouTube einen Tracking-Parameter am Ende der URL hinzu, die im Teilen-Menü angezeigt wird. Obwohl es nicht direkt schädlich ist, macht es die URL länger und gibt YouTube mehr Informationen über dich und die Personen, denen du den Link sendest.", - "feature_desc_removeShareTrackingParamSites": "Auf welchen Seiten soll der Tracking-Parameter entfernt werden?", - "feature_desc_numKeysSkipToTime": "Das Drücken einer Zahlentaste (0-9) springt zu einer bestimmten Zeit im Video", - "feature_desc_fixSpacing": "Behebe diverse Abstandprobleme im Layout", - "feature_helptext_fixSpacing": "Es gibt verschiedene Stellen im User Interface, an denen der Abstand zwischen Elementen inkonsistent ist. Diese Funktion behebt diese Probleme.", - "feature_desc_thumbnailOverlayBehavior": "Wann das Videoelement automatisch durch sein Thumbnail in höchster Auflösung ersetzt werden soll", - "feature_helptext_thumbnailOverlayBehavior": "Das Thumbnail wird über dem Video oder Song angezeigt.\nDies spart keine Bandbreite, da das Video immer noch im Hintergrund geladen und abgespielt wird!", - "feature_desc_thumbnailOverlayToggleBtnShown": "Füge einen Knopf zu den Mediensteuerelementen hinzu, um das Thumbnail manuell zu aktivieren", - "feature_helptext_thumbnailOverlayToggleBtnShown": "Dieser Knopf ermöglicht es dir, das Thumbnail manuell ein- und auszuschalten. Dies wird nicht beeinflusst, wenn das Overlay auf \"nie gezeigt\" eingestellt ist.\nSobald ein neues Video oder Lied abgespielt wird, wird der Standardzustand wiederhergestellt.\nHalte Shift gedrückt, während du klickst oder drücke die mittlere Maustaste, um das Thumbnail in höchster Qualität in einem neuen Tab zu öffnen.", - "feature_desc_thumbnailOverlayShowIndicator": "Zeige einen Indikator in der unteren rechten Ecke des Thumbnails, während es aktiv ist?", - "feature_desc_thumbnailOverlayIndicatorOpacity": "Deckkraft des Thumbnail-Indikators", - "feature_desc_thumbnailOverlayImageFit": "Wie das Thumbnail über dem Videoelement angezeigt werden soll", - "feature_desc_hideCursorOnIdle": "Verstecke den Cursor nach einigen Sekunden Inaktivität über dem Video", - "feature_desc_hideCursorOnIdleDelay": "Wie viele Sekunden Inaktivität vergehen müssen, bevor der Cursor versteckt wird", - "feature_desc_fixHdrIssues": "Verhindere einige Rendering-Probleme bei der Verwendung einer HDR-kompatiblen Grafikkarte mit Monitor", - "feature_desc_showVotes": "Zeige die Anzahl der Likes und Dislikes auf dem aktuell spielenden Song", - "feature_helptext_showVotes": "Dieses Feature ist dank Return YouTube Dislike möglich. Es zeigt die ungefähre Anzahl von Likes und Dislikes auf dem aktuell spielenden Song an.", - "feature_desc_numbersFormat": "Wie sollen Zahlen formatiert werden?", + "feature_desc_watermarkEnabled": "Zeige ein Wasserzeichen unter dem Seitenlogo, das dieses Einstellungsmenü öffnet", + "feature_helptext_watermarkEnabled": "Wenn dies deaktiviert ist, kannst du das Einstellungsmenü immer noch öffnen, indem du die Option im Menü anklickst, das sich öffnet, wenn du auf dein Profilbild in der oberen rechten Ecke klickst.\nEs wird jedoch schwieriger sein, das Easter Egg zu finden ;)", + "feature_desc_removeShareTrackingParam": "Entferne den Tracking-Parameter \"?si\" aus der URL in den Teilen-Menüs", + "feature_helptext_removeShareTrackingParam": "Zu Analysezwecken fügt YouTube einen Tracking-Parameter am Ende der URL hinzu, die im Teilen-Menü angezeigt wird. Obwohl es nicht direkt schädlich ist, macht es die URL länger und gibt YouTube mehr Informationen über dich und die Personen, denen du den Link sendest.", + "feature_desc_removeShareTrackingParamSites": "Auf welchen Seiten soll der Tracking-Parameter entfernt werden?", + "feature_desc_numKeysSkipToTime": "Das Drücken einer Zahlentaste (0-9) springt zu einer bestimmten Zeit im Video", + "feature_desc_fixSpacing": "Behebe diverse Abstandprobleme im Layout", + "feature_helptext_fixSpacing": "Es gibt verschiedene Stellen im User Interface, an denen der Abstand zwischen Elementen inkonsistent ist. Diese Funktion behebt diese Probleme.", + "feature_desc_thumbnailOverlayBehavior": "Wann das Videoelement automatisch durch sein Thumbnail in höchster Auflösung ersetzt werden soll", + "feature_helptext_thumbnailOverlayBehavior": "Das Thumbnail wird über dem Video oder Song angezeigt.\nDies spart keine Bandbreite, da das Video immer noch im Hintergrund geladen und abgespielt wird!", + "feature_desc_thumbnailOverlayToggleBtnShown": "Füge einen Knopf zu den Mediensteuerelementen hinzu, um das Thumbnail manuell zu aktivieren", + "feature_helptext_thumbnailOverlayToggleBtnShown": "Dieser Knopf ermöglicht es dir, das Thumbnail manuell ein- und auszuschalten. Dies wird nicht beeinflusst, wenn das Overlay auf \"nie gezeigt\" eingestellt ist.\nSobald ein neues Video oder Lied abgespielt wird, wird der Standardzustand wiederhergestellt.\nHalte Shift gedrückt, während du klickst oder drücke die mittlere Maustaste, um das Thumbnail in höchster Qualität in einem neuen Tab zu öffnen.", + "feature_desc_thumbnailOverlayShowIndicator": "Zeige einen Indikator in der unteren rechten Ecke des Thumbnails, während es aktiv ist?", + "feature_desc_thumbnailOverlayIndicatorOpacity": "Deckkraft des Thumbnail-Indikators", + "feature_desc_thumbnailOverlayImageFit": "Wie das Thumbnail über dem Videoelement angezeigt werden soll", + "feature_desc_hideCursorOnIdle": "Verstecke den Cursor nach einigen Sekunden Inaktivität über dem Video", + "feature_desc_hideCursorOnIdleDelay": "Wie viele Sekunden Inaktivität vergehen müssen, bevor der Cursor versteckt wird", + "feature_desc_fixHdrIssues": "Verhindere einige Rendering-Probleme bei der Verwendung einer HDR-kompatiblen Grafikkarte mit Monitor", + "feature_desc_showVotes": "Zeige die Anzahl der Likes und Dislikes auf dem aktuell spielenden Song", + "feature_helptext_showVotes": "Dieses Feature ist dank Return YouTube Dislike möglich. Es zeigt die ungefähre Anzahl von Likes und Dislikes auf dem aktuell spielenden Song an.", + "feature_desc_numbersFormat": "Wie sollen Zahlen formatiert werden?", - "feature_desc_volumeSliderLabel": "Füge eine Prozent-Beschriftung neben dem Lautstärkeregler hinzu", - "feature_desc_volumeSliderSize": "Die Breite des Lautstärkereglers in Pixeln", - "feature_desc_volumeSliderStep": "Lautstärkeregler-Sensitivität (um wie wenig Prozent die Lautstärke auf einmal geändert werden kann)", - "feature_desc_volumeSliderScrollStep": "Scrollrad-Sensitivität vom Lautstärkeregler in Prozent - springt zum nächsten Sensitivitätswert von oben", - "feature_helptext_volumeSliderScrollStep": "Um wie viel Prozent die Lautstärke geändert werden soll, wenn der Lautstärkeregler mit dem Mausrad gescrollt wird.\nDies sollte ein Vielfaches der Lautstärkeregler-Sensitivität sein, ansonsten gibt es kleine unregelmäßige Sprünge in der Lautstärke beim Scrollen.", - "feature_desc_volumeSharedBetweenTabs": "Soll die eingestellte Lautstärke zwischen Tabs geteilt und zwischen Sitzungen gespeichert werden?", - "feature_helptext_volumeSharedBetweenTabs": "Wenn du die Lautstärke in einem Tab änderst, wird der Lautstärkepegel in allen anderen Tabs, die diese Funktion aktiviert haben, auf denselben Wert gesetzt.\nDieser Wert wird über Sitzungen hinweg gespeichert und wiederhergestellt, bis dieses Feature deaktiviert wird.", - "feature_desc_setInitialTabVolume": "Setze den Lautstärkepegel auf einen bestimmten Wert, wenn die Seite geöffnet wird", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Diese Funktion ist mit der Funktion \"Lautstärke zwischen Tabs teilen\" nicht kompatibel und wird ignoriert, während die Funktion zum Teilen der Lautstärke aktiviert ist.", - "feature_desc_initialTabVolumeLevel": "Der Wert, auf den die Lautstärke eingestellt werden soll, wenn die Seite geöffnet wird", + "feature_desc_volumeSliderLabel": "Füge eine Prozent-Beschriftung neben dem Lautstärkeregler hinzu", + "feature_desc_volumeSliderSize": "Die Breite des Lautstärkereglers in Pixeln", + "feature_desc_volumeSliderStep": "Lautstärkeregler-Sensitivität (um wie wenig Prozent die Lautstärke auf einmal geändert werden kann)", + "feature_desc_volumeSliderScrollStep": "Scrollrad-Sensitivität vom Lautstärkeregler in Prozent - springt zum nächsten Sensitivitätswert von oben", + "feature_helptext_volumeSliderScrollStep": "Um wie viel Prozent die Lautstärke geändert werden soll, wenn der Lautstärkeregler mit dem Mausrad gescrollt wird.\nDies sollte ein Vielfaches der Lautstärkeregler-Sensitivität sein, ansonsten gibt es kleine unregelmäßige Sprünge in der Lautstärke beim Scrollen.", + "feature_desc_volumeSharedBetweenTabs": "Soll die eingestellte Lautstärke zwischen Tabs geteilt und zwischen Sitzungen gespeichert werden?", + "feature_helptext_volumeSharedBetweenTabs": "Wenn du die Lautstärke in einem Tab änderst, wird der Lautstärkepegel in allen anderen Tabs, die diese Funktion aktiviert haben, auf denselben Wert gesetzt.\nDieser Wert wird über Sitzungen hinweg gespeichert und wiederhergestellt, bis dieses Feature deaktiviert wird.", + "feature_desc_setInitialTabVolume": "Setze den Lautstärkepegel auf einen bestimmten Wert, wenn die Seite geöffnet wird", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Diese Funktion ist mit der Funktion \"Lautstärke zwischen Tabs teilen\" nicht kompatibel und wird ignoriert, während die Funktion zum Teilen der Lautstärke aktiviert ist.", + "feature_desc_initialTabVolumeLevel": "Der Wert, auf den die Lautstärke eingestellt werden soll, wenn die Seite geöffnet wird", - "feature_desc_lyricsQueueButton": "Füge einen Knopf zu jedem Song in einer Liste hinzu, um die Songtext-Seite zu öffnen", - "feature_desc_deleteFromQueueButton": "Füge einen Knopf zu jedem Song in einer Liste hinzu, um ihn schnell zu entfernen", - "feature_desc_listButtonsPlacement": "Wo sollen die Listen-Knöpfe erscheinen?", - "feature_helptext_listButtonsPlacement": "Es gibt verschiedene Songlisten auf der Seite wie Albumseiten, Playlists und die aktuelle Wiedergabeliste.\nMit dieser Option kannst du wählen, wo die Listen-Knöpfe erscheinen sollen.", - "feature_desc_scrollToActiveSongBtn": "Füge einen Knopf über der Wiedergabeliste hinzu, um zum aktuell abgespielten Song zu scrollen", - "feature_desc_clearQueueBtn": "Füge einen Knopf über der Wiedergabeliste hinzu, um sie schnell zu leeren", + "feature_desc_lyricsQueueButton": "Füge einen Knopf zu jedem Song in einer Liste hinzu, um die Songtext-Seite zu öffnen", + "feature_desc_deleteFromQueueButton": "Füge einen Knopf zu jedem Song in einer Liste hinzu, um ihn schnell zu entfernen", + "feature_desc_listButtonsPlacement": "Wo sollen die Listen-Knöpfe erscheinen?", + "feature_helptext_listButtonsPlacement": "Es gibt verschiedene Songlisten auf der Seite wie Albumseiten, Playlists und die aktuelle Wiedergabeliste.\nMit dieser Option kannst du wählen, wo die Listen-Knöpfe erscheinen sollen.", + "feature_desc_scrollToActiveSongBtn": "Füge einen Knopf über der Wiedergabeliste hinzu, um zum aktuell abgespielten Song zu scrollen", + "feature_desc_clearQueueBtn": "Füge einen Knopf über der Wiedergabeliste hinzu, um sie schnell zu leeren", - "feature_desc_disableBeforeUnloadPopup": "Verhindere das Erscheinen des Bestätigungs-Popup beim Verlassen der Seite, während ein Song läuft", - "feature_helptext_disableBeforeUnloadPopup": "Wenn du versuchst, die Seite zu verlassen, während ein Song läuft, erscheint ein Popup, das dich fragt, ob du die Seite wirklich verlassen möchtest. Es könnte etwas in der Art von \"Du hast ungespeicherte Daten\" oder \"Diese Seite fragt, ob du sie schließen möchtest\" sein.\nDiese Funktion deaktiviert dieses Popup vollständig.", - "feature_desc_closeToastsTimeout": "Nach wie vielen Sekunden permanente Benachrichtigungen geschlossen werden sollen - 0 für manuelles Schließen", - "feature_helptext_closeToastsTimeout": "Die meisten Popups, die in der unteren linken Ecke erscheinen, schließen sich automatisch nach 3 Sekunden, mit Ausnahme von bestimmten wie z.B. beim Liken eines Songs.\nDiese Funktion ermöglicht es dir, eine Zeit festzulegen, nach der permanente Popups geschlossen werden.\nDie anderen Popups bleiben unberührt.\nSetze dies auf 0 für das Standardverhalten, permanente Benachrichtigungen nicht zu schließen.", - "feature_desc_rememberSongTime": "Stelle die Zeit des letzten Songs wieder her, wenn die Seite neu geladen oder wiederhergestellt wird", - "feature_helptext_rememberSongTime-1": "Wahrscheinlich möchtest du nach Neuladen der Seite oder nach versehentlichem Schließen an derselben Stelle weiterhören.\nDiese Funktion speichert die Zeit des momentan laufenden Songs sobald er für mindestens eine Sekunde abgespielt wurde und stellt sie für eine kurze Zeit im Nachhinein wieder her.", - "feature_helptext_rememberSongTime-n": "Wahrscheinlich möchtest du nach Neuladen der Seite oder nach versehentlichem Schließen an derselben Stelle weiterhören.\nDiese Funktion speichert die Zeit des momentan laufenden Songs sobald er für mindestens %1 Sekunden abgespielt wurde und stellt sie für eine kurze Zeit im Nachhinein wieder her.", - "feature_desc_rememberSongTimeSites": "Auf welchen Seiten soll die Songzeit gespeichert und wiederhergestellt werden?", - "feature_desc_rememberSongTimeDuration": "Wie lange in Sekunden die Songzeit nach dem letzten Abspielen gespeichert werden soll", - "feature_desc_rememberSongTimeReduction": "Wie viele Sekunden abgezogen werden sollen, wenn ein gespeicherter Song wiederhergestellt wird", - "feature_helptext_rememberSongTimeReduction": "Wenn die Zeit eines Songs, die gespeichert wurde, wiederhergestellt wird, wird diese Anzahl von Sekunden von der gespeicherten Zeit abgezogen, damit du den Teil, der unterbrochen wurde, erneut hören kannst.", - "feature_desc_rememberSongTimeMinPlayTime": "Mindestanzahl an Sekunden, die ein Song gespielt werden muss, damit die Zeit gespeichert wird", + "feature_desc_disableBeforeUnloadPopup": "Verhindere das Erscheinen des Bestätigungs-Popup beim Verlassen der Seite, während ein Song läuft", + "feature_helptext_disableBeforeUnloadPopup": "Wenn du versuchst, die Seite zu verlassen, während ein Song läuft, erscheint ein Popup, das dich fragt, ob du die Seite wirklich verlassen möchtest. Es könnte etwas in der Art von \"Du hast ungespeicherte Daten\" oder \"Diese Seite fragt, ob du sie schließen möchtest\" sein.\nDiese Funktion deaktiviert dieses Popup vollständig.", + "feature_desc_closeToastsTimeout": "Nach wie vielen Sekunden permanente Benachrichtigungen geschlossen werden sollen - 0 für manuelles Schließen", + "feature_helptext_closeToastsTimeout": "Die meisten Popups, die in der unteren linken Ecke erscheinen, schließen sich automatisch nach 3 Sekunden, mit Ausnahme von bestimmten wie z.B. beim Liken eines Songs.\nDiese Funktion ermöglicht es dir, eine Zeit festzulegen, nach der permanente Popups geschlossen werden.\nDie anderen Popups bleiben unberührt.\nSetze dies auf 0 für das Standardverhalten, permanente Benachrichtigungen nicht zu schließen.", + "feature_desc_rememberSongTime": "Stelle die Zeit des letzten Songs wieder her, wenn die Seite neu geladen oder wiederhergestellt wird", + "feature_helptext_rememberSongTime-1": "Wahrscheinlich möchtest du nach Neuladen der Seite oder nach versehentlichem Schließen an derselben Stelle weiterhören.\nDiese Funktion speichert die Zeit des momentan laufenden Songs sobald er für mindestens eine Sekunde abgespielt wurde und stellt sie für eine kurze Zeit im Nachhinein wieder her.", + "feature_helptext_rememberSongTime-n": "Wahrscheinlich möchtest du nach Neuladen der Seite oder nach versehentlichem Schließen an derselben Stelle weiterhören.\nDiese Funktion speichert die Zeit des momentan laufenden Songs sobald er für mindestens %1 Sekunden abgespielt wurde und stellt sie für eine kurze Zeit im Nachhinein wieder her.", + "feature_desc_rememberSongTimeSites": "Auf welchen Seiten soll die Songzeit gespeichert und wiederhergestellt werden?", + "feature_desc_rememberSongTimeDuration": "Wie lange in Sekunden die Songzeit nach dem letzten Abspielen gespeichert werden soll", + "feature_desc_rememberSongTimeReduction": "Wie viele Sekunden abgezogen werden sollen, wenn ein gespeicherter Song wiederhergestellt wird", + "feature_helptext_rememberSongTimeReduction": "Wenn die Zeit eines Songs, die gespeichert wurde, wiederhergestellt wird, wird diese Anzahl von Sekunden von der gespeicherten Zeit abgezogen, damit du den Teil, der unterbrochen wurde, erneut hören kannst.", + "feature_desc_rememberSongTimeMinPlayTime": "Mindestanzahl an Sekunden, die ein Song gespielt werden muss, damit die Zeit gespeichert wird", + "feature_desc_aboveQueueBtnsSticky": "Positioniere das Element, in dem die Knöpfe über der Wiedergabeliste sind, immer am oberen Rand", - "feature_desc_arrowKeySupport": "Benutze die Pfeiltasten um vor- und zurückzuspulen", - "feature_helptext_arrowKeySupport": "Normalerweise kannst du nur in 10 Sekunden Schritten vor- und zurückspulen, indem du die Tasten \"H\" und \"L\" benutzt. Diese Funktion ermöglicht es dir, auch die Pfeiltasten zu benutzen.\nUm die Anzahl der Sekunden zu ändern, um die gespult werden soll, benutze die Option unten.", - "feature_desc_arrowKeySkipBy": "Um wie viele Sekunden vor- und zurückspulen, wenn die Pfeiltasten benutzt werden", - "feature_desc_switchBetweenSites": "Füge einen Hotkey hinzu, um zwischen den YT und YTM Seiten zu wechseln", - "feature_helptext_switchBetweenSites": "Wenn du auf YouTube oder YouTube Music bist, kannst du mit diesem Hotkey zur anderen Seite wechseln, während du auf demselben Video / Song bleibst.", - "feature_desc_switchSitesHotkey": "Welcher Hotkey muss gedrückt werden, um zwischen den Seiten zu wechseln?", - "feature_desc_anchorImprovements": "Links auf der Seite erstellen und verbessern, damit Dinge einfacher in einem neuen Tab geöffnet werden können", - "feature_helptext_anchorImprovements": "Einige Elemente auf der Seite sind nur mit der linken Maustaste klickbar, was bedeutet, dass du sie nicht in einem neuen Tab öffnen kannst, indem du darauf mit der mittleren Maustaste klickst oder durch das Kontextmenü mit Shift + Rechtsklick. Diese Funktion fügt Links zu vielen von ihnen hinzu oder vergrößert vorhandene, um das Klicken zu erleichtern.", - "feature_desc_autoLikeChannels": "Automatisch alle Songs und Videos bestimmter Kanäle liken", - "feature_helpText_autoLikeChannels": "Sobald eingeschaltet, kannst du diese Funktion für bestimmte Kanäle aktivieren, indem du ihre Kanalseite öffnest und den Knopf klickst. Danach wird jeder Song, den du von diesem Kanal spielst, automatisch geliked.\nBenutze die Option unten, um einen Dialog zu öffnen, um die Kanäle zu verwalten.", - "feature_desc_autoLikeChannelToggleBtn": "Füge einen Knopf zu jeder Kanalseite hinzu, um Auto-Liken zu aktivieren oder deaktivieren", - "feature_desc_autoLikePlayerBarToggleBtn": "Füge einen Knopf zu den Mediensteuerelementen hinzu, um Auto-Liken zu aktivieren oder deaktivieren", - "feature_desc_autoLikeTimeout": "Wie viele Sekunden ein Song spielen muss, bevor er automatisch geliked wird", - "feature_desc_autoLikeShowToast": "Zeige eine Toast-Benachrichtigung, wenn ein Song automatisch geliked wird", - "feature_desc_autoLikeOpenMgmtDialog": "Öffne den Dialog, um die auto-gelikten Kanäle zu verwalten", - "feature_btn_autoLikeOpenMgmtDialog": "Dialog öffnen", - "feature_btn_autoLikeOpenMgmtDialog_running": "Wird geöffnet...", + "feature_desc_arrowKeySupport": "Benutze die Pfeiltasten um vor- und zurückzuspulen", + "feature_helptext_arrowKeySupport": "Normalerweise kannst du nur in 10 Sekunden Schritten vor- und zurückspulen, indem du die Tasten \"H\" und \"L\" benutzt. Diese Funktion ermöglicht es dir, auch die Pfeiltasten zu benutzen.\nUm die Anzahl der Sekunden zu ändern, um die gespult werden soll, benutze die Option unten.", + "feature_desc_arrowKeySkipBy": "Um wie viele Sekunden vor- und zurückspulen, wenn die Pfeiltasten benutzt werden", + "feature_desc_switchBetweenSites": "Füge einen Hotkey hinzu, um zwischen den YT und YTM Seiten zu wechseln", + "feature_helptext_switchBetweenSites": "Wenn du auf YouTube oder YouTube Music bist, kannst du mit diesem Hotkey zur anderen Seite wechseln, während du auf demselben Video / Song bleibst.", + "feature_desc_switchSitesHotkey": "Welcher Hotkey muss gedrückt werden, um zwischen den Seiten zu wechseln?", + "feature_desc_anchorImprovements": "Links auf der Seite erstellen und verbessern, damit Dinge einfacher in einem neuen Tab geöffnet werden können", + "feature_helptext_anchorImprovements": "Einige Elemente auf der Seite sind nur mit der linken Maustaste klickbar, was bedeutet, dass du sie nicht in einem neuen Tab öffnen kannst, indem du darauf mit der mittleren Maustaste klickst oder durch das Kontextmenü mit Shift + Rechtsklick. Diese Funktion fügt Links zu vielen von ihnen hinzu oder vergrößert vorhandene, um das Klicken zu erleichtern.", + "feature_desc_autoLikeChannels": "Automatisch alle Songs und Videos bestimmter Kanäle liken", + "feature_helpText_autoLikeChannels": "Sobald eingeschaltet, kannst du diese Funktion für bestimmte Kanäle aktivieren, indem du ihre Kanalseite öffnest und den Knopf klickst. Danach wird jeder Song, den du von diesem Kanal spielst, automatisch geliked.\nBenutze die Option unten, um einen Dialog zu öffnen, um die Kanäle zu verwalten.", + "feature_desc_autoLikeChannelToggleBtn": "Füge einen Knopf zu jeder Kanalseite hinzu, um Auto-Liken zu aktivieren oder deaktivieren", + "feature_desc_autoLikePlayerBarToggleBtn": "Füge einen Knopf zu den Mediensteuerelementen hinzu, um Auto-Liken zu aktivieren oder deaktivieren", + "feature_desc_autoLikeTimeout": "Wie viele Sekunden ein Song spielen muss, bevor er automatisch geliked wird", + "feature_desc_autoLikeShowToast": "Zeige eine Toast-Benachrichtigung, wenn ein Song automatisch geliked wird", + "feature_desc_autoLikeOpenMgmtDialog": "Öffne den Dialog, um die auto-gelikten Kanäle zu verwalten", + "feature_btn_autoLikeOpenMgmtDialog": "Dialog öffnen", + "feature_btn_autoLikeOpenMgmtDialog_running": "Wird geöffnet...", - "feature_desc_geniusLyrics": "Füge einen Knopf zu dem aktuell spielenden Song hinzu, um den Songtext auf genius.com zu öffnen", - "feature_desc_errorOnLyricsNotFound": "Zeige einen Error, wenn die Songtext-Seite für den aktuell spielenden Song nicht gefunden werden konnte", - "feature_desc_geniUrlBase": "Base URL deiner geniURL Instanz, siehe https://github.com/Sv443/geniURL", - "feature_helptext_geniUrlBase": "Wenn du deine eigene geniURL Instanz laufen hast (zum Beispiel um Rate Limiting zu umgehen), kannst du hier ihre Base URL eingeben, um sie für die genius.com Songtext-Suche zu benutzen.\nWenn du nicht weißt, was das ist, kannst du diese Option so lassen, wie sie ist.", - "feature_desc_geniUrlToken": "Auth Token deiner geniURL Instanz", - "feature_helptext_geniUrlToken": "Um das Rate Limiting zu umgehen, kannst du ein Authentifikations-Token angeben, das auch in der .env Datei deiner geniURL Instanz definiert ist.", - "feature_desc_lyricsCacheMaxSize": "Maximale Anzahl an Songtexten, die im Cache gespeichert werden sollen", - "feature_helptext_lyricsCacheMaxSize": "Die Songtexte der Songs, die du hörst, werden im Cache gespeichert, um die Anzahl der Anfragen an den Songtext-Provider zu reduzieren.\nDiese Funktion ermöglicht es dir, die maximale Anzahl an Songtexten festzulegen, die im Cache gespeichert werden sollen.\nWenn das Limit erreicht ist, wird der älteste Eintrag entfernt, um Platz für neue zu schaffen.", - "feature_desc_lyricsCacheTTL": "Maximale Anzahl an Tagen, die ein Songtext-Eintrag im Cache bleiben soll", - "feature_helptext_lyricsCacheTTL": "Der Cache, in dem die Songtexte gespeichert sind, löscht sie automatisch nach dieser Zeit, um sicherzustellen, dass Aktualisierungen der Quelle früher oder später abgerufen werden.\nWenn du sicherstellen möchtest, dass du immer die aktuellsten Songtexte hast, setze diesen Wert auf einen niedrigen Wert wie 4 Tage.", - "feature_desc_clearLyricsCache": "Lösche den Songtext-Cache manuell", - "feature_helptext_clearLyricsCache": "Wenn die Songtexte im lokalen Cache veraltet sind oder du einfach nur etwas Platz freimachen möchtest, kannst du den Cache manuell löschen, indem du diesen Knopf drückst.", - "feature_btn_clearLyricsCache": "Cache löschen", - "feature_btn_clearLyricsCache_running": "Wird gelöscht...", - "feature_desc_advancedLyricsFilter": "Experimentell: Aktiviere erweiterte Filterung für die Songtext-Suche", - "feature_helptext_advancedLyricsFilter": "Erweiterte Filterung beinhaltet mehrere Schichten von Filtern, die darauf abzielen, die Songtext-Suchen zuverlässiger zu machen.\nDiese Filter funktionieren möglicherweise nicht gut für Songs in deiner Sprache und Songs und Künstler mit weniger eindeutigen Titeln im Allgemeinen.\nWarnung: Diese Funktion ist immer noch experimentell und könnte tatsächlich nicht besser funktionieren als die Standard-Songtext-Suche. Stelle sicher, dass du die Meldung bestätigst, die erscheint, wenn du diese Einstellung geändert hast.", + "feature_desc_geniusLyrics": "Füge einen Knopf zu dem aktuell spielenden Song hinzu, um den Songtext auf genius.com zu öffnen", + "feature_desc_errorOnLyricsNotFound": "Zeige einen Error, wenn die Songtext-Seite für den aktuell spielenden Song nicht gefunden werden konnte", + "feature_desc_geniUrlBase": "Base URL deiner geniURL Instanz, siehe https://github.com/Sv443/geniURL", + "feature_helptext_geniUrlBase": "Wenn du deine eigene geniURL Instanz laufen hast (zum Beispiel um Rate Limiting zu umgehen), kannst du hier ihre Base URL eingeben, um sie für die genius.com Songtext-Suche zu benutzen.\nWenn du nicht weißt, was das ist, kannst du diese Option so lassen, wie sie ist.", + "feature_desc_geniUrlToken": "Auth Token deiner geniURL Instanz", + "feature_helptext_geniUrlToken": "Um das Rate Limiting zu umgehen, kannst du ein Authentifikations-Token angeben, das auch in der .env Datei deiner geniURL Instanz definiert ist.", + "feature_desc_lyricsCacheMaxSize": "Maximale Anzahl an Songtexten, die im Cache gespeichert werden sollen", + "feature_helptext_lyricsCacheMaxSize": "Die Songtexte der Songs, die du hörst, werden im Cache gespeichert, um die Anzahl der Anfragen an den Songtext-Provider zu reduzieren.\nDiese Funktion ermöglicht es dir, die maximale Anzahl an Songtexten festzulegen, die im Cache gespeichert werden sollen.\nWenn das Limit erreicht ist, wird der älteste Eintrag entfernt, um Platz für neue zu schaffen.", + "feature_desc_lyricsCacheTTL": "Maximale Anzahl an Tagen, die ein Songtext-Eintrag im Cache bleiben soll", + "feature_helptext_lyricsCacheTTL": "Der Cache, in dem die Songtexte gespeichert sind, löscht sie automatisch nach dieser Zeit, um sicherzustellen, dass Aktualisierungen der Quelle früher oder später abgerufen werden.\nWenn du sicherstellen möchtest, dass du immer die aktuellsten Songtexte hast, setze diesen Wert auf einen niedrigen Wert wie 4 Tage.", + "feature_desc_clearLyricsCache": "Lösche den Songtext-Cache manuell", + "feature_helptext_clearLyricsCache": "Wenn die Songtexte im lokalen Cache veraltet sind oder du einfach nur etwas Platz freimachen möchtest, kannst du den Cache manuell löschen, indem du diesen Knopf drückst.", + "feature_btn_clearLyricsCache": "Cache löschen", + "feature_btn_clearLyricsCache_running": "Wird gelöscht...", + "feature_desc_advancedLyricsFilter": "Experimentell: Aktiviere erweiterte Filterung für die Songtext-Suche", + "feature_helptext_advancedLyricsFilter": "Erweiterte Filterung beinhaltet mehrere Schichten von Filtern, die darauf abzielen, die Songtext-Suchen zuverlässiger zu machen.\nDiese Filter funktionieren möglicherweise nicht gut für Songs in deiner Sprache und Songs und Künstler mit weniger eindeutigen Titeln im Allgemeinen.\nWarnung: Diese Funktion ist immer noch experimentell und könnte tatsächlich nicht besser funktionieren als die Standard-Songtext-Suche. Stelle sicher, dass du die Meldung bestätigst, die erscheint, wenn du diese Einstellung geändert hast.", - "feature_desc_disableDarkReaderSites": "Auf welchen Seiten soll das Dark Reader Addon deaktiviert werden, um Layoutprobleme zu beheben?", - "feature_helptext_disableDarkReaderSites": "Das Dark Reader Addon kann Probleme mit dem Layout der Seite verursachen.\nDiese Funktion ermöglicht es dir, Dark Reader auf bestimmten oder allen Seiten zu deaktivieren, um diese Probleme zu verhindern.\n\nWenn das Addon nicht installiert ist, hat diese Funktion keine Auswirkungen und kann aktiviert bleiben.", - "feature_desc_sponsorBlockIntegration": "Mache einige Fixes im Layout, falls SponsorBlock installiert ist", - "feature_helptext_sponsorBlockIntegration": "Wenn du das SponsorBlock Addon installiert hast, wird dieses Feature einige Fixes im Layout der Seite machen, um Probleme zu verhindern, die eventuell auftreten könnten.\n\nDieses Feature hat keinen Effekt, wenn das Addon nicht installiert ist und kann in diesem Fall aktiviert bleiben.", - "feature_desc_themeSongIntegration": "Behebe einige Styling-Probleme, wenn das ThemeSong Addon installiert ist", - "feature_helptext_themeSongIntegration": "Wenn das ThemeSong Addon installiert ist, aber dieses Feature deaktiviert ist (oder umgekehrt), könntest du kaputtes Styling auf der Seite bemerken.\n\nBitte stelle immer sicher, dass dieses Feature reflektiert, ob das Addon installiert ist oder nicht!", - "feature_desc_themeSongLightness": "Wie hell die Akzentfarben sein sollen, die vom aktuellen ThemeSong-Theme abgeleitet werden", - "feature_helptext_themeSongLightness": "Je nach den Einstellungen, die du für das ThemeSong Addon gewählt hast, ermöglicht dir dieses Feature, die Helligkeit der Akzentfarben anzupassen, die vom aktuellen Theme abgeleitet werden.\n\nDieses Feature hat keinen Effekt, wenn das ThemeSong Addon nicht installiert ist.", - "feature_desc_openPluginList": "Öffne die Liste der installierten Plugins", - "feature_btn_openPluginList": "Liste öffnen", - "feature_btn_openPluginList_running": "Wird geöffnet...", - "feature_desc_initTimeout": "Wie viele Sekunden gewartet werden soll, bis Features initialisiert werden, bevor angenommen wird, dass sie wahrscheinlich in einem fehlerhaften Zustand sind", - "feature_helptext_initTimeout": "Dies ist die Zeit in Sekunden, die das Skript wartet, bis Features initialisiert werden, bevor angenommen wird, dass sie wahrscheinlich in einem fehlerhaften Zustand sind.\nDies wird das Verhalten des Skripts nicht wesentlich beeinflussen, aber wenn eines deiner Plugins nicht rechtzeitig initialisiert werden kann, kannst du versuchen, diesen Wert zu erhöhen.", + "feature_desc_disableDarkReaderSites": "Auf welchen Seiten soll das Dark Reader Addon deaktiviert werden, um Layoutprobleme zu beheben?", + "feature_helptext_disableDarkReaderSites": "Das Dark Reader Addon kann Probleme mit dem Layout der Seite verursachen.\nDiese Funktion ermöglicht es dir, Dark Reader auf bestimmten oder allen Seiten zu deaktivieren, um diese Probleme zu verhindern.\n\nWenn das Addon nicht installiert ist, hat diese Funktion keine Auswirkungen und kann aktiviert bleiben.", + "feature_desc_sponsorBlockIntegration": "Mache einige Fixes im Layout, falls SponsorBlock installiert ist", + "feature_helptext_sponsorBlockIntegration": "Wenn du das SponsorBlock Addon installiert hast, wird dieses Feature einige Fixes im Layout der Seite machen, um Probleme zu verhindern, die eventuell auftreten könnten.\n\nDieses Feature hat keinen Effekt, wenn das Addon nicht installiert ist und kann in diesem Fall aktiviert bleiben.", + "feature_desc_themeSongIntegration": "Behebe einige Styling-Probleme, wenn das ThemeSong Addon installiert ist", + "feature_helptext_themeSongIntegration": "Wenn das ThemeSong Addon installiert ist, aber dieses Feature deaktiviert ist (oder umgekehrt), könntest du kaputtes Styling auf der Seite bemerken.\n\nBitte stelle immer sicher, dass dieses Feature reflektiert, ob das Addon installiert ist oder nicht!", + "feature_desc_themeSongLightness": "Wie hell die Akzentfarben sein sollen, die vom aktuellen ThemeSong-Theme abgeleitet werden", + "feature_helptext_themeSongLightness": "Je nach den Einstellungen, die du für das ThemeSong Addon gewählt hast, ermöglicht dir dieses Feature, die Helligkeit der Akzentfarben anzupassen, die vom aktuellen Theme abgeleitet werden.\n\nDieses Feature hat keinen Effekt, wenn das ThemeSong Addon nicht installiert ist.", - "feature_desc_locale": "Sprache", - "feature_desc_localeFallback": "Verwende für fehlende Übersetzungen Englisch (deaktivieren, wenn du zu Übersetzungen beiträgst)", - "feature_desc_versionCheck": "Prüfe alle 24 Stunden auf Updates", - "feature_helptext_versionCheck": "Diese Funktion prüft alle 24 Stunden auf Updates, benachrichtigt dich, wenn eine neue Version verfügbar ist und ermöglicht es dir, das Skript manuell zu aktualisieren.\nWenn dein Userscript-Manager Skripte automatisch aktualisiert, kannst du diese Funktion deaktivieren.", - "feature_desc_checkVersionNow": "Manuell nach einer neuen Version suchen", - "feature_btn_checkVersionNow": "Jetzt prüfen", - "feature_btn_checkVersionNow_running": "Wird geprüft...", - "feature_desc_logLevel": "Wie viele Informationen sollen in der Konsole geloggt werden?", - "feature_helptext_logLevel": "Das Ändern dieses Wertes ist wirklich nur für Debugging-Zwecke notwendig, wenn du ein Problem hast.\nSolltest du eines haben, kannst du den Log-Level hier erhöhen, die JavaScript-Konsole deines Browsers (normalerweise mit Strg + Shift + K) öffnen und Screenshots dieses Logs in einem GitHub-Issue hinzufügen.", - "feature_desc_toastDuration": "Für wie viele Sekunden Toast-Benachrichtigungen angezeigt werden sollen - 0, um sie vollständig zu deaktivieren", - "feature_desc_showToastOnGenericError": "Zeige eine Benachrichtigung, wenn ein Fehler auftritt?", - "feature_helptext_showToastOnGenericError": "Sollte ein Fehler im Skript auftreten, der Teile davon daran hindert, korrekt zu funktionieren, wird eine Benachrichtigung angezeigt, um dich darüber zu informieren.\nWenn du ein Problem häufig hast, kopiere bitte den Fehler aus der JavaScript-Konsole (normalerweise im F12-Menü) und öffne bitte ein Issue auf GitHub.", - "feature_desc_resetConfig": "Setze alle Einstellungen auf ihre Standardwerte zurück", - "feature_btn_resetConfig": "Einstellungen zurücksetzen", - "feature_btn_resetConfig_running": "Zurücksetzen...", - "feature_desc_resetEverything": "Setze alle gespeicherten Daten auf die Standardwerte zurück", - "feature_btn_resetEverything": "Alles zurücksetzen", - "feature_btn_resetEverything_running": "Zurücksetzen...", - "feature_desc_advancedMode": "Erweiterte Einstellungen anzeigen (lädt Menü neu)", - "feature_helptext_advancedMode": "Nachdem du dies aktiviert hast, wird das Menü neu geladen und erweiterte Einstellungen werden angezeigt, die standardmäßig ausgeblendet sind.\nDies ist nützlich, wenn du das Verhalten des Skripts tiefer anpassen möchtest und dir ein überfülltes Menü egal ist." - } + "feature_desc_openPluginList": "Öffne die Liste der installierten Plugins", + "feature_btn_openPluginList": "Liste öffnen", + "feature_btn_openPluginList_running": "Wird geöffnet...", + "feature_desc_initTimeout": "Wie viele Sekunden gewartet werden soll, bis Features initialisiert werden, bevor angenommen wird, dass sie wahrscheinlich in einem fehlerhaften Zustand sind", + "feature_helptext_initTimeout": "Dies ist die Zeit in Sekunden, die das Skript wartet, bis Features initialisiert werden, bevor angenommen wird, dass sie wahrscheinlich in einem fehlerhaften Zustand sind.\nDies wird das Verhalten des Skripts nicht wesentlich beeinflussen, aber wenn eines deiner Plugins nicht rechtzeitig initialisiert werden kann, kannst du versuchen, diesen Wert zu erhöhen.", + + "feature_desc_locale": "Sprache", + "feature_desc_localeFallback": "Verwende für fehlende Übersetzungen Englisch (deaktivieren, wenn du zu Übersetzungen beiträgst)", + "feature_desc_versionCheck": "Prüfe alle 24 Stunden auf Updates", + "feature_helptext_versionCheck": "Diese Funktion prüft alle 24 Stunden auf Updates, benachrichtigt dich, wenn eine neue Version verfügbar ist und ermöglicht es dir, das Skript manuell zu aktualisieren.\nWenn dein Userscript-Manager Skripte automatisch aktualisiert, kannst du diese Funktion deaktivieren.", + "feature_desc_checkVersionNow": "Manuell nach einer neuen Version suchen", + "feature_btn_checkVersionNow": "Jetzt prüfen", + "feature_btn_checkVersionNow_running": "Wird geprüft...", + "feature_desc_logLevel": "Wie viele Informationen sollen in der Konsole geloggt werden?", + "feature_helptext_logLevel": "Das Ändern dieses Wertes ist wirklich nur für Debugging-Zwecke notwendig, wenn du ein Problem hast.\nSolltest du eines haben, kannst du den Log-Level hier erhöhen, die JavaScript-Konsole deines Browsers (normalerweise mit Strg + Shift + K) öffnen und Screenshots dieses Logs in einem GitHub-Issue hinzufügen.", + "feature_desc_toastDuration": "Für wie viele Sekunden Toast-Benachrichtigungen angezeigt werden sollen - 0, um sie vollständig zu deaktivieren", + "feature_desc_showToastOnGenericError": "Zeige eine Benachrichtigung, wenn ein Fehler auftritt?", + "feature_helptext_showToastOnGenericError": "Sollte ein Fehler im Skript auftreten, der Teile davon daran hindert, korrekt zu funktionieren, wird eine Benachrichtigung angezeigt, um dich darüber zu informieren.\nWenn du ein Problem häufig hast, kopiere bitte den Fehler aus der JavaScript-Konsole (normalerweise im F12-Menü) und öffne bitte ein Issue auf GitHub.", + "feature_desc_resetConfig": "Setze alle Einstellungen auf ihre Standardwerte zurück", + "feature_btn_resetConfig": "Einstellungen zurücksetzen", + "feature_btn_resetConfig_running": "Zurücksetzen...", + "feature_desc_resetEverything": "Setze alle gespeicherten Daten auf die Standardwerte zurück", + "feature_btn_resetEverything": "Alles zurücksetzen", + "feature_btn_resetEverything_running": "Zurücksetzen...", + "feature_desc_advancedMode": "Erweiterte Einstellungen anzeigen (lädt Menü neu)", + "feature_helptext_advancedMode": "Nachdem du dies aktiviert hast, wird das Menü neu geladen und erweiterte Einstellungen werden angezeigt, die standardmäßig ausgeblendet sind.\nDies ist nützlich, wenn du das Verhalten des Skripts tiefer anpassen möchtest und dir ein überfülltes Menü egal ist." } diff --git a/assets/translations/en-GB.json b/assets/translations/en-GB.json index 45cc2c19e7..19783bcf8c 100644 --- a/assets/translations/en-GB.json +++ b/assets/translations/en-GB.json @@ -1,7 +1,13 @@ { - "base": "en-US", - "translations": { - "feature_category_behavior": "Behaviour", - "vote_ratio_blue_gray": "Blue and grey" - } + "meta": { + "base": "en-US", + "langName": "English", + "langNameEnglish": "English (Great Britain)", + "countryName": "Great Britain", + "authors": [ + "Sv443" + ] + }, + "feature_category_behavior": "Behaviour", + "vote_ratio_blue_gray": "Blue and grey" } \ No newline at end of file diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json index 9909d0ea89..9b0e651b22 100644 --- a/assets/translations/en-US.json +++ b/assets/translations/en-US.json @@ -1,363 +1,378 @@ { - "translations": { - "config_menu_option": "%1 Configuration", - "config_menu_title": "%1 - Configuration", - "changelog_menu_title": "%1 - Changelog", - "export_menu_title": "%1 - Export Configuration", - "import_menu_title": "%1 - Import Configuration", - "open_menu_tooltip": "Open %1's configuration menu", - "close_menu_tooltip": "Click to close the menu", - "reload_hint": "Please reload the page to apply your changes", - "reload_now": "Reload now", - "reload_tooltip": "Reload the page", - "feature_requires_reload": "Changing this feature requires a page reload", - "version_tooltip": "Version %1 (build %2) - click to open the changelog", - "bytm_config_export_import_title": "Export or Import Configuration", - "bytm_config_import_desc": "Paste the configuration you want to import into the field below, then click the import button:", - "bytm_config_export_desc": "Copy the following text to export your configuration. Warning: it may contain sensitive data.", - "export_import": "Export/Import", - "export": "Export", - "export_hint": "Copy the following text to export your configuration.\nWarning: it may contain sensitive data.", - "click_to_reveal": "(click to reveal)", - "click_to_reveal_sensitive_info": "(click to reveal sensitive information)", - "export_tooltip": "Export your current configuration", - "import": "Import", - "import_hint": "Paste the configuration you want to import into the field below, then click the import button:", - "import_tooltip": "Import a configuration you have previously exported", - "start_import_tooltip": "Click to import the data you pasted above", - "import_error_invalid": "The imported data is invalid", - "import_error_no_format_version": "The imported data does not contain a format version", - "import_error_no_data": "The imported object does not contain any data", - "import_error_wrong_format_version": "The imported data is in an unsupported format version (expected %1 or lower but got %2)", - "import_success": "Successfully imported the data", - "import_success_confirm_reload": "Successfully imported the data.\nDo you want to reload the page now to apply changes?", - "reset_config_confirm": "Do you really want to reset all settings to their default values?\nThe page will be automatically reloaded.", - "reset_everything_confirm": "Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.", - "copy": "Copy", - "copy_to_clipboard": "Copy to clipboard", - "copy_to_clipboard_error": "Couldn't copy the text to the clipboard. Please copy it from here manually:\n%1", - "copy_config_tooltip": "Copy the configuration to your clipboard", - "copied": "Copied!", - "copied_to_clipboard": "Copied to clipboard!", - "copy_hidden": "Copy hidden", - "copy_hidden_tooltip": "Click to copy the hidden value - this is sensitive data ⚠️", - "open_github": "Open %1 on GitHub", - "open_discord": "Join my Discord server", - "open_greasyfork": "Open %1 on GreasyFork", - "open_openuserjs": "Open %1 on OpenUserJS", - "lang_changed_prompt_reload": "The language was changed.\nDo you want to reload the page now to apply changes?", - "search_placeholder": "Search...", - "search_clear": "Clear search", + "meta": { + "langName": "English", + "langNameEnglish": "English (United States)", + "countryName": "United States", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "%1 Configuration", + "config_menu_title": "%1 - Configuration", + "changelog_menu_title": "%1 - Changelog", + "export_menu_title": "%1 - Export Configuration", + "import_menu_title": "%1 - Import Configuration", + "open_menu_tooltip": "Open %1's configuration menu", + "close_menu_tooltip": "Click to close the menu", + "reload_hint": "Please reload the page to apply your changes", + "reload_now": "Reload now", + "reload_tooltip": "Reload the page", + "feature_requires_reload": "Changing this feature requires a page reload", + "version_tooltip": "Version %1 (build %2) - click to open the changelog", + "bytm_config_export_import_title": "Export or Import Configuration", + "bytm_config_import_desc": "Paste the configuration you want to import into the field below, then click the import button:", + "bytm_config_export_desc": "Copy the following text to export your configuration. Warning: it may contain sensitive data.", + "export_import": "Export/Import", + "export": "Export", + "export_hint": "Copy the following text to export your configuration.\nWarning: it may contain sensitive data.", + "click_to_reveal": "(click to reveal)", + "click_to_reveal_sensitive_info": "(click to reveal sensitive information)", + "export_tooltip": "Export your current configuration", + "import": "Import", + "import_hint": "Paste the configuration you want to import into the field below, then click the import button:", + "import_tooltip": "Import a configuration you have previously exported", + "start_import_tooltip": "Click to import the data you pasted above", + "import_error_invalid": "The imported data is invalid", + "import_error_no_format_version": "The imported data does not contain a format version", + "import_error_no_data": "The imported object does not contain any data", + "import_error_wrong_format_version": "The imported data is in an unsupported format version (expected %1 or lower but got %2)", + "import_success": "Successfully imported the data", + "import_success_confirm_reload": "Successfully imported the data.\nDo you want to reload the page now to apply changes?", + "reset_config_confirm": "Do you really want to reset all settings to their default values?\nThe page will be automatically reloaded.", + "reset_everything_confirm": "Do you really want to reset all stored data to the default values?\nThe page will be automatically reloaded.", + "copy": "Copy", + "copy_to_clipboard": "Copy to clipboard", + "copy_to_clipboard_error": "Couldn't copy the text to the clipboard. Please copy it from here manually:\n%1", + "copy_config_tooltip": "Copy the configuration to your clipboard", + "copied": "Copied!", + "copied_to_clipboard": "Copied to clipboard!", + "copy_hidden": "Copy hidden", + "copy_hidden_tooltip": "Click to copy the hidden value - this is sensitive data ⚠️", + "open_github": "Open %1 on GitHub", + "open_discord": "Join my Discord server", + "open_greasyfork": "Open %1 on GreasyFork", + "open_openuserjs": "Open %1 on OpenUserJS", + "lang_changed_prompt_reload": "The language was changed.\nDo you want to reload the page now to apply changes?", + "search_placeholder": "Search...", + "search_clear": "Clear search", - "reset": "Reset", - "close": "Close", - "log_level_debug": "Debug (most)", - "log_level_info": "Info (only important)", - "toggled_on": "On", - "toggled_off": "Off", - "trigger_btn_action": "Run action", - "trigger_btn_action_running": "Running...", - "new_entry": "New entry", - "new_entry_tooltip": "Click to create a new entry", - "remove_entry": "Remove this entry", - "edit_entry": "Edit this entry", - "remove_from_queue": "Remove this song from the queue", - "delete_from_list": "Delete this song from the list", - "couldnt_remove_from_queue": "Couldn't remove this song from the queue", - "couldnt_delete_from_list": "Couldn't delete this song from the list", - "clear_list": "Clear the list", - "clear_list_confirm": "Do you really want to clear the list and leave only the currently playing song?", - "scroll_to_playing": "Scroll to the currently playing song", - "scroll_to_bottom": "Click to scroll to the bottom", - "volume_tooltip": "Volume: %1% (Sensitivity: %2%)", - "volume_shared_tooltip": "Volume level is shared between tabs - disable in the configuration menu", - "middle_click_open_tab": "Middle-click to open in a new tab", - "example_toast": "Example Toast", - "generic_error_toast_encountered_error_type": "Encountered %1", - "generic_error_toast_click_for_details": "Click for details", - "error": "Error", - "generic_error_dialog_message": "Encountered an error.", - "generic_error_dialog_open_console_note": "If this error keeps occuring, please open the JavaScript console (usually with Ctrl + Shift + K) and attach a screenshot of the **entire** error message that starts with %1 to a new [GitHub issue.](%2)", - "active_mode_display": "%1 Mode", - "active_mode_tooltip-1": "The %1 is currently active", - "active_mode_tooltip-n": "The %1 are currently active", - "dev_mode": "Developer mode", - "dev_mode_short": "Dev", - "advanced_mode": "Advanced mode", - "advanced_mode_short": "Advanced", - "experimental_feature": "Experimental feature", + "reset": "Reset", + "close": "Close", + "log_level_debug": "Debug (most)", + "log_level_info": "Info (only important)", + "toggled_on": "On", + "toggled_off": "Off", + "trigger_btn_action": "Run action", + "trigger_btn_action_running": "Running...", + "new_entry": "New entry", + "new_entry_tooltip": "Click to create a new entry", + "remove_entry": "Remove this entry", + "edit_entry": "Edit this entry", + "remove_from_queue": "Remove this song from the queue", + "delete_from_list": "Delete this song from the list", + "couldnt_remove_from_queue": "Couldn't remove this song from the queue", + "couldnt_delete_from_list": "Couldn't delete this song from the list", + "clear_list": "Clear the list", + "clear_list_confirm": "Do you really want to clear the list and leave only the currently playing song?", + "scroll_to_playing": "Scroll to the currently playing song", + "scroll_to_bottom": "Click to scroll to the bottom", + "volume_tooltip": "Volume: %1% (Sensitivity: %2%)", + "volume_shared_tooltip": "Volume level is shared between tabs - disable in the configuration menu", + "middle_click_open_tab": "Middle-click to open in a new tab", + "example_toast": "Example Toast", + "generic_error_toast_encountered_error_type": "Encountered %1", + "generic_error_toast_click_for_details": "Click for details", + "error": "Error", + "generic_error_dialog_message": "Encountered an error.", + "generic_error_dialog_open_console_note": "If this error keeps occuring, please open the JavaScript console (usually with Ctrl + Shift + K) and attach a screenshot of the **entire** error message that starts with %1 to a new [GitHub issue.](%2)", + "active_mode_display": "%1 Mode", + "active_mode_tooltip-1": "The %1 is currently active", + "active_mode_tooltip-n": "The %1 are currently active", + "dev_mode": "Developer mode", + "dev_mode_short": "Dev", + "advanced_mode": "Advanced mode", + "advanced_mode_short": "Advanced", + "experimental_feature": "Experimental feature", - "open_current_lyrics": "Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search", - "open_lyrics": "Open this song's lyrics in a new tab - ctrl-click to open manual search", - "open_lyrics_search_prompt": "Enter the song title and artist to search for the lyrics:", - "lyrics_loading": "Loading lyrics URL...", - "lyrics_rate_limited-1": "You are being rate limited.\nPlease wait a few seconds before requesting more lyrics.", - "lyrics_rate_limited-n": "You are being rate limited.\nPlease wait %1 seconds before requesting more lyrics.", - "lyrics_not_found_confirm_open_search": "Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?", - "lyrics_not_found_click_open_search": "Couldn't find lyrics URL - click to open the manual lyrics search", - "lyrics_clear_cache_confirm_prompt-1": "The lyrics cache currently has %1 entry.\nDo you really want to delete them?", - "lyrics_clear_cache_confirm_prompt-n": "The lyrics cache currently has %1 entries.\nDo you really want to delete them?", - "lyrics_clear_cache_success": "Lyrics cache was cleared successfully.", - "lyrics_cache_changed_clear_confirm": "You have changed settings that affect the data in the lyrics cache, which breaks lyrics URL lookups.\nDo you want to clear the cache now?", + "open_current_lyrics": "Open the current song's lyrics in a new tab - shift-click to open in a new window or ctrl-click to open manual search", + "open_lyrics": "Open this song's lyrics in a new tab - ctrl-click to open manual search", + "open_lyrics_search_prompt": "Enter the song title and artist to search for the lyrics:", + "lyrics_loading": "Loading lyrics URL...", + "lyrics_rate_limited-1": "You are being rate limited.\nPlease wait a few seconds before requesting more lyrics.", + "lyrics_rate_limited-n": "You are being rate limited.\nPlease wait %1 seconds before requesting more lyrics.", + "lyrics_not_found_confirm_open_search": "Couldn't find a lyrics page for this song.\nDo you want to open genius.com to manually search for it?", + "lyrics_not_found_click_open_search": "Couldn't find lyrics URL - click to open the manual lyrics search", + "lyrics_clear_cache_confirm_prompt-1": "The lyrics cache currently has %1 entry.\nDo you really want to delete them?", + "lyrics_clear_cache_confirm_prompt-n": "The lyrics cache currently has %1 entries.\nDo you really want to delete them?", + "lyrics_clear_cache_success": "Lyrics cache was cleared successfully.", + "lyrics_cache_changed_clear_confirm": "You have changed settings that affect the data in the lyrics cache, which breaks lyrics URL lookups.\nDo you want to clear the cache now?", - "hotkey_input_click_to_change": "Click to change", - "hotkey_input_click_to_change_tooltip": "%1 - Currently set to: %2 - Enter any key combination to change. Note: some screen readers might block certain key combinations.", - "hotkey_input_click_to_reset_tooltip": "Reset to the last saved key combination", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Shift", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "No hotkey selected", + "hotkey_input_click_to_change": "Click to change", + "hotkey_input_click_to_change_tooltip": "%1 - Currently set to: %2 - Enter any key combination to change. Note: some screen readers might block certain key combinations.", + "hotkey_input_click_to_reset_tooltip": "Reset to the last saved key combination", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Shift", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "No hotkey selected", - "welcome_menu_title": "Welcome to %1!", - "config_menu": "Config Menu", - "open_config_menu_tooltip": "Click to open the configuration menu", - "open_changelog": "Changelog", - "open_changelog_tooltip": "Click to open the changelog", - "feature_help_button_tooltip": "Click to get more information about the following feature: \"%1\"", - "welcome_text_line_1": "Thank you for installing!", - "welcome_text_line_2": "I hope you enjoy using %1 as much as I enjoyed making it 😃", - "welcome_text_line_3": "If you like %1, please leave a rating on %2GreasyFork%3 or %4OpenUserJS%5", - "welcome_text_line_4": "My work relies on donations so please consider %1donating ❤️%2", - "welcome_text_line_5": "Found a bug or want to suggest a feature? Please %1open an issue on GitHub%2", + "welcome_menu_title": "Welcome to %1!", + "config_menu": "Config Menu", + "open_config_menu_tooltip": "Click to open the configuration menu", + "open_changelog": "Changelog", + "open_changelog_tooltip": "Click to open the changelog", + "feature_help_button_tooltip": "Click to get more information about the following feature: \"%1\"", + "welcome_text_line_1": "Thank you for installing!", + "welcome_text_line_2": "I hope you enjoy using %1 as much as I enjoyed making it 😃", + "welcome_text_line_3": "If you like %1, please leave a rating on %2GreasyFork%3 or %4OpenUserJS%5", + "welcome_text_line_4": "My work relies on donations so please consider %1donating ❤️%2", + "welcome_text_line_5": "Found a bug or want to suggest a feature? Please %1open an issue on GitHub%2", - "list_button_placement_queue_only": "Currently playing queue only", - "list_button_placement_everywhere": "In every song list", + "list_button_placement_queue_only": "Currently playing queue only", + "list_button_placement_everywhere": "In every song list", - "site_selection_both_sites": "Both sites", - "site_selection_only_yt": "Only YouTube", - "site_selection_only_ytm": "Only YouTube Music", - "site_selection_none": "None (disabled)", + "site_selection_both_sites": "Both sites", + "site_selection_only_yt": "Only YouTube", + "site_selection_only_ytm": "Only YouTube Music", + "site_selection_none": "None (disabled)", - "new_version_available": "A new version of %1 is available!\nYou currently have version %2 installed and you can update to version %3", - "open_update_page_install_manually": "Install on %1", - "disable_update_check": "Disable automatic update checks", - "reenable_in_config_menu": "(can be re-enabled in the configuration menu)", - "close_and_ignore_for_24h": "Close and ignore for 24h", - "close_and_ignore_until_reenabled": "Close and ignore until re-enabled", - "expand_release_notes": "Click to expand the latest release notes", - "collapse_release_notes": "Click to collapse the latest release notes", - "no_updates_found": "No updates found.", + "new_version_available": "A new version of %1 is available!\nYou currently have version %2 installed and you can update to version %3", + "open_update_page_install_manually": "Install on %1", + "disable_update_check": "Disable automatic update checks", + "reenable_in_config_menu": "(can be re-enabled in the configuration menu)", + "close_and_ignore_for_24h": "Close and ignore for 24h", + "close_and_ignore_until_reenabled": "Close and ignore until re-enabled", + "expand_release_notes": "Click to expand the latest release notes", + "collapse_release_notes": "Click to collapse the latest release notes", + "no_new_version_found": "No new version found.", - "thumbnail_overlay_behavior_never": "Never", - "thumbnail_overlay_behavior_videos_only": "Only for videos", - "thumbnail_overlay_behavior_songs_only": "Only for songs", - "thumbnail_overlay_behavior_always": "Always", - "thumbnail_overlay_toggle_btn_tooltip_hide": "Disable the thumbnail overlay - middle-click or shift-click to open in a new tab", - "thumbnail_overlay_toggle_btn_tooltip_show": "Enable the thumbnail overlay - middle-click or shift-click to open in a new tab", - "thumbnail_overlay_indicator_tooltip": "The thumbnail overlay is currently active", - "thumbnail_overlay_image_fit_crop": "Crop if necessary", - "thumbnail_overlay_image_fit_full": "Show full image", - "thumbnail_overlay_image_fit_stretch": "Stretch to fit", + "thumbnail_overlay_behavior_never": "Never", + "thumbnail_overlay_behavior_videos_only": "Only for videos", + "thumbnail_overlay_behavior_songs_only": "Only for songs", + "thumbnail_overlay_behavior_always": "Always", + "thumbnail_overlay_toggle_btn_tooltip_hide": "Disable the thumbnail overlay - middle-click or shift-click to open in a new tab", + "thumbnail_overlay_toggle_btn_tooltip_show": "Enable the thumbnail overlay - middle-click or shift-click to open in a new tab", + "thumbnail_overlay_indicator_tooltip": "The thumbnail overlay is currently active", + "thumbnail_overlay_image_fit_crop": "Crop if necessary", + "thumbnail_overlay_image_fit_full": "Show full image", + "thumbnail_overlay_image_fit_stretch": "Stretch to fit", - "auto_like_channels_dialog_title": "Auto-liked Channels", - "auto_like_channels_dialog_desc": "Here you can see what channels you have set to auto-like and you can edit, enable, disable and remove them.\nYou can also manually create entries, though it's easier to just visit the channel page and click the button there.", - "auto_like": "Auto-like", - "auto_like_button_tooltip_enabled": "Click to disable auto-liking. Shift-click to open the management dialog.", - "auto_like_button_tooltip_disabled": "Click to enable auto-liking. Shift-click to open the management dialog.", - "auto_like_channel_toggle_tooltip": "Toggle auto-liking for the channel %1", - "add_auto_like_channel_id_prompt": "Enter the user ID (@Name / UC...) or full URL of the channel you want to auto-like.\nPress \"cancel\" to exit.", - "add_auto_like_channel_invalid_id": "The entered user ID is invalid.\nPlease make sure you copy the entire channel URL! It should contain a part like \"channel/UC...\" or \"/@...\"", - "add_auto_like_channel_already_exists_prompt_new_name": "A channel with that ID is already in the list.\nDo you want to change the name?", - "add_auto_like_channel_name_prompt": "Enter the name of the channel.\nPress \"cancel\" to exit.", - "auto_like_channel_edit_name_prompt": "Enter the new name for this channel.\nPress \"cancel\" to exit.", - "auto_like_channel_edit_id_prompt": "Enter the new user ID (@Name / UC...) or full URL for this channel.\nPress \"cancel\" to exit.", - "auto_like_enabled_toast": "Auto-liking enabled", - "auto_like_disabled_toast": "Auto-liking disabled", - "auto_liked_a_channels_song": "Liked song by %1", - "auto_liked_a_channels_video": "Liked video by %1", - "auto_like_click_to_configure": "Click to configure", - "auto_like_export_or_import_tooltip": "Export or import your auto-liked channels", - "auto_like_export_import_title": "Export or Import Auto-liked Channels", - "auto_like_export_desc": "Copy the following text to export your auto-liked channels.", - "auto_like_import_desc": "Paste the auto-liked channels you want to import into the field below, then click the import button:", + "auto_like_channels_dialog_title": "Auto-liked Channels", + "auto_like_channels_dialog_desc": "Here you can see what channels you have set to auto-like and you can edit, enable, disable and remove them.\nYou can also manually create entries, though it's easier to just visit the channel page and click the button there.", + "auto_like": "Auto-like", + "auto_like_button_tooltip_enabled": "Click to disable auto-liking. Shift-click to open the management dialog.", + "auto_like_button_tooltip_disabled": "Click to enable auto-liking. Shift-click to open the management dialog.", + "auto_like_channel_toggle_tooltip": "Toggle auto-liking for the channel %1", + "add_auto_like_channel_id_prompt": "Enter the user ID (@Name / UC...) or full URL of the channel you want to auto-like.\nPress \"cancel\" to exit.", + "add_auto_like_channel_invalid_id": "The entered user ID is invalid.\nPlease make sure you copy the entire channel URL! It should contain a part like \"channel/UC...\" or \"/@...\"", + "add_auto_like_channel_already_exists_prompt_new_name": "A channel with that ID is already in the list.\nDo you want to change the name?", + "add_auto_like_channel_name_prompt": "Enter the name of the channel.\nPress \"cancel\" to exit.", + "auto_like_channel_edit_name_prompt": "Enter the new name for this channel.\nPress \"cancel\" to exit.", + "auto_like_channel_edit_id_prompt": "Enter the new user ID (@Name / UC...) or full URL for this channel.\nPress \"cancel\" to exit.", + "auto_like_enabled_toast": "Auto-liking enabled", + "auto_like_disabled_toast": "Auto-liking disabled", + "auto_liked_a_channels_song": "Liked song by %1", + "auto_liked_a_channels_video": "Liked video by %1", + "auto_like_click_to_configure": "Click to configure", + "auto_like_export_or_import_tooltip": "Export or import your auto-liked channels", + "auto_like_export_import_title": "Export or Import Auto-liked Channels", + "auto_like_export_desc": "Copy the following text to export your auto-liked channels.", + "auto_like_import_desc": "Paste the auto-liked channels you want to import into the field below, then click the import button:", - "prompt_confirm": "Confirm", - "prompt_close": "Close", - "prompt_cancel": "Cancel", + "prompt_confirm": "Confirm", + "prompt_close": "Close", + "prompt_cancel": "Cancel", - "click_to_confirm_tooltip": "Click to confirm", - "click_to_close_tooltip": "Click to close", - "click_to_cancel_tooltip": "Click to cancel", + "click_to_confirm_tooltip": "Click to confirm", + "click_to_close_tooltip": "Click to close", + "click_to_cancel_tooltip": "Click to cancel", - "vote_label_likes-1": "%1 like", - "vote_label_likes-n": "%1 likes", - "vote_label_dislikes-1": "%1 dislike", - "vote_label_dislikes-n": "%1 dislikes", + "vote_label_likes-1": "%1 like", + "vote_label_likes-n": "%1 likes", + "vote_label_dislikes-1": "%1 dislike", + "vote_label_dislikes-n": "%1 dislikes", - "vote_ratio_disabled": "Disabled", - "vote_ratio_green_red": "Green and red", - "vote_ratio_blue_gray": "Blue and gray", + "vote_ratio_disabled": "Disabled", + "vote_ratio_green_red": "Green and red", + "vote_ratio_blue_gray": "Blue and gray", - "votes_format_short": "Short", - "votes_format_long": "Long", + "votes_format_short": "Short", + "votes_format_long": "Long", - "unit_entries-1": "entry", - "unit_entries-n": "entries", + "unit_entries-1": "entry", + "unit_entries-n": "entries", - "unit_days-1": "day", - "unit_days-n": "days", + "unit_days-1": "day", + "unit_days-n": "days", - "color_lightness_darker": "Darker", - "color_lightness_normal": "Normal", - "color_lightness_lighter": "Lighter", + "color_lightness_darker": "Darker", + "color_lightness_normal": "Normal", + "color_lightness_lighter": "Lighter", - "plugin_list_title": "Plugin List", - "plugin_list_no_plugins": "No plugins are currently installed.\nVisit %1this page%2 for more information.", - "plugin_list_no_plugins_tooltip": "No plugins are currently installed.", - "plugin_list_permissions_header": "Permissions:", + "plugin_list_title": "Plugin List", + "plugin_list_no_plugins": "No plugins are currently installed.\nVisit %1this page%2 for more information.", + "plugin_list_no_plugins_tooltip": "No plugins are currently installed.", + "plugin_list_permissions_header": "Permissions:", - "plugin_link_type_source": "Repository", - "plugin_link_type_other": "Other / Homepage", - "plugin_link_type_bug": "Report a bug", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "Repository", + "plugin_link_type_other": "Other / Homepage", + "plugin_link_type_bug": "Report a bug", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "This plugin can read the feature configuration", - "plugin_intent_description_WriteFeatureConfig": "This plugin can write to the feature configuration", - "plugin_intent_description_SeeHiddenConfigValues": "This plugin has access to hidden config values", - "plugin_intent_description_WriteLyricsCache": "This plugin can write to the lyrics cache", - "plugin_intent_description_WriteTranslations": "This plugin can add new translations and overwrite existing ones", - "plugin_intent_description_CreateModalDialogs": "This plugin can create modal dialogs", - "plugin_intent_description_ReadAutoLikeData": "This plugin can read auto-like data", - "plugin_intent_description_WriteAutoLikeData": "This plugin can write to auto-like data", + "plugin_intent_name_ReadFeatureConfig": "Read Feature Config", + "plugin_intent_description_ReadFeatureConfig": "This plugin can read the feature configuration", + "plugin_intent_name_WriteFeatureConfig": "Write Feature Config", + "plugin_intent_description_WriteFeatureConfig": "This plugin can write to the feature configuration", + "plugin_intent_name_SeeHiddenConfigValues": "See Hidden Config Values", + "plugin_intent_description_SeeHiddenConfigValues": "This plugin has access to hidden config values", + "plugin_intent_name_WriteLyricsCache": "Write Lyrics Cache", + "plugin_intent_description_WriteLyricsCache": "This plugin can write to the lyrics cache", + "plugin_intent_name_WriteTranslations": "Write Translations", + "plugin_intent_description_WriteTranslations": "This plugin can add new translations and overwrite existing ones", + "plugin_intent_name_CreateModalDialogs": "Create Modal Dialogs", + "plugin_intent_description_CreateModalDialogs": "This plugin can create modal dialogs", + "plugin_intent_name_ReadAutoLikeData": "Read Auto-Like Data", + "plugin_intent_description_ReadAutoLikeData": "This plugin can read auto-like data", + "plugin_intent_name_WriteAutoLikeData": "Write Auto-Like Data", + "plugin_intent_description_WriteAutoLikeData": "This plugin can write to auto-like data", - "plugin_validation_error_no_property": "No property '%1' with type '%2'", - "plugin_validation_error_invalid_property-1": "Property '%1' with value '%2' is invalid. Example value: %3", - "plugin_validation_error_invalid_property-n": "Property '%1' with value '%2' is invalid. Example values: %3", + "plugin_validation_error_no_property": "No property '%1' with type '%2'", + "plugin_validation_error_invalid_property-1": "Property '%1' with value '%2' is invalid. Example value: %3", + "plugin_validation_error_invalid_property-n": "Property '%1' with value '%2' is invalid. Example values: %3", - "feature_category_layout": "Layout", - "feature_category_volume": "Volume", - "feature_category_songLists": "Song Lists", - "feature_category_behavior": "Behavior", - "feature_category_input": "Input", - "feature_category_lyrics": "Lyrics", - "feature_category_integrations": "Integrations", - "feature_category_plugins": "Plugins", - "feature_category_general": "General", + "feature_category_layout": "Layout", + "feature_category_volume": "Volume", + "feature_category_songLists": "Song Lists", + "feature_category_behavior": "Behavior", + "feature_category_input": "Input", + "feature_category_lyrics": "Lyrics", + "feature_category_integrations": "Integrations", + "feature_category_plugins": "Plugins", + "feature_category_general": "General", - "feature_desc_watermarkEnabled": "Show a watermark under the site logo that opens this config menu", - "feature_helptext_watermarkEnabled": "If this is disabled, you can still open the config menu by clicking the option in the menu that opens when you click your profile picture in the top right corner.\nHowever it will be harder to find the easter egg ;)", - "feature_desc_removeShareTrackingParam": "Remove the tracking parameter \"?si\" from the URL in share menus", - "feature_helptext_removeShareTrackingParam": "For analytics purposes YouTube adds a tracking parameter to the end of the URL you can copy in the share menu. While not directly harmful, it makes the URL longer and gives YouTube more information about you and the people you send the link to.", - "feature_desc_removeShareTrackingParamSites": "On which sites should the share tracking parameter be removed?", - "feature_desc_numKeysSkipToTime": "Enable skipping to a specific time in the video by pressing a number key (0-9)", - "feature_desc_fixSpacing": "Fix spacing issues in the layout", - "feature_helptext_fixSpacing": "There are various locations in the user interface where the spacing between elements is inconsistent. This feature fixes those issues.", - "feature_desc_thumbnailOverlayBehavior": "When to automatically replace the video element with its thumbnail in the highest resolution", - "feature_helptext_thumbnailOverlayBehavior": "The thumbnail will be shown over top of the currently playing video or song as an overlay.\nThis means you will not save any bandwidth as the video will still be loaded and played in the background!", - "feature_desc_thumbnailOverlayToggleBtnShown": "Add a button to the media controls to manually toggle the thumbnail", - "feature_helptext_thumbnailOverlayToggleBtnShown": "This button will allow you to manually toggle the thumbnail on and off. This is not affected if the thumbnail replacement option is set to \"never\".\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.", - "feature_desc_thumbnailOverlayShowIndicator": "Show an indicator in the bottom right corner of the thumbnail while it's active?", - "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacity of the thumbnail indicator", - "feature_desc_thumbnailOverlayImageFit": "How to fit the thumbnail image over the video element", - "feature_desc_hideCursorOnIdle": "Hide the cursor after a few seconds of inactivity over the video", - "feature_desc_hideCursorOnIdleDelay": "How many seconds of inactivity before the cursor should be hidden?", - "feature_desc_fixHdrIssues": "Prevent some rendering issues when using an HDR-compatible GPU and monitor", - "feature_desc_showVotes": "Show the amount of likes and dislikes on the currently playing song", - "feature_helptext_showVotes": "This feature is powered by Return YouTube Dislike and will show the approximate amount of likes and dislikes on the currently playing song.", - "feature_desc_numbersFormat": "How should numbers be formatted?", + "feature_desc_watermarkEnabled": "Show a watermark under the site logo that opens this config menu", + "feature_helptext_watermarkEnabled": "If this is disabled, you can still open the config menu by clicking the option in the menu that opens when you click your profile picture in the top right corner.\nHowever it will be harder to find the easter egg ;)", + "feature_desc_removeShareTrackingParam": "Remove the tracking parameter \"?si\" from the URL in share menus", + "feature_helptext_removeShareTrackingParam": "For analytics purposes YouTube adds a tracking parameter to the end of the URL you can copy in the share menu. While not directly harmful, it makes the URL longer and gives YouTube more information about you and the people you send the link to.", + "feature_desc_removeShareTrackingParamSites": "On which sites should the share tracking parameter be removed?", + "feature_desc_numKeysSkipToTime": "Enable skipping to a specific time in the video by pressing a number key (0-9)", + "feature_desc_fixSpacing": "Fix spacing issues in the layout", + "feature_helptext_fixSpacing": "There are various locations in the user interface where the spacing between elements is inconsistent. This feature fixes those issues.", + "feature_desc_thumbnailOverlayBehavior": "When to automatically replace the video element with its thumbnail in the highest resolution", + "feature_helptext_thumbnailOverlayBehavior": "The thumbnail will be shown over top of the currently playing video or song as an overlay.\nThis means you will not save any bandwidth as the video will still be loaded and played in the background!", + "feature_desc_thumbnailOverlayToggleBtnShown": "Add a button to the media controls to manually toggle the thumbnail", + "feature_helptext_thumbnailOverlayToggleBtnShown": "This button will allow you to manually toggle the thumbnail on and off. This is not affected if the thumbnail replacement option is set to \"never\".\nOnce a new video or song starts playing, the default state will be restored.\nHold shift while clicking or press the middle mouse button to open the thumbnail of the highest quality in a new tab.", + "feature_desc_thumbnailOverlayShowIndicator": "Show an indicator in the bottom right corner of the thumbnail while it's active?", + "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacity of the thumbnail indicator", + "feature_desc_thumbnailOverlayImageFit": "How to fit the thumbnail image over the video element", + "feature_desc_hideCursorOnIdle": "Hide the cursor after a few seconds of inactivity over the video", + "feature_desc_hideCursorOnIdleDelay": "How many seconds of inactivity before the cursor should be hidden?", + "feature_desc_fixHdrIssues": "Prevent some rendering issues when using an HDR-compatible GPU and monitor", + "feature_desc_showVotes": "Show the amount of likes and dislikes on the currently playing song", + "feature_helptext_showVotes": "This feature is powered by Return YouTube Dislike and will show the approximate amount of likes and dislikes on the currently playing song.", + "feature_desc_numbersFormat": "How should numbers be formatted?", - "feature_desc_volumeSliderLabel": "Add a percentage label next to the volume slider", - "feature_desc_volumeSliderSize": "The width of the volume slider in pixels", - "feature_desc_volumeSliderStep": "Volume slider sensitivity (by how little percent the volume can be changed at a time)", - "feature_desc_volumeSliderScrollStep": "Volume slider scroll wheel sensitivity in percent - snaps to the nearest sensitivity value from above", - "feature_helptext_volumeSliderScrollStep": "By how much percent the volume should be changed when scrolling the volume slider with the mouse wheel.\nThis should be a multiple of the volume slider sensitivity, otherwise there will be small irregular jumps in the volume when scrolling.", - "feature_desc_volumeSharedBetweenTabs": "Should the set volume be shared between tabs and remembered between sessions?", - "feature_helptext_volumeSharedBetweenTabs": "If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.", - "feature_desc_setInitialTabVolume": "Sets the volume level to a specific value once when opening the site", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "This feature is incompatible with the \"Volume level shared between tabs\" feature and will be ignored while using the shared volume feature!", - "feature_desc_initialTabVolumeLevel": "The value to set the volume level to when opening the site", + "feature_desc_volumeSliderLabel": "Add a percentage label next to the volume slider", + "feature_desc_volumeSliderSize": "The width of the volume slider in pixels", + "feature_desc_volumeSliderStep": "Volume slider sensitivity (by how little percent the volume can be changed at a time)", + "feature_desc_volumeSliderScrollStep": "Volume slider scroll wheel sensitivity in percent - snaps to the nearest sensitivity value from above", + "feature_helptext_volumeSliderScrollStep": "By how much percent the volume should be changed when scrolling the volume slider with the mouse wheel.\nThis should be a multiple of the volume slider sensitivity, otherwise there will be small irregular jumps in the volume when scrolling.", + "feature_desc_volumeSharedBetweenTabs": "Should the set volume be shared between tabs and remembered between sessions?", + "feature_helptext_volumeSharedBetweenTabs": "If you change the volume in one tab, the volume level will be set to the same value in all other tabs that have this feature enabled.\nThis value will be remembered and restored across sessions, until disabled.", + "feature_desc_setInitialTabVolume": "Sets the volume level to a specific value once when opening the site", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "This feature is incompatible with the \"Volume level shared between tabs\" feature and will be ignored while using the shared volume feature!", + "feature_desc_initialTabVolumeLevel": "The value to set the volume level to when opening the site", - "feature_desc_lyricsQueueButton": "Add a button to each song in a list to open its lyrics page", - "feature_desc_deleteFromQueueButton": "Add a button to each song in a list to quickly remove it", - "feature_desc_listButtonsPlacement": "Where should the list buttons show up?", - "feature_helptext_listButtonsPlacement": "There are various song lists on the site like album pages, playlists and the currently playing queue.\nWith this option you can choose where the list buttons should show up.", - "feature_desc_scrollToActiveSongBtn": "Add a button above the queue to scroll to the currently playing song", - "feature_desc_clearQueueBtn": "Add a button above the currently playing queue or playlist to quickly clear it", + "feature_desc_lyricsQueueButton": "Add a button to each song in a list to open its lyrics page", + "feature_desc_deleteFromQueueButton": "Add a button to each song in a list to quickly remove it", + "feature_desc_listButtonsPlacement": "Where should the list buttons show up?", + "feature_helptext_listButtonsPlacement": "There are various song lists on the site like album pages, playlists and the currently playing queue.\nWith this option you can choose where the list buttons should show up.", + "feature_desc_scrollToActiveSongBtn": "Add a button above the queue to scroll to the currently playing song", + "feature_desc_clearQueueBtn": "Add a button above the currently playing queue or playlist to quickly clear it", - "feature_desc_disableBeforeUnloadPopup": "Prevent the confirmation popup that appears when trying to leave the site while a song is playing", - "feature_helptext_disableBeforeUnloadPopup": "When trying to leave the site while a few seconds into a song that is actively playing, a popup will appear asking you to confirm that you want to leave the site. It might say something along the lines of \"you have unsaved data\" or \"this site is asking if you want to close it\".\nThis feature disables that popup entirely.", - "feature_desc_closeToastsTimeout": "After how many seconds to close permanent notifications - 0 to only close them manually (default behavior)", - "feature_helptext_closeToastsTimeout": "Most popups that appear in the bottom left corner will close automatically after 3 seconds with the exception of certain ones like when liking a song.\nThis feature allows you to set a time for those permanent popups to be closed.\nThe other kind of popups will stay unaffected.\nSet this to 0 for the default behavior of not closing permanent notifications.", - "feature_desc_rememberSongTime": "Remember the last song's time when reloading or restoring the tab", - "feature_helptext_rememberSongTime-1": "Sometimes when reloading the page or restoring it after accidentally closing it, you want to resume listening at the same point. This feature allows you to do that.\nIn order to record the song's time, you need to play it for %1 second, then its time will be remembered and be restorable for a short while.", - "feature_helptext_rememberSongTime-n": "Sometimes when reloading the page or restoring it after accidentally closing it, you want to resume listening at the same point. This feature allows you to do that.\nIn order to record the song's time, you need to play it for %1 seconds, then its time will be remembered and be restorable for a short while.", - "feature_desc_rememberSongTimeSites": "On which sites should the song time be remembered and restored?", - "feature_desc_rememberSongTimeDuration": "How long in seconds to remember the song's time for after it was last played", - "feature_desc_rememberSongTimeReduction": "How many seconds to subtract when restoring the time of a remembered song", - "feature_helptext_rememberSongTimeReduction": "When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you have more context when listening to the part that was interrupted again.", - "feature_desc_rememberSongTimeMinPlayTime": "Minimum amount of seconds a song needs to be played for its time to be remembered", + "feature_desc_disableBeforeUnloadPopup": "Prevent the confirmation popup that appears when trying to leave the site while a song is playing", + "feature_helptext_disableBeforeUnloadPopup": "When trying to leave the site while a few seconds into a song that is actively playing, a popup will appear asking you to confirm that you want to leave the site. It might say something along the lines of \"you have unsaved data\" or \"this site is asking if you want to close it\".\nThis feature disables that popup entirely.", + "feature_desc_closeToastsTimeout": "After how many seconds to close permanent notifications - 0 to only close them manually (default behavior)", + "feature_helptext_closeToastsTimeout": "Most popups that appear in the bottom left corner will close automatically after 3 seconds with the exception of certain ones like when liking a song.\nThis feature allows you to set a time for those permanent popups to be closed.\nThe other kind of popups will stay unaffected.\nSet this to 0 for the default behavior of not closing permanent notifications.", + "feature_desc_rememberSongTime": "Remember the last song's time when reloading or restoring the tab", + "feature_helptext_rememberSongTime-1": "Sometimes when reloading the page or restoring it after accidentally closing it, you want to resume listening at the same point. This feature allows you to do that.\nIn order to record the song's time, you need to play it for %1 second, then its time will be remembered and be restorable for a short while.", + "feature_helptext_rememberSongTime-n": "Sometimes when reloading the page or restoring it after accidentally closing it, you want to resume listening at the same point. This feature allows you to do that.\nIn order to record the song's time, you need to play it for %1 seconds, then its time will be remembered and be restorable for a short while.", + "feature_desc_rememberSongTimeSites": "On which sites should the song time be remembered and restored?", + "feature_desc_rememberSongTimeDuration": "How long in seconds to remember the song's time for after it was last played", + "feature_desc_rememberSongTimeReduction": "How many seconds to subtract when restoring the time of a remembered song", + "feature_helptext_rememberSongTimeReduction": "When restoring the time of a song that was remembered, this amount of seconds will be subtracted from the remembered time so you have more context when listening to the part that was interrupted again.", + "feature_desc_rememberSongTimeMinPlayTime": "Minimum amount of seconds a song needs to be played for its time to be remembered", + "feature_desc_aboveQueueBtnsSticky": "Always position the element containing the buttons above the queue at the top of the list", - "feature_desc_arrowKeySupport": "Use arrow keys to skip forwards and backwards in the currently playing song", - "feature_helptext_arrowKeySupport": "Normally you can only skip forwards and backwards by a fixed 10 second interval with the keys \"H\" and \"L\". This feature allows you to use the arrow keys too.\nTo change the amount of seconds to skip, use the option below.", - "feature_desc_arrowKeySkipBy": "By how many seconds to skip when using the arrow keys", - "feature_desc_switchBetweenSites": "Add a hotkey to switch between the YT and YTM sites on a video / song", - "feature_helptext_switchBetweenSites": "Pressing this hotkey will switch to the other site if you are on YouTube or YouTube Music while staying on the same video / song.", - "feature_desc_switchSitesHotkey": "Which hotkey needs to be pressed to switch sites?", - "feature_desc_anchorImprovements": "Add and improve links all over the page so things can be opened in a new tab easier", - "feature_helptext_anchorImprovements": "Some elements on the page are only clickable with the left mouse button, which means you can't open them in a new tab by middle-clicking or through the context menu using shift + right-click. This feature adds links to a lot of them or enlarges existing ones to make clicking easier.", - "feature_desc_autoLikeChannels": "Automatically like all songs and videos of certain channels", - "feature_helpText_autoLikeChannels": "Once enabled, you can enable this feature for certain channels by opening their page and clicking the toggle button. Afterwards, any song you play of that channel will be liked automatically.\nUse the option below to open a dialog to manage the channels.", - "feature_desc_autoLikeChannelToggleBtn": "Add a button to each channel page to enable or disable auto-liking", - "feature_desc_autoLikePlayerBarToggleBtn": "Add a button to the media controls to enable or disable auto-liking", - "feature_desc_autoLikeTimeout": "How many seconds a song needs to play before being auto-liked", - "feature_desc_autoLikeShowToast": "Show a toast notification when a song is auto-liked", - "feature_desc_autoLikeOpenMgmtDialog": "Open the dialog to manage auto-liked channels", - "feature_btn_autoLikeOpenMgmtDialog": "Open dialog", - "feature_btn_autoLikeOpenMgmtDialog_running": "Opening...", + "feature_desc_arrowKeySupport": "Use arrow keys to skip forwards and backwards in the currently playing song", + "feature_helptext_arrowKeySupport": "Normally you can only skip forwards and backwards by a fixed 10 second interval with the keys \"H\" and \"L\". This feature allows you to use the arrow keys too.\nTo change the amount of seconds to skip, use the option below.", + "feature_desc_arrowKeySkipBy": "By how many seconds to skip when using the arrow keys", + "feature_desc_switchBetweenSites": "Add a hotkey to switch between the YT and YTM sites on a video / song", + "feature_helptext_switchBetweenSites": "Pressing this hotkey will switch to the other site if you are on YouTube or YouTube Music while staying on the same video / song.", + "feature_desc_switchSitesHotkey": "Which hotkey needs to be pressed to switch sites?", + "feature_desc_anchorImprovements": "Add and improve links all over the page so things can be opened in a new tab easier", + "feature_helptext_anchorImprovements": "Some elements on the page are only clickable with the left mouse button, which means you can't open them in a new tab by middle-clicking or through the context menu using shift + right-click. This feature adds links to a lot of them or enlarges existing ones to make clicking easier.", + "feature_desc_autoLikeChannels": "Automatically like all songs and videos of certain channels", + "feature_helpText_autoLikeChannels": "Once enabled, you can enable this feature for certain channels by opening their page and clicking the toggle button. Afterwards, any song you play of that channel will be liked automatically.\nUse the option below to open a dialog to manage the channels.", + "feature_desc_autoLikeChannelToggleBtn": "Add a button to each channel page to enable or disable auto-liking", + "feature_desc_autoLikePlayerBarToggleBtn": "Add a button to the media controls to enable or disable auto-liking", + "feature_desc_autoLikeTimeout": "How many seconds a song needs to play before being auto-liked", + "feature_desc_autoLikeShowToast": "Show a toast notification when a song is auto-liked", + "feature_desc_autoLikeOpenMgmtDialog": "Open the dialog to manage auto-liked channels", + "feature_btn_autoLikeOpenMgmtDialog": "Open dialog", + "feature_btn_autoLikeOpenMgmtDialog_running": "Opening...", - "feature_desc_geniusLyrics": "Add a button to the media controls of the currently playing song to open its lyrics on genius.com", - "feature_desc_errorOnLyricsNotFound": "Show an error when the lyrics page for the currently playing song couldn't be found", - "feature_desc_geniUrlBase": "Base URL of your geniURL instance, see https://github.com/Sv443/geniURL", - "feature_helptext_geniUrlBase": "If you have your own instance of geniURL running (for example to bypass rate limiting), you can enter its base URL here to use it for the genius.com lyrics button.\nIf you don't know what this is, you can leave this option as is.", - "feature_desc_geniUrlToken": "Auth token for your geniURL instance", - "feature_helptext_geniUrlToken": "To bypass rate limiting, you can provide an auth token that is also defined in the .env file of your geniURL instance.", - "feature_desc_lyricsCacheMaxSize": "Maximum amount of lyrics to keep in the cache", - "feature_helptext_lyricsCacheMaxSize": "The lyrics of songs you listen to are stored in a cache to reduce the amount of requests to the lyrics provider.\nThis feature allows you to set the maximum amount of lyrics to keep in the cache.\nWhen the limit is reached, the oldest entry will be removed to make space for any new ones.", - "feature_desc_lyricsCacheTTL": "Max amount of days to keep a lyrics entry in the cache", - "feature_helptext_lyricsCacheTTL": "The cache that lyrics are stored in will automatically delete them after this amount of time to ensure that updates to the source are being fetched sooner or later.\nIf you wanna make sure that you always have the most recent lyrics, set this to a low value like 4 days.", - "feature_desc_clearLyricsCache": "Clear the lyrics cache manually", - "feature_helptext_clearLyricsCache": "If the lyrics that are in the local cache are outdated or you just want to free up some space, you can clear the cache manually by pressing this button.", - "feature_btn_clearLyricsCache": "Clear cache", - "feature_btn_clearLyricsCache_running": "Clearing...", - "feature_desc_advancedLyricsFilter": "Experimental: Enable advanced filtering for the lyrics search", - "feature_helptext_advancedLyricsFilter": "Advanced filtering includes multiple layers of filters that aim to make the lyrics lookups more reliable.\nThese filters may not work well for songs in your language, and songs and artists with less unique titles in general.\nWarning: This feature is still experimental and might not actually perform any better than the default lyrics lookup. Make sure to confirm the prompt that appears if you changed this setting.", + "feature_desc_geniusLyrics": "Add a button to the media controls of the currently playing song to open its lyrics on genius.com", + "feature_desc_errorOnLyricsNotFound": "Show an error when the lyrics page for the currently playing song couldn't be found", + "feature_desc_geniUrlBase": "Base URL of your geniURL instance, see https://github.com/Sv443/geniURL", + "feature_helptext_geniUrlBase": "If you have your own instance of geniURL running (for example to bypass rate limiting), you can enter its URL here to use it for the genius.com lyrics button.\nIf you don't know what this is, you can leave this option as is.", + "feature_desc_geniUrlToken": "Auth token for your geniURL instance", + "feature_helptext_geniUrlToken": "To bypass rate limiting, you can provide an auth token that is also defined in the .env file of your geniURL instance.", + "feature_desc_lyricsCacheMaxSize": "Maximum amount of lyrics to keep in the cache", + "feature_helptext_lyricsCacheMaxSize": "The lyrics of songs you listen to are stored in a cache to reduce the amount of requests to the lyrics provider.\nThis feature allows you to set the maximum amount of lyrics to keep in the cache.\nWhen the limit is reached, the oldest entry will be removed to make space for any new ones.", + "feature_desc_lyricsCacheTTL": "Max amount of days to keep a lyrics entry in the cache", + "feature_helptext_lyricsCacheTTL": "The cache that lyrics are stored in will automatically delete them after this amount of time to ensure that updates to the source are being fetched sooner or later.\nIf you wanna make sure that you always have the most recent lyrics, set this to a low value like 4 days.", + "feature_desc_clearLyricsCache": "Clear the lyrics cache manually", + "feature_helptext_clearLyricsCache": "If the lyrics that are in the local cache are outdated or you just want to free up some space, you can clear the cache manually by pressing this button.", + "feature_btn_clearLyricsCache": "Clear cache", + "feature_btn_clearLyricsCache_running": "Clearing...", + "feature_desc_advancedLyricsFilter": "Experimental: Enable advanced filtering for the lyrics search", + "feature_helptext_advancedLyricsFilter": "Advanced filtering includes multiple layers of filters that aim to make the lyrics lookups more reliable.\nThese filters may not work well for songs in your language, and songs and artists with less unique titles in general.\nWarning: This feature is still experimental and might not actually perform any better than the default lyrics lookup. Make sure to confirm the prompt that appears if you changed this setting.", - "feature_desc_disableDarkReaderSites": "On which sites should the Dark Reader extension be disabled to fix layout issues?", - "feature_helptext_disableDarkReaderSites": "The Dark Reader extension can cause issues with the layout of the site.\nThis feature allows you to disable Dark Reader on certain or all sites to prevent those issues.\n\nIf the extension is not installed, this feature will have no effect and can be left activated.", - "feature_desc_sponsorBlockIntegration": "Make some fixes to the layout in case SponsorBlock is installed", - "feature_helptext_sponsorBlockIntegration": "If you have the SponsorBlock extension installed, this feature will make some fixes to the layout of the site to prevent issues that might occur.\n\nThis feature will have no effect if the extension is not installed and can be left activated in that case.", - "feature_desc_themeSongIntegration": "Fix some styling issues if the ThemeSong extension is installed", - "feature_helptext_themeSongIntegration": "If the ThemeSong extension is installed but this feature is turned off (or vice versa), you might notice broken styling on the site.\n\nPlease always make sure this feature reflects whether the extension is installed or not!", - "feature_desc_themeSongLightness": "How light the accent colors should be that are derived from the current ThemeSong theme", - "feature_helptext_themeSongLightness": "Depending on the settings you chose for the ThemeSong extension, this feature allows you to adjust the lightness of the accent colors that are derived from the current theme.\n\nThis feature will have no effect if the ThemeSong extension is not installed.", + "feature_desc_disableDarkReaderSites": "On which sites should the Dark Reader extension be disabled to fix layout issues?", + "feature_helptext_disableDarkReaderSites": "The Dark Reader extension can cause issues with the layout of the site.\nThis feature allows you to disable Dark Reader on certain or all sites to prevent those issues.\n\nIf the extension is not installed, this feature will have no effect and can be left activated.", + "feature_desc_sponsorBlockIntegration": "Make some fixes to the layout in case SponsorBlock is installed", + "feature_helptext_sponsorBlockIntegration": "If you have the SponsorBlock extension installed, this feature will make some fixes to the layout of the site to prevent issues that might occur.\n\nThis feature will have no effect if the extension is not installed and can be left activated in that case.", + "feature_desc_themeSongIntegration": "Fix some styling issues if the ThemeSong extension is installed", + "feature_helptext_themeSongIntegration": "If the ThemeSong extension is installed but this feature is turned off (or vice versa), you might notice broken styling on the site.\n\nPlease always make sure this feature reflects whether the extension is installed or not!", + "feature_desc_themeSongLightness": "How light the accent colors should be that are derived from the current ThemeSong theme", + "feature_helptext_themeSongLightness": "Depending on the settings you chose for the ThemeSong extension, this feature allows you to adjust the lightness of the accent colors that are derived from the current theme.\n\nThis feature will have no effect if the ThemeSong extension is not installed.", - "feature_desc_openPluginList": "Open the list of plugins you have installed", - "feature_btn_openPluginList": "Open list", - "feature_btn_openPluginList_running": "Opening...", - "feature_desc_initTimeout": "How many seconds to wait for features to initialize before considering them to likely be in an errored state", - "feature_helptext_initTimeout": "This is the amount of time in seconds that the script will wait for features to initialize before considering them to likely be in an errored state.\nThis will not affect the script's behavior in a significant way, but if one of your plugins can't initialize in time, you should try increasing this value.", + "feature_desc_openPluginList": "Open the list of plugins you have installed", + "feature_btn_openPluginList": "Open list", + "feature_btn_openPluginList_running": "Opening...", + "feature_desc_initTimeout": "How many seconds to wait for features to initialize before considering them to likely be in an errored state", + "feature_helptext_initTimeout": "This is the amount of time in seconds that the script will wait for features to initialize before considering them to likely be in an errored state.\nThis will not affect the script's behavior in a significant way, but if one of your plugins can't initialize in time, you should try increasing this value.", - "feature_desc_locale": "Language", - "feature_desc_localeFallback": "Use English as a fallback for missing translations (disable if you are contributing translations)", - "feature_desc_versionCheck": "Check for updates every 24 hours", - "feature_helptext_versionCheck": "This feature checks for updates every 24 hours, notifies you if a new version is available and allows you to update the script manually.\nIf your userscript manager extension updates scripts automatically, you can disable this feature.", - "feature_desc_checkVersionNow": "Manually check for a new version", - "feature_btn_checkVersionNow": "Check now", - "feature_btn_checkVersionNow_running": "Checking...", - "feature_desc_logLevel": "How much information to log to the console", - "feature_helptext_logLevel": "Changing this is really only needed for debugging purposes as a result of experiencing a problem.\nShould you have one, you can increase the log level here, open your browser's JavaScript console (usually with Ctrl + Shift + K) and attach screenshots of that log in a GitHub issue.", - "feature_desc_toastDuration": "For how many seconds custom toast notifications should be shown - 0 to disable them entirely", - "feature_desc_showToastOnGenericError": "Show a notification when an error occurs?", - "feature_helptext_showToastOnGenericError": "Should an error occur in the script that prevents parts of it from working correctly, a notification will be shown to inform you about it.\nIf you encounter a problem often, please copy the error from the JavaScript console (usually in the F12 menu) and please open an issue on GitHub.", - "feature_desc_resetConfig": "Reset all settings to their default values", - "feature_btn_resetConfig": "Reset settings", - "feature_btn_resetConfig_running": "Resetting...", - "feature_desc_resetEverything": "Reset all stored data to the default values", - "feature_btn_resetEverything": "Reset everything", - "feature_btn_resetEverything_running": "Resetting...", - "feature_desc_advancedMode": "Show advanced settings (reloads the menu)", - "feature_helptext_advancedMode": "After enabling this, the menu will reload and show advanced settings that are hidden by default.\nThis is useful if you want to more deeply customize the script's behavior and don't care about an overcrowded menu." - } + "feature_desc_locale": "Language", + "feature_desc_localeFallback": "Use English as a fallback for missing translations (disable if you are contributing translations)", + "feature_desc_versionCheck": "Check for updates every 24 hours", + "feature_helptext_versionCheck": "This feature checks for updates every 24 hours, notifies you if a new version is available and allows you to update the script manually.\nIf your userscript manager extension updates scripts automatically, you can disable this feature.", + "feature_desc_checkVersionNow": "Manually check for a new version", + "feature_btn_checkVersionNow": "Check now", + "feature_btn_checkVersionNow_running": "Checking...", + "feature_desc_logLevel": "How much information to log to the console", + "feature_helptext_logLevel": "Changing this is really only needed for debugging purposes as a result of experiencing a problem.\nShould you have one, you can increase the log level here, open your browser's JavaScript console (usually with Ctrl + Shift + K) and attach screenshots of that log in a GitHub issue.", + "feature_desc_toastDuration": "For how many seconds custom toast notifications should be shown - 0 to disable them entirely", + "feature_desc_showToastOnGenericError": "Show a notification when an error occurs?", + "feature_helptext_showToastOnGenericError": "Should an error occur in the script that prevents parts of it from working correctly, a notification will be shown to inform you about it.\nIf you encounter a problem often, please copy the error from the JavaScript console (usually in the F12 menu) and please open an issue on GitHub.", + "feature_desc_resetConfig": "Reset all settings to their default values", + "feature_btn_resetConfig": "Reset settings", + "feature_btn_resetConfig_running": "Resetting...", + "feature_desc_resetEverything": "Reset all stored data to the default values", + "feature_btn_resetEverything": "Reset everything", + "feature_btn_resetEverything_running": "Resetting...", + "feature_desc_advancedMode": "Show advanced settings (reloads the menu)", + "feature_helptext_advancedMode": "After enabling this, the menu will reload and show advanced settings that are hidden by default.\nThis is useful if you want to more deeply customize the script's behavior and don't care about an overcrowded menu." } \ No newline at end of file diff --git a/assets/translations/es-ES.json b/assets/translations/es-ES.json index f69b097e57..ee57fad284 100644 --- a/assets/translations/es-ES.json +++ b/assets/translations/es-ES.json @@ -1,356 +1,362 @@ { - "translations": { - "config_menu_option": "Configuración de %1", - "config_menu_title": "%1 - Configuración", - "changelog_menu_title": "%1 - Registro de cambios", - "export_menu_title": "%1 - Exportar configuración", - "import_menu_title": "%1 - Importar configuración", - "open_menu_tooltip": "Abra el menú de configuración de %1", - "close_menu_tooltip": "Haga clic para cerrar el menú", - "reload_hint": "Recargue la página para aplicar los cambios", - "reload_now": "Recargar ahora", - "reload_tooltip": "Recargar la página", - "feature_requires_reload": "Cambiar esta función requiere una recarga de la página", - "version_tooltip": "Versión %1 (compilación %2) - haga clic para abrir el registro de cambios", - "bytm_config_export_import_title": "Exportar o importar configuración", - "bytm_config_import_desc": "Pegue la configuración que desea importar en el campo a continuación, luego haga clic en el botón de importación:", - "bytm_config_export_desc": "Copie el siguiente texto para exportar su configuración. Advertencia: puede contener datos sensibles.", - "export_import": "Exportar/Importar", - "export": "Exportar", - "export_hint": "Copie el siguiente texto para exportar su configuración.\nAdvertencia: puede contener datos sensibles.", - "click_to_reveal": "(haga clic para revelar)", - "click_to_reveal_sensitive_info": "(haga clic para revelar información sensible)", - "export_tooltip": "Exporte su configuración actual", - "import": "Importar", - "import_hint": "Pegue la configuración que desea importar en el campo a continuación, luego haga clic en el botón de importación:", - "import_tooltip": "Importe una configuración que haya exportado anteriormente", - "start_import_tooltip": "Haga clic para importar los datos que pegó arriba", - "import_error_invalid": "Los datos importados no son válidos", - "import_error_no_format_version": "Los datos importados no contienen una versión de formato", - "import_error_no_data": "El objeto importado no contiene ningún dato", - "import_error_wrong_format_version": "Los datos importados están en una versión de formato no compatible (se esperaba %1 o inferior pero se obtuvo %2)", - "import_success": "Datos importados correctamente", - "import_success_confirm_reload": "Los datos se importaron correctamente.\n¿Desea recargar la página ahora para aplicar los cambios?", - "reset_config_confirm": "¿Realmente quieres restablecer todos los ajustes a sus valores predeterminados?\nLa página se volverá a cargar automáticamente.", - "copy": "Copiar", - "copy_to_clipboard": "Copiar al portapapeles", - "copy_to_clipboard_error": "No se pudo copiar el texto al portapapeles. Cópielo manualmente desde aquí:\n%1", - "copy_config_tooltip": "Copie la configuración en su portapapeles", - "copied": "¡Copiado!", - "copied_to_clipboard": "¡Copiado al portapapeles!", - "copy_hidden": "Copiar oculto", - "copy_hidden_tooltip": "Haga clic para copiar el valor oculto: estos son datos sensibles ⚠️", - "open_github": "Abrir %1 en GitHub", - "open_discord": "Únete a mi servidor de Discord", - "open_greasyfork": "Abrir %1 en GreasyFork", - "open_openuserjs": "Abrir %1 en OpenUserJS", - "lang_changed_prompt_reload": "El idioma se cambió.\n¿Quieres recargar la página ahora para aplicar los cambios?", - "search_placeholder": "Buscar...", - "search_clear": "Borrar búsqueda", + "meta": { + "langName": "Español", + "langNameEnglish": "Spanish (Spain)", + "countryName": "España", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "Configuración de %1", + "config_menu_title": "%1 - Configuración", + "changelog_menu_title": "%1 - Registro de cambios", + "export_menu_title": "%1 - Exportar configuración", + "import_menu_title": "%1 - Importar configuración", + "open_menu_tooltip": "Abra el menú de configuración de %1", + "close_menu_tooltip": "Haga clic para cerrar el menú", + "reload_hint": "Recargue la página para aplicar los cambios", + "reload_now": "Recargar ahora", + "reload_tooltip": "Recargar la página", + "feature_requires_reload": "Cambiar esta función requiere una recarga de la página", + "version_tooltip": "Versión %1 (compilación %2) - haga clic para abrir el registro de cambios", + "bytm_config_export_import_title": "Exportar o importar configuración", + "bytm_config_import_desc": "Pegue la configuración que desea importar en el campo a continuación, luego haga clic en el botón de importación:", + "bytm_config_export_desc": "Copie el siguiente texto para exportar su configuración. Advertencia: puede contener datos sensibles.", + "export_import": "Exportar/Importar", + "export": "Exportar", + "export_hint": "Copie el siguiente texto para exportar su configuración.\nAdvertencia: puede contener datos sensibles.", + "click_to_reveal": "(haga clic para revelar)", + "click_to_reveal_sensitive_info": "(haga clic para revelar información sensible)", + "export_tooltip": "Exporte su configuración actual", + "import": "Importar", + "import_hint": "Pegue la configuración que desea importar en el campo a continuación, luego haga clic en el botón de importación:", + "import_tooltip": "Importe una configuración que haya exportado anteriormente", + "start_import_tooltip": "Haga clic para importar los datos que pegó arriba", + "import_error_invalid": "Los datos importados no son válidos", + "import_error_no_format_version": "Los datos importados no contienen una versión de formato", + "import_error_no_data": "El objeto importado no contiene ningún dato", + "import_error_wrong_format_version": "Los datos importados están en una versión de formato no compatible (se esperaba %1 o inferior pero se obtuvo %2)", + "import_success": "Datos importados correctamente", + "import_success_confirm_reload": "Los datos se importaron correctamente.\n¿Desea recargar la página ahora para aplicar los cambios?", + "reset_config_confirm": "¿Realmente quieres restablecer todos los ajustes a sus valores predeterminados?\nLa página se volverá a cargar automáticamente.", + "copy": "Copiar", + "copy_to_clipboard": "Copiar al portapapeles", + "copy_to_clipboard_error": "No se pudo copiar el texto al portapapeles. Cópielo manualmente desde aquí:\n%1", + "copy_config_tooltip": "Copie la configuración en su portapapeles", + "copied": "¡Copiado!", + "copied_to_clipboard": "¡Copiado al portapapeles!", + "copy_hidden": "Copiar oculto", + "copy_hidden_tooltip": "Haga clic para copiar el valor oculto: estos son datos sensibles ⚠️", + "open_github": "Abrir %1 en GitHub", + "open_discord": "Únete a mi servidor de Discord", + "open_greasyfork": "Abrir %1 en GreasyFork", + "open_openuserjs": "Abrir %1 en OpenUserJS", + "lang_changed_prompt_reload": "El idioma se cambió.\n¿Quieres recargar la página ahora para aplicar los cambios?", + "search_placeholder": "Buscar...", + "search_clear": "Borrar búsqueda", - "reset": "Reiniciar", - "close": "Cerrar", - "log_level_debug": "Depurar (más)", - "log_level_info": "Información (solo importante)", - "toggled_on": "Encendido", - "toggled_off": "Apagado", - "trigger_btn_action": "Disparador", - "trigger_btn_action_running": "Ejecutando...", - "new_entry": "Nueva entrada", - "new_entry_tooltip": "Haga clic para crear una nueva entrada", - "remove_entry": "Eliminar esta entrada", - "edit_entry": "Editar esta entrada", - "remove_from_queue": "Eliminar esta canción de la cola", - "delete_from_list": "Eliminar esta canción de la lista", - "couldnt_remove_from_queue": "No se pudo eliminar esta canción de la cola", - "couldnt_delete_from_list": "No se pudo eliminar esta canción de la lista", - "clear_list": "Borrar la lista", - "clear_list_confirm": "¿Realmente quieres borrar la lista y dejar solo la canción que se está reproduciendo actualmente?", - "scroll_to_playing": "Desplácese hasta la canción que se está reproduciendo actualmente", - "scroll_to_bottom": "Haga clic para desplazarse hasta el final", - "volume_tooltip": "Volumen: %1% (Sensibilidad: %2%)", - "volume_shared_tooltip": "Volumen compartido entre pestañas - deshabilítelo en el menú de configuración", - "middle_click_open_tab": "Haga clic con el botón central para abrir en una nueva pestaña", - "example_toast": "Ejemplo de notificación", - "generic_error_toast_encountered_error_type": "Se encontró %1", - "generic_error_toast_click_for_details": "Haga clic para obtener más detalles", - "error": "Error", - "generic_error_dialog_message": "Se encontró un error.", - "generic_error_dialog_open_console_note": "Si este error sigue ocurriendo, abra la consola de JavaScript (generalmente con Ctrl + Shift + K) y adjunte una captura de pantalla del **mensaje de error completo** que comienza con %1 a un nuevo [problema de GitHub.](%2)", - "active_mode_display": "Modo %1", - "active_mode_tooltip-1": "El %1 está actualmente activo", - "active_mode_tooltip-n": "Los %1 están actualmente activos", - "dev_mode": "Modo de desarrollador", - "dev_mode_short": "Dev", - "advanced_mode": "Modo avanzado", - "advanced_mode_short": "Avanzado", - "experimental_feature": "Función experimental", + "reset": "Reiniciar", + "close": "Cerrar", + "log_level_debug": "Depurar (más)", + "log_level_info": "Información (solo importante)", + "toggled_on": "Encendido", + "toggled_off": "Apagado", + "trigger_btn_action": "Disparador", + "trigger_btn_action_running": "Ejecutando...", + "new_entry": "Nueva entrada", + "new_entry_tooltip": "Haga clic para crear una nueva entrada", + "remove_entry": "Eliminar esta entrada", + "edit_entry": "Editar esta entrada", + "remove_from_queue": "Eliminar esta canción de la cola", + "delete_from_list": "Eliminar esta canción de la lista", + "couldnt_remove_from_queue": "No se pudo eliminar esta canción de la cola", + "couldnt_delete_from_list": "No se pudo eliminar esta canción de la lista", + "clear_list": "Borrar la lista", + "clear_list_confirm": "¿Realmente quieres borrar la lista y dejar solo la canción que se está reproduciendo actualmente?", + "scroll_to_playing": "Desplácese hasta la canción que se está reproduciendo actualmente", + "scroll_to_bottom": "Haga clic para desplazarse hasta el final", + "volume_tooltip": "Volumen: %1% (Sensibilidad: %2%)", + "volume_shared_tooltip": "Volumen compartido entre pestañas - deshabilítelo en el menú de configuración", + "middle_click_open_tab": "Haga clic con el botón central para abrir en una nueva pestaña", + "example_toast": "Ejemplo de notificación", + "generic_error_toast_encountered_error_type": "Se encontró %1", + "generic_error_toast_click_for_details": "Haga clic para obtener más detalles", + "error": "Error", + "generic_error_dialog_message": "Se encontró un error.", + "generic_error_dialog_open_console_note": "Si este error sigue ocurriendo, abra la consola de JavaScript (generalmente con Ctrl + Shift + K) y adjunte una captura de pantalla del **mensaje de error completo** que comienza con %1 a un nuevo [problema de GitHub.](%2)", + "active_mode_display": "Modo %1", + "active_mode_tooltip-1": "El %1 está actualmente activo", + "active_mode_tooltip-n": "Los %1 están actualmente activos", + "dev_mode": "Modo de desarrollador", + "dev_mode_short": "Dev", + "advanced_mode": "Modo avanzado", + "advanced_mode_short": "Avanzado", + "experimental_feature": "Función experimental", - "open_lyrics_search_prompt": "Ingrese el título de la canción y el artista para buscar la letra:", - "lyrics_loading": "Cargando URL de letras...", - "lyrics_rate_limited-1": "Se está limitando la velocidad.\nEspere unos segundos antes de solicitar más letras.", - "lyrics_rate_limited-n": "Se está limitando la velocidad.\nEspere %1 segundos antes de solicitar más letras.", - "lyrics_not_found_confirm_open_search": "No se pudo encontrar una página de letras para esta canción.\n¿Quieres abrir genius.com para buscarla manualmente?", - "lyrics_not_found_click_open_search": "No se pudo encontrar la URL de las letras: haga clic para abrir la búsqueda manual de letras", - "lyrics_clear_cache_confirm_prompt-1": "La caché de letras actualmente tiene %1 entrada.\n¿Realmente quieres eliminarlas?", - "lyrics_clear_cache_confirm_prompt-n": "La caché de letras actualmente tiene %1 entradas.\n¿Realmente quieres eliminarlas?", - "lyrics_clear_cache_success": "La caché de letras se eliminó correctamente.", - "lyrics_cache_changed_clear_confirm": "Ha cambiado la configuración que afecta los datos en la caché de letras, lo que rompe las búsquedas de URL de letras.\n¿Quieres borrar la caché ahora?", + "open_lyrics_search_prompt": "Ingrese el título de la canción y el artista para buscar la letra:", + "lyrics_loading": "Cargando URL de letras...", + "lyrics_rate_limited-1": "Se está limitando la velocidad.\nEspere unos segundos antes de solicitar más letras.", + "lyrics_rate_limited-n": "Se está limitando la velocidad.\nEspere %1 segundos antes de solicitar más letras.", + "lyrics_not_found_confirm_open_search": "No se pudo encontrar una página de letras para esta canción.\n¿Quieres abrir genius.com para buscarla manualmente?", + "lyrics_not_found_click_open_search": "No se pudo encontrar la URL de las letras: haga clic para abrir la búsqueda manual de letras", + "lyrics_clear_cache_confirm_prompt-1": "La caché de letras actualmente tiene %1 entrada.\n¿Realmente quieres eliminarlas?", + "lyrics_clear_cache_confirm_prompt-n": "La caché de letras actualmente tiene %1 entradas.\n¿Realmente quieres eliminarlas?", + "lyrics_clear_cache_success": "La caché de letras se eliminó correctamente.", + "lyrics_cache_changed_clear_confirm": "Ha cambiado la configuración que afecta los datos en la caché de letras, lo que rompe las búsquedas de URL de letras.\n¿Quieres borrar la caché ahora?", - "hotkey_input_click_to_change": "Haga clic para cambiar", - "hotkey_input_click_to_change_tooltip": "%1 - Actualmente configurado en: %2 - Ingrese cualquier combinación de teclas para cambiar. Nota: algunos lectores de pantalla pueden bloquear ciertas combinaciones de teclas.", - "hotkey_input_click_to_reset_tooltip": "Restablecer a la última combinación de teclas guardada", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Mayús", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "Ninguno seleccionado", + "hotkey_input_click_to_change": "Haga clic para cambiar", + "hotkey_input_click_to_change_tooltip": "%1 - Actualmente configurado en: %2 - Ingrese cualquier combinación de teclas para cambiar. Nota: algunos lectores de pantalla pueden bloquear ciertas combinaciones de teclas.", + "hotkey_input_click_to_reset_tooltip": "Restablecer a la última combinación de teclas guardada", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Mayús", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "Ninguno seleccionado", - "welcome_menu_title": "¡Bienvenido a %1!", - "config_menu": "Configuración", - "open_config_menu_tooltip": "Haga clic para abrir el menú de configuración", - "open_changelog": "Registro de cambios", - "open_changelog_tooltip": "Haga clic para abrir el registro de cambios", - "feature_help_button_tooltip": "Haga clic para obtener más información sobre la siguiente función: \"%1\"", - "welcome_text_line_1": "Gracias por instalar!", - "welcome_text_line_2": "Espero que disfrutes usando %1 tanto como yo disfruté haciéndolo 😃", - "welcome_text_line_3": "Si te gusta %1, por favor deja una calificación en %2GreasyFork%3 o %4OpenUserJS%5", - "welcome_text_line_4": "Mi trabajo depende de las donaciones, así que por favor considera %1donar ❤️%2", - "welcome_text_line_5": "¿Encontraste un error o quieres sugerir una función? Por favor %1abre un problema en GitHub%2", + "welcome_menu_title": "¡Bienvenido a %1!", + "config_menu": "Configuración", + "open_config_menu_tooltip": "Haga clic para abrir el menú de configuración", + "open_changelog": "Registro de cambios", + "open_changelog_tooltip": "Haga clic para abrir el registro de cambios", + "feature_help_button_tooltip": "Haga clic para obtener más información sobre la siguiente función: \"%1\"", + "welcome_text_line_1": "Gracias por instalar!", + "welcome_text_line_2": "Espero que disfrutes usando %1 tanto como yo disfruté haciéndolo 😃", + "welcome_text_line_3": "Si te gusta %1, por favor deja una calificación en %2GreasyFork%3 o %4OpenUserJS%5", + "welcome_text_line_4": "Mi trabajo depende de las donaciones, así que por favor considera %1donar ❤️%2", + "welcome_text_line_5": "¿Encontraste un error o quieres sugerir una función? Por favor %1abre un problema en GitHub%2", - "list_button_placement_queue_only": "Solo en la cola", - "list_button_placement_everywhere": "En todas las listas de canciones", + "list_button_placement_queue_only": "Solo en la cola", + "list_button_placement_everywhere": "En todas las listas de canciones", - "site_selection_both_sites": "Ambos sitios", - "site_selection_only_yt": "Solo YouTube", - "site_selection_only_ytm": "Solo YouTube Music", - "site_selection_none": "Ninguno (deshabilitado)", + "site_selection_both_sites": "Ambos sitios", + "site_selection_only_yt": "Solo YouTube", + "site_selection_only_ytm": "Solo YouTube Music", + "site_selection_none": "Ninguno (deshabilitado)", - "new_version_available": "¡Nueva versión disponible!", - "open_update_page_install_manually": "Haga clic para abrir la página de actualización / instalación manual", - "disable_update_check": "Deshabilitar la comprobación automática de actualizaciones", - "reenable_in_config_menu": "(se puede volver a habilitar en el menú de configuración)", - "close_and_ignore_for_24h": "Cerrar y omitir durante 24 horas", - "close_and_ignore_until_reenabled": "Cerrar y omitir hasta que se vuelva a habilitar", - "expand_release_notes": "Haga clic para expandir las últimas notas de la versión", - "collapse_release_notes": "Haga clic para contraer las últimas notas de la versión", - "no_updates_found": "No se encontraron actualizaciones.", + "new_version_available": "¡Nueva versión disponible!", + "open_update_page_install_manually": "Haga clic para abrir la página de actualización / instalación manual", + "disable_update_check": "Deshabilitar la comprobación automática de actualizaciones", + "reenable_in_config_menu": "(se puede volver a habilitar en el menú de configuración)", + "close_and_ignore_for_24h": "Cerrar y omitir durante 24 horas", + "close_and_ignore_until_reenabled": "Cerrar y omitir hasta que se vuelva a habilitar", + "expand_release_notes": "Haga clic para expandir las últimas notas de la versión", + "collapse_release_notes": "Haga clic para contraer las últimas notas de la versión", + "no_new_version_found": "No se encontró una nueva versión.", - "thumbnail_overlay_behavior_never": "Nunca", - "thumbnail_overlay_behavior_videos_only": "Solo para videos", - "thumbnail_overlay_behavior_songs_only": "Solo para canciones", - "thumbnail_overlay_behavior_always": "Siempre", - "thumbnail_overlay_toggle_btn_tooltip_hide": "Desactivar la superposición de miniaturas: haga clic con el botón central o con Mayús para abrir en una nueva pestaña", - "thumbnail_overlay_toggle_btn_tooltip_show": "Habilitar la superposición de miniaturas: haga clic con el botón central o con Mayús para abrir en una nueva pestaña", - "thumbnail_overlay_indicator_tooltip": "La superposición de miniaturas está actualmente activa", - "thumbnail_overlay_image_fit_crop": "Recortar si es necesario", - "thumbnail_overlay_image_fit_full": "Mostrar la imagen completa", - "thumbnail_overlay_image_fit_stretch": "Estirar para ajustar", + "thumbnail_overlay_behavior_never": "Nunca", + "thumbnail_overlay_behavior_videos_only": "Solo para videos", + "thumbnail_overlay_behavior_songs_only": "Solo para canciones", + "thumbnail_overlay_behavior_always": "Siempre", + "thumbnail_overlay_toggle_btn_tooltip_hide": "Desactivar la superposición de miniaturas: haga clic con el botón central o con Mayús para abrir en una nueva pestaña", + "thumbnail_overlay_toggle_btn_tooltip_show": "Habilitar la superposición de miniaturas: haga clic con el botón central o con Mayús para abrir en una nueva pestaña", + "thumbnail_overlay_indicator_tooltip": "La superposición de miniaturas está actualmente activa", + "thumbnail_overlay_image_fit_crop": "Recortar si es necesario", + "thumbnail_overlay_image_fit_full": "Mostrar la imagen completa", + "thumbnail_overlay_image_fit_stretch": "Estirar para ajustar", - "auto_like_channels_dialog_title": "Canales con me gusta automático", - "auto_like_channels_dialog_desc": "Aquí puedes ver qué canales tienes configurados para dar me gusta automáticamente y puedes editar, habilitar, deshabilitar y eliminarlos.\nTambién puedes crear manualmente entradas, aunque es más fácil simplemente visitar la página del canal y hacer clic en el botón allí.", - "auto_like": "Me gusta automático", - "auto_like_button_tooltip_enabled": "Haga clic para deshabilitar el me gusta automático. Haga clic con Mayús para abrir el diálogo de administración.", - "auto_like_button_tooltip_disabled": "Haga clic para habilitar el me gusta automático. Haga clic con Mayús para abrir el diálogo de administración.", - "auto_like_channel_toggle_tooltip": "Alternar el me gusta automático para el canal %1", - "add_auto_like_channel_id_prompt": "Ingrese el ID de usuario (@Nombre / UC...) o la URL completa del canal que desea dar me gusta automáticamente.\nPresione \"cancelar\" para salir.", - "add_auto_like_channel_invalid_id": "El ID de usuario ingresado no es válido.\n¡Asegúrese de copiar la URL completa del canal! Debería contener una parte como \"channel/UC...\" o \"/@...\"", - "add_auto_like_channel_already_exists_prompt_new_name": "Ya hay un canal con ese ID en la lista.\n¿Quieres cambiar el nombre?", - "add_auto_like_channel_name_prompt": "Ingrese el nombre del canal.\nPresione \"cancelar\" para salir.", - "auto_like_channel_edit_name_prompt": "Ingrese el nuevo nombre para este canal.\nPresione \"cancelar\" para salir.", - "auto_like_channel_edit_id_prompt": "Ingrese el nuevo ID de usuario (@Nombre / UC...) o la URL completa para este canal.\nPresione \"cancelar\" para salir.", - "auto_like_enabled_toast": "Me gusta automático habilitado", - "auto_like_disabled_toast": "Me gusta automático deshabilitado", - "auto_liked_a_channels_song": "Canción con me gusta de %1", - "auto_liked_a_channels_video": "Video con me gusta de %1", - "auto_like_click_to_configure": "Haga clic para configurar", - "auto_like_export_or_import_tooltip": "Exportar o importar tus canales con me gusta automático", - "auto_like_export_import_title": "Exportar o importar canales con me gusta automático", - "auto_like_export_desc": "Copia el siguiente texto para exportar tus canales con me gusta automático.", - "auto_like_import_desc": "Pega los canales con me gusta automático que deseas importar en el campo a continuación, luego haz clic en el botón de importación:", + "auto_like_channels_dialog_title": "Canales con me gusta automático", + "auto_like_channels_dialog_desc": "Aquí puedes ver qué canales tienes configurados para dar me gusta automáticamente y puedes editar, habilitar, deshabilitar y eliminarlos.\nTambién puedes crear manualmente entradas, aunque es más fácil simplemente visitar la página del canal y hacer clic en el botón allí.", + "auto_like": "Me gusta automático", + "auto_like_button_tooltip_enabled": "Haga clic para deshabilitar el me gusta automático. Haga clic con Mayús para abrir el diálogo de administración.", + "auto_like_button_tooltip_disabled": "Haga clic para habilitar el me gusta automático. Haga clic con Mayús para abrir el diálogo de administración.", + "auto_like_channel_toggle_tooltip": "Alternar el me gusta automático para el canal %1", + "add_auto_like_channel_id_prompt": "Ingrese el ID de usuario (@Nombre / UC...) o la URL completa del canal que desea dar me gusta automáticamente.\nPresione \"cancelar\" para salir.", + "add_auto_like_channel_invalid_id": "El ID de usuario ingresado no es válido.\n¡Asegúrese de copiar la URL completa del canal! Debería contener una parte como \"channel/UC...\" o \"/@...\"", + "add_auto_like_channel_already_exists_prompt_new_name": "Ya hay un canal con ese ID en la lista.\n¿Quieres cambiar el nombre?", + "add_auto_like_channel_name_prompt": "Ingrese el nombre del canal.\nPresione \"cancelar\" para salir.", + "auto_like_channel_edit_name_prompt": "Ingrese el nuevo nombre para este canal.\nPresione \"cancelar\" para salir.", + "auto_like_channel_edit_id_prompt": "Ingrese el nuevo ID de usuario (@Nombre / UC...) o la URL completa para este canal.\nPresione \"cancelar\" para salir.", + "auto_like_enabled_toast": "Me gusta automático habilitado", + "auto_like_disabled_toast": "Me gusta automático deshabilitado", + "auto_liked_a_channels_song": "Canción con me gusta de %1", + "auto_liked_a_channels_video": "Video con me gusta de %1", + "auto_like_click_to_configure": "Haga clic para configurar", + "auto_like_export_or_import_tooltip": "Exportar o importar tus canales con me gusta automático", + "auto_like_export_import_title": "Exportar o importar canales con me gusta automático", + "auto_like_export_desc": "Copia el siguiente texto para exportar tus canales con me gusta automático.", + "auto_like_import_desc": "Pega los canales con me gusta automático que deseas importar en el campo a continuación, luego haz clic en el botón de importación:", - "prompt_confirm": "Confirmar", - "prompt_close": "Cerrar", - "prompt_cancel": "Cancelar", + "prompt_confirm": "Confirmar", + "prompt_close": "Cerrar", + "prompt_cancel": "Cancelar", - "click_to_confirm_tooltip": "Haga clic para confirmar", - "click_to_close_tooltip": "Haga clic para cerrar", - "click_to_cancel_tooltip": "Haga clic para cancelar", + "click_to_confirm_tooltip": "Haga clic para confirmar", + "click_to_close_tooltip": "Haga clic para cerrar", + "click_to_cancel_tooltip": "Haga clic para cancelar", - "vote_label_likes-1": "%1 me gusta", - "vote_label_likes-n": "%1 me gusta", - "vote_label_dislikes-1": "%1 no me gusta", - "vote_label_dislikes-n": "%1 no me gusta", + "vote_label_likes-1": "%1 me gusta", + "vote_label_likes-n": "%1 me gusta", + "vote_label_dislikes-1": "%1 no me gusta", + "vote_label_dislikes-n": "%1 no me gusta", - "vote_ratio_disabled": "Deshabilitado", - "vote_ratio_green_red": "Verde y rojo", - "vote_ratio_blue_gray": "Azul y gris", + "vote_ratio_disabled": "Deshabilitado", + "vote_ratio_green_red": "Verde y rojo", + "vote_ratio_blue_gray": "Azul y gris", - "votes_format_short": "Corto", - "votes_format_long": "Largo", + "votes_format_short": "Corto", + "votes_format_long": "Largo", - "unit_entries-1": "entrada", - "unit_entries-n": "entradas", + "unit_entries-1": "entrada", + "unit_entries-n": "entradas", - "unit_days-1": "día", - "unit_days-n": "días", + "unit_days-1": "día", + "unit_days-n": "días", - "color_lightness_darker": "Más oscuro", - "color_lightness_normal": "Normal", - "color_lightness_lighter": "Más claro", + "color_lightness_darker": "Más oscuro", + "color_lightness_normal": "Normal", + "color_lightness_lighter": "Más claro", - "plugin_list_title": "Lista de plugins", - "plugin_list_no_plugins": "Actualmente no hay plugins instalados.\nVisite %1esta página%2 para obtener más información.", - "plugin_list_no_plugins_tooltip": "No hay ningún plugin instalado actualmente.", - "plugin_list_permissions_header": "Permisos:", + "plugin_list_title": "Lista de plugins", + "plugin_list_no_plugins": "Actualmente no hay plugins instalados.\nVisite %1esta página%2 para obtener más información.", + "plugin_list_no_plugins_tooltip": "No hay ningún plugin instalado actualmente.", + "plugin_list_permissions_header": "Permisos:", - "plugin_link_type_source": "Repositorio", - "plugin_link_type_other": "Otro / Página de inicio", - "plugin_link_type_bug": "Informar de un error", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "Repositorio", + "plugin_link_type_other": "Otro / Página de inicio", + "plugin_link_type_bug": "Informar de un error", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "Este plugin puede leer la configuración de la función", - "plugin_intent_description_WriteFeatureConfig": "Este plugin puede escribir en la configuración de la función", - "plugin_intent_description_SeeHiddenConfigValues": "Este plugin tiene acceso a valores de configuración ocultos", - "plugin_intent_description_WriteLyricsCache": "Este plugin puede escribir en la caché de letras", - "plugin_intent_description_WriteTranslations": "Este plugin puede agregar nuevas traducciones y sobrescribir las existentes", - "plugin_intent_description_CreateModalDialogs": "Este plugin puede crear diálogos modales", - "plugin_intent_description_ReadAutoLikeData": "Este plugin puede leer datos de me gusta automático", - "plugin_intent_description_WriteAutoLikeData": "Este plugin puede escribir en datos de me gusta automático", + "plugin_intent_description_ReadFeatureConfig": "Este plugin puede leer la configuración de la función", + "plugin_intent_description_WriteFeatureConfig": "Este plugin puede escribir en la configuración de la función", + "plugin_intent_description_SeeHiddenConfigValues": "Este plugin tiene acceso a valores de configuración ocultos", + "plugin_intent_description_WriteLyricsCache": "Este plugin puede escribir en la caché de letras", + "plugin_intent_description_WriteTranslations": "Este plugin puede agregar nuevas traducciones y sobrescribir las existentes", + "plugin_intent_description_CreateModalDialogs": "Este plugin puede crear diálogos modales", + "plugin_intent_description_ReadAutoLikeData": "Este plugin puede leer datos de me gusta automático", + "plugin_intent_description_WriteAutoLikeData": "Este plugin puede escribir en datos de me gusta automático", - "plugin_validation_error_no_property": "No hay ninguna propiedad '%1' con el tipo '%2'", - "plugin_validation_error_invalid_property-1": "La propiedad '%1' con el valor '%2' no es válida. Valor de ejemplo: %3", - "plugin_validation_error_invalid_property-n": "La propiedad '%1' con el valor '%2' no es válida. Valores de ejemplo: %3", + "plugin_validation_error_no_property": "No hay ninguna propiedad '%1' con el tipo '%2'", + "plugin_validation_error_invalid_property-1": "La propiedad '%1' con el valor '%2' no es válida. Valor de ejemplo: %3", + "plugin_validation_error_invalid_property-n": "La propiedad '%1' con el valor '%2' no es válida. Valores de ejemplo: %3", - "feature_category_layout": "Diseño", - "feature_category_volume": "Volumen", - "feature_category_songLists": "Listas de canciones", - "feature_category_behavior": "Comportamiento", - "feature_category_input": "Entrada", - "feature_category_lyrics": "Letras", - "feature_category_integrations": "Integraciones", - "feature_category_plugins": "Plugins", - "feature_category_general": "General", + "feature_category_layout": "Diseño", + "feature_category_volume": "Volumen", + "feature_category_songLists": "Listas de canciones", + "feature_category_behavior": "Comportamiento", + "feature_category_input": "Entrada", + "feature_category_lyrics": "Letras", + "feature_category_integrations": "Integraciones", + "feature_category_plugins": "Plugins", + "feature_category_general": "General", - "feature_desc_watermarkEnabled": "Mostrar una marca de agua debajo del logotipo del sitio que abre este menú de configuración", - "feature_helptext_watermarkEnabled": "Si esto está deshabilitado, aún puede abrir el menú de configuración haciendo clic en la opción en el menú que se abre cuando hace clic en su imagen de perfil en la esquina superior derecha.\nSin embargo, será más difícil encontrar el huevo de pascua ;)", - "feature_desc_removeShareTrackingParam": "Eliminar el parámetro de seguimiento \"?si\" de la URL en los menús de compartir", - "feature_helptext_removeShareTrackingParam": "Por motivos de análisis, YouTube agrega un parámetro de seguimiento al final de la URL que puede copiar en el menú Compartir. Si bien no es directamente perjudicial, hace que la URL sea más larga y le da a YouTube más información sobre usted y las personas a las que envía el enlace.", - "feature_desc_removeShareTrackingParamSites": "¿En qué sitios se debe eliminar el parámetro de seguimiento de compartir?", - "feature_desc_numKeysSkipToTime": "Habilitar el salto a un momento específico en el video presionando una tecla numérica (0-9)", - "feature_desc_fixSpacing": "Solucionar problemas de espaciado en el diseño", - "feature_helptext_fixSpacing": "Hay varios lugares en la interfaz de usuario donde el espaciado entre elementos es inconsistente. Esta función soluciona esos problemas.", - "feature_desc_thumbnailOverlayBehavior": "Cuándo reemplazar automáticamente el elemento de video con su miniatura en la mayor resolución", - "feature_helptext_thumbnailOverlayBehavior": "La miniatura se mostrará sobre el video o la canción que se está reproduciendo actualmente.\n¡Esto no ahorrará ancho de banda ya que el video seguirá cargándose y reproduciéndose en segundo plano!", - "feature_desc_thumbnailOverlayToggleBtnShown": "Agregue un botón a los controles multimedia para mostrar u ocultar manualmente la miniatura", - "feature_helptext_thumbnailOverlayToggleBtnShown": "Este botón le permitirá alternar manualmente la miniatura.\nEsto no se verá afectado si la superposición está configurada en \"nunca se muestra\".\nUna vez que comienza a reproducirse un nuevo video o canción, el estado predeterminado se restaurará.\nMantenga presionada la tecla Mayús mientras hace clic o presione el botón central del mouse para abrir la miniatura de mayor calidad en una nueva pestaña.", - "feature_desc_thumbnailOverlayShowIndicator": "Mostrar un indicador en la esquina inferior derecha de la miniatura mientras está activo", - "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacidad del indicador de miniatura", - "feature_desc_thumbnailOverlayImageFit": "Cómo ajustar la imagen de la miniatura sobre el elemento de video", - "feature_desc_hideCursorOnIdle": "Ocultar el cursor después de unos segundos de inactividad sobre el video", - "feature_desc_hideCursorOnIdleDelay": "¿Cuántos segundos de inactividad antes de que se oculte el cursor?", - "feature_desc_fixHdrIssues": "Prevenir algunos problemas de renderizado al usar una GPU y un monitor compatibles con HDR", - "feature_desc_showVotes": "Mostrar la cantidad de me gusta y no me gusta en la canción que se está reproduciendo actualmente", - "feature_helptext_showVotes": "Esta función está alimentada por Return YouTube Dislike y mostrará la cantidad aproximada de me gusta y no me gusta en la canción que se está reproduciendo actualmente.", - "feature_desc_numbersFormat": "¿Cómo deberían formatearse los números?", + "feature_desc_watermarkEnabled": "Mostrar una marca de agua debajo del logotipo del sitio que abre este menú de configuración", + "feature_helptext_watermarkEnabled": "Si esto está deshabilitado, aún puede abrir el menú de configuración haciendo clic en la opción en el menú que se abre cuando hace clic en su imagen de perfil en la esquina superior derecha.\nSin embargo, será más difícil encontrar el huevo de pascua ;)", + "feature_desc_removeShareTrackingParam": "Eliminar el parámetro de seguimiento \"?si\" de la URL en los menús de compartir", + "feature_helptext_removeShareTrackingParam": "Por motivos de análisis, YouTube agrega un parámetro de seguimiento al final de la URL que puede copiar en el menú Compartir. Si bien no es directamente perjudicial, hace que la URL sea más larga y le da a YouTube más información sobre usted y las personas a las que envía el enlace.", + "feature_desc_removeShareTrackingParamSites": "¿En qué sitios se debe eliminar el parámetro de seguimiento de compartir?", + "feature_desc_numKeysSkipToTime": "Habilitar el salto a un momento específico en el video presionando una tecla numérica (0-9)", + "feature_desc_fixSpacing": "Solucionar problemas de espaciado en el diseño", + "feature_helptext_fixSpacing": "Hay varios lugares en la interfaz de usuario donde el espaciado entre elementos es inconsistente. Esta función soluciona esos problemas.", + "feature_desc_thumbnailOverlayBehavior": "Cuándo reemplazar automáticamente el elemento de video con su miniatura en la mayor resolución", + "feature_helptext_thumbnailOverlayBehavior": "La miniatura se mostrará sobre el video o la canción que se está reproduciendo actualmente.\n¡Esto no ahorrará ancho de banda ya que el video seguirá cargándose y reproduciéndose en segundo plano!", + "feature_desc_thumbnailOverlayToggleBtnShown": "Agregue un botón a los controles multimedia para mostrar u ocultar manualmente la miniatura", + "feature_helptext_thumbnailOverlayToggleBtnShown": "Este botón le permitirá alternar manualmente la miniatura.\nEsto no se verá afectado si la superposición está configurada en \"nunca se muestra\".\nUna vez que comienza a reproducirse un nuevo video o canción, el estado predeterminado se restaurará.\nMantenga presionada la tecla Mayús mientras hace clic o presione el botón central del mouse para abrir la miniatura de mayor calidad en una nueva pestaña.", + "feature_desc_thumbnailOverlayShowIndicator": "Mostrar un indicador en la esquina inferior derecha de la miniatura mientras está activo", + "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacidad del indicador de miniatura", + "feature_desc_thumbnailOverlayImageFit": "Cómo ajustar la imagen de la miniatura sobre el elemento de video", + "feature_desc_hideCursorOnIdle": "Ocultar el cursor después de unos segundos de inactividad sobre el video", + "feature_desc_hideCursorOnIdleDelay": "¿Cuántos segundos de inactividad antes de que se oculte el cursor?", + "feature_desc_fixHdrIssues": "Prevenir algunos problemas de renderizado al usar una GPU y un monitor compatibles con HDR", + "feature_desc_showVotes": "Mostrar la cantidad de me gusta y no me gusta en la canción que se está reproduciendo actualmente", + "feature_helptext_showVotes": "Esta función está alimentada por Return YouTube Dislike y mostrará la cantidad aproximada de me gusta y no me gusta en la canción que se está reproduciendo actualmente.", + "feature_desc_numbersFormat": "¿Cómo deberían formatearse los números?", - "feature_desc_volumeSliderLabel": "Agregue una etiqueta de porcentaje junto al control deslizante de volumen", - "feature_desc_volumeSliderSize": "El ancho del control deslizante de volumen en píxeles", - "feature_desc_volumeSliderStep": "Sensibilidad del control deslizante de volumen (en qué porcentaje se puede cambiar el volumen a la vez)", - "feature_desc_volumeSliderScrollStep": "Sensibilidad del control deslizante de volumen al desplazarse con la rueda del mouse en porcentaje - se ajusta al valor de sensibilidad más cercano desde arriba", - "feature_helptext_volumeSliderScrollStep": "Por cuánto porcentaje debe cambiarse el volumen al desplazar el control deslizante de volumen con la rueda del mouse.\nEsto debe ser un múltiplo de la sensibilidad del control deslizante de volumen, de lo contrario habrá pequeños saltos irregulares en el volumen al desplazarse.", - "feature_desc_volumeSharedBetweenTabs": "¿Debería el volumen establecido ser compartido entre pestañas y recordado entre sesiones?", - "feature_helptext_volumeSharedBetweenTabs": "Si cambia el volumen en una pestaña, el nivel de volumen se establecerá en el mismo valor en todas las demás pestañas que tengan esta función habilitada.\nEste valor se recordará y restaurará en las sesiones, hasta que se desactive.", - "feature_desc_setInitialTabVolume": "Establece el nivel de volumen en un valor específico una vez al abrir el sitio", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Esta función es incompatible con la función \"Volumen compartido entre pestañas\" y se ignorará mientras se use la función de volumen compartido!", - "feature_desc_initialTabVolumeLevel": "El valor para establecer el nivel de volumen al abrir el sitio", + "feature_desc_volumeSliderLabel": "Agregue una etiqueta de porcentaje junto al control deslizante de volumen", + "feature_desc_volumeSliderSize": "El ancho del control deslizante de volumen en píxeles", + "feature_desc_volumeSliderStep": "Sensibilidad del control deslizante de volumen (en qué porcentaje se puede cambiar el volumen a la vez)", + "feature_desc_volumeSliderScrollStep": "Sensibilidad del control deslizante de volumen al desplazarse con la rueda del mouse en porcentaje - se ajusta al valor de sensibilidad más cercano desde arriba", + "feature_helptext_volumeSliderScrollStep": "Por cuánto porcentaje debe cambiarse el volumen al desplazar el control deslizante de volumen con la rueda del mouse.\nEsto debe ser un múltiplo de la sensibilidad del control deslizante de volumen, de lo contrario habrá pequeños saltos irregulares en el volumen al desplazarse.", + "feature_desc_volumeSharedBetweenTabs": "¿Debería el volumen establecido ser compartido entre pestañas y recordado entre sesiones?", + "feature_helptext_volumeSharedBetweenTabs": "Si cambia el volumen en una pestaña, el nivel de volumen se establecerá en el mismo valor en todas las demás pestañas que tengan esta función habilitada.\nEste valor se recordará y restaurará en las sesiones, hasta que se desactive.", + "feature_desc_setInitialTabVolume": "Establece el nivel de volumen en un valor específico una vez al abrir el sitio", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Esta función es incompatible con la función \"Volumen compartido entre pestañas\" y se ignorará mientras se use la función de volumen compartido!", + "feature_desc_initialTabVolumeLevel": "El valor para establecer el nivel de volumen al abrir el sitio", - "feature_desc_lyricsQueueButton": "Agregue un botón a cada canción en una lista para abrir su página de letras", - "feature_desc_deleteFromQueueButton": "Agregue un botón a cada canción en una lista para eliminarla rápidamente", - "feature_desc_listButtonsPlacement": "¿Dónde deberían aparecer los botones de la lista?", - "feature_helptext_listButtonsPlacement": "Hay varias listas de canciones en el sitio como páginas de álbumes, listas de reproducción y la cola de reproducción actual.\nCon esta opción, puede elegir dónde deben aparecer los botones de la lista.", - "feature_desc_scrollToActiveSongBtn": "Agregue un botón para desplazarse hasta la canción que se está reproduciendo actualmente", - "feature_desc_clearQueueBtn": "Agregue un botón para borrar rápidamente la cola de reproducción o la lista de reproducción actual", + "feature_desc_lyricsQueueButton": "Agregue un botón a cada canción en una lista para abrir su página de letras", + "feature_desc_deleteFromQueueButton": "Agregue un botón a cada canción en una lista para eliminarla rápidamente", + "feature_desc_listButtonsPlacement": "¿Dónde deberían aparecer los botones de la lista?", + "feature_helptext_listButtonsPlacement": "Hay varias listas de canciones en el sitio como páginas de álbumes, listas de reproducción y la cola de reproducción actual.\nCon esta opción, puede elegir dónde deben aparecer los botones de la lista.", + "feature_desc_scrollToActiveSongBtn": "Agregue un botón para desplazarse hasta la canción que se está reproduciendo actualmente", + "feature_desc_clearQueueBtn": "Agregue un botón para borrar rápidamente la cola de reproducción o la lista de reproducción actual", - "feature_desc_disableBeforeUnloadPopup": "Evite la ventana emergente de confirmación que aparece al intentar salir del sitio mientras se reproduce una canción", - "feature_helptext_disableBeforeUnloadPopup": "Cuando intenta salir del sitio mientras está reproduciendo una canción que lleva unos segundos, aparecerá una ventana emergente que le pedirá que confirme que desea salir del sitio. Podría decir algo así como \"tiene datos no guardados\" o \"este sitio está preguntando si desea cerrarlo\".\nEsta función deshabilita completamente esa ventana emergente.", - "feature_desc_closeToastsTimeout": "Después de cuántos segundos cerrar las notificaciones permanentes - 0 para cerrarlas solo manualmente (comportamiento predeterminado)", - "feature_helptext_closeToastsTimeout": "La mayoría de las notificaciones que aparecen en la esquina inferior izquierda se cerrarán automáticamente después de 3 segundos, con la excepción de ciertas como cuando le gusta una canción.\nEsta función le permite establecer un tiempo para que se cierren las notificaciones permanentes.\nEl otro tipo de notificaciones no se verá afectado.\nEstablezca esto en 0 para el comportamiento predeterminado de no cerrar las notificaciones permanentes.", - "feature_desc_rememberSongTime": "Recuerde el tiempo de la última canción al volver a cargar o restaurar la pestaña", - "feature_helptext_rememberSongTime-1": "A veces, al volver a cargar la página o restaurarla después de cerrarla accidentalmente, desea reanudar la escucha en el mismo punto. Esta función le permite hacer eso.\nPara registrar el tiempo de la canción, debe reproducirla durante %1 segundo, luego su tiempo se recordará y se podrá restaurar por un corto tiempo.", - "feature_helptext_rememberSongTime-n": "A veces, al volver a cargar la página o restaurarla después de cerrarla accidentalmente, desea reanudar la escucha en el mismo punto. Esta función le permite hacer eso.\nPara registrar el tiempo de la canción, debe reproducirla durante %1 segundos, luego su tiempo se recordará y se podrá restaurar por un corto tiempo.", - "feature_desc_rememberSongTimeSites": "¿En qué sitios se debe recordar y restaurar el tiempo de la canción?", - "feature_desc_rememberSongTimeDuration": "Duración en segundos para recordar el tiempo de la canción después de que se haya reproducido por completo", - "feature_desc_rememberSongTimeReduction": "Cuántos segundos restar al restaurar el tiempo de una canción recordada", - "feature_helptext_rememberSongTimeReduction": "Al restaurar el tiempo de una canción que se recordó, se restarán esta cantidad de segundos del tiempo recordado para que pueda volver a escuchar la parte que se interrumpió.", - "feature_desc_rememberSongTimeMinPlayTime": "Cantidad mínima de segundos que una canción debe reproducirse para recordar su tiempo", + "feature_desc_disableBeforeUnloadPopup": "Evite la ventana emergente de confirmación que aparece al intentar salir del sitio mientras se reproduce una canción", + "feature_helptext_disableBeforeUnloadPopup": "Cuando intenta salir del sitio mientras está reproduciendo una canción que lleva unos segundos, aparecerá una ventana emergente que le pedirá que confirme que desea salir del sitio. Podría decir algo así como \"tiene datos no guardados\" o \"este sitio está preguntando si desea cerrarlo\".\nEsta función deshabilita completamente esa ventana emergente.", + "feature_desc_closeToastsTimeout": "Después de cuántos segundos cerrar las notificaciones permanentes - 0 para cerrarlas solo manualmente (comportamiento predeterminado)", + "feature_helptext_closeToastsTimeout": "La mayoría de las notificaciones que aparecen en la esquina inferior izquierda se cerrarán automáticamente después de 3 segundos, con la excepción de ciertas como cuando le gusta una canción.\nEsta función le permite establecer un tiempo para que se cierren las notificaciones permanentes.\nEl otro tipo de notificaciones no se verá afectado.\nEstablezca esto en 0 para el comportamiento predeterminado de no cerrar las notificaciones permanentes.", + "feature_desc_rememberSongTime": "Recuerde el tiempo de la última canción al volver a cargar o restaurar la pestaña", + "feature_helptext_rememberSongTime-1": "A veces, al volver a cargar la página o restaurarla después de cerrarla accidentalmente, desea reanudar la escucha en el mismo punto. Esta función le permite hacer eso.\nPara registrar el tiempo de la canción, debe reproducirla durante %1 segundo, luego su tiempo se recordará y se podrá restaurar por un corto tiempo.", + "feature_helptext_rememberSongTime-n": "A veces, al volver a cargar la página o restaurarla después de cerrarla accidentalmente, desea reanudar la escucha en el mismo punto. Esta función le permite hacer eso.\nPara registrar el tiempo de la canción, debe reproducirla durante %1 segundos, luego su tiempo se recordará y se podrá restaurar por un corto tiempo.", + "feature_desc_rememberSongTimeSites": "¿En qué sitios se debe recordar y restaurar el tiempo de la canción?", + "feature_desc_rememberSongTimeDuration": "Duración en segundos para recordar el tiempo de la canción después de que se haya reproducido por completo", + "feature_desc_rememberSongTimeReduction": "Cuántos segundos restar al restaurar el tiempo de una canción recordada", + "feature_helptext_rememberSongTimeReduction": "Al restaurar el tiempo de una canción que se recordó, se restarán esta cantidad de segundos del tiempo recordado para que pueda volver a escuchar la parte que se interrumpió.", + "feature_desc_rememberSongTimeMinPlayTime": "Cantidad mínima de segundos que una canción debe reproducirse para recordar su tiempo", - "feature_desc_arrowKeySupport": "Use las teclas de flecha para saltar hacia adelante y hacia atrás en la canción que se está reproduciendo actualmente", - "feature_helptext_arrowKeySupport": "Normalmente solo puede saltar hacia adelante y hacia atrás en un intervalo fijo de 10 segundos con las teclas \"H\" y \"L\". Esta función le permite usar las teclas de flecha también.\nPara cambiar la cantidad de segundos para saltar, use la opción a continuación.", - "feature_desc_arrowKeySkipBy": "Por cuántos segundos saltar al usar las teclas de flecha", - "feature_desc_switchBetweenSites": "Agregue una tecla de acceso rápido para cambiar entre los sitios YT y YTM en una canción", - "feature_helptext_switchBetweenSites": "Al presionar esta tecla de acceso rápido, cambiará al otro sitio si está en YouTube o YouTube Music mientras se mantiene en el mismo video / canción.", - "feature_desc_switchSitesHotkey": "¿Qué tecla de acceso rápido debe presionarse para cambiar de sitio?", - "feature_desc_anchorImprovements": "Agregue e improvise enlaces en toda la página para que las cosas se puedan abrir en una nueva pestaña más fácilmente", - "feature_helptext_anchorImprovements": "Algunos elementos en la página solo se pueden hacer clic con el botón izquierdo del mouse, lo que significa que no se pueden abrir en una nueva pestaña haciendo clic con el botón central o mediante el menú contextual con shift + clic derecho. Esta función agrega enlaces a muchos de ellos o los agranda para facilitar el clic.", - "feature_desc_autoLikeChannels": "Dar me gusta automáticamente a todas las canciones y videos de ciertos canales", - "feature_helpText_autoLikeChannels": "Una vez habilitado, puede habilitar esta función para ciertos canales abriendo su página y haciendo clic en el botón de alternar. Después, cualquier canción que reproduzca de ese canal se dará me gusta automáticamente.\nUtilice la opción a continuación para abrir un diálogo para administrar los canales.", - "feature_desc_autoLikeChannelToggleBtn": "Agregue un botón a cada página de canal para habilitar o deshabilitar el me gusta automático", - "feature_desc_autoLikePlayerBarToggleBtn": "Agregue un botón a los controles multimedia para habilitar o deshabilitar el me gusta automático", - "feature_desc_autoLikeTimeout": "Cuántos segundos necesita reproducirse una canción antes de darle me gusta automáticamente", - "feature_desc_autoLikeShowToast": "Mostrar una notificación de tostada cuando se da me gusta automáticamente a una canción", - "feature_desc_autoLikeOpenMgmtDialog": "Abrir el diálogo para administrar los canales con me gusta automático", - "feature_btn_autoLikeOpenMgmtDialog": "Abrir diálogo", - "feature_btn_autoLikeOpenMgmtDialog_running": "Abriendo...", + "feature_desc_arrowKeySupport": "Use las teclas de flecha para saltar hacia adelante y hacia atrás en la canción que se está reproduciendo actualmente", + "feature_helptext_arrowKeySupport": "Normalmente solo puede saltar hacia adelante y hacia atrás en un intervalo fijo de 10 segundos con las teclas \"H\" y \"L\". Esta función le permite usar las teclas de flecha también.\nPara cambiar la cantidad de segundos para saltar, use la opción a continuación.", + "feature_desc_arrowKeySkipBy": "Por cuántos segundos saltar al usar las teclas de flecha", + "feature_desc_switchBetweenSites": "Agregue una tecla de acceso rápido para cambiar entre los sitios YT y YTM en una canción", + "feature_helptext_switchBetweenSites": "Al presionar esta tecla de acceso rápido, cambiará al otro sitio si está en YouTube o YouTube Music mientras se mantiene en el mismo video / canción.", + "feature_desc_switchSitesHotkey": "¿Qué tecla de acceso rápido debe presionarse para cambiar de sitio?", + "feature_desc_anchorImprovements": "Agregue e improvise enlaces en toda la página para que las cosas se puedan abrir en una nueva pestaña más fácilmente", + "feature_helptext_anchorImprovements": "Algunos elementos en la página solo se pueden hacer clic con el botón izquierdo del mouse, lo que significa que no se pueden abrir en una nueva pestaña haciendo clic con el botón central o mediante el menú contextual con shift + clic derecho. Esta función agrega enlaces a muchos de ellos o los agranda para facilitar el clic.", + "feature_desc_autoLikeChannels": "Dar me gusta automáticamente a todas las canciones y videos de ciertos canales", + "feature_helpText_autoLikeChannels": "Una vez habilitado, puede habilitar esta función para ciertos canales abriendo su página y haciendo clic en el botón de alternar. Después, cualquier canción que reproduzca de ese canal se dará me gusta automáticamente.\nUtilice la opción a continuación para abrir un diálogo para administrar los canales.", + "feature_desc_autoLikeChannelToggleBtn": "Agregue un botón a cada página de canal para habilitar o deshabilitar el me gusta automático", + "feature_desc_autoLikePlayerBarToggleBtn": "Agregue un botón a los controles multimedia para habilitar o deshabilitar el me gusta automático", + "feature_desc_autoLikeTimeout": "Cuántos segundos necesita reproducirse una canción antes de darle me gusta automáticamente", + "feature_desc_autoLikeShowToast": "Mostrar una notificación de tostada cuando se da me gusta automáticamente a una canción", + "feature_desc_autoLikeOpenMgmtDialog": "Abrir el diálogo para administrar los canales con me gusta automático", + "feature_btn_autoLikeOpenMgmtDialog": "Abrir diálogo", + "feature_btn_autoLikeOpenMgmtDialog_running": "Abriendo...", - "feature_desc_geniusLyrics": "Agregue un botón a los controles multimedia de la canción que se está reproduciendo actualmente para abrir sus letras en genius.com", - "feature_desc_errorOnLyricsNotFound": "Mostrar un error cuando no se pudo encontrar la página de letras para la canción que se está reproduciendo actualmente", - "feature_desc_geniUrlBase": "Base URL de su instancia de geniURL, consulte https://github.com/Sv443/geniURL", - "feature_helptext_geniUrlBase": "Si tiene su propia instancia de geniURL en ejecución (por ejemplo, para evitar la limitación de velocidad), puede ingresar su base URL aquí para usarla para el botón de letras de genius.com.\nSi no sabe qué es esto, puede dejar esta opción como está.", - "feature_desc_geniUrlToken": "Token de autenticación para su instancia de geniURL", - "feature_helptext_geniUrlToken": "Para evitar la limitación de velocidad, puede proporcionar un token de autenticación que también está definido en el archivo .env de su instancia de geniURL.", - "feature_desc_lyricsCacheMaxSize": "Cantidad máxima de letras para mantener en la caché", - "feature_helptext_lyricsCacheMaxSize": "Las letras de las canciones que escuchas se almacenan en una caché para reducir la cantidad de solicitudes al proveedor de letras.\nEsta función le permite establecer la cantidad máxima de letras para mantener en la caché.\nCuando se alcanza el límite, la entrada más antigua se eliminará para hacer espacio para cualquier nueva.", - "feature_desc_lyricsCacheTTL": "Cantidad máxima de días para mantener una entrada de letras en la caché", - "feature_helptext_lyricsCacheTTL": "La caché en la que se almacenan las letras eliminará automáticamente las entradas después de este tiempo para asegurarse de que las actualizaciones de la fuente se estén obteniendo más temprano o más tarde.\nSi quieres asegurarte de que siempre tienes las letras más recientes, establece esto en un valor bajo como 4 días.", - "feature_desc_clearLyricsCache": "Borrar la caché de letras manualmente", - "feature_helptext_clearLyricsCache": "Si las letras que están en la caché local están desactualizadas o simplemente quieres liberar algo de espacio, puedes borrar la caché manualmente presionando este botón.", - "feature_btn_clearLyricsCache": "Borrar caché", - "feature_btn_clearLyricsCache_running": "Borrando...", - "feature_desc_advancedLyricsFilter": "Experimental: Habilitar el filtrado avanzado para la búsqueda de letras", - "feature_helptext_advancedLyricsFilter": "El filtrado avanzado incluye múltiples capas de filtros que tienen como objetivo hacer que las búsquedas de letras sean más confiables.\nEstos filtros pueden no funcionar bien para canciones en su idioma y canciones y artistas con títulos menos únicos en general.\nAdvertencia: esta función todavía es experimental y es posible que no funcione mejor que la búsqueda de letras predeterminada. Asegúrese de confirmar el mensaje que aparece si cambió esta configuración.", + "feature_desc_geniusLyrics": "Agregue un botón a los controles multimedia de la canción que se está reproduciendo actualmente para abrir sus letras en genius.com", + "feature_desc_errorOnLyricsNotFound": "Mostrar un error cuando no se pudo encontrar la página de letras para la canción que se está reproduciendo actualmente", + "feature_desc_geniUrlBase": "Base URL de su instancia de geniURL, consulte https://github.com/Sv443/geniURL", + "feature_helptext_geniUrlBase": "Si tiene su propia instancia de geniURL en ejecución (por ejemplo, para evitar la limitación de velocidad), puede ingresar su base URL aquí para usarla para el botón de letras de genius.com.\nSi no sabe qué es esto, puede dejar esta opción como está.", + "feature_desc_geniUrlToken": "Token de autenticación para su instancia de geniURL", + "feature_helptext_geniUrlToken": "Para evitar la limitación de velocidad, puede proporcionar un token de autenticación que también está definido en el archivo .env de su instancia de geniURL.", + "feature_desc_lyricsCacheMaxSize": "Cantidad máxima de letras para mantener en la caché", + "feature_helptext_lyricsCacheMaxSize": "Las letras de las canciones que escuchas se almacenan en una caché para reducir la cantidad de solicitudes al proveedor de letras.\nEsta función le permite establecer la cantidad máxima de letras para mantener en la caché.\nCuando se alcanza el límite, la entrada más antigua se eliminará para hacer espacio para cualquier nueva.", + "feature_desc_lyricsCacheTTL": "Cantidad máxima de días para mantener una entrada de letras en la caché", + "feature_helptext_lyricsCacheTTL": "La caché en la que se almacenan las letras eliminará automáticamente las entradas después de este tiempo para asegurarse de que las actualizaciones de la fuente se estén obteniendo más temprano o más tarde.\nSi quieres asegurarte de que siempre tienes las letras más recientes, establece esto en un valor bajo como 4 días.", + "feature_desc_clearLyricsCache": "Borrar la caché de letras manualmente", + "feature_helptext_clearLyricsCache": "Si las letras que están en la caché local están desactualizadas o simplemente quieres liberar algo de espacio, puedes borrar la caché manualmente presionando este botón.", + "feature_btn_clearLyricsCache": "Borrar caché", + "feature_btn_clearLyricsCache_running": "Borrando...", + "feature_desc_advancedLyricsFilter": "Experimental: Habilitar el filtrado avanzado para la búsqueda de letras", + "feature_helptext_advancedLyricsFilter": "El filtrado avanzado incluye múltiples capas de filtros que tienen como objetivo hacer que las búsquedas de letras sean más confiables.\nEstos filtros pueden no funcionar bien para canciones en su idioma y canciones y artistas con títulos menos únicos en general.\nAdvertencia: esta función todavía es experimental y es posible que no funcione mejor que la búsqueda de letras predeterminada. Asegúrese de confirmar el mensaje que aparece si cambió esta configuración.", - "feature_desc_disableDarkReaderSites": "En qué sitios debería deshabilitarse la extensión Dark Reader para corregir problemas de diseño", - "feature_helptext_disableDarkReaderSites": "La extensión Dark Reader puede causar problemas con el diseño del sitio.\nEsta función le permite deshabilitar Dark Reader en ciertos o todos los sitios para evitar esos problemas.\n\nSi la extensión no está instalada, esta función no tendrá efecto y se puede dejar activada.", - "feature_desc_sponsorBlockIntegration": "Hacer algunas correcciones en el diseño en caso de que SponsorBlock esté instalado", - "feature_helptext_sponsorBlockIntegration": "Si tiene la extensión SponsorBlock instalada, esta función hará algunas correcciones en el diseño del sitio para evitar problemas que puedan ocurrir.\n\nEsta función no tendrá efecto si la extensión no está instalada y se puede dejar activada en ese caso.", - "feature_desc_themeSongIntegration": "Corregir algunos problemas de estilo si la extensión ThemeSong está instalada", - "feature_helptext_themeSongIntegration": "Si la extensión ThemeSong está instalada pero esta función está desactivada (o viceversa), es posible que notes un estilo roto en el sitio.\n\n¡Asegúrate siempre de que esta función refleje si la extensión está instalada o no!", - "feature_desc_themeSongLightness": "¿Qué tan claro deben ser los colores de acento derivados del tema actual de ThemeSong?", - "feature_helptext_themeSongLightness": "Dependiendo de la configuración que hayas elegido para la extensión ThemeSong, esta función te permite ajustar la claridad de los colores de acento que se derivan del tema actual.\n\nEsta función no tendrá efecto si la extensión ThemeSong no está instalada.", - "feature_desc_openPluginList": "Abrir la lista de plugins que tienes instalados", - "feature_btn_openPluginList": "Abrir lista", - "feature_btn_openPluginList_running": "Abriendo...", - "feature_desc_initTimeout": "Cuántos segundos esperar a que se inicialicen las funciones antes de considerarlas probablemente en un estado de error", - "feature_helptext_initTimeout": "Este es el tiempo en segundos que el script esperará a que se inicialicen las funciones antes de considerarlas probablemente en un estado de error.\nEsto no afectará significativamente el comportamiento del script, pero si uno de sus complementos no puede inicializarse a tiempo, debería intentar aumentar este valor.", + "feature_desc_disableDarkReaderSites": "En qué sitios debería deshabilitarse la extensión Dark Reader para corregir problemas de diseño", + "feature_helptext_disableDarkReaderSites": "La extensión Dark Reader puede causar problemas con el diseño del sitio.\nEsta función le permite deshabilitar Dark Reader en ciertos o todos los sitios para evitar esos problemas.\n\nSi la extensión no está instalada, esta función no tendrá efecto y se puede dejar activada.", + "feature_desc_sponsorBlockIntegration": "Hacer algunas correcciones en el diseño en caso de que SponsorBlock esté instalado", + "feature_helptext_sponsorBlockIntegration": "Si tiene la extensión SponsorBlock instalada, esta función hará algunas correcciones en el diseño del sitio para evitar problemas que puedan ocurrir.\n\nEsta función no tendrá efecto si la extensión no está instalada y se puede dejar activada en ese caso.", + "feature_desc_themeSongIntegration": "Corregir algunos problemas de estilo si la extensión ThemeSong está instalada", + "feature_helptext_themeSongIntegration": "Si la extensión ThemeSong está instalada pero esta función está desactivada (o viceversa), es posible que notes un estilo roto en el sitio.\n\n¡Asegúrate siempre de que esta función refleje si la extensión está instalada o no!", + "feature_desc_themeSongLightness": "¿Qué tan claro deben ser los colores de acento derivados del tema actual de ThemeSong?", + "feature_helptext_themeSongLightness": "Dependiendo de la configuración que hayas elegido para la extensión ThemeSong, esta función te permite ajustar la claridad de los colores de acento que se derivan del tema actual.\n\nEsta función no tendrá efecto si la extensión ThemeSong no está instalada.", + "feature_desc_openPluginList": "Abrir la lista de plugins que tienes instalados", + "feature_btn_openPluginList": "Abrir lista", + "feature_btn_openPluginList_running": "Abriendo...", + "feature_desc_initTimeout": "Cuántos segundos esperar a que se inicialicen las funciones antes de considerarlas probablemente en un estado de error", + "feature_helptext_initTimeout": "Este es el tiempo en segundos que el script esperará a que se inicialicen las funciones antes de considerarlas probablemente en un estado de error.\nEsto no afectará significativamente el comportamiento del script, pero si uno de sus complementos no puede inicializarse a tiempo, debería intentar aumentar este valor.", - "feature_desc_locale": "Idioma", - "feature_desc_localeFallback": "Utilizar el inglés para las traducciones que faltan (desactivar si contribuye a las traducciones)", - "feature_desc_versionCheck": "Compruebe si hay actualizaciones", - "feature_helptext_versionCheck": "Esta función comprueba si hay actualizaciones cada 24 horas, le notifica si hay una nueva versión disponible y le permite actualizar el script manualmente.\nSi su extensión de administrador de usuarios de scripts actualiza los scripts automáticamente, puede desactivar esta función.", - "feature_desc_checkVersionNow": "Compruebe manualmente si hay una nueva versión", - "feature_btn_checkVersionNow": "Comprobar ahora", - "feature_btn_checkVersionNow_running": "Comprobando...", - "feature_desc_logLevel": "Cuánta información registrar en la consola", - "feature_helptext_logLevel": "Cambiar esto solo es necesario para fines de depuración como resultado de experimentar un problema.\nSi tiene uno, puede aumentar el nivel de registro aquí, abrir la consola de JavaScript de su navegador (generalmente con Ctrl + Shift + K) y adjuntar capturas de pantalla de ese registro en un problema de GitHub.", - "feature_desc_toastDuration": "Duración en segundos durante cuánto tiempo se deben mostrar las notificaciones personalizadas - 0 para deshabilitarlas por completo", - "feature_desc_showToastOnGenericError": "¿Mostrar una notificación cuando se produce un error genérico?", - "feature_helptext_showToastOnGenericError": "Si se produce un error en el script que impide que partes de él funcionen correctamente, se mostrará una notificación para informarle al respecto.\nSi encuentra un problema con frecuencia, copie el error de la consola de JavaScript (generalmente en el menú F12) y abra un problema en GitHub.", - "feature_desc_resetConfig": "Restablecer todas las configuraciones a sus valores predeterminados", - "feature_btn_resetConfig": "Restablecer configuraciones", - "feature_btn_resetConfig_running": "Restableciendo...", - "feature_desc_advancedMode": "Mostrar configuración avanzada (recarga el menú)", - "feature_helptext_advancedMode": "Después de habilitar esto, el menú se recargará y mostrará configuraciones avanzadas que están ocultas de forma predeterminada.\nEsto es útil si desea personalizar más profundamente el comportamiento del script y no le importa un menú abarrotado." - } + "feature_desc_locale": "Idioma", + "feature_desc_localeFallback": "Utilizar el inglés para las traducciones que faltan (desactivar si contribuye a las traducciones)", + "feature_desc_versionCheck": "Compruebe si hay actualizaciones", + "feature_helptext_versionCheck": "Esta función comprueba si hay actualizaciones cada 24 horas, le notifica si hay una nueva versión disponible y le permite actualizar el script manualmente.\nSi su extensión de administrador de usuarios de scripts actualiza los scripts automáticamente, puede desactivar esta función.", + "feature_desc_checkVersionNow": "Compruebe manualmente si hay una nueva versión", + "feature_btn_checkVersionNow": "Comprobar ahora", + "feature_btn_checkVersionNow_running": "Comprobando...", + "feature_desc_logLevel": "Cuánta información registrar en la consola", + "feature_helptext_logLevel": "Cambiar esto solo es necesario para fines de depuración como resultado de experimentar un problema.\nSi tiene uno, puede aumentar el nivel de registro aquí, abrir la consola de JavaScript de su navegador (generalmente con Ctrl + Shift + K) y adjuntar capturas de pantalla de ese registro en un problema de GitHub.", + "feature_desc_toastDuration": "Duración en segundos durante cuánto tiempo se deben mostrar las notificaciones personalizadas - 0 para deshabilitarlas por completo", + "feature_desc_showToastOnGenericError": "¿Mostrar una notificación cuando se produce un error genérico?", + "feature_helptext_showToastOnGenericError": "Si se produce un error en el script que impide que partes de él funcionen correctamente, se mostrará una notificación para informarle al respecto.\nSi encuentra un problema con frecuencia, copie el error de la consola de JavaScript (generalmente en el menú F12) y abra un problema en GitHub.", + "feature_desc_resetConfig": "Restablecer todas las configuraciones a sus valores predeterminados", + "feature_btn_resetConfig": "Restablecer configuraciones", + "feature_btn_resetConfig_running": "Restableciendo...", + "feature_desc_advancedMode": "Mostrar configuración avanzada (recarga el menú)", + "feature_helptext_advancedMode": "Después de habilitar esto, el menú se recargará y mostrará configuraciones avanzadas que están ocultas de forma predeterminada.\nEsto es útil si desea personalizar más profundamente el comportamiento del script y no le importa un menú abarrotado." } diff --git a/assets/translations/fr-FR.json b/assets/translations/fr-FR.json index 2987208ba9..60ab2dc93b 100644 --- a/assets/translations/fr-FR.json +++ b/assets/translations/fr-FR.json @@ -1,356 +1,362 @@ { - "translations": { - "config_menu_option": "Configuration de %1", - "config_menu_title": "%1 - Configuration", - "changelog_menu_title": "%1 - Historique des modifications", - "export_menu_title": "%1 - Exporter la configuration", - "import_menu_title": "%1 - Importer la configuration", - "open_menu_tooltip": "Ouvrir le menu de configuration de %1", - "close_menu_tooltip": "Cliquez pour fermer le menu", - "reload_hint": "Veuillez recharger la page pour appliquer vos modifications", - "reload_now": "Recharger maintenant", - "reload_tooltip": "Recharger la page", - "feature_requires_reload": "Cette fonctionnalité nécessite un rechargement de la page", - "version_tooltip": "Version %1 (build %2) - cliquez pour ouvrir l'historique des modifications", - "bytm_config_export_import_title": "Exporter ou importer la configuration", - "bytm_config_import_desc": "Collez la configuration que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:", - "bytm_config_export_desc": "Copiez le texte suivant pour exporter votre configuration. Attention: il peut contenir des données sensibles.", - "export_import": "Export/Import", - "export": "Exporter", - "export_hint": "Copiez le texte suivant pour exporter votre configuration:", - "click_to_reveal": "(cliquez pour révéler)", - "click_to_reveal_sensitive_info": "(cliquez pour révéler des informations sensibles)", - "export_tooltip": "Exporter votre configuration actuelle", - "import": "Importer", - "import_hint": "Collez la configuration que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:", - "import_tooltip": "Importer une configuration que vous avez précédemment exportée", - "start_import_tooltip": "Cliquez pour importer les données que vous avez collées ci-dessus", - "import_error_invalid": "Les données importées ne sont pas valides", - "import_error_no_format_version": "Les données importées ne contiennent pas de version de format", - "import_error_no_data": "L'objet importé ne contient aucune donnée", - "import_error_wrong_format_version": "Les données importées sont dans une version de format non prise en charge (attendue %1 ou inférieure mais obtenue %2)", - "import_success": "Données importées avec succès", - "import_success_confirm_reload": "Les données ont été importées avec succès.\nVoulez-vous recharger la page maintenant pour appliquer les modifications?", - "reset_config_confirm": "Voulez-vous vraiment réinitialiser tous les paramètres à leurs valeurs par défaut?\nLa page sera automatiquement rechargée.", - "copy": "Copier", - "copy_to_clipboard": "Copier dans le presse-papiers", - "copy_to_clipboard_error": "Impossible de copier le texte dans le presse-papiers. Veuillez le copier manuellement d'ici:\n%1", - "copy_config_tooltip": "Copiez la configuration dans votre presse-papiers", - "copied": "Copié!", - "copied_to_clipboard": "Copié dans le presse-papiers!", - "copy_hidden": "Copier caché", - "copy_hidden_tooltip": "Cliquez pour copier la valeur cachée - il s'agit de données sensibles ⚠️", - "open_github": "Ouvrir %1 sur GitHub", - "open_discord": "Rejoignez mon serveur Discord", - "open_greasyfork": "Ouvrir %1 sur GreasyFork", - "open_openuserjs": "Ouvrir %1 sur OpenUserJS", - "lang_changed_prompt_reload": "La langue a été modifiée.\nVoulez-vous recharger la page maintenant pour appliquer les modifications?", - "search_placeholder": "Rechercher...", - "search_clear": "Effacer la recherche", + "meta": { + "langName": "Français", + "langNameEnglish": "French (France)", + "countryName": "France", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "Configuration de %1", + "config_menu_title": "%1 - Configuration", + "changelog_menu_title": "%1 - Historique des modifications", + "export_menu_title": "%1 - Exporter la configuration", + "import_menu_title": "%1 - Importer la configuration", + "open_menu_tooltip": "Ouvrir le menu de configuration de %1", + "close_menu_tooltip": "Cliquez pour fermer le menu", + "reload_hint": "Veuillez recharger la page pour appliquer vos modifications", + "reload_now": "Recharger maintenant", + "reload_tooltip": "Recharger la page", + "feature_requires_reload": "Cette fonctionnalité nécessite un rechargement de la page", + "version_tooltip": "Version %1 (build %2) - cliquez pour ouvrir l'historique des modifications", + "bytm_config_export_import_title": "Exporter ou importer la configuration", + "bytm_config_import_desc": "Collez la configuration que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:", + "bytm_config_export_desc": "Copiez le texte suivant pour exporter votre configuration. Attention: il peut contenir des données sensibles.", + "export_import": "Export/Import", + "export": "Exporter", + "export_hint": "Copiez le texte suivant pour exporter votre configuration:", + "click_to_reveal": "(cliquez pour révéler)", + "click_to_reveal_sensitive_info": "(cliquez pour révéler des informations sensibles)", + "export_tooltip": "Exporter votre configuration actuelle", + "import": "Importer", + "import_hint": "Collez la configuration que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:", + "import_tooltip": "Importer une configuration que vous avez précédemment exportée", + "start_import_tooltip": "Cliquez pour importer les données que vous avez collées ci-dessus", + "import_error_invalid": "Les données importées ne sont pas valides", + "import_error_no_format_version": "Les données importées ne contiennent pas de version de format", + "import_error_no_data": "L'objet importé ne contient aucune donnée", + "import_error_wrong_format_version": "Les données importées sont dans une version de format non prise en charge (attendue %1 ou inférieure mais obtenue %2)", + "import_success": "Données importées avec succès", + "import_success_confirm_reload": "Les données ont été importées avec succès.\nVoulez-vous recharger la page maintenant pour appliquer les modifications?", + "reset_config_confirm": "Voulez-vous vraiment réinitialiser tous les paramètres à leurs valeurs par défaut?\nLa page sera automatiquement rechargée.", + "copy": "Copier", + "copy_to_clipboard": "Copier dans le presse-papiers", + "copy_to_clipboard_error": "Impossible de copier le texte dans le presse-papiers. Veuillez le copier manuellement d'ici:\n%1", + "copy_config_tooltip": "Copiez la configuration dans votre presse-papiers", + "copied": "Copié!", + "copied_to_clipboard": "Copié dans le presse-papiers!", + "copy_hidden": "Copier caché", + "copy_hidden_tooltip": "Cliquez pour copier la valeur cachée - il s'agit de données sensibles ⚠️", + "open_github": "Ouvrir %1 sur GitHub", + "open_discord": "Rejoignez mon serveur Discord", + "open_greasyfork": "Ouvrir %1 sur GreasyFork", + "open_openuserjs": "Ouvrir %1 sur OpenUserJS", + "lang_changed_prompt_reload": "La langue a été modifiée.\nVoulez-vous recharger la page maintenant pour appliquer les modifications?", + "search_placeholder": "Rechercher...", + "search_clear": "Effacer la recherche", - "reset": "Réinitialiser", - "close": "Fermer", - "log_level_debug": "Déboguer (le plus)", - "log_level_info": "Info (seulement important)", - "toggled_on": "Activé", - "toggled_off": "Désactivé", - "trigger_btn_action": "Action", - "trigger_btn_action_running": "En cours...", - "new_entry": "Nouvelle entrée", - "new_entry_tooltip": "Cliquez pour créer une nouvelle entrée", - "remove_entry": "Supprimer cette entrée", - "edit_entry": "Modifier cette entrée", - "remove_from_queue": "Supprimer cette chanson de la file d'attente", - "delete_from_list": "Supprimer cette chanson de la liste", - "couldnt_remove_from_queue": "Impossible de supprimer cette chanson de la file d'attente", - "couldnt_delete_from_list": "Impossible de supprimer cette chanson de la liste", - "clear_list": "Effacer la liste", - "clear_list_confirm": "Voulez-vous vraiment effacer la liste et ne laisser que la chanson en cours de lecture?", - "scroll_to_playing": "Faites défiler jusqu'à la chanson en cours de lecture", - "scroll_to_bottom": "Cliquez pour faire défiler vers le bas", - "volume_tooltip": "Volume: %1% (Sensibilité: %2%)", - "volume_shared_tooltip": "Le niveau de volume est partagé entre les onglets - désactivez-le dans le menu de configuration", - "middle_click_open_tab": "Cliquez avec le bouton du milieu pour ouvrir dans un nouvel onglet", - "example_toast": "Exemple de notification", - "generic_error_toast_encountered_error_type": "Erreur rencontrée: %1", - "generic_error_toast_click_for_details": "Cliquez pour plus de détails", - "error": "Erreur", - "generic_error_dialog_message": "Une erreur est survenue.", - "generic_error_dialog_open_console_note": "Si cette erreur se produit à nouveau, veuillez ouvrir la console JavaScript (généralement avec Ctrl + Maj + K) et joindre une capture d'écran du message d'erreur **entier** qui commence par %1 à un nouveau [problème GitHub.](%2)", - "active_mode_display": "Mode %1", - "active_mode_tooltip-1": "Le %1 est actuellement actif", - "active_mode_tooltip-n": "Les %1 sont actuellement actifs", - "dev_mode": "Mode développeur", - "dev_mode_short": "Dev", - "advanced_mode": "Mode avancé", - "advanced_mode_short": "Avancé", - "experimental_feature": "Fonctionnalité expérimentale", + "reset": "Réinitialiser", + "close": "Fermer", + "log_level_debug": "Déboguer (le plus)", + "log_level_info": "Info (seulement important)", + "toggled_on": "Activé", + "toggled_off": "Désactivé", + "trigger_btn_action": "Action", + "trigger_btn_action_running": "En cours...", + "new_entry": "Nouvelle entrée", + "new_entry_tooltip": "Cliquez pour créer une nouvelle entrée", + "remove_entry": "Supprimer cette entrée", + "edit_entry": "Modifier cette entrée", + "remove_from_queue": "Supprimer cette chanson de la file d'attente", + "delete_from_list": "Supprimer cette chanson de la liste", + "couldnt_remove_from_queue": "Impossible de supprimer cette chanson de la file d'attente", + "couldnt_delete_from_list": "Impossible de supprimer cette chanson de la liste", + "clear_list": "Effacer la liste", + "clear_list_confirm": "Voulez-vous vraiment effacer la liste et ne laisser que la chanson en cours de lecture?", + "scroll_to_playing": "Faites défiler jusqu'à la chanson en cours de lecture", + "scroll_to_bottom": "Cliquez pour faire défiler vers le bas", + "volume_tooltip": "Volume: %1% (Sensibilité: %2%)", + "volume_shared_tooltip": "Le niveau de volume est partagé entre les onglets - désactivez-le dans le menu de configuration", + "middle_click_open_tab": "Cliquez avec le bouton du milieu pour ouvrir dans un nouvel onglet", + "example_toast": "Exemple de notification", + "generic_error_toast_encountered_error_type": "Erreur rencontrée: %1", + "generic_error_toast_click_for_details": "Cliquez pour plus de détails", + "error": "Erreur", + "generic_error_dialog_message": "Une erreur est survenue.", + "generic_error_dialog_open_console_note": "Si cette erreur se produit à nouveau, veuillez ouvrir la console JavaScript (généralement avec Ctrl + Maj + K) et joindre une capture d'écran du message d'erreur **entier** qui commence par %1 à un nouveau [problème GitHub.](%2)", + "active_mode_display": "Mode %1", + "active_mode_tooltip-1": "Le %1 est actuellement actif", + "active_mode_tooltip-n": "Les %1 sont actuellement actifs", + "dev_mode": "Mode développeur", + "dev_mode_short": "Dev", + "advanced_mode": "Mode avancé", + "advanced_mode_short": "Avancé", + "experimental_feature": "Fonctionnalité expérimentale", - "open_lyrics_search_prompt": "Entrez le titre de la chanson et l'artiste pour rechercher les paroles:", - "lyrics_loading": "Chargement de l'URL des paroles...", - "lyrics_rate_limited-1": "Vous êtes limité par le taux.\nVeuillez patienter quelques secondes avant de demander plus de paroles.", - "lyrics_rate_limited-n": "Vous êtes limité par le taux.\nVeuillez patienter %1 secondes avant de demander plus de paroles.", - "lyrics_not_found_confirm_open_search": "Il n'y a pas de page de paroles pour cette chanson.\nVoulez-vous ouvrir genius.com pour la rechercher manuellement?", - "lyrics_not_found_click_open_search": "Impossible de trouver l'URL des paroles - cliquez pour ouvrir la recherche manuelle des paroles", - "lyrics_clear_cache_confirm_prompt-1": "Le cache des paroles contient actuellement %1 entrée.\nVoulez-vous vraiment les supprimer?", - "lyrics_clear_cache_confirm_prompt-n": "Le cache des paroles contient actuellement %1 entrées.\nVoulez-vous vraiment les supprimer?", - "lyrics_clear_cache_success": "Le cache des paroles a été vidé avec succès.", - "lyrics_cache_changed_clear_confirm": "Vous avez modifié des paramètres qui affectent les données dans le cache des paroles, ce qui casse les recherches d'URL de paroles.\nVoulez-vous vider le cache maintenant?", + "open_lyrics_search_prompt": "Entrez le titre de la chanson et l'artiste pour rechercher les paroles:", + "lyrics_loading": "Chargement de l'URL des paroles...", + "lyrics_rate_limited-1": "Vous êtes limité par le taux.\nVeuillez patienter quelques secondes avant de demander plus de paroles.", + "lyrics_rate_limited-n": "Vous êtes limité par le taux.\nVeuillez patienter %1 secondes avant de demander plus de paroles.", + "lyrics_not_found_confirm_open_search": "Il n'y a pas de page de paroles pour cette chanson.\nVoulez-vous ouvrir genius.com pour la rechercher manuellement?", + "lyrics_not_found_click_open_search": "Impossible de trouver l'URL des paroles - cliquez pour ouvrir la recherche manuelle des paroles", + "lyrics_clear_cache_confirm_prompt-1": "Le cache des paroles contient actuellement %1 entrée.\nVoulez-vous vraiment les supprimer?", + "lyrics_clear_cache_confirm_prompt-n": "Le cache des paroles contient actuellement %1 entrées.\nVoulez-vous vraiment les supprimer?", + "lyrics_clear_cache_success": "Le cache des paroles a été vidé avec succès.", + "lyrics_cache_changed_clear_confirm": "Vous avez modifié des paramètres qui affectent les données dans le cache des paroles, ce qui casse les recherches d'URL de paroles.\nVoulez-vous vider le cache maintenant?", - "hotkey_input_click_to_change": "Cliquez pour changer", - "hotkey_input_click_to_change_tooltip": "%1 - Actuellement défini sur: %2 - Entrez n'importe quelle combinaison de touches pour changer. Remarque: certains lecteurs d'écran peuvent bloquer certaines combinaisons de touches.", - "hotkey_input_click_to_reset_tooltip": "Réinitialiser à la dernière combinaison de touches enregistrée", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Maj", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "Aucune touche", + "hotkey_input_click_to_change": "Cliquez pour changer", + "hotkey_input_click_to_change_tooltip": "%1 - Actuellement défini sur: %2 - Entrez n'importe quelle combinaison de touches pour changer. Remarque: certains lecteurs d'écran peuvent bloquer certaines combinaisons de touches.", + "hotkey_input_click_to_reset_tooltip": "Réinitialiser à la dernière combinaison de touches enregistrée", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Maj", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "Aucune touche", - "welcome_menu_title": "Bienvenue sur %1!", - "config_menu": "Menu de configuration", - "open_config_menu_tooltip": "Cliquez pour ouvrir le menu de configuration", - "open_changelog": "Historique des modifications", - "open_changelog_tooltip": "Cliquez pour ouvrir l'historique des modifications", - "feature_help_button_tooltip": "Cliquez pour obtenir plus d'informations sur la fonctionnalité suivante: \"%1\"", - "welcome_text_line_1": "Merci d'avoir installé!", - "welcome_text_line_2": "J'espère que vous apprécierez d'utiliser %1 autant que j'ai apprécié de le faire 😃", - "welcome_text_line_3": "Si vous aimez %1, laissez une note sur %2GreasyFork%3 ou %4OpenUserJS%5", - "welcome_text_line_4": "Mon travail repose sur des dons, alors veuillez envisager de %1faire un don ❤️%2", - "welcome_text_line_5": "Vous avez trouvé un bug ou souhaitez suggérer une fonctionnalité? Veuillez %1ouvrir un problème sur GitHub%2", + "welcome_menu_title": "Bienvenue sur %1!", + "config_menu": "Menu de configuration", + "open_config_menu_tooltip": "Cliquez pour ouvrir le menu de configuration", + "open_changelog": "Historique des modifications", + "open_changelog_tooltip": "Cliquez pour ouvrir l'historique des modifications", + "feature_help_button_tooltip": "Cliquez pour obtenir plus d'informations sur la fonctionnalité suivante: \"%1\"", + "welcome_text_line_1": "Merci d'avoir installé!", + "welcome_text_line_2": "J'espère que vous apprécierez d'utiliser %1 autant que j'ai apprécié de le faire 😃", + "welcome_text_line_3": "Si vous aimez %1, laissez une note sur %2GreasyFork%3 ou %4OpenUserJS%5", + "welcome_text_line_4": "Mon travail repose sur des dons, alors veuillez envisager de %1faire un don ❤️%2", + "welcome_text_line_5": "Vous avez trouvé un bug ou souhaitez suggérer une fonctionnalité? Veuillez %1ouvrir un problème sur GitHub%2", - "list_button_placement_queue_only": "Seulement dans la file d'attente", - "list_button_placement_everywhere": "Dans chaque liste de chansons", + "list_button_placement_queue_only": "Seulement dans la file d'attente", + "list_button_placement_everywhere": "Dans chaque liste de chansons", - "site_selection_both_sites": "Les deux sites", - "site_selection_only_yt": "Seulement YouTube", - "site_selection_only_ytm": "Seulement YouTube Music", - "site_selection_none": "Aucun (désactivé)", + "site_selection_both_sites": "Les deux sites", + "site_selection_only_yt": "Seulement YouTube", + "site_selection_only_ytm": "Seulement YouTube Music", + "site_selection_none": "Aucun (désactivé)", - "new_version_available": "Une nouvelle version de %1 est disponible!\nVous avez actuellement la version %2 installée et vous pouvez mettre à jour vers la version %3", - "open_update_page_install_manually": "Installer sur %1", - "disable_update_check": "Désactiver la vérification des mises à jour", - "reenable_in_config_menu": "(peut être réactivé dans le menu de configuration)", - "close_and_ignore_for_24h": "Fermer et ignorer pendant 24h", - "close_and_ignore_until_reenabled": "Fermer et ignorer jusqu'à ce qu'il soit réactivé", - "expand_release_notes": "Cliquez pour afficher les notes de version", - "collapse_release_notes": "Cliquez pour réduire les notes de version", - "no_updates_found": "Aucune mise à jour trouvée.", + "new_version_available": "Une nouvelle version de %1 est disponible!\nVous avez actuellement la version %2 installée et vous pouvez mettre à jour vers la version %3", + "open_update_page_install_manually": "Installer sur %1", + "disable_update_check": "Désactiver la vérification des mises à jour", + "reenable_in_config_menu": "(peut être réactivé dans le menu de configuration)", + "close_and_ignore_for_24h": "Fermer et ignorer pendant 24h", + "close_and_ignore_until_reenabled": "Fermer et ignorer jusqu'à ce qu'il soit réactivé", + "expand_release_notes": "Cliquez pour afficher les notes de version", + "collapse_release_notes": "Cliquez pour réduire les notes de version", + "no_new_version_found": "Aucune nouvelle version trouvée.", - "thumbnail_overlay_behavior_never": "Jamais", - "thumbnail_overlay_behavior_videos_only": "Seulement pour les vidéos", - "thumbnail_overlay_behavior_songs_only": "Seulement pour les chansons", - "thumbnail_overlay_behavior_always": "Toujours", - "thumbnail_overlay_toggle_btn_tooltip_hide": "Désactiver le superposition de vignette - cliquez au milieu ou avec Maj pour ouvrir dans un nouvel onglet", - "thumbnail_overlay_toggle_btn_tooltip_show": "Activer la superposition de vignette - cliquez au milieu ou avec Maj pour ouvrir dans un nouvel onglet", - "thumbnail_overlay_indicator_tooltip": "La superposition de vignette est actuellement active", - "thumbnail_overlay_image_fit_crop": "Rogner si nécessaire", - "thumbnail_overlay_image_fit_full": "Afficher l'image complète", - "thumbnail_overlay_image_fit_stretch": "Étirer pour s'adapter", + "thumbnail_overlay_behavior_never": "Jamais", + "thumbnail_overlay_behavior_videos_only": "Seulement pour les vidéos", + "thumbnail_overlay_behavior_songs_only": "Seulement pour les chansons", + "thumbnail_overlay_behavior_always": "Toujours", + "thumbnail_overlay_toggle_btn_tooltip_hide": "Désactiver le superposition de vignette - cliquez au milieu ou avec Maj pour ouvrir dans un nouvel onglet", + "thumbnail_overlay_toggle_btn_tooltip_show": "Activer la superposition de vignette - cliquez au milieu ou avec Maj pour ouvrir dans un nouvel onglet", + "thumbnail_overlay_indicator_tooltip": "La superposition de vignette est actuellement active", + "thumbnail_overlay_image_fit_crop": "Rogner si nécessaire", + "thumbnail_overlay_image_fit_full": "Afficher l'image complète", + "thumbnail_overlay_image_fit_stretch": "Étirer pour s'adapter", - "auto_like_channels_dialog_title": "Chaînes auto-aimées", - "auto_like_channels_dialog_desc": "Ici, vous pouvez voir les chaînes que vous avez configurées pour aimer automatiquement et vous pouvez les modifier, les activer, les désactiver et les supprimer.\nVous pouvez également créer manuellement des entrées, bien qu'il soit plus simple de simplement visiter la page de la chaîne et de cliquer sur le bouton qui s'y trouve.", - "auto_like": "Auto-aimer", - "auto_like_button_tooltip_enabled": "Cliquez pour désactiver l'auto-aimer. Cliquez sur Maj pour ouvrir le dialogue de gestion.", - "auto_like_button_tooltip_disabled": "Cliquez pour activer l'auto-aimer. Cliquez sur Maj pour ouvrir le dialogue de gestion.", - "auto_like_channel_toggle_tooltip": "Activer ou désactiver l'auto-aimer pour la chaîne %1", - "add_auto_like_channel_id_prompt": "Entrez l'ID de l'utilisateur (@Nom / UC...) ou l'URL complète de la chaîne que vous souhaitez auto-aimer.\nAppuyez sur \"Annuler\" pour quitter.", - "add_auto_like_channel_invalid_id": "L'ID de l'utilisateur saisi est invalide.\nAssurez-vous de copier l'intégralité de l'URL de la chaîne! Elle doit contenir une partie comme \"channel/UC...\" ou \"/@...\"", - "add_auto_like_channel_already_exists_prompt_new_name": "Une chaîne avec cet ID est déjà dans la liste.\nVoulez-vous changer le nom?", - "add_auto_like_channel_name_prompt": "Entrez le nom de la chaîne.\nAppuyez sur \"Annuler\" pour quitter.", - "auto_like_channel_edit_name_prompt": "Entrez le nouveau nom de cette chaîne.\nAppuyez sur \"Annuler\" pour quitter.", - "auto_like_channel_edit_id_prompt": "Entrez le nouvel ID de l'utilisateur (@Nom / UC...) ou l'URL complète de cette chaîne.\nAppuyez sur \"Annuler\" pour quitter.", - "auto_like_enabled_toast": "Auto-aimer activé", - "auto_like_disabled_toast": "Auto-aimer désactivé", - "auto_liked_a_channels_song": "Chanson aimée par %1", - "auto_liked_a_channels_video": "Vidéo aimée par %1", - "auto_like_click_to_configure": "Cliquez pour configurer", - "auto_like_export_or_import_tooltip": "Exporter ou importer vos chaînes auto-aimées", - "auto_like_export_import_title": "Exporter ou importer les chaînes auto-aimées", - "auto_like_export_desc": "Copiez le texte suivant pour exporter vos chaînes auto-aimées.", - "auto_like_import_desc": "Collez les chaînes auto-aimées que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:", + "auto_like_channels_dialog_title": "Chaînes auto-aimées", + "auto_like_channels_dialog_desc": "Ici, vous pouvez voir les chaînes que vous avez configurées pour aimer automatiquement et vous pouvez les modifier, les activer, les désactiver et les supprimer.\nVous pouvez également créer manuellement des entrées, bien qu'il soit plus simple de simplement visiter la page de la chaîne et de cliquer sur le bouton qui s'y trouve.", + "auto_like": "Auto-aimer", + "auto_like_button_tooltip_enabled": "Cliquez pour désactiver l'auto-aimer. Cliquez sur Maj pour ouvrir le dialogue de gestion.", + "auto_like_button_tooltip_disabled": "Cliquez pour activer l'auto-aimer. Cliquez sur Maj pour ouvrir le dialogue de gestion.", + "auto_like_channel_toggle_tooltip": "Activer ou désactiver l'auto-aimer pour la chaîne %1", + "add_auto_like_channel_id_prompt": "Entrez l'ID de l'utilisateur (@Nom / UC...) ou l'URL complète de la chaîne que vous souhaitez auto-aimer.\nAppuyez sur \"Annuler\" pour quitter.", + "add_auto_like_channel_invalid_id": "L'ID de l'utilisateur saisi est invalide.\nAssurez-vous de copier l'intégralité de l'URL de la chaîne! Elle doit contenir une partie comme \"channel/UC...\" ou \"/@...\"", + "add_auto_like_channel_already_exists_prompt_new_name": "Une chaîne avec cet ID est déjà dans la liste.\nVoulez-vous changer le nom?", + "add_auto_like_channel_name_prompt": "Entrez le nom de la chaîne.\nAppuyez sur \"Annuler\" pour quitter.", + "auto_like_channel_edit_name_prompt": "Entrez le nouveau nom de cette chaîne.\nAppuyez sur \"Annuler\" pour quitter.", + "auto_like_channel_edit_id_prompt": "Entrez le nouvel ID de l'utilisateur (@Nom / UC...) ou l'URL complète de cette chaîne.\nAppuyez sur \"Annuler\" pour quitter.", + "auto_like_enabled_toast": "Auto-aimer activé", + "auto_like_disabled_toast": "Auto-aimer désactivé", + "auto_liked_a_channels_song": "Chanson aimée par %1", + "auto_liked_a_channels_video": "Vidéo aimée par %1", + "auto_like_click_to_configure": "Cliquez pour configurer", + "auto_like_export_or_import_tooltip": "Exporter ou importer vos chaînes auto-aimées", + "auto_like_export_import_title": "Exporter ou importer les chaînes auto-aimées", + "auto_like_export_desc": "Copiez le texte suivant pour exporter vos chaînes auto-aimées.", + "auto_like_import_desc": "Collez les chaînes auto-aimées que vous souhaitez importer dans le champ ci-dessous, puis cliquez sur le bouton d'importation:", - "prompt_confirm": "Confirmer", - "prompt_close": "Fermer", - "prompt_cancel": "Annuler", + "prompt_confirm": "Confirmer", + "prompt_close": "Fermer", + "prompt_cancel": "Annuler", - "click_to_confirm_tooltip": "Cliquez pour confirmer", - "click_to_close_tooltip": "Cliquez pour fermer", - "click_to_cancel_tooltip": "Cliquez pour annuler", + "click_to_confirm_tooltip": "Cliquez pour confirmer", + "click_to_close_tooltip": "Cliquez pour fermer", + "click_to_cancel_tooltip": "Cliquez pour annuler", - "vote_label_likes-1": "%1 j'aime", - "vote_label_likes-n": "%1 j'aimes", - "vote_label_dislikes-1": "%1 je n'aime pas", - "vote_label_dislikes-n": "%1 je n'aime pas", + "vote_label_likes-1": "%1 j'aime", + "vote_label_likes-n": "%1 j'aimes", + "vote_label_dislikes-1": "%1 je n'aime pas", + "vote_label_dislikes-n": "%1 je n'aime pas", - "vote_ratio_disabled": "Désactivé", - "vote_ratio_green_red": "Vert et rouge", - "vote_ratio_blue_gray": "Bleu et gris", + "vote_ratio_disabled": "Désactivé", + "vote_ratio_green_red": "Vert et rouge", + "vote_ratio_blue_gray": "Bleu et gris", - "votes_format_short": "Court", - "votes_format_long": "Long", + "votes_format_short": "Court", + "votes_format_long": "Long", - "unit_entries-1": "entrée", - "unit_entries-n": "entrées", + "unit_entries-1": "entrée", + "unit_entries-n": "entrées", - "unit_days-1": "jour", - "unit_days-n": "jours", + "unit_days-1": "jour", + "unit_days-n": "jours", - "color_lightness_darker": "Plus foncé", - "color_lightness_normal": "Normal", - "color_lightness_lighter": "Plus clair", + "color_lightness_darker": "Plus foncé", + "color_lightness_normal": "Normal", + "color_lightness_lighter": "Plus clair", - "plugin_list_title": "Liste des plugins", - "plugin_list_no_plugins": "Aucun plugin n'est actuellement installé.\nVisitez %1cette page%2 pour plus d'informations.", - "plugin_list_no_plugins_tooltip": "Aucun plugin n'est actuellement installé.", - "plugin_list_permissions_header": "Permissions:", + "plugin_list_title": "Liste des plugins", + "plugin_list_no_plugins": "Aucun plugin n'est actuellement installé.\nVisitez %1cette page%2 pour plus d'informations.", + "plugin_list_no_plugins_tooltip": "Aucun plugin n'est actuellement installé.", + "plugin_list_permissions_header": "Permissions:", - "plugin_link_type_source": "Dépôt", - "plugin_link_type_other": "Autre / Page d'accueil", - "plugin_link_type_bug": "Signaler un bug", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "Dépôt", + "plugin_link_type_other": "Autre / Page d'accueil", + "plugin_link_type_bug": "Signaler un bug", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "Ce plugin peut lire la configuration de la fonctionnalité", - "plugin_intent_description_WriteFeatureConfig": "Ce plugin peut écrire dans la configuration de la fonctionnalité", - "plugin_intent_description_SeeHiddenConfigValues": "Ce plugin a accès à des valeurs de configuration cachées", - "plugin_intent_description_WriteLyricsCache": "Ce plugin peut écrire dans le cache des paroles", - "plugin_intent_description_WriteTranslations": "Ce plugin peut ajouter de nouvelles traductions et écraser les existantes", - "plugin_intent_description_CreateModalDialogs": "Ce plugin peut créer des boîtes de dialogue modales", - "plugin_intent_description_ReadAutoLikeData": "Ce plugin peut lire les données d'auto-aimer", - "plugin_intent_description_WriteAutoLikeData": "Ce plugin peut écrire dans les données d'auto-aimer", + "plugin_intent_description_ReadFeatureConfig": "Ce plugin peut lire la configuration de la fonctionnalité", + "plugin_intent_description_WriteFeatureConfig": "Ce plugin peut écrire dans la configuration de la fonctionnalité", + "plugin_intent_description_SeeHiddenConfigValues": "Ce plugin a accès à des valeurs de configuration cachées", + "plugin_intent_description_WriteLyricsCache": "Ce plugin peut écrire dans le cache des paroles", + "plugin_intent_description_WriteTranslations": "Ce plugin peut ajouter de nouvelles traductions et écraser les existantes", + "plugin_intent_description_CreateModalDialogs": "Ce plugin peut créer des boîtes de dialogue modales", + "plugin_intent_description_ReadAutoLikeData": "Ce plugin peut lire les données d'auto-aimer", + "plugin_intent_description_WriteAutoLikeData": "Ce plugin peut écrire dans les données d'auto-aimer", - "plugin_validation_error_no_property": "Aucune propriété '%1' avec le type '%2'", - "plugin_validation_error_invalid_property-1": "La propriété '%1' avec la valeur '%2' est invalide. Exemple de valeur: %3", - "plugin_validation_error_invalid_property-n": "La propriété '%1' avec la valeur '%2' est invalide. Exemples de valeurs: %3", + "plugin_validation_error_no_property": "Aucune propriété '%1' avec le type '%2'", + "plugin_validation_error_invalid_property-1": "La propriété '%1' avec la valeur '%2' est invalide. Exemple de valeur: %3", + "plugin_validation_error_invalid_property-n": "La propriété '%1' avec la valeur '%2' est invalide. Exemples de valeurs: %3", - "feature_category_layout": "Disposition", - "feature_category_volume": "Volume", - "feature_category_songLists": "Listes de chansons", - "feature_category_behavior": "Comportement", - "feature_category_input": "Entrée", - "feature_category_lyrics": "Paroles", - "feature_category_integrations": "Intégrations", - "feature_category_plugins": "Plugins", - "feature_category_general": "Général", + "feature_category_layout": "Disposition", + "feature_category_volume": "Volume", + "feature_category_songLists": "Listes de chansons", + "feature_category_behavior": "Comportement", + "feature_category_input": "Entrée", + "feature_category_lyrics": "Paroles", + "feature_category_integrations": "Intégrations", + "feature_category_plugins": "Plugins", + "feature_category_general": "Général", - "feature_desc_watermarkEnabled": "Afficher un filigrane sous le logo du site qui ouvre ce menu de configuration", - "feature_helptext_watermarkEnabled": "Si cela est désactivé, vous pouvez toujours ouvrir le menu de configuration en cliquant sur l'option dans le menu qui s'ouvre lorsque vous cliquez sur votre photo de profil dans le coin supérieur droit.\nCependant, il sera plus difficile de trouver l'easter egg ;)", - "feature_desc_removeShareTrackingParam": "Supprimer le paramètre de suivi \"?si\" de l'URL dans les menus de partage", - "feature_helptext_removeShareTrackingParam": "À des fins d'analyse, YouTube ajoute un paramètre de suivi à la fin de l'URL que vous pouvez copier dans le menu de partage. Bien qu'il ne soit pas directement nocif, il rend l'URL plus longue et donne à YouTube plus d'informations sur vous et les personnes à qui vous envoyez le lien.", - "feature_desc_removeShareTrackingParamSites": "Sur quels sites le paramètre de suivi de partage doit-il être supprimé?", - "feature_desc_numKeysSkipToTime": "Activer le saut à un moment spécifique de la vidéo en appuyant sur une touche numérique (0-9)", - "feature_desc_fixSpacing": "Corriger les problèmes d'espacement dans la mise en page", - "feature_helptext_fixSpacing": "Il existe divers endroits dans l'interface utilisateur où l'espacement entre les éléments est incohérent. Cette fonctionnalité corrige ces problèmes.", - "feature_desc_thumbnailOverlayBehavior": "Quand remplacer automatiquement l'élément vidéo par sa vignette dans la plus haute résolution", - "feature_helptext_thumbnailOverlayBehavior": "Si vous choisissez de ne jamais afficher la vignette, vous pouvez toujours la voir en cliquant sur le bouton de superposition de vignette dans les contrôles multimédias.\nSi vous choisissez de toujours afficher la vignette, vous pouvez la masquer en cliquant sur le même bouton.", - "feature_desc_thumbnailOverlayToggleBtnShown": "Ajouter un bouton aux contrôles multimédias pour activer ou désactiver manuellement la vignette", - "feature_helptext_thumbnailOverlayToggleBtnShown": "Ce bouton vous permettra d'activer ou de désactiver manuellement la vignette. Cela n'est pas affecté si la superposition est définie sur \"jamais affichée\".\nUne fois qu'une nouvelle vidéo ou chanson commence à jouer, l'état par défaut sera restauré.\nMaintenez la touche Maj enfoncée tout en cliquant ou appuyez sur le bouton du milieu de la souris pour ouvrir la vignette de la plus haute qualité dans un nouvel onglet.", - "feature_desc_thumbnailOverlayShowIndicator": "Afficher un indicateur dans le coin inférieur droit de la vignette lorsqu'elle est active?", - "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacité de l'indicateur de vignette", - "feature_desc_thumbnailOverlayImageFit": "Comment ajuster l'image de la vignette sur l'élément vidéo", - "feature_desc_hideCursorOnIdle": "Masquer le curseur après quelques secondes d'inactivité sur la vidéo", - "feature_desc_hideCursorOnIdleDelay": "Combien de secondes d'inactivité avant que le curseur ne soit masqué?", - "feature_desc_fixHdrIssues": "Prévenir certains problèmes de rendu lors de l'utilisation d'un GPU et d'un moniteur compatibles HDR", - "feature_desc_showVotes": "Afficher le nombre de j'aime et de je n'aime pas sur la chanson en cours de lecture", - "feature_helptext_showVotes": "Cette fonctionnalité est alimentée par Return YouTube Dislike et affichera le nombre approximatif de j'aime et de je n'aime pas sur la chanson en cours de lecture.", - "feature_desc_numbersFormat": "Comment les nombres doivent-ils être formatés?", + "feature_desc_watermarkEnabled": "Afficher un filigrane sous le logo du site qui ouvre ce menu de configuration", + "feature_helptext_watermarkEnabled": "Si cela est désactivé, vous pouvez toujours ouvrir le menu de configuration en cliquant sur l'option dans le menu qui s'ouvre lorsque vous cliquez sur votre photo de profil dans le coin supérieur droit.\nCependant, il sera plus difficile de trouver l'easter egg ;)", + "feature_desc_removeShareTrackingParam": "Supprimer le paramètre de suivi \"?si\" de l'URL dans les menus de partage", + "feature_helptext_removeShareTrackingParam": "À des fins d'analyse, YouTube ajoute un paramètre de suivi à la fin de l'URL que vous pouvez copier dans le menu de partage. Bien qu'il ne soit pas directement nocif, il rend l'URL plus longue et donne à YouTube plus d'informations sur vous et les personnes à qui vous envoyez le lien.", + "feature_desc_removeShareTrackingParamSites": "Sur quels sites le paramètre de suivi de partage doit-il être supprimé?", + "feature_desc_numKeysSkipToTime": "Activer le saut à un moment spécifique de la vidéo en appuyant sur une touche numérique (0-9)", + "feature_desc_fixSpacing": "Corriger les problèmes d'espacement dans la mise en page", + "feature_helptext_fixSpacing": "Il existe divers endroits dans l'interface utilisateur où l'espacement entre les éléments est incohérent. Cette fonctionnalité corrige ces problèmes.", + "feature_desc_thumbnailOverlayBehavior": "Quand remplacer automatiquement l'élément vidéo par sa vignette dans la plus haute résolution", + "feature_helptext_thumbnailOverlayBehavior": "Si vous choisissez de ne jamais afficher la vignette, vous pouvez toujours la voir en cliquant sur le bouton de superposition de vignette dans les contrôles multimédias.\nSi vous choisissez de toujours afficher la vignette, vous pouvez la masquer en cliquant sur le même bouton.", + "feature_desc_thumbnailOverlayToggleBtnShown": "Ajouter un bouton aux contrôles multimédias pour activer ou désactiver manuellement la vignette", + "feature_helptext_thumbnailOverlayToggleBtnShown": "Ce bouton vous permettra d'activer ou de désactiver manuellement la vignette. Cela n'est pas affecté si la superposition est définie sur \"jamais affichée\".\nUne fois qu'une nouvelle vidéo ou chanson commence à jouer, l'état par défaut sera restauré.\nMaintenez la touche Maj enfoncée tout en cliquant ou appuyez sur le bouton du milieu de la souris pour ouvrir la vignette de la plus haute qualité dans un nouvel onglet.", + "feature_desc_thumbnailOverlayShowIndicator": "Afficher un indicateur dans le coin inférieur droit de la vignette lorsqu'elle est active?", + "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacité de l'indicateur de vignette", + "feature_desc_thumbnailOverlayImageFit": "Comment ajuster l'image de la vignette sur l'élément vidéo", + "feature_desc_hideCursorOnIdle": "Masquer le curseur après quelques secondes d'inactivité sur la vidéo", + "feature_desc_hideCursorOnIdleDelay": "Combien de secondes d'inactivité avant que le curseur ne soit masqué?", + "feature_desc_fixHdrIssues": "Prévenir certains problèmes de rendu lors de l'utilisation d'un GPU et d'un moniteur compatibles HDR", + "feature_desc_showVotes": "Afficher le nombre de j'aime et de je n'aime pas sur la chanson en cours de lecture", + "feature_helptext_showVotes": "Cette fonctionnalité est alimentée par Return YouTube Dislike et affichera le nombre approximatif de j'aime et de je n'aime pas sur la chanson en cours de lecture.", + "feature_desc_numbersFormat": "Comment les nombres doivent-ils être formatés?", - "feature_desc_volumeSliderLabel": "Ajouter une étiquette de pourcentage à côté du curseur de volume", - "feature_desc_volumeSliderSize": "La largeur du curseur de volume en pixels", - "feature_desc_volumeSliderStep": "Sensibilité du curseur de volume (de combien de pour cent le volume peut être modifié à la fois)", - "feature_desc_volumeSliderScrollStep": "Sensibilité de la molette de la souris pour le curseur de volume en pour cent - se fixe à la valeur de sensibilité la plus proche par le haut", - "feature_helptext_volumeSliderScrollStep": "De combien de pour cent le volume doit être modifié lors du défilement du curseur de volume avec la molette de la souris.\nCela doit être un multiple de la sensibilité du curseur de volume, sinon il y aura de petits sauts irréguliers dans le volume lors du défilement.", - "feature_desc_volumeSharedBetweenTabs": "Le niveau de volume est partagé entre les onglets et mémorisé entre les sessions", - "feature_helptext_volumeSharedBetweenTabs": "Si vous modifiez le volume dans un onglet, le niveau de volume sera défini à la même valeur dans tous les autres onglets qui ont cette fonctionnalité activée.\nCette valeur sera mémorisée et restaurée entre les sessions, jusqu'à ce qu'elle soit désactivée.", - "feature_desc_setInitialTabVolume": "Définir le niveau de volume sur une valeur spécifique une fois lors de l'ouverture du site", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Cette fonctionnalité est incompatible avec la fonctionnalité \"Niveau de volume partagé entre les onglets\" et sera ignorée lors de l'utilisation de la fonctionnalité de volume partagé!", - "feature_desc_initialTabVolumeLevel": "La valeur à définir pour le niveau de volume lors de l'ouverture du site", + "feature_desc_volumeSliderLabel": "Ajouter une étiquette de pourcentage à côté du curseur de volume", + "feature_desc_volumeSliderSize": "La largeur du curseur de volume en pixels", + "feature_desc_volumeSliderStep": "Sensibilité du curseur de volume (de combien de pour cent le volume peut être modifié à la fois)", + "feature_desc_volumeSliderScrollStep": "Sensibilité de la molette de la souris pour le curseur de volume en pour cent - se fixe à la valeur de sensibilité la plus proche par le haut", + "feature_helptext_volumeSliderScrollStep": "De combien de pour cent le volume doit être modifié lors du défilement du curseur de volume avec la molette de la souris.\nCela doit être un multiple de la sensibilité du curseur de volume, sinon il y aura de petits sauts irréguliers dans le volume lors du défilement.", + "feature_desc_volumeSharedBetweenTabs": "Le niveau de volume est partagé entre les onglets et mémorisé entre les sessions", + "feature_helptext_volumeSharedBetweenTabs": "Si vous modifiez le volume dans un onglet, le niveau de volume sera défini à la même valeur dans tous les autres onglets qui ont cette fonctionnalité activée.\nCette valeur sera mémorisée et restaurée entre les sessions, jusqu'à ce qu'elle soit désactivée.", + "feature_desc_setInitialTabVolume": "Définir le niveau de volume sur une valeur spécifique une fois lors de l'ouverture du site", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Cette fonctionnalité est incompatible avec la fonctionnalité \"Niveau de volume partagé entre les onglets\" et sera ignorée lors de l'utilisation de la fonctionnalité de volume partagé!", + "feature_desc_initialTabVolumeLevel": "La valeur à définir pour le niveau de volume lors de l'ouverture du site", - "feature_desc_lyricsQueueButton": "Ajouter un bouton à chaque chanson dans une liste pour ouvrir sa page de paroles", - "feature_desc_deleteFromQueueButton": "Ajouter un bouton à chaque chanson dans une liste pour la supprimer rapidement", - "feature_desc_listButtonsPlacement": "Où les boutons de liste doivent-ils apparaître?", - "feature_helptext_listButtonsPlacement": "Il existe diverses listes de chansons sur le site comme les pages d'album, les listes de lecture et la file d'attente en cours de lecture.\nAvec cette option, vous pouvez choisir où les boutons de liste doivent apparaître.", - "feature_desc_scrollToActiveSongBtn": "Ajouter un bouton au-dessus de la file d'attente pour faire défiler jusqu'à la chanson en cours de lecture", - "feature_desc_clearQueueBtn": "Ajouter un bouton au-dessus de la file d'attente ou de la liste de lecture en cours pour la vider rapidement", + "feature_desc_lyricsQueueButton": "Ajouter un bouton à chaque chanson dans une liste pour ouvrir sa page de paroles", + "feature_desc_deleteFromQueueButton": "Ajouter un bouton à chaque chanson dans une liste pour la supprimer rapidement", + "feature_desc_listButtonsPlacement": "Où les boutons de liste doivent-ils apparaître?", + "feature_helptext_listButtonsPlacement": "Il existe diverses listes de chansons sur le site comme les pages d'album, les listes de lecture et la file d'attente en cours de lecture.\nAvec cette option, vous pouvez choisir où les boutons de liste doivent apparaître.", + "feature_desc_scrollToActiveSongBtn": "Ajouter un bouton au-dessus de la file d'attente pour faire défiler jusqu'à la chanson en cours de lecture", + "feature_desc_clearQueueBtn": "Ajouter un bouton au-dessus de la file d'attente ou de la liste de lecture en cours pour la vider rapidement", - "feature_desc_disableBeforeUnloadPopup": "Empêcher la fenêtre contextuelle de confirmation qui apparaît lors de la tentative de quitter le site pendant qu'une chanson est en cours de lecture", - "feature_helptext_disableBeforeUnloadPopup": "Lorsque vous essayez de quitter le site alors que vous êtes quelques secondes dans une chanson qui est en cours de lecture, une fenêtre contextuelle apparaîtra pour vous demander de confirmer que vous voulez quitter le site. Elle pourrait dire quelque chose comme \"vous avez des données non enregistrées\" ou \"ce site demande si vous voulez le fermer\".\nCette fonctionnalité désactive complètement cette fenêtre contextuelle.", - "feature_desc_closeToastsTimeout": "Au bout de combien de secondes fermer les notifications permanentes - 0 pour ne les fermer qu'à la main (comportement par défaut)", - "feature_helptext_closeToastsTimeout": "La plupart des notifications qui apparaissent dans le coin inférieur gauche se fermeront automatiquement après 3 secondes, à l'exception de certaines comme lorsque vous aimez une chanson.\nCette fonctionnalité vous permet de définir un délai pour la fermeture de ces notifications permanentes.\nLes autres types de notifications resteront inchangés.\nRéglez cette valeur à 0 pour le comportement par défaut de ne pas fermer les notifications permanentes.", - "feature_desc_rememberSongTime": "Se souvenir du temps de la dernière chanson lors du rechargement ou de la restauration de l'onglet", - "feature_helptext_rememberSongTime-1": "Parfois, lorsque vous rechargez la page ou la restaurez après l'avoir fermée accidentellement, vous voulez reprendre l'écoute au même point. Cette fonctionnalité vous permet de le faire.\nPour enregistrer le temps de la chanson, vous devez la lire pendant %1 seconde, puis son temps sera mémorisé et pourra être restauré pendant un court instant.", - "feature_helptext_rememberSongTime-n": "Parfois, lorsque vous rechargez la page ou la restaurez après l'avoir fermée accidentellement, vous voulez reprendre l'écoute au même point. Cette fonctionnalité vous permet de le faire.\nPour enregistrer le temps de la chanson, vous devez la lire pendant %1 secondes, puis son temps sera mémorisé et pourra être restauré pendant un court instant.", - "feature_desc_rememberSongTimeSites": "Sur quels sites le temps de la chanson doit-il être mémorisé et restauré?", - "feature_desc_rememberSongTimeDuration": "Combien de temps en secondes mémoriser le temps de la chanson après qu'elle a été lue pour la dernière fois", - "feature_desc_rememberSongTimeReduction": "De combien de secondes réduire le temps de la chanson mémorisée lors de sa restauration", - "feature_helptext_rememberSongTimeReduction": "Lors de la restauration du temps d'une chanson qui a été mémorisée, ce nombre de secondes sera soustrait du temps mémorisé pour que vous puissiez réécouter la partie qui a été interrompue.", - "feature_desc_rememberSongTimeMinPlayTime": "Temps minimum en secondes que la chanson doit être lue pour que son temps soit mémorisé", + "feature_desc_disableBeforeUnloadPopup": "Empêcher la fenêtre contextuelle de confirmation qui apparaît lors de la tentative de quitter le site pendant qu'une chanson est en cours de lecture", + "feature_helptext_disableBeforeUnloadPopup": "Lorsque vous essayez de quitter le site alors que vous êtes quelques secondes dans une chanson qui est en cours de lecture, une fenêtre contextuelle apparaîtra pour vous demander de confirmer que vous voulez quitter le site. Elle pourrait dire quelque chose comme \"vous avez des données non enregistrées\" ou \"ce site demande si vous voulez le fermer\".\nCette fonctionnalité désactive complètement cette fenêtre contextuelle.", + "feature_desc_closeToastsTimeout": "Au bout de combien de secondes fermer les notifications permanentes - 0 pour ne les fermer qu'à la main (comportement par défaut)", + "feature_helptext_closeToastsTimeout": "La plupart des notifications qui apparaissent dans le coin inférieur gauche se fermeront automatiquement après 3 secondes, à l'exception de certaines comme lorsque vous aimez une chanson.\nCette fonctionnalité vous permet de définir un délai pour la fermeture de ces notifications permanentes.\nLes autres types de notifications resteront inchangés.\nRéglez cette valeur à 0 pour le comportement par défaut de ne pas fermer les notifications permanentes.", + "feature_desc_rememberSongTime": "Se souvenir du temps de la dernière chanson lors du rechargement ou de la restauration de l'onglet", + "feature_helptext_rememberSongTime-1": "Parfois, lorsque vous rechargez la page ou la restaurez après l'avoir fermée accidentellement, vous voulez reprendre l'écoute au même point. Cette fonctionnalité vous permet de le faire.\nPour enregistrer le temps de la chanson, vous devez la lire pendant %1 seconde, puis son temps sera mémorisé et pourra être restauré pendant un court instant.", + "feature_helptext_rememberSongTime-n": "Parfois, lorsque vous rechargez la page ou la restaurez après l'avoir fermée accidentellement, vous voulez reprendre l'écoute au même point. Cette fonctionnalité vous permet de le faire.\nPour enregistrer le temps de la chanson, vous devez la lire pendant %1 secondes, puis son temps sera mémorisé et pourra être restauré pendant un court instant.", + "feature_desc_rememberSongTimeSites": "Sur quels sites le temps de la chanson doit-il être mémorisé et restauré?", + "feature_desc_rememberSongTimeDuration": "Combien de temps en secondes mémoriser le temps de la chanson après qu'elle a été lue pour la dernière fois", + "feature_desc_rememberSongTimeReduction": "De combien de secondes réduire le temps de la chanson mémorisée lors de sa restauration", + "feature_helptext_rememberSongTimeReduction": "Lors de la restauration du temps d'une chanson qui a été mémorisée, ce nombre de secondes sera soustrait du temps mémorisé pour que vous puissiez réécouter la partie qui a été interrompue.", + "feature_desc_rememberSongTimeMinPlayTime": "Temps minimum en secondes que la chanson doit être lue pour que son temps soit mémorisé", - "feature_desc_arrowKeySupport": "Utilisez les touches fléchées pour avancer et reculer dans la chanson en cours de lecture", - "feature_helptext_arrowKeySupport": "Normalement, vous ne pouvez avancer et reculer que par intervalles fixes de 10 secondes avec les touches \"H\" et \"L\". Cette fonctionnalité vous permet d'utiliser aussi les touches fléchées.\nPour changer le nombre de secondes à sauter, utilisez l'option ci-dessous.", - "feature_desc_arrowKeySkipBy": "De combien de secondes sauter en utilisant les touches fléchées", - "feature_desc_switchBetweenSites": "Ajouter un raccourci pour passer d'un site à l'autre sur une vidéo / chanson", - "feature_helptext_switchBetweenSites": "En appuyant sur ce raccourci, vous passerez à l'autre site si vous êtes sur YouTube ou YouTube Music tout en restant sur la même vidéo / chanson.", - "feature_desc_switchSitesHotkey": "Quelle touche de raccourci doit être enfoncée pour passer d'un site à l'autre?", - "feature_desc_anchorImprovements": "Ajouter et améliorer les liens sur toute la page pour que les choses puissent être ouvertes dans un nouvel onglet plus facilement", - "feature_helptext_anchorImprovements": "Certains éléments de la page ne sont cliquables qu'avec le bouton gauche de la souris, ce qui signifie que vous ne pouvez pas les ouvrir dans un nouvel onglet en cliquant au milieu ou via le menu contextuel en utilisant Maj + clic droit. Cette fonctionnalité ajoute des liens à beaucoup d'entre eux ou les agrandit pour faciliter le clic.", - "feature_desc_autoLikeChannels": "Aimer automatiquement toutes les chansons et vidéos de certaines chaînes", - "feature_helpText_autoLikeChannels": "Une fois activée, vous pouvez activer cette fonctionnalité pour certaines chaînes en ouvrant leur page et en cliquant sur le bouton bascule. Ensuite, toute chanson que vous jouez de cette chaîne sera aimée automatiquement.\nUtilisez l'option ci-dessous pour ouvrir une boîte de dialogue pour gérer les chaînes.", - "feature_desc_autoLikeChannelToggleBtn": "Ajouter un bouton à chaque page de chaîne pour activer ou désactiver l'auto-aimer", - "feature_desc_autoLikePlayerBarToggleBtn": "Ajouter un bouton aux contrôles multimédias pour activer ou désactiver l'auto-aimer", - "feature_desc_autoLikeTimeout": "Combien de secondes une chanson doit-elle jouer avant d'être aimée automatiquement", - "feature_desc_autoLikeShowToast": "Afficher une notification toast lorsqu'une chanson est aimée automatiquement", - "feature_desc_autoLikeOpenMgmtDialog": "Ouvrir la boîte de dialogue pour gérer les chaînes auto-aimées", - "feature_btn_autoLikeOpenMgmtDialog": "Ouvrir la boîte de dialogue", - "feature_btn_autoLikeOpenMgmtDialog_running": "Ouverture...", + "feature_desc_arrowKeySupport": "Utilisez les touches fléchées pour avancer et reculer dans la chanson en cours de lecture", + "feature_helptext_arrowKeySupport": "Normalement, vous ne pouvez avancer et reculer que par intervalles fixes de 10 secondes avec les touches \"H\" et \"L\". Cette fonctionnalité vous permet d'utiliser aussi les touches fléchées.\nPour changer le nombre de secondes à sauter, utilisez l'option ci-dessous.", + "feature_desc_arrowKeySkipBy": "De combien de secondes sauter en utilisant les touches fléchées", + "feature_desc_switchBetweenSites": "Ajouter un raccourci pour passer d'un site à l'autre sur une vidéo / chanson", + "feature_helptext_switchBetweenSites": "En appuyant sur ce raccourci, vous passerez à l'autre site si vous êtes sur YouTube ou YouTube Music tout en restant sur la même vidéo / chanson.", + "feature_desc_switchSitesHotkey": "Quelle touche de raccourci doit être enfoncée pour passer d'un site à l'autre?", + "feature_desc_anchorImprovements": "Ajouter et améliorer les liens sur toute la page pour que les choses puissent être ouvertes dans un nouvel onglet plus facilement", + "feature_helptext_anchorImprovements": "Certains éléments de la page ne sont cliquables qu'avec le bouton gauche de la souris, ce qui signifie que vous ne pouvez pas les ouvrir dans un nouvel onglet en cliquant au milieu ou via le menu contextuel en utilisant Maj + clic droit. Cette fonctionnalité ajoute des liens à beaucoup d'entre eux ou les agrandit pour faciliter le clic.", + "feature_desc_autoLikeChannels": "Aimer automatiquement toutes les chansons et vidéos de certaines chaînes", + "feature_helpText_autoLikeChannels": "Une fois activée, vous pouvez activer cette fonctionnalité pour certaines chaînes en ouvrant leur page et en cliquant sur le bouton bascule. Ensuite, toute chanson que vous jouez de cette chaîne sera aimée automatiquement.\nUtilisez l'option ci-dessous pour ouvrir une boîte de dialogue pour gérer les chaînes.", + "feature_desc_autoLikeChannelToggleBtn": "Ajouter un bouton à chaque page de chaîne pour activer ou désactiver l'auto-aimer", + "feature_desc_autoLikePlayerBarToggleBtn": "Ajouter un bouton aux contrôles multimédias pour activer ou désactiver l'auto-aimer", + "feature_desc_autoLikeTimeout": "Combien de secondes une chanson doit-elle jouer avant d'être aimée automatiquement", + "feature_desc_autoLikeShowToast": "Afficher une notification toast lorsqu'une chanson est aimée automatiquement", + "feature_desc_autoLikeOpenMgmtDialog": "Ouvrir la boîte de dialogue pour gérer les chaînes auto-aimées", + "feature_btn_autoLikeOpenMgmtDialog": "Ouvrir la boîte de dialogue", + "feature_btn_autoLikeOpenMgmtDialog_running": "Ouverture...", - "feature_desc_geniusLyrics": "Ajouter un bouton aux contrôles multimédias de la chanson en cours de lecture pour ouvrir ses paroles sur genius.com", - "feature_desc_errorOnLyricsNotFound": "Afficher une erreur lorsque la page de paroles pour la chanson en cours de lecture n'a pas pu être trouvée", - "feature_desc_geniUrlBase": "URL de base de votre instance geniURL, voir https://github.com/Sv443/geniURL", - "feature_helptext_geniUrlBase": "Si vous avez votre propre instance de geniURL en cours d'exécution (par exemple pour contourner la limitation du taux), vous pouvez entrer son URL de base ici pour l'utiliser pour le bouton de paroles de genius.com.\nSi vous ne savez pas ce que c'est, vous pouvez laisser cette option telle quelle.", - "feature_desc_geniUrlToken": "Jeton d'authentification pour votre instance geniURL", - "feature_helptext_geniUrlToken": "Pour contourner la limitation du taux, vous pouvez fournir un jeton d'authentification qui est également défini dans le fichier .env de votre instance geniURL.", - "feature_desc_lyricsCacheMaxSize": "Quantité maximale de paroles à conserver dans le cache", - "feature_helptext_lyricsCacheMaxSize": "Les paroles des chansons que vous écoutez sont stockées dans un cache afin de réduire le nombre de requêtes adressées au fournisseur de paroles.\nCette fonction vous permet de définir la quantité maximale de paroles à conserver dans le cache.\nLorsque la limite est atteinte, l'entrée la plus ancienne est supprimée pour faire de la place aux nouvelles.", - "feature_desc_lyricsCacheTTL": "Quantité maximale de jours pour conserver une entrée de paroles dans le cache", - "feature_helptext_lyricsCacheTTL": "Le cache dans lequel les paroles sont stockées les supprimera automatiquement après ce laps de temps pour garantir que les mises à jour de la source sont récupérées tôt ou tard.\nSi vous voulez vous assurer d'avoir toujours les paroles les plus récentes, définissez cette valeur sur une faible comme 4 jours.", - "feature_desc_clearLyricsCache": "Vider le cache des paroles manuellement", - "feature_helptext_clearLyricsCache": "Si les paroles qui sont dans le cache local sont obsolètes ou si vous voulez simplement libérer de l'espace, vous pouvez vider le cache manuellement en appuyant sur ce bouton.", - "feature_btn_clearLyricsCache": "Vider le cache", - "feature_btn_clearLyricsCache_running": "Vider...", - "feature_desc_advancedLyricsFilter": "Expérimental: Activer le filtrage avancé pour la recherche de paroles", - "feature_helptext_advancedLyricsFilter": "Le filtrage avancé comprend plusieurs couches de filtres qui visent à rendre les recherches de paroles plus fiables.\nCes filtres peuvent ne pas bien fonctionner pour les chansons dans votre langue et les chansons et artistes avec des titres moins uniques en général.\nAvertissement: Cette fonctionnalité est encore expérimentale et pourrait ne pas fonctionner mieux que la recherche de paroles par défaut. Assurez-vous de confirmer l'invite qui apparaît si vous avez modifié ce paramètre.", + "feature_desc_geniusLyrics": "Ajouter un bouton aux contrôles multimédias de la chanson en cours de lecture pour ouvrir ses paroles sur genius.com", + "feature_desc_errorOnLyricsNotFound": "Afficher une erreur lorsque la page de paroles pour la chanson en cours de lecture n'a pas pu être trouvée", + "feature_desc_geniUrlBase": "URL de base de votre instance geniURL, voir https://github.com/Sv443/geniURL", + "feature_helptext_geniUrlBase": "Si vous avez votre propre instance de geniURL en cours d'exécution (par exemple pour contourner la limitation du taux), vous pouvez entrer son URL de base ici pour l'utiliser pour le bouton de paroles de genius.com.\nSi vous ne savez pas ce que c'est, vous pouvez laisser cette option telle quelle.", + "feature_desc_geniUrlToken": "Jeton d'authentification pour votre instance geniURL", + "feature_helptext_geniUrlToken": "Pour contourner la limitation du taux, vous pouvez fournir un jeton d'authentification qui est également défini dans le fichier .env de votre instance geniURL.", + "feature_desc_lyricsCacheMaxSize": "Quantité maximale de paroles à conserver dans le cache", + "feature_helptext_lyricsCacheMaxSize": "Les paroles des chansons que vous écoutez sont stockées dans un cache afin de réduire le nombre de requêtes adressées au fournisseur de paroles.\nCette fonction vous permet de définir la quantité maximale de paroles à conserver dans le cache.\nLorsque la limite est atteinte, l'entrée la plus ancienne est supprimée pour faire de la place aux nouvelles.", + "feature_desc_lyricsCacheTTL": "Quantité maximale de jours pour conserver une entrée de paroles dans le cache", + "feature_helptext_lyricsCacheTTL": "Le cache dans lequel les paroles sont stockées les supprimera automatiquement après ce laps de temps pour garantir que les mises à jour de la source sont récupérées tôt ou tard.\nSi vous voulez vous assurer d'avoir toujours les paroles les plus récentes, définissez cette valeur sur une faible comme 4 jours.", + "feature_desc_clearLyricsCache": "Vider le cache des paroles manuellement", + "feature_helptext_clearLyricsCache": "Si les paroles qui sont dans le cache local sont obsolètes ou si vous voulez simplement libérer de l'espace, vous pouvez vider le cache manuellement en appuyant sur ce bouton.", + "feature_btn_clearLyricsCache": "Vider le cache", + "feature_btn_clearLyricsCache_running": "Vider...", + "feature_desc_advancedLyricsFilter": "Expérimental: Activer le filtrage avancé pour la recherche de paroles", + "feature_helptext_advancedLyricsFilter": "Le filtrage avancé comprend plusieurs couches de filtres qui visent à rendre les recherches de paroles plus fiables.\nCes filtres peuvent ne pas bien fonctionner pour les chansons dans votre langue et les chansons et artistes avec des titres moins uniques en général.\nAvertissement: Cette fonctionnalité est encore expérimentale et pourrait ne pas fonctionner mieux que la recherche de paroles par défaut. Assurez-vous de confirmer l'invite qui apparaît si vous avez modifié ce paramètre.", - "feature_desc_disableDarkReaderSites": "Sur quels sites devrait-on désactiver l'extension Dark Reader pour corriger les problèmes de mise en page?", - "feature_helptext_disableDarkReaderSites": "L'extension Dark Reader peut causer des problèmes de mise en page sur le site.\nCette fonctionnalité vous permet de désactiver Dark Reader sur certains ou tous les sites pour éviter ces problèmes.\n\nSi l'extension n'est pas installée, cette fonctionnalité n'aura aucun effet et peut être laissée activée.", - "feature_desc_sponsorBlockIntegration": "Apporter quelques corrections à la mise en page en cas d'installation de SponsorBlock", - "feature_helptext_sponsorBlockIntegration": "Si vous avez l'extension SponsorBlock installée, cette fonctionnalité apportera quelques corrections à la mise en page du site pour éviter les problèmes qui pourraient survenir.\n\nCette fonctionnalité n'aura aucun effet si l'extension n'est pas installée et peut être laissée activée dans ce cas.", - "feature_desc_themeSongIntegration": "Corriger certains problèmes de style si l'extension ThemeSong est installée", - "feature_helptext_themeSongIntegration": "Si l'extension ThemeSong est installée mais que cette fonctionnalité est désactivée (ou vice versa), vous pourriez remarquer des problèmes de style sur le site.\n\nAssurez-vous toujours que cette fonctionnalité reflète si l'extension est installée ou non!", - "feature_desc_themeSongLightness": "À quel point les couleurs d'accentuation dérivées du thème actuel de ThemeSong doivent-elles être claires", - "feature_helptext_themeSongLightness": "En fonction des paramètres que vous avez choisis pour l'extension ThemeSong, cette fonctionnalité vous permet d'ajuster la clarté des couleurs d'accentuation dérivées du thème actuel.\n\nCette fonctionnalité n'aura aucun effet si l'extension ThemeSong n'est pas installée.", - "feature_desc_openPluginList": "Ouvrir la liste des plugins que vous avez installés", - "feature_btn_openPluginList": "Ouvrir la liste", - "feature_btn_openPluginList_running": "Ouverture...", - "feature_desc_initTimeout": "Combien de secondes attendre que les fonctionnalités s'initialisent avant de les considérer probablement dans un état d'erreur", - "feature_helptext_initTimeout": "C'est le temps en secondes que le script attendra que les fonctionnalités s'initialisent avant de les considérer probablement dans un état d'erreur.\nCela n'affectera pas le comportement du script de manière significative, mais si l'un de vos plugins ne peut pas s'initialiser à temps, vous devriez essayer d'augmenter cette valeur.", + "feature_desc_disableDarkReaderSites": "Sur quels sites devrait-on désactiver l'extension Dark Reader pour corriger les problèmes de mise en page?", + "feature_helptext_disableDarkReaderSites": "L'extension Dark Reader peut causer des problèmes de mise en page sur le site.\nCette fonctionnalité vous permet de désactiver Dark Reader sur certains ou tous les sites pour éviter ces problèmes.\n\nSi l'extension n'est pas installée, cette fonctionnalité n'aura aucun effet et peut être laissée activée.", + "feature_desc_sponsorBlockIntegration": "Apporter quelques corrections à la mise en page en cas d'installation de SponsorBlock", + "feature_helptext_sponsorBlockIntegration": "Si vous avez l'extension SponsorBlock installée, cette fonctionnalité apportera quelques corrections à la mise en page du site pour éviter les problèmes qui pourraient survenir.\n\nCette fonctionnalité n'aura aucun effet si l'extension n'est pas installée et peut être laissée activée dans ce cas.", + "feature_desc_themeSongIntegration": "Corriger certains problèmes de style si l'extension ThemeSong est installée", + "feature_helptext_themeSongIntegration": "Si l'extension ThemeSong est installée mais que cette fonctionnalité est désactivée (ou vice versa), vous pourriez remarquer des problèmes de style sur le site.\n\nAssurez-vous toujours que cette fonctionnalité reflète si l'extension est installée ou non!", + "feature_desc_themeSongLightness": "À quel point les couleurs d'accentuation dérivées du thème actuel de ThemeSong doivent-elles être claires", + "feature_helptext_themeSongLightness": "En fonction des paramètres que vous avez choisis pour l'extension ThemeSong, cette fonctionnalité vous permet d'ajuster la clarté des couleurs d'accentuation dérivées du thème actuel.\n\nCette fonctionnalité n'aura aucun effet si l'extension ThemeSong n'est pas installée.", + "feature_desc_openPluginList": "Ouvrir la liste des plugins que vous avez installés", + "feature_btn_openPluginList": "Ouvrir la liste", + "feature_btn_openPluginList_running": "Ouverture...", + "feature_desc_initTimeout": "Combien de secondes attendre que les fonctionnalités s'initialisent avant de les considérer probablement dans un état d'erreur", + "feature_helptext_initTimeout": "C'est le temps en secondes que le script attendra que les fonctionnalités s'initialisent avant de les considérer probablement dans un état d'erreur.\nCela n'affectera pas le comportement du script de manière significative, mais si l'un de vos plugins ne peut pas s'initialiser à temps, vous devriez essayer d'augmenter cette valeur.", - "feature_desc_locale": "Langue", - "feature_desc_localeFallback": "Utiliser l'anglais pour les traductions manquantes (désactiver si vous contribuez aux traductions)", - "feature_desc_versionCheck": "Vérifier les mises à jour", - "feature_helptext_versionCheck": "Cette fonctionnalité vérifie les mises à jour toutes les 24 heures, vous avertit si une nouvelle version est disponible et vous permet de mettre à jour le script manuellement.\nSi votre gestionnaire de scripts utilisateur met à jour les scripts automatiquement, vous pouvez désactiver cette fonctionnalité.", - "feature_desc_checkVersionNow": "Rechercher manuellement une nouvelle version", - "feature_btn_checkVersionNow": "Vérifier maintenant", - "feature_btn_checkVersionNow_running": "Vérification...", - "feature_desc_logLevel": "Combien d'informations à enregistrer dans la console", - "feature_helptext_logLevel": "Changer cela n'est vraiment nécessaire que pour le débogage à la suite d'un problème rencontré.\nSi vous en avez un, vous pouvez augmenter le niveau de journalisation ici, ouvrir la console JavaScript de votre navigateur (généralement avec Ctrl + Maj + K) et joindre des captures d'écran de ce journal dans un problème GitHub.", - "feature_desc_toastDuration": "Pendant combien de secondes les notifications toast personnalisées doivent-elles être affichées - 0 pour les désactiver entièrement", - "feature_desc_showToastOnGenericError": "Afficher une notification lorsqu'une erreur générique se produit?", - "feature_helptext_showToastOnGenericError": "Si une erreur se produit dans le script qui empêche certaines parties de fonctionner correctement, une notification sera affichée pour vous en informer.\nSi vous rencontrez souvent un problème, veuillez copier l'erreur de la console JavaScript (généralement dans le menu F12) et ouvrir un problème sur GitHub.", - "feature_desc_resetConfig": "Réinitialiser tous les paramètres à leurs valeurs par défaut", - "feature_btn_resetConfig": "Réinitialiser les paramètres", - "feature_btn_resetConfig_running": "Réinitialisation...", - "feature_desc_advancedMode": "Afficher les paramètres avancés (cela recharge le menu)", - "feature_helptext_advancedMode": "Après avoir activé cela, le menu se rechargera et affichera les paramètres avancés qui sont masqués par défaut.\nCela est utile si vous souhaitez personnaliser plus en profondeur le comportement du script et que vous ne vous souciez pas d'un menu surchargé." - } + "feature_desc_locale": "Langue", + "feature_desc_localeFallback": "Utiliser l'anglais pour les traductions manquantes (désactiver si vous contribuez aux traductions)", + "feature_desc_versionCheck": "Vérifier les mises à jour", + "feature_helptext_versionCheck": "Cette fonctionnalité vérifie les mises à jour toutes les 24 heures, vous avertit si une nouvelle version est disponible et vous permet de mettre à jour le script manuellement.\nSi votre gestionnaire de scripts utilisateur met à jour les scripts automatiquement, vous pouvez désactiver cette fonctionnalité.", + "feature_desc_checkVersionNow": "Rechercher manuellement une nouvelle version", + "feature_btn_checkVersionNow": "Vérifier maintenant", + "feature_btn_checkVersionNow_running": "Vérification...", + "feature_desc_logLevel": "Combien d'informations à enregistrer dans la console", + "feature_helptext_logLevel": "Changer cela n'est vraiment nécessaire que pour le débogage à la suite d'un problème rencontré.\nSi vous en avez un, vous pouvez augmenter le niveau de journalisation ici, ouvrir la console JavaScript de votre navigateur (généralement avec Ctrl + Maj + K) et joindre des captures d'écran de ce journal dans un problème GitHub.", + "feature_desc_toastDuration": "Pendant combien de secondes les notifications toast personnalisées doivent-elles être affichées - 0 pour les désactiver entièrement", + "feature_desc_showToastOnGenericError": "Afficher une notification lorsqu'une erreur générique se produit?", + "feature_helptext_showToastOnGenericError": "Si une erreur se produit dans le script qui empêche certaines parties de fonctionner correctement, une notification sera affichée pour vous en informer.\nSi vous rencontrez souvent un problème, veuillez copier l'erreur de la console JavaScript (généralement dans le menu F12) et ouvrir un problème sur GitHub.", + "feature_desc_resetConfig": "Réinitialiser tous les paramètres à leurs valeurs par défaut", + "feature_btn_resetConfig": "Réinitialiser les paramètres", + "feature_btn_resetConfig_running": "Réinitialisation...", + "feature_desc_advancedMode": "Afficher les paramètres avancés (cela recharge le menu)", + "feature_helptext_advancedMode": "Après avoir activé cela, le menu se rechargera et affichera les paramètres avancés qui sont masqués par défaut.\nCela est utile si vous souhaitez personnaliser plus en profondeur le comportement du script et que vous ne vous souciez pas d'un menu surchargé." } diff --git a/assets/translations/hi-IN.json b/assets/translations/hi-IN.json index 476321d75f..e073c2e880 100644 --- a/assets/translations/hi-IN.json +++ b/assets/translations/hi-IN.json @@ -1,356 +1,362 @@ { - "translations": { - "config_menu_option": "%1 कॉन्फ़िगरेशन", - "config_menu_title": "%1 - कॉन्फ़िगरेशन", - "changelog_menu_title": "%1 - चेंजलॉग", - "export_menu_title": "%1 - निर्यात कॉन्फ़िगरेशन", - "import_menu_title": "%1 - आयात कॉन्फ़िगरेशन", - "open_menu_tooltip": "%1 का कॉन्फ़िगरेशन मेनू खोलें", - "close_menu_tooltip": "मेनू बंद करने के लिए क्लिक करें", - "reload_hint": "कृपया अपने परिवर्तन लागू करने के लिए पृष्ठ को पुनः लोड करें", - "reload_now": "अब पुनः लोड करें", - "reload_tooltip": "पृष्ठ को पुनः लोड करें", - "feature_requires_reload": "इस सुविधा को लागू करने के लिए पृष्ठ को पुनः लोड करना होगा", - "version_tooltip": "संस्करण %1 (बिल्ड %2) - चेंजलॉग खोलने के लिए क्लिक करें", - "bytm_config_export_import_title": "कॉन्फ़िगरेशन निर्यात या आयात करें", - "bytm_config_import_desc": "आप जो कॉन्फ़िगरेशन आयात करना चाहते हैं, उसे नीचे दिए गए फ़ील्ड में पेस्ट करें, फिर आयात बटन पर क्लिक करें:", - "bytm_config_export_desc": "निम्नलिखित पाठ को अपनी कॉन्फ़िगरेशन निर्यात करने के लिए कॉपी करें। चेतावनी: यह संवेदनशील डेटा शामिल हो सकता है।", - "export_import": "निर्यात/आयात", - "export": "निर्यात", - "export_hint": "निम्नलिखित पाठ को अपनी कॉन्फ़िगरेशन निर्यात करने के लिए कॉपी करें:", - "click_to_reveal": "(खोलने के लिए क्लिक करें)", - "click_to_reveal_sensitive_info": "(संवेदनशील जानकारी खोलने के लिए क्लिक करें)", - "export_tooltip": "अपनी वर्तमान कॉन्फ़िगरेशन निर्यात करें", - "import": "आयात", - "import_hint": "आप जो कॉन्फ़िगरेशन आयात करना चाहते हैं, उसे नीचे दिए गए फ़ील्ड में पेस्ट करें, फिर आयात बटन पर क्लिक करें:", - "import_tooltip": "आपने पहले से निर्यात की गई कॉन्फ़िगरेशन आयात करें", - "start_import_tooltip": "ऊपर जो डेटा आपने पेस्ट किया है, उसे आयात करने के लिए क्लिक करें", - "import_error_invalid": "आयात की गई डेटा अमान्य है", - "import_error_no_format_version": "आयात की गई डेटा में कोई फ़ॉर्मेट संस्करण नहीं है", - "import_error_no_data": "आयात किया गया ऑब्जेक्ट किसी भी डेटा को नहीं शामिल करता है", - "import_error_wrong_format_version": "आयात की गई डेटा एक असमर्थित फ़ॉर्मेट संस्करण में है (अपेक्षित %1 या नीचे लेकिन %2 मिला)", - "import_success": "डेटा सफलतापूर्वक आयात किया गया", - "import_success_confirm_reload": "डेटा सफलतापूर्वक आयात किया गया है।\nक्या आप परिवर्तन लागू करने के लिए पृष्ठ को अब पुनः लोड करना चाहते हैं?", - "reset_config_confirm": "क्या आप वास्तव में सभी सेटिंग्स को उनके डिफ़ॉल्ट मानों पर रीसेट करना चाहते हैं?\nपृष्ठ स्वचालित रूप से पुनः लोड हो जाएगा।", - "copy": "कॉपी", - "copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें", - "copy_to_clipboard_error": "टेक्स्ट को क्लिपबोर्ड पर कॉपी नहीं किया जा सका। कृपया इसे यहां से मैन्युअल रूप से कॉपी करें:\n%1", - "copy_config_tooltip": "कॉन्फ़िगरेशन को अपने क्लिपबोर्ड पर कॉपी करें", - "copied": "कॉपी किया गया!", - "copied_to_clipboard": "क्लिपबोर्ड पर कॉपी किया गया!", - "copy_hidden": "छिपा हुआ कॉपी करें", - "copy_hidden_tooltip": "छिपा हुआ मान कॉपी करने के लिए क्लिक करें - यह संवेदनशील डेटा है ⚠️", - "open_github": "GitHub पर खोलें", - "open_discord": "मेरे Discord सर्वर में शामिल हों", - "open_greasyfork": "GreasyFork पर खोलें", - "open_openuserjs": "OpenUserJS पर खोलें", - "lang_changed_prompt_reload": "भाषा बदल दी गई थी।\nक्या आप परिवर्तन लागू करने के लिए पृष्ठ को अब पुनः लोड करना चाहते हैं?", - "search_placeholder": "खोजें...", - "search_clear": "खोज साफ़ करें", + "meta": { + "langName": "हिन्दी", + "langNameEnglish": "Hindi (India)", + "countryName": "भारत", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "%1 कॉन्फ़िगरेशन", + "config_menu_title": "%1 - कॉन्फ़िगरेशन", + "changelog_menu_title": "%1 - चेंजलॉग", + "export_menu_title": "%1 - निर्यात कॉन्फ़िगरेशन", + "import_menu_title": "%1 - आयात कॉन्फ़िगरेशन", + "open_menu_tooltip": "%1 का कॉन्फ़िगरेशन मेनू खोलें", + "close_menu_tooltip": "मेनू बंद करने के लिए क्लिक करें", + "reload_hint": "कृपया अपने परिवर्तन लागू करने के लिए पृष्ठ को पुनः लोड करें", + "reload_now": "अब पुनः लोड करें", + "reload_tooltip": "पृष्ठ को पुनः लोड करें", + "feature_requires_reload": "इस सुविधा को लागू करने के लिए पृष्ठ को पुनः लोड करना होगा", + "version_tooltip": "संस्करण %1 (बिल्ड %2) - चेंजलॉग खोलने के लिए क्लिक करें", + "bytm_config_export_import_title": "कॉन्फ़िगरेशन निर्यात या आयात करें", + "bytm_config_import_desc": "आप जो कॉन्फ़िगरेशन आयात करना चाहते हैं, उसे नीचे दिए गए फ़ील्ड में पेस्ट करें, फिर आयात बटन पर क्लिक करें:", + "bytm_config_export_desc": "निम्नलिखित पाठ को अपनी कॉन्फ़िगरेशन निर्यात करने के लिए कॉपी करें। चेतावनी: यह संवेदनशील डेटा शामिल हो सकता है।", + "export_import": "निर्यात/आयात", + "export": "निर्यात", + "export_hint": "निम्नलिखित पाठ को अपनी कॉन्फ़िगरेशन निर्यात करने के लिए कॉपी करें:", + "click_to_reveal": "(खोलने के लिए क्लिक करें)", + "click_to_reveal_sensitive_info": "(संवेदनशील जानकारी खोलने के लिए क्लिक करें)", + "export_tooltip": "अपनी वर्तमान कॉन्फ़िगरेशन निर्यात करें", + "import": "आयात", + "import_hint": "आप जो कॉन्फ़िगरेशन आयात करना चाहते हैं, उसे नीचे दिए गए फ़ील्ड में पेस्ट करें, फिर आयात बटन पर क्लिक करें:", + "import_tooltip": "आपने पहले से निर्यात की गई कॉन्फ़िगरेशन आयात करें", + "start_import_tooltip": "ऊपर जो डेटा आपने पेस्ट किया है, उसे आयात करने के लिए क्लिक करें", + "import_error_invalid": "आयात की गई डेटा अमान्य है", + "import_error_no_format_version": "आयात की गई डेटा में कोई फ़ॉर्मेट संस्करण नहीं है", + "import_error_no_data": "आयात किया गया ऑब्जेक्ट किसी भी डेटा को नहीं शामिल करता है", + "import_error_wrong_format_version": "आयात की गई डेटा एक असमर्थित फ़ॉर्मेट संस्करण में है (अपेक्षित %1 या नीचे लेकिन %2 मिला)", + "import_success": "डेटा सफलतापूर्वक आयात किया गया", + "import_success_confirm_reload": "डेटा सफलतापूर्वक आयात किया गया है।\nक्या आप परिवर्तन लागू करने के लिए पृष्ठ को अब पुनः लोड करना चाहते हैं?", + "reset_config_confirm": "क्या आप वास्तव में सभी सेटिंग्स को उनके डिफ़ॉल्ट मानों पर रीसेट करना चाहते हैं?\nपृष्ठ स्वचालित रूप से पुनः लोड हो जाएगा।", + "copy": "कॉपी", + "copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें", + "copy_to_clipboard_error": "टेक्स्ट को क्लिपबोर्ड पर कॉपी नहीं किया जा सका। कृपया इसे यहां से मैन्युअल रूप से कॉपी करें:\n%1", + "copy_config_tooltip": "कॉन्फ़िगरेशन को अपने क्लिपबोर्ड पर कॉपी करें", + "copied": "कॉपी किया गया!", + "copied_to_clipboard": "क्लिपबोर्ड पर कॉपी किया गया!", + "copy_hidden": "छिपा हुआ कॉपी करें", + "copy_hidden_tooltip": "छिपा हुआ मान कॉपी करने के लिए क्लिक करें - यह संवेदनशील डेटा है ⚠️", + "open_github": "GitHub पर खोलें", + "open_discord": "मेरे Discord सर्वर में शामिल हों", + "open_greasyfork": "GreasyFork पर खोलें", + "open_openuserjs": "OpenUserJS पर खोलें", + "lang_changed_prompt_reload": "भाषा बदल दी गई थी।\nक्या आप परिवर्तन लागू करने के लिए पृष्ठ को अब पुनः लोड करना चाहते हैं?", + "search_placeholder": "खोजें...", + "search_clear": "खोज साफ़ करें", - "reset": "रीसेट", - "close": "बंद करें", - "log_level_debug": "डीबग (सबसे अधिक)", - "log_level_info": "जानकारी (केवल महत्वपूर्ण)", - "toggled_on": "सक्रिय", - "toggled_off": "अक्षम", - "trigger_btn_action": "ट्रिगर", - "trigger_btn_action_running": "चल रहा है...", - "new_entry": "नया एंट्री", - "new_entry_tooltip": "नई एंट्री बनाने के लिए क्लिक करें", - "remove_entry": "इस एंट्री को हटाएं", - "edit_entry": "इस एंट्री को संपादित करें", - "remove_from_queue": "इस गीत को कतार से हटाएं", - "delete_from_list": "इस गीत को सूची से हटाएं", - "couldnt_remove_from_queue": "कतार से इस गीत को हटाने में असमर्थ", - "couldnt_delete_from_list": "सूची से इस गीत को हटाने में असमर्थ", - "clear_list": "सूची साफ़ करें", - "clear_list_confirm": "क्या आप वाकई सूची को साफ़ करना चाहते हैं और केवल वर्तमान में चल रहे गीत को छोड़ना चाहते हैं?", - "scroll_to_playing": "वर्तमान में चल रहे गीत पर स्क्रॉल करें", - "scroll_to_bottom": "नीचे स्क्रॉल करें", - "volume_tooltip": "वॉल्यूम: %1% (संवेदनशीलता: %2%)", - "volume_shared_tooltip": "वॉल्यूम स्तर टैब के बीच साझा किया गया है - कॉन्फ़िगरेशन मेनू में अक्षम करें", - "middle_click_open_tab": "मध्य बटन क्लिक करें ताकि एक नई टैब में खुल जाए", - "example_toast": "उदाहरण टोस्ट", - "generic_error_toast_encountered_error_type": "%1 से मुलाकात हुई", - "generic_error_toast_click_for_details": "विवरण के लिए क्लिक करें", - "error": "त्रुटि", - "generic_error_dialog_message": "त्रुटि हुई।", - "generic_error_dialog_open_console_note": "यदि यह त्रुटि बार-बार होती है, तो कृपया जावास्क्रिप्ट कन्सोल (सामान्यत: Ctrl + Shift + K) खोलें और उस त्रुटि संदेश का स्क्रीनशॉट लेकर एक नई [GitHub समस्या](%2) में जोड़ें।", - "active_mode_display": "%1 मोड", - "active_mode_tooltip-1": "वर्तमान में %1 सक्रिय है", - "active_mode_tooltip-n": "वर्तमान में %1 सक्रिय हैं", - "dev_mode": "डेवलपर मोड", - "dev_mode_short": "डेव", - "advanced_mode": "उन्नत मोड", - "advanced_mode_short": "उन्नत", - "experimental_feature": "प्रायोगिक सुविधा", + "reset": "रीसेट", + "close": "बंद करें", + "log_level_debug": "डीबग (सबसे अधिक)", + "log_level_info": "जानकारी (केवल महत्वपूर्ण)", + "toggled_on": "सक्रिय", + "toggled_off": "अक्षम", + "trigger_btn_action": "ट्रिगर", + "trigger_btn_action_running": "चल रहा है...", + "new_entry": "नया एंट्री", + "new_entry_tooltip": "नई एंट्री बनाने के लिए क्लिक करें", + "remove_entry": "इस एंट्री को हटाएं", + "edit_entry": "इस एंट्री को संपादित करें", + "remove_from_queue": "इस गीत को कतार से हटाएं", + "delete_from_list": "इस गीत को सूची से हटाएं", + "couldnt_remove_from_queue": "कतार से इस गीत को हटाने में असमर्थ", + "couldnt_delete_from_list": "सूची से इस गीत को हटाने में असमर्थ", + "clear_list": "सूची साफ़ करें", + "clear_list_confirm": "क्या आप वाकई सूची को साफ़ करना चाहते हैं और केवल वर्तमान में चल रहे गीत को छोड़ना चाहते हैं?", + "scroll_to_playing": "वर्तमान में चल रहे गीत पर स्क्रॉल करें", + "scroll_to_bottom": "नीचे स्क्रॉल करें", + "volume_tooltip": "वॉल्यूम: %1% (संवेदनशीलता: %2%)", + "volume_shared_tooltip": "वॉल्यूम स्तर टैब के बीच साझा किया गया है - कॉन्फ़िगरेशन मेनू में अक्षम करें", + "middle_click_open_tab": "मध्य बटन क्लिक करें ताकि एक नई टैब में खुल जाए", + "example_toast": "उदाहरण टोस्ट", + "generic_error_toast_encountered_error_type": "%1 से मुलाकात हुई", + "generic_error_toast_click_for_details": "विवरण के लिए क्लिक करें", + "error": "त्रुटि", + "generic_error_dialog_message": "त्रुटि हुई।", + "generic_error_dialog_open_console_note": "यदि यह त्रुटि बार-बार होती है, तो कृपया जावास्क्रिप्ट कन्सोल (सामान्यत: Ctrl + Shift + K) खोलें और उस त्रुटि संदेश का स्क्रीनशॉट लेकर एक नई [GitHub समस्या](%2) में जोड़ें।", + "active_mode_display": "%1 मोड", + "active_mode_tooltip-1": "वर्तमान में %1 सक्रिय है", + "active_mode_tooltip-n": "वर्तमान में %1 सक्रिय हैं", + "dev_mode": "डेवलपर मोड", + "dev_mode_short": "डेव", + "advanced_mode": "उन्नत मोड", + "advanced_mode_short": "उन्नत", + "experimental_feature": "प्रायोगिक सुविधा", - "open_lyrics_search_prompt": "बोल खोजने के लिए गीत का शीर्षक और कलाकार दर्ज करें:", - "lyrics_loading": "बोल लोड हो रहे हैं...", - "lyrics_rate_limited-1": "आपको रेट सीमित किया जा रहा है।\nकृपया अधिक बोल का अनुरोध करने से पहले कुछ सेकंड प्रतीक्षा करें।", - "lyrics_rate_limited-n": "आपको रेट सीमित किया जा रहा है।\nकृपया अधिक बोल का अनुरोध करने से पहले %1 सेकंड प्रतीक्षा करें।", - "lyrics_not_found_confirm_open_search": "इस गीत के लिए बोल पृष्ठ नहीं मिला।\nक्या आप इसे मैन्युअल रूप से खोजने के लिए genius.com को खोलना चाहते हैं?", - "lyrics_not_found_click_open_search": "बोल URL नहीं मिला - मैन्युअल बोल खोजने के लिए क्लिक करें", - "lyrics_clear_cache_confirm_prompt-1": "बोल कैश में वर्तमान में %1 एंट्री है।\nक्या आप वाकई इसे हटाना चाहते हैं?", - "lyrics_clear_cache_confirm_prompt-n": "बोल कैश में वर्तमान में %1 एंट्री हैं।\nक्या आप वाकई इसे हटाना चाहते हैं?", - "lyrics_clear_cache_success": "बोल कैश सफलतापूर्वक हटा दिया गया।", - "lyrics_cache_changed_clear_confirm": "आपने उन सेटिंग्स को बदल दिया है जो बोल कैश में डेटा पर प्रभाव डालते हैं, जो बोल URL खोजों को तोड़ देते हैं।\nक्या आप वाकई अब बोल कैश हटाना चाहते हैं?", + "open_lyrics_search_prompt": "बोल खोजने के लिए गीत का शीर्षक और कलाकार दर्ज करें:", + "lyrics_loading": "बोल लोड हो रहे हैं...", + "lyrics_rate_limited-1": "आपको रेट सीमित किया जा रहा है।\nकृपया अधिक बोल का अनुरोध करने से पहले कुछ सेकंड प्रतीक्षा करें।", + "lyrics_rate_limited-n": "आपको रेट सीमित किया जा रहा है।\nकृपया अधिक बोल का अनुरोध करने से पहले %1 सेकंड प्रतीक्षा करें।", + "lyrics_not_found_confirm_open_search": "इस गीत के लिए बोल पृष्ठ नहीं मिला।\nक्या आप इसे मैन्युअल रूप से खोजने के लिए genius.com को खोलना चाहते हैं?", + "lyrics_not_found_click_open_search": "बोल URL नहीं मिला - मैन्युअल बोल खोजने के लिए क्लिक करें", + "lyrics_clear_cache_confirm_prompt-1": "बोल कैश में वर्तमान में %1 एंट्री है।\nक्या आप वाकई इसे हटाना चाहते हैं?", + "lyrics_clear_cache_confirm_prompt-n": "बोल कैश में वर्तमान में %1 एंट्री हैं।\nक्या आप वाकई इसे हटाना चाहते हैं?", + "lyrics_clear_cache_success": "बोल कैश सफलतापूर्वक हटा दिया गया।", + "lyrics_cache_changed_clear_confirm": "आपने उन सेटिंग्स को बदल दिया है जो बोल कैश में डेटा पर प्रभाव डालते हैं, जो बोल URL खोजों को तोड़ देते हैं।\nक्या आप वाकई अब बोल कैश हटाना चाहते हैं?", - "hotkey_input_click_to_change": "बदलने के लिए क्लिक करें", - "hotkey_input_click_to_change_tooltip": "%1 - वर्तमान रूप में सेट किया गया है: %2 - किसी भी कुंजी संयोजन को बदलने के लिए कोई भी कुंजी संयोजन दर्ज करें। ध्यान दें: कुछ स्क्रीन रीडर्स कुछ कुंजी संयोजनों को ब्लॉक कर सकते हैं।", - "hotkey_input_click_to_reset_tooltip": "अंतिम सहेजे गए कुंजी संयोजन पर रीसेट करें", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Shift", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "कोई नहीं", + "hotkey_input_click_to_change": "बदलने के लिए क्लिक करें", + "hotkey_input_click_to_change_tooltip": "%1 - वर्तमान रूप में सेट किया गया है: %2 - किसी भी कुंजी संयोजन को बदलने के लिए कोई भी कुंजी संयोजन दर्ज करें। ध्यान दें: कुछ स्क्रीन रीडर्स कुछ कुंजी संयोजनों को ब्लॉक कर सकते हैं।", + "hotkey_input_click_to_reset_tooltip": "अंतिम सहेजे गए कुंजी संयोजन पर रीसेट करें", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Shift", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "कोई नहीं", - "welcome_menu_title": "%1 में आपका स्वागत है!", - "config_menu": "कॉन्फ़िगरेशन मेनू", - "open_config_menu_tooltip": "कॉन्फ़िगरेशन मेनू खोलने के लिए क्लिक करें", - "open_changelog": "चेंजलॉग खोलें", - "open_changelog_tooltip": "चेंजलॉग खोलने के लिए क्लिक करें", - "feature_help_button_tooltip": "निम्नलिखित सुविधा के बारे में अधिक जानकारी प्राप्त करने के लिए क्लिक करें: \"%1\"", - "welcome_text_line_1": "स्थापित करने के लिए धन्यवाद!", - "welcome_text_line_2": "मैं आशा करता हूं कि आप %1 का उपयोग करने में इतना मज़ा लेंगे जितना मैंने इसे बनाने में लिया है 😃", - "welcome_text_line_3": "यदि आप %1 पसंद करते हैं, तो कृपया %2GreasyFork%3 या %4OpenUserJS%5 पर एक रेटिंग दें", - "welcome_text_line_4": "मेरा काम दान पर निर्भर करता है, इसलिए कृपया %1दान करने का विचार करें ❤️%2", - "welcome_text_line_5": "कोई बग मिला या कोई सुविधा सुझाना चाहते हैं? कृपया %1GitHub%2 पर एक समस्या खोलें", + "welcome_menu_title": "%1 में आपका स्वागत है!", + "config_menu": "कॉन्फ़िगरेशन मेनू", + "open_config_menu_tooltip": "कॉन्फ़िगरेशन मेनू खोलने के लिए क्लिक करें", + "open_changelog": "चेंजलॉग खोलें", + "open_changelog_tooltip": "चेंजलॉग खोलने के लिए क्लिक करें", + "feature_help_button_tooltip": "निम्नलिखित सुविधा के बारे में अधिक जानकारी प्राप्त करने के लिए क्लिक करें: \"%1\"", + "welcome_text_line_1": "स्थापित करने के लिए धन्यवाद!", + "welcome_text_line_2": "मैं आशा करता हूं कि आप %1 का उपयोग करने में इतना मज़ा लेंगे जितना मैंने इसे बनाने में लिया है 😃", + "welcome_text_line_3": "यदि आप %1 पसंद करते हैं, तो कृपया %2GreasyFork%3 या %4OpenUserJS%5 पर एक रेटिंग दें", + "welcome_text_line_4": "मेरा काम दान पर निर्भर करता है, इसलिए कृपया %1दान करने का विचार करें ❤️%2", + "welcome_text_line_5": "कोई बग मिला या कोई सुविधा सुझाना चाहते हैं? कृपया %1GitHub%2 पर एक समस्या खोलें", - "list_button_placement_queue_only": "केवल कतार में", - "list_button_placement_everywhere": "हर गीत सूची में", + "list_button_placement_queue_only": "केवल कतार में", + "list_button_placement_everywhere": "हर गीत सूची में", - "site_selection_both_sites": "दोनों साइटें", - "site_selection_only_yt": "केवल YouTube", - "site_selection_only_ytm": "केवल YouTube Music", - "site_selection_none": "कोई नहीं (अक्षम)", + "site_selection_both_sites": "दोनों साइटें", + "site_selection_only_yt": "केवल YouTube", + "site_selection_only_ytm": "केवल YouTube Music", + "site_selection_none": "कोई नहीं (अक्षम)", - "new_version_available": "%1 का नया संस्करण उपलब्ध है!\nआपके पास वर्तमान में संस्करण%2 स्थापित है और आप संस्करण%3 में अद्यतन कर सकते हैं", - "open_update_page_install_manually": "%1 पर इंस्टॉल करें", - "disable_update_check": "स्वचालित अद्यतन जाँच अक्षम करें", - "reenable_in_config_menu": "(कॉन्फ़िग मेनू में पुनः सक्षम किया जा सकता है)", - "close_and_ignore_for_24h": "बंद करें और 24 घंटे के लिए नजरअंदाज करें", - "close_and_ignore_until_reenabled": "बंद करें और नजरअंदाज करें जब तक पुनः सक्षम नहीं होता", - "expand_release_notes": "नवीनतम रिलीज़ नोट्स खोलने के लिए क्लिक करें", - "collapse_release_notes": "नवीनतम रिलीज़ नोट्स को संक्षेपित करने के लिए क्लिक करें", - "no_updates_found": "कोई अद्यतन नहीं मिला।", + "new_version_available": "%1 का नया संस्करण उपलब्ध है!\nआपके पास वर्तमान में संस्करण%2 स्थापित है और आप संस्करण%3 में अद्यतन कर सकते हैं", + "open_update_page_install_manually": "%1 पर इंस्टॉल करें", + "disable_update_check": "स्वचालित अद्यतन जाँच अक्षम करें", + "reenable_in_config_menu": "(कॉन्फ़िग मेनू में पुनः सक्षम किया जा सकता है)", + "close_and_ignore_for_24h": "बंद करें और 24 घंटे के लिए नजरअंदाज करें", + "close_and_ignore_until_reenabled": "बंद करें और नजरअंदाज करें जब तक पुनः सक्षम नहीं होता", + "expand_release_notes": "नवीनतम रिलीज़ नोट्स खोलने के लिए क्लिक करें", + "collapse_release_notes": "नवीनतम रिलीज़ नोट्स को संक्षेपित करने के लिए क्लिक करें", + "no_new_version_found": "कोई नया संस्करण नहीं मिला।", - "thumbnail_overlay_behavior_never": "कभी नहीं", - "thumbnail_overlay_behavior_videos_only": "केवल वीडियो के लिए", - "thumbnail_overlay_behavior_songs_only": "केवल गीत के लिए", - "thumbnail_overlay_behavior_always": "हमेशा", - "thumbnail_overlay_toggle_btn_tooltip_hide": "थंबनेल ओवरले अक्षम करें - मिडल-क्लिक या शिफ्ट-क्लिक करें एक नई टैब में खोलने के लिए", - "thumbnail_overlay_toggle_btn_tooltip_show": "थंबनेल ओवरले सक्षम करें - मिडल-क्लिक या शिफ्ट-क्लिक करें एक नई टैब में खोलने के लिए", - "thumbnail_overlay_indicator_tooltip": "थंबनेल ओवरले वर्तमान में सक्रिय है", - "thumbnail_overlay_image_fit_crop": "क्रॉप करें यदि आवश्यक है", - "thumbnail_overlay_image_fit_full": "पूरी छवि दिखाएं", - "thumbnail_overlay_image_fit_stretch": "खींचें ताकि फिट हो जाए", + "thumbnail_overlay_behavior_never": "कभी नहीं", + "thumbnail_overlay_behavior_videos_only": "केवल वीडियो के लिए", + "thumbnail_overlay_behavior_songs_only": "केवल गीत के लिए", + "thumbnail_overlay_behavior_always": "हमेशा", + "thumbnail_overlay_toggle_btn_tooltip_hide": "थंबनेल ओवरले अक्षम करें - मिडल-क्लिक या शिफ्ट-क्लिक करें एक नई टैब में खोलने के लिए", + "thumbnail_overlay_toggle_btn_tooltip_show": "थंबनेल ओवरले सक्षम करें - मिडल-क्लिक या शिफ्ट-क्लिक करें एक नई टैब में खोलने के लिए", + "thumbnail_overlay_indicator_tooltip": "थंबनेल ओवरले वर्तमान में सक्रिय है", + "thumbnail_overlay_image_fit_crop": "क्रॉप करें यदि आवश्यक है", + "thumbnail_overlay_image_fit_full": "पूरी छवि दिखाएं", + "thumbnail_overlay_image_fit_stretch": "खींचें ताकि फिट हो जाए", - "auto_like_channels_dialog_title": "ऑटो-पसंद किए गए चैनल", - "auto_like_channels_dialog_desc": "यहां आप देख सकते हैं कि आपने किस चैनल को ऑटो-पसंद करने के लिए सेट किया है और आप उन्हें संपादित, सक्षम, अक्षम और हटा सकते हैं।\nआप भी मैन्युअल रूप से एंट्री बना सकते हैं, हालांकि यह चैनल पृष्ठ पर जाना और वहां बटन पर क्लिक करना आसान है।", - "auto_like": "ऑटो-पसंद", - "auto_like_button_tooltip_enabled": "ऑटो-पसंद को अक्षम करने के लिए क्लिक करें। शिफ्ट-क्लिक करने पर प्रबंधन संवाद खोलें।", - "auto_like_button_tooltip_disabled": "ऑटो-पसंद को सक्षम करने के लिए क्लिक करें। शिफ्ट-क्लिक करने पर प्रबंधन संवाद खोलें।", - "auto_like_channel_toggle_tooltip": "चैनल %1 के लिए ऑटो-पसंद को सक्षम/अक्षम करें", - "add_auto_like_channel_id_prompt": "उस चैनल का उपयोगकर्ता आईडी (@नाम / यूसी...) या पूरा यूआरएल दर्ज करें जिसे आप स्वतः पसंद करना चाहते हैं।\nबाहर निकलने के लिए \"रद्द करें\" दबाएँ।", - "add_auto_like_channel_invalid_id": "दर्ज किया गया उपयोगकर्ता आईडी अमान्य है।\nकृपया सुनिश्चित करें कि आपने पूरा चैनल यूआरएल कॉपी किया है! इसमें \"चैनल/UC...\" या \"/@...\" जैसा भाग होना चाहिए", - "add_auto_like_channel_already_exists_prompt_new_name": "उस चैनल का नाम पहले से ही सूची में है।\nक्या आप नाम बदलना चाहते हैं?", - "add_auto_like_channel_name_prompt": "चैनल का नाम दर्ज करें।\nबाहर निकलने के लिए \"रद्द करें\" दबाएँ।", - "auto_like_channel_edit_name_prompt": "इस चैनल के लिए नया नाम दर्ज करें।\n\"रद्द\" दबाने के लिए बाहर निकलें।", - "auto_like_channel_edit_id_prompt": "इस चैनल के लिए नया उपयोगकर्ता आईडी (@नाम / यूसी...) या पूरा यूआरएल दर्ज करें।\n\"रद्द\" दबाने के लिए बाहर निकलें।", - "auto_like_enabled_toast": "स्वतः पसंद सक्षम किया गया", - "auto_like_disabled_toast": "स्वतः पसंद अक्षम किया गया", - "auto_liked_a_channels_song": "%1 द्वारा पसंद किया गया गाना", - "auto_liked_a_channels_video": "%1 द्वारा पसंद किया गया वीडियो", - "auto_like_click_to_configure": "कॉन्फ़िगर करने के लिए क्लिक करें", - "auto_like_export_or_import_tooltip": "अपने स्वतः पसंद किए गए चैनलों को निर्यात या आयात करें", - "auto_like_export_import_title": "स्वतः पसंद किए गए चैनलों का निर्यात या आयात करें", - "auto_like_export_desc": "निम्नलिखित पाठ को अपने स्वतः पसंद किए गए चैनलों का निर्यात करने के लिए कॉपी करें।", - "auto_like_import_desc": "निम्नलिखित क्षेत्र में आपके द्वारा आयात करना चाहिए ऑटो-पसंद किए गए चैनलों को दर्ज करें, फिर आयात बटन पर क्लिक करें:", + "auto_like_channels_dialog_title": "ऑटो-पसंद किए गए चैनल", + "auto_like_channels_dialog_desc": "यहां आप देख सकते हैं कि आपने किस चैनल को ऑटो-पसंद करने के लिए सेट किया है और आप उन्हें संपादित, सक्षम, अक्षम और हटा सकते हैं।\nआप भी मैन्युअल रूप से एंट्री बना सकते हैं, हालांकि यह चैनल पृष्ठ पर जाना और वहां बटन पर क्लिक करना आसान है।", + "auto_like": "ऑटो-पसंद", + "auto_like_button_tooltip_enabled": "ऑटो-पसंद को अक्षम करने के लिए क्लिक करें। शिफ्ट-क्लिक करने पर प्रबंधन संवाद खोलें।", + "auto_like_button_tooltip_disabled": "ऑटो-पसंद को सक्षम करने के लिए क्लिक करें। शिफ्ट-क्लिक करने पर प्रबंधन संवाद खोलें।", + "auto_like_channel_toggle_tooltip": "चैनल %1 के लिए ऑटो-पसंद को सक्षम/अक्षम करें", + "add_auto_like_channel_id_prompt": "उस चैनल का उपयोगकर्ता आईडी (@नाम / यूसी...) या पूरा यूआरएल दर्ज करें जिसे आप स्वतः पसंद करना चाहते हैं।\nबाहर निकलने के लिए \"रद्द करें\" दबाएँ।", + "add_auto_like_channel_invalid_id": "दर्ज किया गया उपयोगकर्ता आईडी अमान्य है।\nकृपया सुनिश्चित करें कि आपने पूरा चैनल यूआरएल कॉपी किया है! इसमें \"चैनल/UC...\" या \"/@...\" जैसा भाग होना चाहिए", + "add_auto_like_channel_already_exists_prompt_new_name": "उस चैनल का नाम पहले से ही सूची में है।\nक्या आप नाम बदलना चाहते हैं?", + "add_auto_like_channel_name_prompt": "चैनल का नाम दर्ज करें।\nबाहर निकलने के लिए \"रद्द करें\" दबाएँ।", + "auto_like_channel_edit_name_prompt": "इस चैनल के लिए नया नाम दर्ज करें।\n\"रद्द\" दबाने के लिए बाहर निकलें।", + "auto_like_channel_edit_id_prompt": "इस चैनल के लिए नया उपयोगकर्ता आईडी (@नाम / यूसी...) या पूरा यूआरएल दर्ज करें।\n\"रद्द\" दबाने के लिए बाहर निकलें।", + "auto_like_enabled_toast": "स्वतः पसंद सक्षम किया गया", + "auto_like_disabled_toast": "स्वतः पसंद अक्षम किया गया", + "auto_liked_a_channels_song": "%1 द्वारा पसंद किया गया गाना", + "auto_liked_a_channels_video": "%1 द्वारा पसंद किया गया वीडियो", + "auto_like_click_to_configure": "कॉन्फ़िगर करने के लिए क्लिक करें", + "auto_like_export_or_import_tooltip": "अपने स्वतः पसंद किए गए चैनलों को निर्यात या आयात करें", + "auto_like_export_import_title": "स्वतः पसंद किए गए चैनलों का निर्यात या आयात करें", + "auto_like_export_desc": "निम्नलिखित पाठ को अपने स्वतः पसंद किए गए चैनलों का निर्यात करने के लिए कॉपी करें।", + "auto_like_import_desc": "निम्नलिखित क्षेत्र में आपके द्वारा आयात करना चाहिए ऑटो-पसंद किए गए चैनलों को दर्ज करें, फिर आयात बटन पर क्लिक करें:", - "prompt_confirm": "पुष्टि करना", - "prompt_close": "बंद करें", - "prompt_cancel": "रद्द करें", + "prompt_confirm": "पुष्टि करना", + "prompt_close": "बंद करें", + "prompt_cancel": "रद्द करें", - "click_to_confirm_tooltip": "क्लिक करने के लिए पुष्टि करें", - "click_to_close_tooltip": "बंद करने के लिए क्लिक करें", - "click_to_cancel_tooltip": "बदलने के लिए क्लिक करें, फिर रिकवर करें", + "click_to_confirm_tooltip": "क्लिक करने के लिए पुष्टि करें", + "click_to_close_tooltip": "बंद करने के लिए क्लिक करें", + "click_to_cancel_tooltip": "बदलने के लिए क्लिक करें, फिर रिकवर करें", - "vote_label_likes-1": "%1 लाइक", - "vote_label_likes-n": "%1 लाइक", - "vote_label_dislikes-1": "%1 नापसंद", - "vote_label_dislikes-n": "%1 नापसंद", + "vote_label_likes-1": "%1 लाइक", + "vote_label_likes-n": "%1 लाइक", + "vote_label_dislikes-1": "%1 नापसंद", + "vote_label_dislikes-n": "%1 नापसंद", - "vote_ratio_disabled": "अक्षम", - "vote_ratio_green_red": "हरा और लाल", - "vote_ratio_blue_gray": "नीला और ग्रे", + "vote_ratio_disabled": "अक्षम", + "vote_ratio_green_red": "हरा और लाल", + "vote_ratio_blue_gray": "नीला और ग्रे", - "votes_format_short": "छोटा", - "votes_format_long": "लंबा", + "votes_format_short": "छोटा", + "votes_format_long": "लंबा", - "unit_entries-1": "विषय", - "unit_entries-n": "विषय", + "unit_entries-1": "विषय", + "unit_entries-n": "विषय", - "unit_days-1": "दिन", - "unit_days-n": "दिन", + "unit_days-1": "दिन", + "unit_days-n": "दिन", - "color_lightness_darker": "गहरे", - "color_lightness_normal": "सामान्य", - "color_lightness_lighter": "हल्का", + "color_lightness_darker": "गहरे", + "color_lightness_normal": "सामान्य", + "color_lightness_lighter": "हल्का", - "plugin_list_title": "प्लगइन सूची", - "plugin_list_no_plugins": "कोई प्लगइन वर्तमान में स्थापित नहीं हैं।\nअधिक जानकारी के लिए %1इस पृष्ठ%2 पर जाएं।", - "plugin_list_no_plugins_tooltip": "कोई प्लगइन वर्तमान में स्थापित नहीं हैं।", - "plugin_list_permissions_header": "अनुमतियाँ:", + "plugin_list_title": "प्लगइन सूची", + "plugin_list_no_plugins": "कोई प्लगइन वर्तमान में स्थापित नहीं हैं।\nअधिक जानकारी के लिए %1इस पृष्ठ%2 पर जाएं।", + "plugin_list_no_plugins_tooltip": "कोई प्लगइन वर्तमान में स्थापित नहीं हैं।", + "plugin_list_permissions_header": "अनुमतियाँ:", - "plugin_link_type_source": "संग्रह", - "plugin_link_type_other": "अन्य / होमपेज", - "plugin_link_type_bug": "बग रिपोर्ट करें", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "संग्रह", + "plugin_link_type_other": "अन्य / होमपेज", + "plugin_link_type_bug": "बग रिपोर्ट करें", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "यह प्लगइन फीचर कॉन्फ़िगरेशन को पढ़ सकता है", - "plugin_intent_description_WriteFeatureConfig": "यह प्लगइन फीचर कॉन्फ़िगरेशन में लिख सकता है", - "plugin_intent_description_SeeHiddenConfigValues": "इस प्लगइन में छिपे हुए कॉन्फ़िगरेशन मानों तक पहुंच है", - "plugin_intent_description_WriteLyricsCache": "यह प्लगइन गीत कैश में लिख सकता है", - "plugin_intent_description_WriteTranslations": "यह प्लगइन नए अनुवाद जोड़ सकता है और मौजूदा अनुवादों को अधिलेखित कर सकता है", - "plugin_intent_description_CreateModalDialogs": "यह प्लगइन मोडल डायलॉग बना सकता है", - "plugin_intent_description_ReadAutoLikeData": "यह प्लगइन ऑटो-पसंद डेटा पढ़ सकता है", - "plugin_intent_description_WriteAutoLikeData": "यह प्लगइन ऑटो-पसंद डेटा में लिख सकता है", + "plugin_intent_description_ReadFeatureConfig": "यह प्लगइन फीचर कॉन्फ़िगरेशन को पढ़ सकता है", + "plugin_intent_description_WriteFeatureConfig": "यह प्लगइन फीचर कॉन्फ़िगरेशन में लिख सकता है", + "plugin_intent_description_SeeHiddenConfigValues": "इस प्लगइन में छिपे हुए कॉन्फ़िगरेशन मानों तक पहुंच है", + "plugin_intent_description_WriteLyricsCache": "यह प्लगइन गीत कैश में लिख सकता है", + "plugin_intent_description_WriteTranslations": "यह प्लगइन नए अनुवाद जोड़ सकता है और मौजूदा अनुवादों को अधिलेखित कर सकता है", + "plugin_intent_description_CreateModalDialogs": "यह प्लगइन मोडल डायलॉग बना सकता है", + "plugin_intent_description_ReadAutoLikeData": "यह प्लगइन ऑटो-पसंद डेटा पढ़ सकता है", + "plugin_intent_description_WriteAutoLikeData": "यह प्लगइन ऑटो-पसंद डेटा में लिख सकता है", - "plugin_validation_error_no_property": "कोई गुण '%1' जिसके पास '%2' प्रकार है नहीं है", - "plugin_validation_error_invalid_property-1": "गुण '%1' जिसके पास '%2' प्रकार है अमान्य है। उदाहरण मान: %3", - "plugin_validation_error_invalid_property-n": "गुण '%1' जिसके पास '%2' प्रकार है अमान्य है। उदाहरण मान: %3", + "plugin_validation_error_no_property": "कोई गुण '%1' जिसके पास '%2' प्रकार है नहीं है", + "plugin_validation_error_invalid_property-1": "गुण '%1' जिसके पास '%2' प्रकार है अमान्य है। उदाहरण मान: %3", + "plugin_validation_error_invalid_property-n": "गुण '%1' जिसके पास '%2' प्रकार है अमान्य है। उदाहरण मान: %3", - "feature_category_layout": "लेआउट", - "feature_category_volume": "वॉल्यूम", - "feature_category_songLists": "गीत सूचियाँ", - "feature_category_behavior": "व्यवहार", - "feature_category_input": "इनपुट", - "feature_category_lyrics": "बोल", - "feature_category_integrations": "एकीकरण", - "feature_category_plugins": "प्लगइन्स", - "feature_category_general": "सामान्य", + "feature_category_layout": "लेआउट", + "feature_category_volume": "वॉल्यूम", + "feature_category_songLists": "गीत सूचियाँ", + "feature_category_behavior": "व्यवहार", + "feature_category_input": "इनपुट", + "feature_category_lyrics": "बोल", + "feature_category_integrations": "एकीकरण", + "feature_category_plugins": "प्लगइन्स", + "feature_category_general": "सामान्य", - "feature_desc_watermarkEnabled": "एक वॉटरमार्क दिखाएं जो इस कॉन्फ़िग मेनू को खोलता है", - "feature_helptext_watermarkEnabled": "यदि यह अक्षम है, तो आप फिर से कॉन्फ़िग मेनू खोल सकते हैं जब आप अपनी प्रोफ़ाइल चित्र पर क्लिक करते हैं जो कि आपके ब्राउज़र के दाएं कोने में होता है।\nहालांकि इसे ईस्टर एग को ढूंढना मुश्किल हो जाएगा ;)", - "feature_desc_removeShareTrackingParam": "शेयर मेनू में URL से ट्रैकिंग पैरामीटर \"?si\" हटाएं", - "feature_helptext_removeShareTrackingParam": "विशेष रूप से यूट्यूब शेयर पॉपअप में जो लिंक दिया जाता है, उसमें एक ट्रैकिंग पैरामीटर जोड़ा जाता है। यह न केवल लिंक को लंबा बनाता है, बल्कि यूट्यूब को आपके बारे में और उन लोगों के बारे में अधिक जानकारी देता है जिन्हें आप लिंक भेजते हैं।", - "feature_desc_removeShareTrackingParamSites": "शेयर ट्रैकिंग पैरामीटर किस साइटों से हटाएं?", - "feature_desc_numKeysSkipToTime": "एक नंबर कुंजी (0-9) दबाकर वीडियो में एक विशिष्ट समय पर छोड़ने को सक्षम करें", - "feature_desc_fixSpacing": "लेआउट में स्पेसिंग समस्याओं को ठीक करें", - "feature_helptext_fixSpacing": "यहां विभिन्न स्थान हैं जहां तत्वों के बीच स्पेसिंग असंगत है। यह सुविधा उन समस्याओं को ठीक करती है।", - "feature_desc_thumbnailOverlayBehavior": "वीडियो तत्व को उसके थंबनेल के साथ स्वचालित रूप से बदलने का समय", - "feature_helptext_thumbnailOverlayBehavior": "यदि यह सेट किया गया है, तो जब आप एक वीडियो पर क्लिक करते हैं, तो उसके थंबनेल को वीडियो तत्व के ऊपर दिखाया जाएगा।\nयह वीडियो को बंद करने या शुरू करने के लिए एक बटन के रूप में काम करता है।", - "feature_desc_thumbnailOverlayToggleBtnShown": "थंबनेल ओवरले के ऊपर एक बटन दिखाएं जो इसे त्वरित रूप से बंद करता है", - "feature_helptext_thumbnailOverlayToggleBtnShown": "यह बटन आपको थंबनेल को मैन्युअल रूप से चालू और बंद करने की अनुमति देगा। यह इस पर प्रभावित नहीं होगा यदि ओवरले को \"कभी नहीं दिखाया\" पर सेट किया गया है।\nएक नया वीडियो या गीत चलने पर, डिफ़ॉल्ट स्थिति पुनः स्थापित हो जाएगी।\nएक नई टैब में उच्चतम गुणवत्ता का थंबनेल खोलने के लिए एक नया टैब खोलने के लिए शिफ्ट दबाएं या मिडल माउस बटन दबाएं।", - "feature_desc_thumbnailOverlayShowIndicator": "थंबनेल ओवरले के ऊपर एक संकेतक दिखाएं जब यह सक्रिय हो", - "feature_desc_thumbnailOverlayIndicatorOpacity": "थंबनेल संकेतक की अस्पष्टता", - "feature_desc_thumbnailOverlayImageFit": "थंबनेल छवि को वीडियो तत्व पर कैसे फिट करें", - "feature_desc_hideCursorOnIdle": "वीडियो पर निष्क्रियता के कुछ सेकंड बाद कर्सर छुपाएं", - "feature_desc_hideCursorOnIdleDelay": "कितने सेकंड निष्क्रियता के बाद कर्सर छुपाना चाहिए?", - "feature_desc_fixHdrIssues": "एचडीआर-संगत जीपीयू और मॉनिटर का उपयोग करते समय कुछ रेंडरिंग समस्याओं को रोकें", - "feature_desc_showVotes": "वर्तमान गाने पर लाइक और नापसंद की संख्या दिखाएं", - "feature_helptext_showVotes": "यह सुविधा Return YouTube Dislike द्वारा संचालित है और वर्तमान गाने पर लाइक और नापसंद की लगभग संख्या दिखाएगी।", - "feature_desc_numbersFormat": "संख्याएँ कैसे स्वरूपित की जाएं?", + "feature_desc_watermarkEnabled": "एक वॉटरमार्क दिखाएं जो इस कॉन्फ़िग मेनू को खोलता है", + "feature_helptext_watermarkEnabled": "यदि यह अक्षम है, तो आप फिर से कॉन्फ़िग मेनू खोल सकते हैं जब आप अपनी प्रोफ़ाइल चित्र पर क्लिक करते हैं जो कि आपके ब्राउज़र के दाएं कोने में होता है।\nहालांकि इसे ईस्टर एग को ढूंढना मुश्किल हो जाएगा ;)", + "feature_desc_removeShareTrackingParam": "शेयर मेनू में URL से ट्रैकिंग पैरामीटर \"?si\" हटाएं", + "feature_helptext_removeShareTrackingParam": "विशेष रूप से यूट्यूब शेयर पॉपअप में जो लिंक दिया जाता है, उसमें एक ट्रैकिंग पैरामीटर जोड़ा जाता है। यह न केवल लिंक को लंबा बनाता है, बल्कि यूट्यूब को आपके बारे में और उन लोगों के बारे में अधिक जानकारी देता है जिन्हें आप लिंक भेजते हैं।", + "feature_desc_removeShareTrackingParamSites": "शेयर ट्रैकिंग पैरामीटर किस साइटों से हटाएं?", + "feature_desc_numKeysSkipToTime": "एक नंबर कुंजी (0-9) दबाकर वीडियो में एक विशिष्ट समय पर छोड़ने को सक्षम करें", + "feature_desc_fixSpacing": "लेआउट में स्पेसिंग समस्याओं को ठीक करें", + "feature_helptext_fixSpacing": "यहां विभिन्न स्थान हैं जहां तत्वों के बीच स्पेसिंग असंगत है। यह सुविधा उन समस्याओं को ठीक करती है।", + "feature_desc_thumbnailOverlayBehavior": "वीडियो तत्व को उसके थंबनेल के साथ स्वचालित रूप से बदलने का समय", + "feature_helptext_thumbnailOverlayBehavior": "यदि यह सेट किया गया है, तो जब आप एक वीडियो पर क्लिक करते हैं, तो उसके थंबनेल को वीडियो तत्व के ऊपर दिखाया जाएगा।\nयह वीडियो को बंद करने या शुरू करने के लिए एक बटन के रूप में काम करता है।", + "feature_desc_thumbnailOverlayToggleBtnShown": "थंबनेल ओवरले के ऊपर एक बटन दिखाएं जो इसे त्वरित रूप से बंद करता है", + "feature_helptext_thumbnailOverlayToggleBtnShown": "यह बटन आपको थंबनेल को मैन्युअल रूप से चालू और बंद करने की अनुमति देगा। यह इस पर प्रभावित नहीं होगा यदि ओवरले को \"कभी नहीं दिखाया\" पर सेट किया गया है।\nएक नया वीडियो या गीत चलने पर, डिफ़ॉल्ट स्थिति पुनः स्थापित हो जाएगी।\nएक नई टैब में उच्चतम गुणवत्ता का थंबनेल खोलने के लिए एक नया टैब खोलने के लिए शिफ्ट दबाएं या मिडल माउस बटन दबाएं।", + "feature_desc_thumbnailOverlayShowIndicator": "थंबनेल ओवरले के ऊपर एक संकेतक दिखाएं जब यह सक्रिय हो", + "feature_desc_thumbnailOverlayIndicatorOpacity": "थंबनेल संकेतक की अस्पष्टता", + "feature_desc_thumbnailOverlayImageFit": "थंबनेल छवि को वीडियो तत्व पर कैसे फिट करें", + "feature_desc_hideCursorOnIdle": "वीडियो पर निष्क्रियता के कुछ सेकंड बाद कर्सर छुपाएं", + "feature_desc_hideCursorOnIdleDelay": "कितने सेकंड निष्क्रियता के बाद कर्सर छुपाना चाहिए?", + "feature_desc_fixHdrIssues": "एचडीआर-संगत जीपीयू और मॉनिटर का उपयोग करते समय कुछ रेंडरिंग समस्याओं को रोकें", + "feature_desc_showVotes": "वर्तमान गाने पर लाइक और नापसंद की संख्या दिखाएं", + "feature_helptext_showVotes": "यह सुविधा Return YouTube Dislike द्वारा संचालित है और वर्तमान गाने पर लाइक और नापसंद की लगभग संख्या दिखाएगी।", + "feature_desc_numbersFormat": "संख्याएँ कैसे स्वरूपित की जाएं?", - "feature_desc_volumeSliderLabel": "वॉल्यूम स्लाइडर के पास एक प्रतिशत लेबल जोड़ें", - "feature_desc_volumeSliderSize": "वॉल्यूम स्लाइडर की चौड़ाई पिक्सेल में", - "feature_desc_volumeSliderStep": "वॉल्यूम स्लाइडर संवेदनशीलता (वॉल्यूम कितने प्रतिशत कम किया जा सकता है)", - "feature_desc_volumeSliderScrollStep": "वॉल्यूम स्लाइडर स्क्रॉल स्टेप", - "feature_helptext_volumeSliderScrollStep": "जब आप माउस व्हील के साथ वॉल्यूम स्लाइडर को स्क्रॉल करते हैं, तो वॉल्यूम कितने प्रतिशत बदला जाना चाहिए।\nयह वॉल्यूम स्लाइडर संवेदनशीलता का एक गुणक होना चाहिए, अन्यथा जब आप स्क्रॉल करते समय वॉल्यूम में छोटे अनियमित छलांग होगी।", - "feature_desc_volumeSharedBetweenTabs": "क्या सेट वॉल्यूम टैब के बीच साझा किया जाना चाहिए और सत्रों के बीच याद किया जाना चाहिए?", - "feature_helptext_volumeSharedBetweenTabs": "यदि आप एक टैब में वॉल्यूम बदलते हैं, तो उसी मान में सभी अन्य टैब में भी वॉल्यूम स्तर सेट किया जाएगा जिनमें इस सुविधा को सक्षम किया गया है।\nयह मान याद किया जाएगा और सत्रों के बीच स्थापित किया जाएगा, जब तक यह अक्षम नहीं किया जाता।", - "feature_desc_setInitialTabVolume": "साइट खोलने पर वॉल्यूम स्तर को एक विशिष्ट मान पर सेट करें", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "यह सुविधा \"टैब के बीच साझा वॉल्यूम स्तर\" सुविधा के साथ असंगत है और साझा वॉल्यूम सुविधा का उपयोग करते समय नजरअंदाज किया जाएगा!", - "feature_desc_initialTabVolumeLevel": "साइट खोलने पर वॉल्यूम स्तर को सेट करने के लिए मान", + "feature_desc_volumeSliderLabel": "वॉल्यूम स्लाइडर के पास एक प्रतिशत लेबल जोड़ें", + "feature_desc_volumeSliderSize": "वॉल्यूम स्लाइडर की चौड़ाई पिक्सेल में", + "feature_desc_volumeSliderStep": "वॉल्यूम स्लाइडर संवेदनशीलता (वॉल्यूम कितने प्रतिशत कम किया जा सकता है)", + "feature_desc_volumeSliderScrollStep": "वॉल्यूम स्लाइडर स्क्रॉल स्टेप", + "feature_helptext_volumeSliderScrollStep": "जब आप माउस व्हील के साथ वॉल्यूम स्लाइडर को स्क्रॉल करते हैं, तो वॉल्यूम कितने प्रतिशत बदला जाना चाहिए।\nयह वॉल्यूम स्लाइडर संवेदनशीलता का एक गुणक होना चाहिए, अन्यथा जब आप स्क्रॉल करते समय वॉल्यूम में छोटे अनियमित छलांग होगी।", + "feature_desc_volumeSharedBetweenTabs": "क्या सेट वॉल्यूम टैब के बीच साझा किया जाना चाहिए और सत्रों के बीच याद किया जाना चाहिए?", + "feature_helptext_volumeSharedBetweenTabs": "यदि आप एक टैब में वॉल्यूम बदलते हैं, तो उसी मान में सभी अन्य टैब में भी वॉल्यूम स्तर सेट किया जाएगा जिनमें इस सुविधा को सक्षम किया गया है।\nयह मान याद किया जाएगा और सत्रों के बीच स्थापित किया जाएगा, जब तक यह अक्षम नहीं किया जाता।", + "feature_desc_setInitialTabVolume": "साइट खोलने पर वॉल्यूम स्तर को एक विशिष्ट मान पर सेट करें", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "यह सुविधा \"टैब के बीच साझा वॉल्यूम स्तर\" सुविधा के साथ असंगत है और साझा वॉल्यूम सुविधा का उपयोग करते समय नजरअंदाज किया जाएगा!", + "feature_desc_initialTabVolumeLevel": "साइट खोलने पर वॉल्यूम स्तर को सेट करने के लिए मान", - "feature_desc_lyricsQueueButton": "प्रत्येक गीत में एक बटन जोड़ें जो इसके बोल वेबपृष्ठ को खोलता है", - "feature_desc_deleteFromQueueButton": "प्रत्येक गीत में एक बटन जोड़ें जो इसे त्वरित रूप से कतार से हटाता है", - "feature_desc_listButtonsPlacement": "कतार में बटन कहाँ दिखाएं?", - "feature_helptext_listButtonsPlacement": "साइट पर विभिन्न गीत सूचियाँ हैं जैसे एल्बम पेज, प्लेलिस्ट और वर्तमान में चल रही कतार।\nइस विकल्प के साथ आप यह चुन सकते हैं कि बटन कहाँ दिखाएं।", - "feature_desc_scrollToActiveSongBtn": "कतार में वर्तमान गीत के ऊपर स्क्रॉल करने के लिए एक बटन जोड़ें", - "feature_desc_clearQueueBtn": "वर्तमान में चल रही कतार या प्लेलिस्ट के ऊपर त्वरित रूप से इसे साफ करने के लिए एक बटन जोड़ें", + "feature_desc_lyricsQueueButton": "प्रत्येक गीत में एक बटन जोड़ें जो इसके बोल वेबपृष्ठ को खोलता है", + "feature_desc_deleteFromQueueButton": "प्रत्येक गीत में एक बटन जोड़ें जो इसे त्वरित रूप से कतार से हटाता है", + "feature_desc_listButtonsPlacement": "कतार में बटन कहाँ दिखाएं?", + "feature_helptext_listButtonsPlacement": "साइट पर विभिन्न गीत सूचियाँ हैं जैसे एल्बम पेज, प्लेलिस्ट और वर्तमान में चल रही कतार।\nइस विकल्प के साथ आप यह चुन सकते हैं कि बटन कहाँ दिखाएं।", + "feature_desc_scrollToActiveSongBtn": "कतार में वर्तमान गीत के ऊपर स्क्रॉल करने के लिए एक बटन जोड़ें", + "feature_desc_clearQueueBtn": "वर्तमान में चल रही कतार या प्लेलिस्ट के ऊपर त्वरित रूप से इसे साफ करने के लिए एक बटन जोड़ें", - "feature_desc_disableBeforeUnloadPopup": "एक गीत चल रहे होने पर साइट छोड़ने का प्रयास करने पर आने वाली पुष्टि पॉपअप को रोकें", - "feature_helptext_disableBeforeUnloadPopup": "जब आप वेबसाइट छोड़ने की कोशिश करते हैं जब आप एक गीत को थोड़े समय के लिए सुन रहे होते हैं, तो एक पॉपअप आता है जो आपसे पुष्टि करता है कि क्या आप वाकई साइट छोड़ना चाहते हैं। यह कुछ इस प्रकार का हो सकता है \"आपके पास असहेज डेटा है\" या \"यह साइट आपसे पूछ रही है कि क्या आप इसे बंद करना चाहते हैं\"।\nयह सुविधा इस पॉपअप को पूरी तरह से अक्षम करती है।", - "feature_desc_closeToastsTimeout": "कितने सेकंड के बाद स्थायी सूचनाओं को बंद करें - केवल उन्हें मैन्युअल रूप से बंद करने के लिए 0 (डिफ़ॉल्ट व्यवहार)", - "feature_helptext_closeToastsTimeout": "बहुत सारी स्थायी सूचनाएँ जो नीचे बाएं कोने में दिखाई देती हैं, वे 3 सेकंड के बाद स्वचालित रूप से बंद हो जाएंगी उनमें से कुछ ऐसी होती हैं जैसे गाना पसंद करने पर।\nयह सुविधा आपको उन स्थायी सूचनाओं को बंद करने के लिए समय निर्धारित करने की अनुमति देती है।\nअन्य प्रकार की स्थायी सूचनाएँ अस्पष्ट रहेंगी।\nइसे 0 के लिए सेट करें ताकि स्थायी सूचनाएँ बंद न हों।", - "feature_desc_rememberSongTime": "टैब को फिर से लोड करने या बहाल करने पर अंतिम गीत का समय याद रखें", - "feature_helptext_rememberSongTime-1": "कभी-कभी पृष्ठ को फिर से लोड करने या उसे अनजाने में बंद करने के बाद, आप चाहते हैं कि आप वही समय पर सुनना जारी रखें। यह सुविधा आपको इसे करने की अनुमति देती है।\nगीत का समय याद करने और बहाल करने के लिए, आपको इसे %1 सेकंड तक चलाना होगा, फिर इसका समय याद किया जाएगा और थोड़ी देर के लिए बहाल किया जा सकता है।", - "feature_helptext_rememberSongTime-n": "कभी-कभी पृष्ठ को फिर से लोड करने या उसे अनजाने में बंद करने के बाद, आप चाहते हैं कि आप वही समय पर सुनना जारी रखें। यह सुविधा आपको इसे करने की अनुमति देती है।\nगीत का समय याद करने और बहाल करने के लिए, आपको इसे %1 सेकंड तक चलाना होगा, फिर इसका समय याद किया जाएगा और थोड़ी देर के लिए बहाल किया जा सकता है।", - "feature_desc_rememberSongTimeSites": "गीत का समय किन साइटों पर याद रखें और बहाल करें?", - "feature_desc_rememberSongTimeDuration": "गीत के समय को याद रखने के लिए कितने सेकंड तक चलाएं?", - "feature_desc_rememberSongTimeReduction": "याद रखे गए गीत के समय को बहाल करते समय कितने सेकंड कम करें?", - "feature_helptext_rememberSongTimeReduction": "जब याद किए गए गीत का समय बहाल किया जाता है, तो याद किए गए समय से यह संख्या घटाई जाएगी ताकि आप उस भाग को फिर से सुन सकें जिसे रोक दिया गया था।", - "feature_desc_rememberSongTimeMinPlayTime": "गीत को याद रखने के लिए न्यूनतम समय (सेकंड) जिसे गीत को खेलना चाहिए", + "feature_desc_disableBeforeUnloadPopup": "एक गीत चल रहे होने पर साइट छोड़ने का प्रयास करने पर आने वाली पुष्टि पॉपअप को रोकें", + "feature_helptext_disableBeforeUnloadPopup": "जब आप वेबसाइट छोड़ने की कोशिश करते हैं जब आप एक गीत को थोड़े समय के लिए सुन रहे होते हैं, तो एक पॉपअप आता है जो आपसे पुष्टि करता है कि क्या आप वाकई साइट छोड़ना चाहते हैं। यह कुछ इस प्रकार का हो सकता है \"आपके पास असहेज डेटा है\" या \"यह साइट आपसे पूछ रही है कि क्या आप इसे बंद करना चाहते हैं\"।\nयह सुविधा इस पॉपअप को पूरी तरह से अक्षम करती है।", + "feature_desc_closeToastsTimeout": "कितने सेकंड के बाद स्थायी सूचनाओं को बंद करें - केवल उन्हें मैन्युअल रूप से बंद करने के लिए 0 (डिफ़ॉल्ट व्यवहार)", + "feature_helptext_closeToastsTimeout": "बहुत सारी स्थायी सूचनाएँ जो नीचे बाएं कोने में दिखाई देती हैं, वे 3 सेकंड के बाद स्वचालित रूप से बंद हो जाएंगी उनमें से कुछ ऐसी होती हैं जैसे गाना पसंद करने पर।\nयह सुविधा आपको उन स्थायी सूचनाओं को बंद करने के लिए समय निर्धारित करने की अनुमति देती है।\nअन्य प्रकार की स्थायी सूचनाएँ अस्पष्ट रहेंगी।\nइसे 0 के लिए सेट करें ताकि स्थायी सूचनाएँ बंद न हों।", + "feature_desc_rememberSongTime": "टैब को फिर से लोड करने या बहाल करने पर अंतिम गीत का समय याद रखें", + "feature_helptext_rememberSongTime-1": "कभी-कभी पृष्ठ को फिर से लोड करने या उसे अनजाने में बंद करने के बाद, आप चाहते हैं कि आप वही समय पर सुनना जारी रखें। यह सुविधा आपको इसे करने की अनुमति देती है।\nगीत का समय याद करने और बहाल करने के लिए, आपको इसे %1 सेकंड तक चलाना होगा, फिर इसका समय याद किया जाएगा और थोड़ी देर के लिए बहाल किया जा सकता है।", + "feature_helptext_rememberSongTime-n": "कभी-कभी पृष्ठ को फिर से लोड करने या उसे अनजाने में बंद करने के बाद, आप चाहते हैं कि आप वही समय पर सुनना जारी रखें। यह सुविधा आपको इसे करने की अनुमति देती है।\nगीत का समय याद करने और बहाल करने के लिए, आपको इसे %1 सेकंड तक चलाना होगा, फिर इसका समय याद किया जाएगा और थोड़ी देर के लिए बहाल किया जा सकता है।", + "feature_desc_rememberSongTimeSites": "गीत का समय किन साइटों पर याद रखें और बहाल करें?", + "feature_desc_rememberSongTimeDuration": "गीत के समय को याद रखने के लिए कितने सेकंड तक चलाएं?", + "feature_desc_rememberSongTimeReduction": "याद रखे गए गीत के समय को बहाल करते समय कितने सेकंड कम करें?", + "feature_helptext_rememberSongTimeReduction": "जब याद किए गए गीत का समय बहाल किया जाता है, तो याद किए गए समय से यह संख्या घटाई जाएगी ताकि आप उस भाग को फिर से सुन सकें जिसे रोक दिया गया था।", + "feature_desc_rememberSongTimeMinPlayTime": "गीत को याद रखने के लिए न्यूनतम समय (सेकंड) जिसे गीत को खेलना चाहिए", - "feature_desc_arrowKeySupport": "वर्तमान में चल रहे गीत के मीडिया नियंत्रणों में एक बटन जो एरो कुंजियों का समर्थन करता है", - "feature_helptext_arrowKeySupport": "सामान्य रूप से आप केवल बाएं और दाएं तीर कुंजियों का उपयोग करके एक निश्चित 10 सेकंड के अंतराल में छोड़ सकते हैं। इस सुविधा की मदद से आप तीर कुंजियों का उपयोग कर सकते हैं।\nछोड़ने के लिए सेकंडों की मात्रा बदलने के लिए, नीचे दिए गए विकल्प का उपयोग करें।", - "feature_desc_arrowKeySkipBy": "एरो कुंजियों का उपयोग करते समय कितने सेकंड छोड़ें", - "feature_desc_switchBetweenSites": "वीडियो / गीत पर YT और YTM साइटों के बीच स्विच करने के लिए एक हॉटकी जोड़ें", - "feature_helptext_switchBetweenSites": "इस हॉटकी को दबाने से आप वर्तमान में चल रहे वीडियो / गीत के बीच स्विच कर सकते हैं जब आप YT या YTM पर होते हैं।", - "feature_desc_switchSitesHotkey": "साइटों को स्विच करने के लिए कौन सी हॉटकी दबानी चाहिए?", - "feature_desc_anchorImprovements": "एक और बेहतर लिंक जो चीजों को एक नए टैब में खोलने के लिए आसान बनाता है", - "feature_helptext_anchorImprovements": "पृष्ठ पर कुछ तत्व केवल बाएं माउस बटन के साथ क्लिक करके ही खोले जा सकते हैं, जिसका मतलब है कि आप उन्हें मध्य बटन क्लिक करके नए टैब में नहीं खोल सकते या तो तीन बटन क्लिक करके या तो शिफ्ट + दाएं क्लिक के माध्यम से संदर्भ मेनू के माध्यम से। यह सुविधा उनमें से बहुत से को लिंक जोड़ती है या मौजूदे को बड़ा करती है ताकि क्लिक करना आसान हो।", - "feature_desc_autoLikeChannels": "कुछ चैनलों के सभी गानों और वीडियो को स्वचालित रूप से पसंद करें", - "feature_helpText_autoLikeChannels": "एक बार सक्षम करने के बाद, आप उन चैनलों के लिए इस सुविधा को सक्षम कर सकते हैं जिनके पृष्ठ को खोलकर टॉगल बटन पर क्लिक करके। इसके बाद, जिस गाने को आप उस चैनल का खेलते हैं, वह स्वचालित रूप से पसंद किया जाएगा।\nचैनलों को प्रबंधित करने के लिए डायलॉग खोलने के लिए नीचे दिए गए विकल्प का उपयोग करें।", - "feature_desc_autoLikeChannelToggleBtn": "प्रत्येक चैनल पृष्ठ में एक बटन जोड़ें जो स्वचालित रूप से पसंद करने को सक्षम या अक्षम करता है", - "feature_desc_autoLikePlayerBarToggleBtn": "मीडिया नियंत्रणों में एक बटन जोड़ें जो स्वचालित रूप से पसंद करने को सक्षम या अक्षम करता है", - "feature_desc_autoLikeTimeout": "एक गाने को स्वचालित रूप से पसंद करने के लिए कितने सेकंड की आवश्यकता है", - "feature_desc_autoLikeShowToast": "एक सूचना दिखाएं जब एक गाना स्वचालित रूप से पसंद किया जाता है", - "feature_desc_autoLikeOpenMgmtDialog": "स्वचालित रूप से पसंद किए गए चैनलों का प्रबंधन करने के लिए डायलॉग खोलें", - "feature_btn_autoLikeOpenMgmtDialog": "डायलॉग खोलें", - "feature_btn_autoLikeOpenMgmtDialog_running": "खोल रहा है...", + "feature_desc_arrowKeySupport": "वर्तमान में चल रहे गीत के मीडिया नियंत्रणों में एक बटन जो एरो कुंजियों का समर्थन करता है", + "feature_helptext_arrowKeySupport": "सामान्य रूप से आप केवल बाएं और दाएं तीर कुंजियों का उपयोग करके एक निश्चित 10 सेकंड के अंतराल में छोड़ सकते हैं। इस सुविधा की मदद से आप तीर कुंजियों का उपयोग कर सकते हैं।\nछोड़ने के लिए सेकंडों की मात्रा बदलने के लिए, नीचे दिए गए विकल्प का उपयोग करें।", + "feature_desc_arrowKeySkipBy": "एरो कुंजियों का उपयोग करते समय कितने सेकंड छोड़ें", + "feature_desc_switchBetweenSites": "वीडियो / गीत पर YT और YTM साइटों के बीच स्विच करने के लिए एक हॉटकी जोड़ें", + "feature_helptext_switchBetweenSites": "इस हॉटकी को दबाने से आप वर्तमान में चल रहे वीडियो / गीत के बीच स्विच कर सकते हैं जब आप YT या YTM पर होते हैं।", + "feature_desc_switchSitesHotkey": "साइटों को स्विच करने के लिए कौन सी हॉटकी दबानी चाहिए?", + "feature_desc_anchorImprovements": "एक और बेहतर लिंक जो चीजों को एक नए टैब में खोलने के लिए आसान बनाता है", + "feature_helptext_anchorImprovements": "पृष्ठ पर कुछ तत्व केवल बाएं माउस बटन के साथ क्लिक करके ही खोले जा सकते हैं, जिसका मतलब है कि आप उन्हें मध्य बटन क्लिक करके नए टैब में नहीं खोल सकते या तो तीन बटन क्लिक करके या तो शिफ्ट + दाएं क्लिक के माध्यम से संदर्भ मेनू के माध्यम से। यह सुविधा उनमें से बहुत से को लिंक जोड़ती है या मौजूदे को बड़ा करती है ताकि क्लिक करना आसान हो।", + "feature_desc_autoLikeChannels": "कुछ चैनलों के सभी गानों और वीडियो को स्वचालित रूप से पसंद करें", + "feature_helpText_autoLikeChannels": "एक बार सक्षम करने के बाद, आप उन चैनलों के लिए इस सुविधा को सक्षम कर सकते हैं जिनके पृष्ठ को खोलकर टॉगल बटन पर क्लिक करके। इसके बाद, जिस गाने को आप उस चैनल का खेलते हैं, वह स्वचालित रूप से पसंद किया जाएगा।\nचैनलों को प्रबंधित करने के लिए डायलॉग खोलने के लिए नीचे दिए गए विकल्प का उपयोग करें।", + "feature_desc_autoLikeChannelToggleBtn": "प्रत्येक चैनल पृष्ठ में एक बटन जोड़ें जो स्वचालित रूप से पसंद करने को सक्षम या अक्षम करता है", + "feature_desc_autoLikePlayerBarToggleBtn": "मीडिया नियंत्रणों में एक बटन जोड़ें जो स्वचालित रूप से पसंद करने को सक्षम या अक्षम करता है", + "feature_desc_autoLikeTimeout": "एक गाने को स्वचालित रूप से पसंद करने के लिए कितने सेकंड की आवश्यकता है", + "feature_desc_autoLikeShowToast": "एक सूचना दिखाएं जब एक गाना स्वचालित रूप से पसंद किया जाता है", + "feature_desc_autoLikeOpenMgmtDialog": "स्वचालित रूप से पसंद किए गए चैनलों का प्रबंधन करने के लिए डायलॉग खोलें", + "feature_btn_autoLikeOpenMgmtDialog": "डायलॉग खोलें", + "feature_btn_autoLikeOpenMgmtDialog_running": "खोल रहा है...", - "feature_desc_geniusLyrics": "वर्तमान में चल रहे गीत के मीडिया नियंत्रणों में एक बटन जो genius.com पर इसके बोल खोलता है", - "feature_desc_errorOnLyricsNotFound": "वर्तमान में चल रहे गीत के लिए बोल पृष्ठ नहीं मिलने पर एक त्रुटि दिखाएं", - "feature_desc_geniUrlBase": "आपके geniURL इंस्टेंस का बेस URL, https://github.com/Sv443/geniURL देखें", - "feature_helptext_geniUrlBase": "यदि आपके पास अपना geniURL इंस्टेंस चल रहा है (उदाहरण के लिए रेट लिमिटिंग को छोड़ने के लिए), तो आप इसका बेस URL यहां दर्ज कर सकते हैं ताकि आप genius.com बोल बटन के लिए इसका उपयोग कर सकें।\nयदि आप नहीं जानते कि यह क्या है, तो आप इस विकल्प को वैसे ही छोड़ सकते हैं।", - "feature_desc_geniUrlToken": "आपके geniURL इंस्टेंस के लिए एक ऑथ टोकन", - "feature_helptext_geniUrlToken": "रेट लिमिटिंग को छोड़ने के लिए, आप एक ऑथ टोकन दे सकते हैं जो आपके geniURL इंस्टेंस के .env फ़ाइल में भी परिभाषित है", - "feature_desc_lyricsCacheMaxSize": "कैश में रखने के लिए बोलों की अधिकतम संख्या", - "feature_helptext_lyricsCacheMaxSize": "आपके द्वारा सुने गए गानों के बोल कैश में रखे जाते हैं ताकि बोल प्रदाता को अनुरोधों की मात्रा को कम किया जा सके।\nयह सुविधा आपको कैश में रखने की बोलों की अधिकतम संख्या सेट करने की अनुमति देती है।\nजब सीमा पहुंच जाती है, तो सबसे पुराना प्रविष्टि हटा दिया जाएगा ताकि किसी नए प्रविष्टि के लिए जगह बना सके।", - "feature_desc_lyricsCacheTTL": "कैश में बोलों को रखने के लिए अधिकतम दिनों की संख्या", - "feature_helptext_lyricsCacheTTL": "बोलों को रखने के लिए उन्हें इस समय के बाद स्वचालित रूप से हटा दिया जाएगा ताकि स्रोत के अपडेट को निश्चित किया जा सके।\nयदि आप चाहते हैं कि आपके पास हमेशा सबसे हाल के बोल हों, तो इसे 4 दिन जैसे कम समय के लिए सेट करें।", - "feature_desc_clearLyricsCache": "बोलों कैश को मैन्युअल रूप से साफ़ करें", - "feature_helptext_clearLyricsCache": "यदि लोकल कैश में बोल अपडेट नहीं हो रहे हैं या आप बस कुछ जगह खाली करना चाहते हैं, तो आप इस बटन को दबाकर कैश को मैन्युअल रूप से साफ़ कर सकते हैं।", - "feature_btn_clearLyricsCache": "कैश साफ़ करें", - "feature_btn_clearLyricsCache_running": "कैश साफ़ कर रहा है...", - "feature_desc_advancedLyricsFilter": "प्रायोगिक: बोल खोज के लिए उन्नत फ़िल्टरिंग को सक्षम करें", - "feature_helptext_advancedLyricsFilter": "उन्नत फ़िल्टरिंग में विभिन्न फ़िल्टरों के कई स्तर शामिल हैं जो बोल खोज को अधिक विश्वसनीय बनाने का उद्देश्य रखते हैं।\nयह फ़िल्टर आपकी भाषा में गानों और कम अनूठे शीर्षक वाले गानों और कलाकारों के लिए अच्छे परिणाम नहीं दे सकते हैं।\nचेतावनी: यह सुविधा अभी भी प्रायोगिक है और डिफ़ॉल्ट बोल खोज की तुलना में वास्तव में अधिक अच्छा प्रदर्शन नहीं कर सकती है। यह सुनिश्चित करें कि आपने इस सेटिंग को बदलने पर प्रॉम्प्ट की पुष्टि की है।", + "feature_desc_geniusLyrics": "वर्तमान में चल रहे गीत के मीडिया नियंत्रणों में एक बटन जो genius.com पर इसके बोल खोलता है", + "feature_desc_errorOnLyricsNotFound": "वर्तमान में चल रहे गीत के लिए बोल पृष्ठ नहीं मिलने पर एक त्रुटि दिखाएं", + "feature_desc_geniUrlBase": "आपके geniURL इंस्टेंस का बेस URL, https://github.com/Sv443/geniURL देखें", + "feature_helptext_geniUrlBase": "यदि आपके पास अपना geniURL इंस्टेंस चल रहा है (उदाहरण के लिए रेट लिमिटिंग को छोड़ने के लिए), तो आप इसका बेस URL यहां दर्ज कर सकते हैं ताकि आप genius.com बोल बटन के लिए इसका उपयोग कर सकें।\nयदि आप नहीं जानते कि यह क्या है, तो आप इस विकल्प को वैसे ही छोड़ सकते हैं।", + "feature_desc_geniUrlToken": "आपके geniURL इंस्टेंस के लिए एक ऑथ टोकन", + "feature_helptext_geniUrlToken": "रेट लिमिटिंग को छोड़ने के लिए, आप एक ऑथ टोकन दे सकते हैं जो आपके geniURL इंस्टेंस के .env फ़ाइल में भी परिभाषित है", + "feature_desc_lyricsCacheMaxSize": "कैश में रखने के लिए बोलों की अधिकतम संख्या", + "feature_helptext_lyricsCacheMaxSize": "आपके द्वारा सुने गए गानों के बोल कैश में रखे जाते हैं ताकि बोल प्रदाता को अनुरोधों की मात्रा को कम किया जा सके।\nयह सुविधा आपको कैश में रखने की बोलों की अधिकतम संख्या सेट करने की अनुमति देती है।\nजब सीमा पहुंच जाती है, तो सबसे पुराना प्रविष्टि हटा दिया जाएगा ताकि किसी नए प्रविष्टि के लिए जगह बना सके।", + "feature_desc_lyricsCacheTTL": "कैश में बोलों को रखने के लिए अधिकतम दिनों की संख्या", + "feature_helptext_lyricsCacheTTL": "बोलों को रखने के लिए उन्हें इस समय के बाद स्वचालित रूप से हटा दिया जाएगा ताकि स्रोत के अपडेट को निश्चित किया जा सके।\nयदि आप चाहते हैं कि आपके पास हमेशा सबसे हाल के बोल हों, तो इसे 4 दिन जैसे कम समय के लिए सेट करें।", + "feature_desc_clearLyricsCache": "बोलों कैश को मैन्युअल रूप से साफ़ करें", + "feature_helptext_clearLyricsCache": "यदि लोकल कैश में बोल अपडेट नहीं हो रहे हैं या आप बस कुछ जगह खाली करना चाहते हैं, तो आप इस बटन को दबाकर कैश को मैन्युअल रूप से साफ़ कर सकते हैं।", + "feature_btn_clearLyricsCache": "कैश साफ़ करें", + "feature_btn_clearLyricsCache_running": "कैश साफ़ कर रहा है...", + "feature_desc_advancedLyricsFilter": "प्रायोगिक: बोल खोज के लिए उन्नत फ़िल्टरिंग को सक्षम करें", + "feature_helptext_advancedLyricsFilter": "उन्नत फ़िल्टरिंग में विभिन्न फ़िल्टरों के कई स्तर शामिल हैं जो बोल खोज को अधिक विश्वसनीय बनाने का उद्देश्य रखते हैं।\nयह फ़िल्टर आपकी भाषा में गानों और कम अनूठे शीर्षक वाले गानों और कलाकारों के लिए अच्छे परिणाम नहीं दे सकते हैं।\nचेतावनी: यह सुविधा अभी भी प्रायोगिक है और डिफ़ॉल्ट बोल खोज की तुलना में वास्तव में अधिक अच्छा प्रदर्शन नहीं कर सकती है। यह सुनिश्चित करें कि आपने इस सेटिंग को बदलने पर प्रॉम्प्ट की पुष्टि की है।", - "feature_desc_disableDarkReaderSites": "लेआउट समस्याओं को ठीक करने के लिए किन साइटों पर \"डार्क रीडर\" एक्सटेंशन को अक्षम किया जाना चाहिए?", - "feature_helptext_disableDarkReaderSites": "डार्क रीडर एक्सटेंशन साइट के लेआउट में समस्याएँ पैदा कर सकता है।\nयह सुविधा आपको उन समस्याओं को रोकने के लिए कुछ या सभी साइटों पर डार्क रीडर को अक्षम करने की अनुमति देती है।\n\nयदि एक्सटेंशन इंस्टॉल नहीं है, तो इस सुविधा का कोई प्रभाव नहीं होगा और इसे सक्रिय छोड़ा जा सकता है।", - "feature_desc_sponsorBlockIntegration": "स्पॉन्सरब्लॉक इंस्टॉल किया गया है तो लेआउट में कुछ सुधार करें", - "feature_helptext_sponsorBlockIntegration": "यदि आपके पास स्पॉन्सरब्लॉक एक्सटेंशन इंस्टॉल किया है, तो यह सुविधा साइट के लेआउट में होने वाली कुछ समस्याओं को रोकने के लिए कुछ सुधार करेगी।\n\nयदि एक्सटेंशन इंस्टॉल नहीं है, तो इस सुविधा का कोई प्रभाव नहीं होगा और इसे सक्रिय छोड़ा जा सकता है।", - "feature_desc_themeSongIntegration": "यदि थीमसॉंग एक्सटेंशन इंस्टॉल किया गया है तो कुछ स्टाइलिंग समस्याओं को ठीक करें", - "feature_helptext_themeSongIntegration": "यदि थीमसॉंग एक्सटेंशन इंस्टॉल किया गया है लेकिन यह सुविधा बंद है (या उम्मीद है कि इसे बंद किया गया है), तो आप साइट पर टूटी हुई स्टाइलिंग देख सकते हैं।\n\nकृपया हमेशा सुनिश्चित करें कि यह सुविधा यह दर्शाती है कि एक्सटेंशन इंस्टॉल किया गया है या नहीं!", - "feature_desc_themeSongLightness": "वर्तमान थीमसॉंग थीम से निकले एक्सेंट रंगों को कितना हल्का होना चाहिए", - "feature_helptext_themeSongLightness": "थीमसॉंग एक्सटेंशन के लिए आपने जो सेटिंग्स चुनी हैं, उन एक्सेंट रंगों की हल्काई को समायोजित करने की अनुमति देती है।\n\nयदि थीमसॉंग एक्सटेंशन इंस्टॉल नहीं है, तो इस सुविधा का कोई प्रभाव नहीं होगा।", - "feature_desc_openPluginList": "आपके द्वारा इंस्टॉल किए गए प्लगइनों की सूची खोलें", - "feature_btn_openPluginList": "सूची खोलें", - "feature_btn_openPluginList_running": "खोल रहा है...", - "feature_desc_initTimeout": "विशेषताएँ शुरू होने के लिए कितने सेकंड इंतजार करें - उन्हें लिखने के लिए लिकेली एरर्ड स्थिति में माना जाएगा", - "feature_helptext_initTimeout": "यह वक्त जिसमें स्क्रिप्ट विशेषताएँ शुरू होने के लिए इंतजार करेगा इसे उन्हें लिखने के लिए लिखा जाएगा।\nयह स्क्रिप्ट के व्यवहार पर किसी बड़े तरीके से प्रभाव नहीं डालेगा, लेकिन यदि आपके प्लगइन में से कोई भी वक्त में शुरू नहीं हो सकता है, तो आपको इस मानक को बढ़ाने की कोशिश करनी चाहिए।", + "feature_desc_disableDarkReaderSites": "लेआउट समस्याओं को ठीक करने के लिए किन साइटों पर \"डार्क रीडर\" एक्सटेंशन को अक्षम किया जाना चाहिए?", + "feature_helptext_disableDarkReaderSites": "डार्क रीडर एक्सटेंशन साइट के लेआउट में समस्याएँ पैदा कर सकता है।\nयह सुविधा आपको उन समस्याओं को रोकने के लिए कुछ या सभी साइटों पर डार्क रीडर को अक्षम करने की अनुमति देती है।\n\nयदि एक्सटेंशन इंस्टॉल नहीं है, तो इस सुविधा का कोई प्रभाव नहीं होगा और इसे सक्रिय छोड़ा जा सकता है।", + "feature_desc_sponsorBlockIntegration": "स्पॉन्सरब्लॉक इंस्टॉल किया गया है तो लेआउट में कुछ सुधार करें", + "feature_helptext_sponsorBlockIntegration": "यदि आपके पास स्पॉन्सरब्लॉक एक्सटेंशन इंस्टॉल किया है, तो यह सुविधा साइट के लेआउट में होने वाली कुछ समस्याओं को रोकने के लिए कुछ सुधार करेगी।\n\nयदि एक्सटेंशन इंस्टॉल नहीं है, तो इस सुविधा का कोई प्रभाव नहीं होगा और इसे सक्रिय छोड़ा जा सकता है।", + "feature_desc_themeSongIntegration": "यदि थीमसॉंग एक्सटेंशन इंस्टॉल किया गया है तो कुछ स्टाइलिंग समस्याओं को ठीक करें", + "feature_helptext_themeSongIntegration": "यदि थीमसॉंग एक्सटेंशन इंस्टॉल किया गया है लेकिन यह सुविधा बंद है (या उम्मीद है कि इसे बंद किया गया है), तो आप साइट पर टूटी हुई स्टाइलिंग देख सकते हैं।\n\nकृपया हमेशा सुनिश्चित करें कि यह सुविधा यह दर्शाती है कि एक्सटेंशन इंस्टॉल किया गया है या नहीं!", + "feature_desc_themeSongLightness": "वर्तमान थीमसॉंग थीम से निकले एक्सेंट रंगों को कितना हल्का होना चाहिए", + "feature_helptext_themeSongLightness": "थीमसॉंग एक्सटेंशन के लिए आपने जो सेटिंग्स चुनी हैं, उन एक्सेंट रंगों की हल्काई को समायोजित करने की अनुमति देती है।\n\nयदि थीमसॉंग एक्सटेंशन इंस्टॉल नहीं है, तो इस सुविधा का कोई प्रभाव नहीं होगा।", + "feature_desc_openPluginList": "आपके द्वारा इंस्टॉल किए गए प्लगइनों की सूची खोलें", + "feature_btn_openPluginList": "सूची खोलें", + "feature_btn_openPluginList_running": "खोल रहा है...", + "feature_desc_initTimeout": "विशेषताएँ शुरू होने के लिए कितने सेकंड इंतजार करें - उन्हें लिखने के लिए लिकेली एरर्ड स्थिति में माना जाएगा", + "feature_helptext_initTimeout": "यह वक्त जिसमें स्क्रिप्ट विशेषताएँ शुरू होने के लिए इंतजार करेगा इसे उन्हें लिखने के लिए लिखा जाएगा।\nयह स्क्रिप्ट के व्यवहार पर किसी बड़े तरीके से प्रभाव नहीं डालेगा, लेकिन यदि आपके प्लगइन में से कोई भी वक्त में शुरू नहीं हो सकता है, तो आपको इस मानक को बढ़ाने की कोशिश करनी चाहिए।", - "feature_desc_locale": "भाषा", - "feature_desc_localeFallback": "अनुवादों के लिए अंग्रेजी का उपयोग करें (अनुवाद में योगदान कर रहे हैं तो इसे अक्षम करें)", - "feature_desc_versionCheck": "अपडेट की जांच करें", - "feature_helptext_versionCheck": "यह सुविधा हर 24 घंटे में अपडेट की जांच करती है, आपको अगर एक नया संस्करण उपलब्ध है तो सूचित करती है और आपको स्क्रिप्ट को मैन्युअल रूप से अपडेट करने की अनुमति देती है।\nयदि आपके यूज़रस्क्रिप्ट प्रबंधक एक्सटेंशन स्क्रिप्ट को स्वचालित रूप से अपडेट करता है, तो आप इस सुविधा को अक्षम कर सकते हैं।", - "feature_desc_checkVersionNow": "मैन्युअल रूप से नया संस्करण जांचें", - "feature_btn_checkVersionNow": "अभी जांचें", - "feature_btn_checkVersionNow_running": "जांच रहा है...", - "feature_desc_logLevel": "कंसोल पर कितनी जानकारी लॉग इन करनी है", - "feature_helptext_logLevel": "इसे बदलने की वास्तव में केवल डिबगिंग के उद्देश्य से आवश्यक है क्योंकि किसी समस्या का सामना करने के परिणामस्वरूप।\nयदि आपके पास एक है, तो आप यहां लॉग स्तर बढ़ा सकते हैं, अपने ब्राउज़र के जावास्क्रिप्ट कंसोल (सामान्यतः Ctrl + Shift + K के साथ) खोल सकते हैं और उस लॉग की स्क्रीनशॉट एक गिटहब समस्या में अटैच कर सकते हैं।", - "feature_desc_toastDuration": "कितने सेकंड तक कस्टम टोस्ट सूचनाएं दिखाई जानी चाहिए - उन्हें पूरी तरह से अक्षम करने के लिए 0", - "feature_desc_showToastOnGenericError": "जब कोई त्रुटि होती है, तो एक सूचना दिखाएं?", - "feature_helptext_showToastOnGenericError": "यदि स्क्रिप्ट में कोई त्रुटि होती है जो इसके कुछ हिस्से को सही से काम नहीं करने देती है, तो एक सूचना दिखाई जाएगी जो आपको इसके बारे में सूचित करेगी।\nयदि आप बार-बार किसी समस्या का सामना करते हैं, तो कृपया जावास्क्रिप्ट कंसोल से त्रुटि की प्रतिलिपि कॉपी करें (सामान्यतः F12 मेनू में) और कृपया गिटहब पर एक मुद्दा खोलें।", - "feature_desc_resetConfig": "सभी सेटिंग्स को उनके डिफ़ॉल्ट मानों पर रीसेट करें", - "feature_btn_resetConfig": "सेटिंग्स रीसेट करें", - "feature_btn_resetConfig_running": "रीसेट कर रहा है...", - "feature_desc_advancedMode": "उन्नत सेटिंग्स दिखाएं (मेनू को फिर से लोड करता है)", - "feature_helptext_advancedMode": "इसे सक्षम करने के बाद, मेनू फिर से लोड होगा और डिफ़ॉल्ट रूप से छिपे हुए उन्नत सेटिंग्स दिखाएंगे।\nयह उपयोगी है यदि आप स्क्रिप्ट के व्यवहार को और गहराई से अनुकूलित करना चाहते हैं और एक अधिक भरा हुआ मेनू के बारे में चिंता नहीं करते।" - } + "feature_desc_locale": "भाषा", + "feature_desc_localeFallback": "अनुवादों के लिए अंग्रेजी का उपयोग करें (अनुवाद में योगदान कर रहे हैं तो इसे अक्षम करें)", + "feature_desc_versionCheck": "अपडेट की जांच करें", + "feature_helptext_versionCheck": "यह सुविधा हर 24 घंटे में अपडेट की जांच करती है, आपको अगर एक नया संस्करण उपलब्ध है तो सूचित करती है और आपको स्क्रिप्ट को मैन्युअल रूप से अपडेट करने की अनुमति देती है।\nयदि आपके यूज़रस्क्रिप्ट प्रबंधक एक्सटेंशन स्क्रिप्ट को स्वचालित रूप से अपडेट करता है, तो आप इस सुविधा को अक्षम कर सकते हैं।", + "feature_desc_checkVersionNow": "मैन्युअल रूप से नया संस्करण जांचें", + "feature_btn_checkVersionNow": "अभी जांचें", + "feature_btn_checkVersionNow_running": "जांच रहा है...", + "feature_desc_logLevel": "कंसोल पर कितनी जानकारी लॉग इन करनी है", + "feature_helptext_logLevel": "इसे बदलने की वास्तव में केवल डिबगिंग के उद्देश्य से आवश्यक है क्योंकि किसी समस्या का सामना करने के परिणामस्वरूप।\nयदि आपके पास एक है, तो आप यहां लॉग स्तर बढ़ा सकते हैं, अपने ब्राउज़र के जावास्क्रिप्ट कंसोल (सामान्यतः Ctrl + Shift + K के साथ) खोल सकते हैं और उस लॉग की स्क्रीनशॉट एक गिटहब समस्या में अटैच कर सकते हैं।", + "feature_desc_toastDuration": "कितने सेकंड तक कस्टम टोस्ट सूचनाएं दिखाई जानी चाहिए - उन्हें पूरी तरह से अक्षम करने के लिए 0", + "feature_desc_showToastOnGenericError": "जब कोई त्रुटि होती है, तो एक सूचना दिखाएं?", + "feature_helptext_showToastOnGenericError": "यदि स्क्रिप्ट में कोई त्रुटि होती है जो इसके कुछ हिस्से को सही से काम नहीं करने देती है, तो एक सूचना दिखाई जाएगी जो आपको इसके बारे में सूचित करेगी।\nयदि आप बार-बार किसी समस्या का सामना करते हैं, तो कृपया जावास्क्रिप्ट कंसोल से त्रुटि की प्रतिलिपि कॉपी करें (सामान्यतः F12 मेनू में) और कृपया गिटहब पर एक मुद्दा खोलें।", + "feature_desc_resetConfig": "सभी सेटिंग्स को उनके डिफ़ॉल्ट मानों पर रीसेट करें", + "feature_btn_resetConfig": "सेटिंग्स रीसेट करें", + "feature_btn_resetConfig_running": "रीसेट कर रहा है...", + "feature_desc_advancedMode": "उन्नत सेटिंग्स दिखाएं (मेनू को फिर से लोड करता है)", + "feature_helptext_advancedMode": "इसे सक्षम करने के बाद, मेनू फिर से लोड होगा और डिफ़ॉल्ट रूप से छिपे हुए उन्नत सेटिंग्स दिखाएंगे।\nयह उपयोगी है यदि आप स्क्रिप्ट के व्यवहार को और गहराई से अनुकूलित करना चाहते हैं और एक अधिक भरा हुआ मेनू के बारे में चिंता नहीं करते।" } diff --git a/assets/translations/ja-JP.json b/assets/translations/ja-JP.json index 3662d06aaf..87809d5835 100644 --- a/assets/translations/ja-JP.json +++ b/assets/translations/ja-JP.json @@ -1,355 +1,361 @@ { - "translations": { - "config_menu_option": "%1 構成", - "config_menu_title": "%1 - 構成", - "changelog_menu_title": "%1 - 更新履歴", - "export_menu_title": "%1 - 構成をエクスポート", - "import_menu_title": "%1 - 構成をインポート", - "open_menu_tooltip": "%1 の構成メニューを開く", - "close_menu_tooltip": "クリックしてメニューを閉じる", - "reload_hint": "変更を適用するには、ページをリロードしてください。", - "reload_now": "今すぐ再読み込み", - "reload_tooltip": "ページを再読み込みする", - "feature_requires_reload": "この機能を変更するにはページを再読み込みする必要があります", - "version_tooltip": "バージョン %1 (ビルド %2) - クリックして更新履歴を開く", - "bytm_config_export_import_title": "構成のエクスポートまたはインポート", - "bytm_config_import_desc": "インポートする構成を以下のフィールドに貼り付け、インポートボタンをクリックしてください:", - "bytm_config_export_desc": "次のテキストをコピーして構成をエクスポートします。警告:機密データが含まれる可能性があります。", - "export_import": "エクスポート/インポート", - "export": "エクスポート", - "export_hint": "次のテキストをコピーして構成をエクスポートします。", - "click_to_reveal": "(クリックして表示)", - "click_to_reveal_sensitive_info": "(クリックして機密情報を表示)", - "export_tooltip": "現在の構成をエクスポートする", - "import": "インポート", - "import_hint": "インポートしたい構成を以下のフィールドに貼り付け、インポートボタンをクリックしてください。", - "import_tooltip": "以前にエクスポートした構成をインポートする", - "start_import_tooltip": "上に貼り付けたデータをインポートするにはクリックしてください", - "import_error_invalid": "インポートされたデータが無効です", - "import_error_no_format_version": "インポートされたデータにフォーマットバージョンが含まれていません", - "import_error_no_data": "インポートされたオブジェクトにデータが含まれていません", - "import_error_wrong_format_version": "インポートされたデータはサポートされていないフォーマットバージョンです(%1 以下が必要ですが、%2 が指定されています)", - "import_success": "データのインポートに成功しました", - "import_success_confirm_reload": "データを正常にインポートしました。\n変更を適用するにはページを再読み込みしますか?", - "reset_config_confirm": "すべての設定をデフォルト値にリセットしてもよろしいですか?\nページは自動的に再読み込みされます。", - "copy": "コピー", - "copy_to_clipboard": "クリップボードにコピー", - "copy_to_clipboard_error": "テキストをクリップボードにコピーできませんでした。ここから手動でコピーしてください:\n%1", - "copy_config_tooltip": "構成をクリップボードにコピーする", - "copied": "コピーしました!", - "copied_to_clipboard": "クリップボードにコピーしました!", - "copy_hidden": "コピー隠し", - "copy_hidden_tooltip": "クリックして隠し値をコピーします - これは機密データです ⚠️", - "open_github": "GitHub で %1 を開く", - "open_discord": "Discord サーバーに参加する", - "open_greasyfork": "GreasyFork で %1 を開く", - "open_openuserjs": "OpenUserJS で %1 を開く", - "lang_changed_prompt_reload": "言語が変更されました。\n変更を適用するには今すぐページを再読み込みしますか?", - "search_placeholder": "検索...", - "search_clear": "検索をクリア", + "meta": { + "langName": "日本語", + "langNameEnglish": "Japanese (Japan)", + "countryName": "日本", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "%1 構成", + "config_menu_title": "%1 - 構成", + "changelog_menu_title": "%1 - 更新履歴", + "export_menu_title": "%1 - 構成をエクスポート", + "import_menu_title": "%1 - 構成をインポート", + "open_menu_tooltip": "%1 の構成メニューを開く", + "close_menu_tooltip": "クリックしてメニューを閉じる", + "reload_hint": "変更を適用するには、ページをリロードしてください。", + "reload_now": "今すぐ再読み込み", + "reload_tooltip": "ページを再読み込みする", + "feature_requires_reload": "この機能を変更するにはページを再読み込みする必要があります", + "version_tooltip": "バージョン %1 (ビルド %2) - クリックして更新履歴を開く", + "bytm_config_export_import_title": "構成のエクスポートまたはインポート", + "bytm_config_import_desc": "インポートする構成を以下のフィールドに貼り付け、インポートボタンをクリックしてください:", + "bytm_config_export_desc": "次のテキストをコピーして構成をエクスポートします。警告:機密データが含まれる可能性があります。", + "export_import": "エクスポート/インポート", + "export": "エクスポート", + "export_hint": "次のテキストをコピーして構成をエクスポートします。", + "click_to_reveal": "(クリックして表示)", + "click_to_reveal_sensitive_info": "(クリックして機密情報を表示)", + "export_tooltip": "現在の構成をエクスポートする", + "import": "インポート", + "import_hint": "インポートしたい構成を以下のフィールドに貼り付け、インポートボタンをクリックしてください。", + "import_tooltip": "以前にエクスポートした構成をインポートする", + "start_import_tooltip": "上に貼り付けたデータをインポートするにはクリックしてください", + "import_error_invalid": "インポートされたデータが無効です", + "import_error_no_format_version": "インポートされたデータにフォーマットバージョンが含まれていません", + "import_error_no_data": "インポートされたオブジェクトにデータが含まれていません", + "import_error_wrong_format_version": "インポートされたデータはサポートされていないフォーマットバージョンです(%1 以下が必要ですが、%2 が指定されています)", + "import_success": "データのインポートに成功しました", + "import_success_confirm_reload": "データを正常にインポートしました。\n変更を適用するにはページを再読み込みしますか?", + "reset_config_confirm": "すべての設定をデフォルト値にリセットしてもよろしいですか?\nページは自動的に再読み込みされます。", + "copy": "コピー", + "copy_to_clipboard": "クリップボードにコピー", + "copy_to_clipboard_error": "テキストをクリップボードにコピーできませんでした。ここから手動でコピーしてください:\n%1", + "copy_config_tooltip": "構成をクリップボードにコピーする", + "copied": "コピーしました!", + "copied_to_clipboard": "クリップボードにコピーしました!", + "copy_hidden": "コピー隠し", + "copy_hidden_tooltip": "クリックして隠し値をコピーします - これは機密データです ⚠️", + "open_github": "GitHub で %1 を開く", + "open_discord": "Discord サーバーに参加する", + "open_greasyfork": "GreasyFork で %1 を開く", + "open_openuserjs": "OpenUserJS で %1 を開く", + "lang_changed_prompt_reload": "言語が変更されました。\n変更を適用するには今すぐページを再読み込みしますか?", + "search_placeholder": "検索...", + "search_clear": "検索をクリア", - "reset": "リセット", - "close": "閉じる", - "log_level_debug": "デバッグ (最大)", - "log_level_info": "情報 (重要なもののみ)", - "toggled_on": "オン", - "toggled_off": "オフ", - "trigger_btn_action": "トリガー", - "trigger_btn_action_running": "実行中...", - "new_entry": "新しいエントリ", - "new_entry_tooltip": "クリックして新しいエントリを作成", - "remove_entry": "このエントリを削除", - "edit_entry": "このエントリを編集", - "remove_from_queue": "この曲をキューから削除", - "delete_from_list": "この曲をリストから削除", - "couldnt_remove_from_queue": "この曲をキューから削除できませんでした", - "couldnt_delete_from_list": "この曲をリストから削除できませんでした", - "clear_list": "リストをクリア", - "clear_list_confirm": "リストをクリアして、現在再生中の曲のみにしてもよろしいですか?", - "scroll_to_playing": "現在再生中の曲までスクロール", - "scroll_to_bottom": "クリックして一番下までスクロール", - "volume_tooltip": "音量: %1% (感度: %2%)", - "volume_shared_tooltip": "音量レベルはタブ間で共有されます - 構成メニューで無効にしてください", - "middle_click_open_tab": "中クリックで新しいタブで開く", - "example_toast": "例のトースト", - "generic_error_toast_encountered_error_type": "%1 に遭遇しました", - "generic_error_toast_click_for_details": "詳細をクリック", - "error": "エラー", - "generic_error_dialog_message": "エラーが発生しました。", - "generic_error_dialog_open_console_note": "このエラーが続く場合は、JavaScript コンソールを開いてください(通常は Ctrl + Shift + K)、「%1」で始まるエラーメッセージのスクリーンショットを新しい [GitHub 課題](%2) に添付してください。", - "active_mode_display": "%1 モード", - "active_mode_tooltip-1": "%1 は現在アクティブです", - "active_mode_tooltip-n": "%1 は現在アクティブです", - "dev_mode": "開発者モード", - "dev_mode_short": "開発者モード", - "advanced_mode": "詳細モード", - "advanced_mode_short": "詳細モード", - "experimental_feature": "実験的な機能", + "reset": "リセット", + "close": "閉じる", + "log_level_debug": "デバッグ (最大)", + "log_level_info": "情報 (重要なもののみ)", + "toggled_on": "オン", + "toggled_off": "オフ", + "trigger_btn_action": "トリガー", + "trigger_btn_action_running": "実行中...", + "new_entry": "新しいエントリ", + "new_entry_tooltip": "クリックして新しいエントリを作成", + "remove_entry": "このエントリを削除", + "edit_entry": "このエントリを編集", + "remove_from_queue": "この曲をキューから削除", + "delete_from_list": "この曲をリストから削除", + "couldnt_remove_from_queue": "この曲をキューから削除できませんでした", + "couldnt_delete_from_list": "この曲をリストから削除できませんでした", + "clear_list": "リストをクリア", + "clear_list_confirm": "リストをクリアして、現在再生中の曲のみにしてもよろしいですか?", + "scroll_to_playing": "現在再生中の曲までスクロール", + "scroll_to_bottom": "クリックして一番下までスクロール", + "volume_tooltip": "音量: %1% (感度: %2%)", + "volume_shared_tooltip": "音量レベルはタブ間で共有されます - 構成メニューで無効にしてください", + "middle_click_open_tab": "中クリックで新しいタブで開く", + "example_toast": "例のトースト", + "generic_error_toast_encountered_error_type": "%1 に遭遇しました", + "generic_error_toast_click_for_details": "詳細をクリック", + "error": "エラー", + "generic_error_dialog_message": "エラーが発生しました。", + "generic_error_dialog_open_console_note": "このエラーが続く場合は、JavaScript コンソールを開いてください(通常は Ctrl + Shift + K)、「%1」で始まるエラーメッセージのスクリーンショットを新しい [GitHub 課題](%2) に添付してください。", + "active_mode_display": "%1 モード", + "active_mode_tooltip-1": "%1 は現在アクティブです", + "active_mode_tooltip-n": "%1 は現在アクティブです", + "dev_mode": "開発者モード", + "dev_mode_short": "開発者モード", + "advanced_mode": "詳細モード", + "advanced_mode_short": "詳細モード", + "experimental_feature": "実験的な機能", - "open_lyrics_search_prompt": "歌詞を検索するには曲名とアーティスト名を入力してください:", - "lyrics_loading": "歌詞 URL を読み込んでいます...", - "lyrics_rate_limited-1": "レート制限されています。\nもう少し待ってから歌詞をリクエストしてください。", - "lyrics_rate_limited-n": "レート制限されています。\nもう %1 秒待ってから歌詞をリクエストしてください。", - "lyrics_not_found_confirm_open_search": "この曲の歌詞ページが見つかりませんでした。\ngenius.com を開いて手動で検索しますか?", - "lyrics_not_found_click_open_search": "歌詞 URL が見つかりませんでした - 手動で歌詞検索を開くにはクリックしてください", - "lyrics_clear_cache_confirm_prompt-1": "歌詞キャッシュには現在 %1 エントリがあります。\n本当に削除しますか?", - "lyrics_clear_cache_confirm_prompt-n": "歌詞キャッシュには現在 %1 エントリがあります。\n本当に削除しますか?", - "lyrics_clear_cache_success": "歌詞キャッシュが正常にクリアされました。", - "lyrics_cache_changed_clear_confirm": "歌詞キャッシュに影響を与える設定を変更しました。これにより歌詞 URL の検索が壊れます。\nキャッシュをクリアしますか?", + "open_lyrics_search_prompt": "歌詞を検索するには曲名とアーティスト名を入力してください:", + "lyrics_loading": "歌詞 URL を読み込んでいます...", + "lyrics_rate_limited-1": "レート制限されています。\nもう少し待ってから歌詞をリクエストしてください。", + "lyrics_rate_limited-n": "レート制限されています。\nもう %1 秒待ってから歌詞をリクエストしてください。", + "lyrics_not_found_confirm_open_search": "この曲の歌詞ページが見つかりませんでした。\ngenius.com を開いて手動で検索しますか?", + "lyrics_not_found_click_open_search": "歌詞 URL が見つかりませんでした - 手動で歌詞検索を開くにはクリックしてください", + "lyrics_clear_cache_confirm_prompt-1": "歌詞キャッシュには現在 %1 エントリがあります。\n本当に削除しますか?", + "lyrics_clear_cache_confirm_prompt-n": "歌詞キャッシュには現在 %1 エントリがあります。\n本当に削除しますか?", + "lyrics_clear_cache_success": "歌詞キャッシュが正常にクリアされました。", + "lyrics_cache_changed_clear_confirm": "歌詞キャッシュに影響を与える設定を変更しました。これにより歌詞 URL の検索が壊れます。\nキャッシュをクリアしますか?", - "hotkey_input_click_to_change": "クリックして変更", - "hotkey_input_click_to_change_tooltip": "%1 - 現在の設定: %2 - 任意のキーの組み合わせを入力して変更してください。注意: 一部のスクリーンリーダーは特定のキーの組み合わせをブロックする場合があります。", - "hotkey_input_click_to_reset_tooltip": "最後に保存されたキーの組み合わせにリセット", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Shift", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "なし", + "hotkey_input_click_to_change": "クリックして変更", + "hotkey_input_click_to_change_tooltip": "%1 - 現在の設定: %2 - 任意のキーの組み合わせを入力して変更してください。注意: 一部のスクリーンリーダーは特定のキーの組み合わせをブロックする場合があります。", + "hotkey_input_click_to_reset_tooltip": "最後に保存されたキーの組み合わせにリセット", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Shift", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "なし", - "welcome_menu_title": "%1 へようこそ!", - "config_menu": "構成メニュー", - "open_config_menu_tooltip": "クリックして構成メニューを開く", - "open_changelog": "更新履歴", - "open_changelog_tooltip": "クリックして更新履歴を開く", - "feature_help_button_tooltip": "次の機能について詳細を表示するにはクリックしてください: \"%1\"", - "welcome_text_line_1": "ようこそ!", - "welcome_text_line_2": "%1 を使っていただきありがとうございます 😃", - "welcome_text_line_3": "もし %1 を気に入っていただけたら、%2GreasyFork%3 か %4OpenUserJS%5 で評価をお願いします", - "welcome_text_line_4": "私の作業は寄付に依存しているので、%1寄付%2 を検討してください ❤️", - "welcome_text_line_5": "バグを見つけた、または機能を提案したいですか?%1GitHubで課題を開いてください。%2", + "welcome_menu_title": "%1 へようこそ!", + "config_menu": "構成メニュー", + "open_config_menu_tooltip": "クリックして構成メニューを開く", + "open_changelog": "更新履歴", + "open_changelog_tooltip": "クリックして更新履歴を開く", + "feature_help_button_tooltip": "次の機能について詳細を表示するにはクリックしてください: \"%1\"", + "welcome_text_line_1": "ようこそ!", + "welcome_text_line_2": "%1 を使っていただきありがとうございます 😃", + "welcome_text_line_3": "もし %1 を気に入っていただけたら、%2GreasyFork%3 か %4OpenUserJS%5 で評価をお願いします", + "welcome_text_line_4": "私の作業は寄付に依存しているので、%1寄付%2 を検討してください ❤️", + "welcome_text_line_5": "バグを見つけた、または機能を提案したいですか?%1GitHubで課題を開いてください。%2", - "list_button_placement_queue_only": "キュー内のみ", - "list_button_placement_everywhere": "すべての曲リスト", + "list_button_placement_queue_only": "キュー内のみ", + "list_button_placement_everywhere": "すべての曲リスト", - "site_selection_both_sites": "すべてのサイト", - "site_selection_only_yt": "YouTube のみ", - "site_selection_only_ytm": "YouTube Music のみ", - "site_selection_none": "なし(無効)", + "site_selection_both_sites": "すべてのサイト", + "site_selection_only_yt": "YouTube のみ", + "site_selection_only_ytm": "YouTube Music のみ", + "site_selection_none": "なし(無効)", - "new_version_available": "新しいバージョンが利用可能です!\n現在のバージョンは %1 ですが、バージョン %2 に更新できます", - "open_update_page_install_manually": "%1 にインストールする", - "disable_update_check": "自動更新チェックを無効にする", - "reenable_in_config_menu": "(構成メニューで再有効化できます)", - "close_and_ignore_for_24h": "24 時間閉じて無視する", - "close_and_ignore_until_reenabled": "閉じて無視する(再有効化されるまで)", - "expand_release_notes": "最新のリリースノートを展開する", - "collapse_release_notes": "最新のリリースノートを折りたたむ", - "no_updates_found": "更新は見つかりませんでした。", + "new_version_available": "新しいバージョンが利用可能です!\n現在のバージョンは %1 ですが、バージョン %2 に更新できます", + "open_update_page_install_manually": "%1 にインストールする", + "disable_update_check": "自動更新チェックを無効にする", + "reenable_in_config_menu": "(構成メニューで再有効化できます)", + "close_and_ignore_for_24h": "24 時間閉じて無視する", + "close_and_ignore_until_reenabled": "閉じて無視する(再有効化されるまで)", + "expand_release_notes": "最新のリリースノートを展開する", + "collapse_release_notes": "最新のリリースノートを折りたたむ", + "no_new_version_found": "新しいバージョンは見つかりませんでした。", - "thumbnail_overlay_behavior_never": "表示しない", - "thumbnail_overlay_behavior_videos_only": "動画のみ", - "thumbnail_overlay_behavior_songs_only": "曲のみ", - "thumbnail_overlay_behavior_always": "常に", - "thumbnail_overlay_toggle_btn_tooltip_hide": "サムネイルオーバーレイを無効にする - 中クリックまたはシフトクリックで新しいタブで開く", - "thumbnail_overlay_toggle_btn_tooltip_show": "サムネイルオーバーレイを有効にする - 中クリックまたはシフトクリックで新しいタブで開く", - "thumbnail_overlay_indicator_tooltip": "サムネイルオーバーレイが現在有効です", - "thumbnail_overlay_image_fit_crop": "切り取る", - "thumbnail_overlay_image_fit_full": "フルイメージ", - "thumbnail_overlay_image_fit_stretch": "ストレッチ", + "thumbnail_overlay_behavior_never": "表示しない", + "thumbnail_overlay_behavior_videos_only": "動画のみ", + "thumbnail_overlay_behavior_songs_only": "曲のみ", + "thumbnail_overlay_behavior_always": "常に", + "thumbnail_overlay_toggle_btn_tooltip_hide": "サムネイルオーバーレイを無効にする - 中クリックまたはシフトクリックで新しいタブで開く", + "thumbnail_overlay_toggle_btn_tooltip_show": "サムネイルオーバーレイを有効にする - 中クリックまたはシフトクリックで新しいタブで開く", + "thumbnail_overlay_indicator_tooltip": "サムネイルオーバーレイが現在有効です", + "thumbnail_overlay_image_fit_crop": "切り取る", + "thumbnail_overlay_image_fit_full": "フルイメージ", + "thumbnail_overlay_image_fit_stretch": "ストレッチ", - "auto_like_channels_dialog_title": "自動的に好きなチャンネル", - "auto_like_channels_dialog_desc": "ここでは、自動的に好きなチャンネルを設定しているかどうかを確認し、編集、有効化、無効化、削除することができます。\n手動でエントリを作成することもできますが、チャンネルページを訪れてそこでボタンをクリックする方が簡単です。", - "auto_like": "自動的に好き", - "auto_like_button_tooltip_enabled": "クリックして自動的に好きにするのを無効にします。シフトクリックで管理ダイアログを開きます。", - "auto_like_button_tooltip_disabled": "クリックして自動的に好きにするのを有効にします。シフトクリックで管理ダイアログを開きます。", - "auto_like_channel_toggle_tooltip": "チャンネル %1 の自動的に好きにするを切り替える", - "add_auto_like_channel_id_prompt": "自動的に好きなチャンネルのユーザー ID(@名前 / UC...)またはフル URL を入力してください。\n「キャンセル」を押して終了します。", - "add_auto_like_channel_invalid_id": "入力されたユーザー ID が無効です。\nチャンネル URL 全体をコピーしたことを確認してください!「channel/UC...」または「/@...」のような部分が含まれている必要があります。", - "add_auto_like_channel_already_exists_prompt_new_name": "その ID のチャンネルはすでにリストにあります。\n名前を変更しますか?", - "add_auto_like_channel_name_prompt": "チャンネルの名前を入力してください。\n「キャンセル」を押して終了します。", - "auto_like_channel_edit_name_prompt": "このチャンネルの新しい名前を入力してください。\n「キャンセル」を押して終了します。", - "auto_like_channel_edit_id_prompt": "このチャンネルの新しいユーザー ID(@名前 / UC...)またはフル URL を入力してください。\n「キャンセル」を押して終了します。", - "auto_like_enabled_toast": "自動的に好きにするのを有効にしました", - "auto_like_disabled_toast": "自動的に好きにするのを無効にしました", - "auto_liked_a_channels_song": "%1 が好きな曲", - "auto_liked_a_channels_video": "%1 が好きなビデオ", - "auto_like_click_to_configure": "クリックして構成", - "auto_like_export_or_import_tooltip": "自動的に好きなチャンネルをエクスポートまたはインポートする", - "auto_like_export_import_title": "自動的に好きなチャンネルをエクスポートまたはインポート", - "auto_like_export_desc": "次のテキストをコピーして自動的に好きなチャンネルをエクスポートします。", - "auto_like_import_desc": "インポートする自動的に好きなチャンネルを以下のフィールドに貼り付け、インポートボタンをクリックしてください:", + "auto_like_channels_dialog_title": "自動的に好きなチャンネル", + "auto_like_channels_dialog_desc": "ここでは、自動的に好きなチャンネルを設定しているかどうかを確認し、編集、有効化、無効化、削除することができます。\n手動でエントリを作成することもできますが、チャンネルページを訪れてそこでボタンをクリックする方が簡単です。", + "auto_like": "自動的に好き", + "auto_like_button_tooltip_enabled": "クリックして自動的に好きにするのを無効にします。シフトクリックで管理ダイアログを開きます。", + "auto_like_button_tooltip_disabled": "クリックして自動的に好きにするのを有効にします。シフトクリックで管理ダイアログを開きます。", + "auto_like_channel_toggle_tooltip": "チャンネル %1 の自動的に好きにするを切り替える", + "add_auto_like_channel_id_prompt": "自動的に好きなチャンネルのユーザー ID(@名前 / UC...)またはフル URL を入力してください。\n「キャンセル」を押して終了します。", + "add_auto_like_channel_invalid_id": "入力されたユーザー ID が無効です。\nチャンネル URL 全体をコピーしたことを確認してください!「channel/UC...」または「/@...」のような部分が含まれている必要があります。", + "add_auto_like_channel_already_exists_prompt_new_name": "その ID のチャンネルはすでにリストにあります。\n名前を変更しますか?", + "add_auto_like_channel_name_prompt": "チャンネルの名前を入力してください。\n「キャンセル」を押して終了します。", + "auto_like_channel_edit_name_prompt": "このチャンネルの新しい名前を入力してください。\n「キャンセル」を押して終了します。", + "auto_like_channel_edit_id_prompt": "このチャンネルの新しいユーザー ID(@名前 / UC...)またはフル URL を入力してください。\n「キャンセル」を押して終了します。", + "auto_like_enabled_toast": "自動的に好きにするのを有効にしました", + "auto_like_disabled_toast": "自動的に好きにするのを無効にしました", + "auto_liked_a_channels_song": "%1 が好きな曲", + "auto_liked_a_channels_video": "%1 が好きなビデオ", + "auto_like_click_to_configure": "クリックして構成", + "auto_like_export_or_import_tooltip": "自動的に好きなチャンネルをエクスポートまたはインポートする", + "auto_like_export_import_title": "自動的に好きなチャンネルをエクスポートまたはインポート", + "auto_like_export_desc": "次のテキストをコピーして自動的に好きなチャンネルをエクスポートします。", + "auto_like_import_desc": "インポートする自動的に好きなチャンネルを以下のフィールドに貼り付け、インポートボタンをクリックしてください:", - "prompt_confirm": "確認", - "prompt_close": "閉じる", - "prompt_cancel": "キャンセル", + "prompt_confirm": "確認", + "prompt_close": "閉じる", + "prompt_cancel": "キャンセル", - "click_to_confirm_tooltip": "クリックして確認", - "click_to_close_tooltip": "クリックして閉じる", - "click_to_cancel_tooltip": "クリックしてキャンセル", + "click_to_confirm_tooltip": "クリックして確認", + "click_to_close_tooltip": "クリックして閉じる", + "click_to_cancel_tooltip": "クリックしてキャンセル", - "vote_label_likes-1": "%1 いいね", - "vote_label_likes-n": "%1 いいね", - "vote_label_dislikes-1": "%1 嫌い", - "vote_label_dislikes-n": "%1 嫌い", + "vote_label_likes-1": "%1 いいね", + "vote_label_likes-n": "%1 いいね", + "vote_label_dislikes-1": "%1 嫌い", + "vote_label_dislikes-n": "%1 嫌い", - "vote_ratio_disabled": "無効", - "vote_ratio_green_red": "緑と赤", - "vote_ratio_blue_gray": "青と灰色", + "vote_ratio_disabled": "無効", + "vote_ratio_green_red": "緑と赤", + "vote_ratio_blue_gray": "青と灰色", - "votes_format_short": "短い", - "votes_format_long": "長い", + "votes_format_short": "短い", + "votes_format_long": "長い", - "unit_entries-1": "項目", - "unit_entries-n": "項目", + "unit_entries-1": "項目", + "unit_entries-n": "項目", - "unit_days-1": "日", - "unit_days-n": "日", + "unit_days-1": "日", + "unit_days-n": "日", - "color_lightness_darker": "暗い", - "color_lightness_normal": "通常", - "color_lightness_lighter": "明るい", + "color_lightness_darker": "暗い", + "color_lightness_normal": "通常", + "color_lightness_lighter": "明るい", - "plugin_list_title": "プラグインリスト", - "plugin_list_no_plugins": "現在インストールされているプラグインはありません。\n詳細については %1このページ%2 をご覧ください。", - "plugin_list_no_plugins_tooltip": "現在インストールされているプラグインはありません。", - "plugin_list_permissions_header": "権限:", + "plugin_list_title": "プラグインリスト", + "plugin_list_no_plugins": "現在インストールされているプラグインはありません。\n詳細については %1このページ%2 をご覧ください。", + "plugin_list_no_plugins_tooltip": "現在インストールされているプラグインはありません。", + "plugin_list_permissions_header": "権限:", - "plugin_link_type_source": "リポジトリ", - "plugin_link_type_other": "その他 / ホームページ", - "plugin_link_type_bug": "バグを報告", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "このプラグインは機能構成を読み取ることができます", - "plugin_intent_description_WriteFeatureConfig": "このプラグインは機能構成に書き込むことができます", - "plugin_intent_description_SeeHiddenConfigValues": "このプラグインは隠し構成値にアクセスできます", - "plugin_intent_description_WriteLyricsCache": "このプラグインは歌詞キャッシュに書き込むことができます", - "plugin_intent_description_WriteTranslations": "このプラグインは新しい翻訳を追加し、既存の翻訳を上書きできます", - "plugin_intent_description_CreateModalDialogs": "このプラグインはモーダルダイアログを作成できます", - "plugin_intent_description_ReadAutoLikeData": "このプラグインは自動的に好きなデータを読み取ることができます", - "plugin_intent_description_WriteAutoLikeData": "このプラグインは自動的に好きなデータに書き込むことができます", + "plugin_link_type_source": "リポジトリ", + "plugin_link_type_other": "その他 / ホームページ", + "plugin_link_type_bug": "バグを報告", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_intent_description_ReadFeatureConfig": "このプラグインは機能構成を読み取ることができます", + "plugin_intent_description_WriteFeatureConfig": "このプラグインは機能構成に書き込むことができます", + "plugin_intent_description_SeeHiddenConfigValues": "このプラグインは隠し構成値にアクセスできます", + "plugin_intent_description_WriteLyricsCache": "このプラグインは歌詞キャッシュに書き込むことができます", + "plugin_intent_description_WriteTranslations": "このプラグインは新しい翻訳を追加し、既存の翻訳を上書きできます", + "plugin_intent_description_CreateModalDialogs": "このプラグインはモーダルダイアログを作成できます", + "plugin_intent_description_ReadAutoLikeData": "このプラグインは自動的に好きなデータを読み取ることができます", + "plugin_intent_description_WriteAutoLikeData": "このプラグインは自動的に好きなデータに書き込むことができます", - "plugin_validation_error_no_property": "タイプ '%2' のプロパティ '%1' がありません", - "plugin_validation_error_invalid_property-1": "プロパティ '%1' の値 '%2' が無効です。例: %3", - "plugin_validation_error_invalid_property-n": "プロパティ '%1' の値 '%2' が無効です。例: %3", + "plugin_validation_error_no_property": "タイプ '%2' のプロパティ '%1' がありません", + "plugin_validation_error_invalid_property-1": "プロパティ '%1' の値 '%2' が無効です。例: %3", + "plugin_validation_error_invalid_property-n": "プロパティ '%1' の値 '%2' が無効です。例: %3", - "feature_category_layout": "レイアウト", - "feature_category_volume": "音量", - "feature_category_songLists": "曲リスト", - "feature_category_behavior": "動作", - "feature_category_input": "入力", - "feature_category_lyrics": "歌詞", - "feature_category_integrations": "統合", - "feature_category_plugins": "プラグイン", - "feature_category_general": "一般的な", + "feature_category_layout": "レイアウト", + "feature_category_volume": "音量", + "feature_category_songLists": "曲リスト", + "feature_category_behavior": "動作", + "feature_category_input": "入力", + "feature_category_lyrics": "歌詞", + "feature_category_integrations": "統合", + "feature_category_plugins": "プラグイン", + "feature_category_general": "一般的な", - "feature_desc_watermarkEnabled": "この構成メニューを開くサイトロゴの下に透かしを表示する", - "feature_helptext_watermarkEnabled": "これが無効になっている場合、プロフィール画像をクリックして右上隅にあるメニューを開くと、構成メニューを開くことができます。\nただし、イースターエッグを見つけるのは難しくなります ;)", - "feature_desc_removeShareTrackingParam": "共有メニューでコピーできる URL から追跡パラメータ \"?si\" を削除する", - "feature_helptext_removeShareTrackingParam": "アナリティクスの目的で、YouTube は共有メニューでコピーできる URL の末尾に追跡パラメータを追加します。直接的な害はありませんが、URL を長くし、YouTube にリンクを送信する人々についての情報をより多く提供します。", - "feature_desc_removeShareTrackingParamSites": "共有トラッキングパラメータを削除するサイトはどこですか?", - "feature_desc_numKeysSkipToTime": "数字キー (0-9) を押して特定の時間にスキップする", - "feature_desc_fixSpacing": "レイアウトのスペーシング問題を修正する", - "feature_helptext_fixSpacing": "ユーザーインターフェイスには、要素間のスペーシングが一貫していない場所がいくつかあります。この機能はそれらの問題を修正します。", - "feature_desc_thumbnailOverlayBehavior": "最高解像度のサムネイルでビデオ要素を自動的に置き換えるタイミング", - "feature_helptext_thumbnailOverlayBehavior": "この機能を使用すると、ビデオ要素が自動的にサムネイルで置き換えられます。これにより、ビデオの読み込みが遅くなり、帯域幅が節約されます。\nサムネイルをクリックしてビデオを再生することもできます。", - "feature_desc_thumbnailOverlayToggleBtnShown": "サムネイルオーバーレイを手動で切り替えるボタンを追加する", - "feature_helptext_thumbnailOverlayToggleBtnShown": "このボタンを押すと、サムネイルのオンとオフを手動で切り替えることができます\n新しいビデオや曲の再生が始まると、デフォルトの状態に戻ります。シフトを押しながらクリックするか、マウスの中ボタンを押すと、最高画質のサムネイルが新しいタブで開きます。", - "feature_desc_thumbnailOverlayShowIndicator": "サムネイルがアクティブな間、サムネイルの右下隅にインジケータを表示する", - "feature_desc_thumbnailOverlayIndicatorOpacity": "サムネイルインジケータの不透明度", - "feature_desc_thumbnailOverlayImageFit": "サムネイル画像をビデオ要素に合わせる方法", - "feature_desc_hideCursorOnIdle": "ビデオの上で数秒間アクティビティがないとカーソルを非表示にする", - "feature_desc_hideCursorOnIdleDelay": "カーソルを非表示にするまでのアイドル時間(秒)", - "feature_desc_fixHdrIssues": "HDR 互換 GPU とモニターを使用しているときの一部のレンダリング問題を防止する", - "feature_desc_showVotes": "現在再生中の曲のいいねと嫌いの数を表示する", - "feature_helptext_showVotes": "この機能は Return YouTube Dislike によって提供され、現在再生中の曲のいいねと嫌いの数をおおよその数で表示します。", - "feature_desc_numbersFormat": "数字のフォーマット方法", + "feature_desc_watermarkEnabled": "この構成メニューを開くサイトロゴの下に透かしを表示する", + "feature_helptext_watermarkEnabled": "これが無効になっている場合、プロフィール画像をクリックして右上隅にあるメニューを開くと、構成メニューを開くことができます。\nただし、イースターエッグを見つけるのは難しくなります ;)", + "feature_desc_removeShareTrackingParam": "共有メニューでコピーできる URL から追跡パラメータ \"?si\" を削除する", + "feature_helptext_removeShareTrackingParam": "アナリティクスの目的で、YouTube は共有メニューでコピーできる URL の末尾に追跡パラメータを追加します。直接的な害はありませんが、URL を長くし、YouTube にリンクを送信する人々についての情報をより多く提供します。", + "feature_desc_removeShareTrackingParamSites": "共有トラッキングパラメータを削除するサイトはどこですか?", + "feature_desc_numKeysSkipToTime": "数字キー (0-9) を押して特定の時間にスキップする", + "feature_desc_fixSpacing": "レイアウトのスペーシング問題を修正する", + "feature_helptext_fixSpacing": "ユーザーインターフェイスには、要素間のスペーシングが一貫していない場所がいくつかあります。この機能はそれらの問題を修正します。", + "feature_desc_thumbnailOverlayBehavior": "最高解像度のサムネイルでビデオ要素を自動的に置き換えるタイミング", + "feature_helptext_thumbnailOverlayBehavior": "この機能を使用すると、ビデオ要素が自動的にサムネイルで置き換えられます。これにより、ビデオの読み込みが遅くなり、帯域幅が節約されます。\nサムネイルをクリックしてビデオを再生することもできます。", + "feature_desc_thumbnailOverlayToggleBtnShown": "サムネイルオーバーレイを手動で切り替えるボタンを追加する", + "feature_helptext_thumbnailOverlayToggleBtnShown": "このボタンを押すと、サムネイルのオンとオフを手動で切り替えることができます\n新しいビデオや曲の再生が始まると、デフォルトの状態に戻ります。シフトを押しながらクリックするか、マウスの中ボタンを押すと、最高画質のサムネイルが新しいタブで開きます。", + "feature_desc_thumbnailOverlayShowIndicator": "サムネイルがアクティブな間、サムネイルの右下隅にインジケータを表示する", + "feature_desc_thumbnailOverlayIndicatorOpacity": "サムネイルインジケータの不透明度", + "feature_desc_thumbnailOverlayImageFit": "サムネイル画像をビデオ要素に合わせる方法", + "feature_desc_hideCursorOnIdle": "ビデオの上で数秒間アクティビティがないとカーソルを非表示にする", + "feature_desc_hideCursorOnIdleDelay": "カーソルを非表示にするまでのアイドル時間(秒)", + "feature_desc_fixHdrIssues": "HDR 互換 GPU とモニターを使用しているときの一部のレンダリング問題を防止する", + "feature_desc_showVotes": "現在再生中の曲のいいねと嫌いの数を表示する", + "feature_helptext_showVotes": "この機能は Return YouTube Dislike によって提供され、現在再生中の曲のいいねと嫌いの数をおおよその数で表示します。", + "feature_desc_numbersFormat": "数字のフォーマット方法", - "feature_desc_volumeSliderLabel": "音量スライダーの横にパーセンテージラベルを追加する", - "feature_desc_volumeSliderSize": "音量スライダーの幅(ピクセル単位)", - "feature_desc_volumeSliderStep": "音量スライダーの感度(音量を一度にどれだけのパーセントで変更できるか)", - "feature_desc_volumeSliderScrollStep": "音量スライダーをスクロールする感度(パーセント単位) - 上記の感度値から最も近い感度値にスナップします", - "feature_helptext_volumeSliderScrollStep": "マウスホイールで音量スライダーをスクロールするときに音量を変更するパーセント", - "feature_desc_volumeSharedBetweenTabs": "音量レベルをタブ間で共有する", - "feature_helptext_volumeSharedBetweenTabs": "1 つのタブで音量を変更すると、この機能が有効になっている他のすべてのタブで音量レベルが同じ値に設定されます。\nこの値は、無効になるまで記憶され、セッション間で復元されます。", - "feature_desc_setInitialTabVolume": "サイトを開いたときに音量レベルを特定の値に設定する", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "この機能は「タブ間で音量を共有する」機能と互換性がなく、共有音量機能を使用している間は無視されます!", - "feature_desc_initialTabVolumeLevel": "サイトを開いたときに音量レベルを設定する値", + "feature_desc_volumeSliderLabel": "音量スライダーの横にパーセンテージラベルを追加する", + "feature_desc_volumeSliderSize": "音量スライダーの幅(ピクセル単位)", + "feature_desc_volumeSliderStep": "音量スライダーの感度(音量を一度にどれだけのパーセントで変更できるか)", + "feature_desc_volumeSliderScrollStep": "音量スライダーをスクロールする感度(パーセント単位) - 上記の感度値から最も近い感度値にスナップします", + "feature_helptext_volumeSliderScrollStep": "マウスホイールで音量スライダーをスクロールするときに音量を変更するパーセント", + "feature_desc_volumeSharedBetweenTabs": "音量レベルをタブ間で共有する", + "feature_helptext_volumeSharedBetweenTabs": "1 つのタブで音量を変更すると、この機能が有効になっている他のすべてのタブで音量レベルが同じ値に設定されます。\nこの値は、無効になるまで記憶され、セッション間で復元されます。", + "feature_desc_setInitialTabVolume": "サイトを開いたときに音量レベルを特定の値に設定する", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "この機能は「タブ間で音量を共有する」機能と互換性がなく、共有音量機能を使用している間は無視されます!", + "feature_desc_initialTabVolumeLevel": "サイトを開いたときに音量レベルを設定する値", - "feature_desc_lyricsQueueButton": "リスト内の各曲にボタンを追加して、歌詞ページを開く", - "feature_desc_deleteFromQueueButton": "リスト内の各曲にボタンを追加して、すばやく削除する", - "feature_desc_listButtonsPlacement": "リストボタンの配置場所", - "feature_helptext_listButtonsPlacement": "サイトにはアルバムページ、プレイリスト、現在再生中のキューなど、さまざまな曲リストがあります。\nこのオプションを使用すると、リストボタンを表示する場所を選択できます。", - "feature_desc_scrollToActiveSongBtn": "現在再生中の曲にスクロールするボタンをキューの上に追加する", - "feature_desc_clearQueueBtn": "現在再生中のキューまたはプレイリストの上にボタンを追加して、すばやくクリアする", + "feature_desc_lyricsQueueButton": "リスト内の各曲にボタンを追加して、歌詞ページを開く", + "feature_desc_deleteFromQueueButton": "リスト内の各曲にボタンを追加して、すばやく削除する", + "feature_desc_listButtonsPlacement": "リストボタンの配置場所", + "feature_helptext_listButtonsPlacement": "サイトにはアルバムページ、プレイリスト、現在再生中のキューなど、さまざまな曲リストがあります。\nこのオプションを使用すると、リストボタンを表示する場所を選択できます。", + "feature_desc_scrollToActiveSongBtn": "現在再生中の曲にスクロールするボタンをキューの上に追加する", + "feature_desc_clearQueueBtn": "現在再生中のキューまたはプレイリストの上にボタンを追加して、すばやくクリアする", - "feature_desc_disableBeforeUnloadPopup": "曲が再生されている間にサイトを離れようとすると表示される確認ポップアップを防止する", - "feature_helptext_disableBeforeUnloadPopup": "曲が再生されている間にサイトを離れようとすると、数秒後にポップアップが表示され、サイトを離れるかどうかを確認するように求められます。それは「保存されていないデータがあります」とか「このサイトを閉じるかどうかを尋ねています」とかのようなことが書かれているかもしれません。\nこの機能はそのポップアップを完全に無効にします。", - "feature_desc_closeToastsTimeout": "永続的な通知を閉じるまでの秒数 - 手動で閉じるには 0 (デフォルト動作)", - "feature_helptext_closeToastsTimeout": "左下隅に表示されるほとんどのポップアップは、曲を好きにするといった特定のものを除いて、3 秒後に自動的に閉じます。\nこの機能を使用すると、永続的なポップアップを閉じる時間を設定できます。\n他の種類のポップアップは影響を受けません。\n永続的な通知を閉じないデフォルト動作にするには 0 を設定してください。", - "feature_desc_rememberSongTime": "リロードまたはタブの復元時に最後の曲の時間を記憶する", - "feature_helptext_rememberSongTime-1": "ページを再読み込みしたり、誤って閉じたりして復元したときに、同じ場所で聞き直したいことがあります。この機能を使用すると、それが可能になります。\n曲の時間を記録するには、%1 秒再生する必要があります。その後、その時間が記憶され、しばらくの間復元可能になります。", - "feature_helptext_rememberSongTime-n": "ページを再読み込みしたり、誤って閉じたりして復元したときに、同じ場所で聞き直したいことがあります。この機能を使用すると、それが可能になります。\n曲の時間を記録するには、%1 秒再生する必要があります。その後、その時間が記憶され、しばらくの間復元可能になります。", - "feature_desc_rememberSongTimeSites": "曲の時間を記憶して復元するサイトはどこですか?", - "feature_desc_rememberSongTimeDuration": "曲の時間を記憶する秒数", - "feature_desc_rememberSongTimeReduction": "記憶された曲の時間を復元するときに減算する秒数", - "feature_helptext_rememberSongTimeReduction": "記憶された曲の時間を復元するときに、記憶された時間からこの秒数が減算され、中断された部分を再度聞くことができます。", - "feature_desc_rememberSongTimeMinPlayTime": "曲の時間を記憶するために再生する必要のある最小秒数", + "feature_desc_disableBeforeUnloadPopup": "曲が再生されている間にサイトを離れようとすると表示される確認ポップアップを防止する", + "feature_helptext_disableBeforeUnloadPopup": "曲が再生されている間にサイトを離れようとすると、数秒後にポップアップが表示され、サイトを離れるかどうかを確認するように求められます。それは「保存されていないデータがあります」とか「このサイトを閉じるかどうかを尋ねています」とかのようなことが書かれているかもしれません。\nこの機能はそのポップアップを完全に無効にします。", + "feature_desc_closeToastsTimeout": "永続的な通知を閉じるまでの秒数 - 手動で閉じるには 0 (デフォルト動作)", + "feature_helptext_closeToastsTimeout": "左下隅に表示されるほとんどのポップアップは、曲を好きにするといった特定のものを除いて、3 秒後に自動的に閉じます。\nこの機能を使用すると、永続的なポップアップを閉じる時間を設定できます。\n他の種類のポップアップは影響を受けません。\n永続的な通知を閉じないデフォルト動作にするには 0 を設定してください。", + "feature_desc_rememberSongTime": "リロードまたはタブの復元時に最後の曲の時間を記憶する", + "feature_helptext_rememberSongTime-1": "ページを再読み込みしたり、誤って閉じたりして復元したときに、同じ場所で聞き直したいことがあります。この機能を使用すると、それが可能になります。\n曲の時間を記録するには、%1 秒再生する必要があります。その後、その時間が記憶され、しばらくの間復元可能になります。", + "feature_helptext_rememberSongTime-n": "ページを再読み込みしたり、誤って閉じたりして復元したときに、同じ場所で聞き直したいことがあります。この機能を使用すると、それが可能になります。\n曲の時間を記録するには、%1 秒再生する必要があります。その後、その時間が記憶され、しばらくの間復元可能になります。", + "feature_desc_rememberSongTimeSites": "曲の時間を記憶して復元するサイトはどこですか?", + "feature_desc_rememberSongTimeDuration": "曲の時間を記憶する秒数", + "feature_desc_rememberSongTimeReduction": "記憶された曲の時間を復元するときに減算する秒数", + "feature_helptext_rememberSongTimeReduction": "記憶された曲の時間を復元するときに、記憶された時間からこの秒数が減算され、中断された部分を再度聞くことができます。", + "feature_desc_rememberSongTimeMinPlayTime": "曲の時間を記憶するために再生する必要のある最小秒数", - "feature_desc_arrowKeySupport": "現在再生中の曲で前後にスキップするには矢印キーを使用する", - "feature_helptext_arrowKeySupport": "通常、キー \"H\" と \"L\" を使用して 10 秒間隔で前後にスキップすることができます。この機能を使用すると、矢印キーも使用できます。\nスキップする秒数を変更するには、以下のオプションを使用してください。", - "feature_desc_arrowKeySkipBy": "矢印キーを使用してスキップする秒数", - "feature_desc_switchBetweenSites": "ビデオ / 曲の YT と YTM サイトを切り替えるホットキーを追加する", - "feature_helptext_switchBetweenSites": "このホットキーを押すと、同じビデオ / 曲のままで YouTube または YouTube Music のどちらかに切り替わります。", - "feature_desc_switchSitesHotkey": "サイトを切り替えるために押す必要があるホットキーはどれですか?", - "feature_desc_anchorImprovements": "リンクを追加してページ全体でリンクを開くことができるようにする", - "feature_helptext_anchorImprovements": "ページ上のいくつかの要素は左クリックのみでクリックできるため、中クリックやシフト + 右クリックを使用して新しいタブで開くことができません。この機能はそれらの要素にリンクを追加するか、既存のリンクを拡大してクリックしやすくします。", - "feature_desc_autoLikeChannels": "特定のチャンネルのすべての曲とビデオを自動的に好きにする", - "feature_helpText_autoLikeChannels": "一度有効にすると、そのチャンネルのページを開いてトグルボタンをクリックすることで、そのチャンネルのすべての曲を自動的に好きにすることができます。\nチャンネルを管理するダイアログを開くためのオプションを使用してください。", - "feature_desc_autoLikeChannelToggleBtn": "各チャンネルページにトグルボタンを追加して、自動的に好きにするかどうかを切り替える", - "feature_desc_autoLikePlayerBarToggleBtn": "メディアコントロールにボタンを追加して、自動的に好きにするかどうかを切り替える", - "feature_desc_autoLikeTimeout": "自動的に好きにする前に曲が再生される必要がある秒数", - "feature_desc_autoLikeShowToast": "曲が自動的に好きになったときにトースト通知を表示する", - "feature_desc_autoLikeOpenMgmtDialog": "自動的に好きなチャンネルを管理するダイアログを開く", - "feature_btn_autoLikeOpenMgmtDialog": "ダイアログを開く", - "feature_btn_autoLikeOpenMgmtDialog_running": "開いています...", + "feature_desc_arrowKeySupport": "現在再生中の曲で前後にスキップするには矢印キーを使用する", + "feature_helptext_arrowKeySupport": "通常、キー \"H\" と \"L\" を使用して 10 秒間隔で前後にスキップすることができます。この機能を使用すると、矢印キーも使用できます。\nスキップする秒数を変更するには、以下のオプションを使用してください。", + "feature_desc_arrowKeySkipBy": "矢印キーを使用してスキップする秒数", + "feature_desc_switchBetweenSites": "ビデオ / 曲の YT と YTM サイトを切り替えるホットキーを追加する", + "feature_helptext_switchBetweenSites": "このホットキーを押すと、同じビデオ / 曲のままで YouTube または YouTube Music のどちらかに切り替わります。", + "feature_desc_switchSitesHotkey": "サイトを切り替えるために押す必要があるホットキーはどれですか?", + "feature_desc_anchorImprovements": "リンクを追加してページ全体でリンクを開くことができるようにする", + "feature_helptext_anchorImprovements": "ページ上のいくつかの要素は左クリックのみでクリックできるため、中クリックやシフト + 右クリックを使用して新しいタブで開くことができません。この機能はそれらの要素にリンクを追加するか、既存のリンクを拡大してクリックしやすくします。", + "feature_desc_autoLikeChannels": "特定のチャンネルのすべての曲とビデオを自動的に好きにする", + "feature_helpText_autoLikeChannels": "一度有効にすると、そのチャンネルのページを開いてトグルボタンをクリックすることで、そのチャンネルのすべての曲を自動的に好きにすることができます。\nチャンネルを管理するダイアログを開くためのオプションを使用してください。", + "feature_desc_autoLikeChannelToggleBtn": "各チャンネルページにトグルボタンを追加して、自動的に好きにするかどうかを切り替える", + "feature_desc_autoLikePlayerBarToggleBtn": "メディアコントロールにボタンを追加して、自動的に好きにするかどうかを切り替える", + "feature_desc_autoLikeTimeout": "自動的に好きにする前に曲が再生される必要がある秒数", + "feature_desc_autoLikeShowToast": "曲が自動的に好きになったときにトースト通知を表示する", + "feature_desc_autoLikeOpenMgmtDialog": "自動的に好きなチャンネルを管理するダイアログを開く", + "feature_btn_autoLikeOpenMgmtDialog": "ダイアログを開く", + "feature_btn_autoLikeOpenMgmtDialog_running": "開いています...", - "feature_desc_geniusLyrics": "現在再生中の曲のメディアコントロールにボタンを追加して、genius.com で歌詞を開く", - "feature_desc_errorOnLyricsNotFound": "現在再生中の曲の歌詞ページが見つからなかった場合にエラーを表示する", - "feature_desc_geniUrlBase": "geniURL インスタンスのベース URL。https://github.com/Sv443/geniURL を参照してください。", - "feature_helptext_geniUrlBase": "自分の geniURL インスタンスを実行している場合(例えばレート制限をバイパスするため)、ここにそのベース URL を入力して、genius.com の歌詞ボタンに使用できます。\nこれが何かわからない場合は、このオプションをそのままにしておくことができます。", - "feature_desc_geniUrlToken": "geniURL インスタンスの認証トークン", - "feature_helptext_geniUrlToken": "レート制限をバイパスするために、geniURL インスタンスの .env ファイルで定義されている認証トークンを提供することができます。", - "feature_desc_lyricsCacheMaxSize": "キャッシュに保持する歌詞の最大量", - "feature_helptext_lyricsCacheMaxSize": "聞いた曲の歌詞はキャッシュに保存され、歌詞プロバイダへのリクエストを減らすために使用されます。\nこの機能を使用して、キャッシュに保持する歌詞の最大量を設定できます。\nこの制限に達すると、最も古いエントリが新しいエントリのために削除されます。", - "feature_desc_lyricsCacheTTL": "キャッシュに歌詞エントリを保持する最大日数", - "feature_helptext_lyricsCacheTTL": "歌詞はキャッシュに保存され、更新された歌詞を取得するために定期的に削除されます。\n常に最新の歌詞を持っていたい場合は、4 日などの低い値に設定してください。", - "feature_desc_clearLyricsCache": "歌詞キャッシュを手動でクリアする", - "feature_helptext_clearLyricsCache": "ローカルキャッシュにある歌詞が古くなっている場合や、スペースを解放したい場合は、このボタンを押してキャッシュを手動でクリアできます。", - "feature_btn_clearLyricsCache": "キャッシュをクリア", - "feature_btn_clearLyricsCache_running": "クリア中...", - "feature_desc_advancedLyricsFilter": "実験的: 歌詞検索のための高度なフィルタリングを有効にする", - "feature_helptext_advancedLyricsFilter": "高度なフィルタリングには、歌詞検索をより信頼性の高いものにするための複数のフィルタのレイヤが含まれています。\nこれらのフィルタは、あなたの言語の曲や、一般的にタイトルが一意でない曲やアーティストにはうまく機能しないかもしれません。\n警告: この機能はまだ実験的であり、デフォルトの歌詞検索よりも実際に優れたパフォーマンスを発揮しないかもしれません。この設定を変更した場合は、表示されるプロンプトを確認してください。", + "feature_desc_geniusLyrics": "現在再生中の曲のメディアコントロールにボタンを追加して、genius.com で歌詞を開く", + "feature_desc_errorOnLyricsNotFound": "現在再生中の曲の歌詞ページが見つからなかった場合にエラーを表示する", + "feature_desc_geniUrlBase": "geniURL インスタンスのベース URL。https://github.com/Sv443/geniURL を参照してください。", + "feature_helptext_geniUrlBase": "自分の geniURL インスタンスを実行している場合(例えばレート制限をバイパスするため)、ここにそのベース URL を入力して、genius.com の歌詞ボタンに使用できます。\nこれが何かわからない場合は、このオプションをそのままにしておくことができます。", + "feature_desc_geniUrlToken": "geniURL インスタンスの認証トークン", + "feature_helptext_geniUrlToken": "レート制限をバイパスするために、geniURL インスタンスの .env ファイルで定義されている認証トークンを提供することができます。", + "feature_desc_lyricsCacheMaxSize": "キャッシュに保持する歌詞の最大量", + "feature_helptext_lyricsCacheMaxSize": "聞いた曲の歌詞はキャッシュに保存され、歌詞プロバイダへのリクエストを減らすために使用されます。\nこの機能を使用して、キャッシュに保持する歌詞の最大量を設定できます。\nこの制限に達すると、最も古いエントリが新しいエントリのために削除されます。", + "feature_desc_lyricsCacheTTL": "キャッシュに歌詞エントリを保持する最大日数", + "feature_helptext_lyricsCacheTTL": "歌詞はキャッシュに保存され、更新された歌詞を取得するために定期的に削除されます。\n常に最新の歌詞を持っていたい場合は、4 日などの低い値に設定してください。", + "feature_desc_clearLyricsCache": "歌詞キャッシュを手動でクリアする", + "feature_helptext_clearLyricsCache": "ローカルキャッシュにある歌詞が古くなっている場合や、スペースを解放したい場合は、このボタンを押してキャッシュを手動でクリアできます。", + "feature_btn_clearLyricsCache": "キャッシュをクリア", + "feature_btn_clearLyricsCache_running": "クリア中...", + "feature_desc_advancedLyricsFilter": "実験的: 歌詞検索のための高度なフィルタリングを有効にする", + "feature_helptext_advancedLyricsFilter": "高度なフィルタリングには、歌詞検索をより信頼性の高いものにするための複数のフィルタのレイヤが含まれています。\nこれらのフィルタは、あなたの言語の曲や、一般的にタイトルが一意でない曲やアーティストにはうまく機能しないかもしれません。\n警告: この機能はまだ実験的であり、デフォルトの歌詞検索よりも実際に優れたパフォーマンスを発揮しないかもしれません。この設定を変更した場合は、表示されるプロンプトを確認してください。", - "feature_desc_disableDarkReaderSites": "レイアウト問題を修正するために Dark Reader 拡張機能を無効にするサイト", - "feature_helptext_disableDarkReaderSites": "Dark Reader 拡張機能はサイトのレイアウトに問題を引き起こす可能性があります。\nこの機能を使用すると、それらの問題を防ぐために特定のサイトまたはすべてのサイトで Dark Reader を無効にできます。\n\n拡張機能がインストールされていない場合、この機能は効果がありませんので、有効にしたままにしておくことができます。", - "feature_desc_sponsorBlockIntegration": "SponsorBlock がインストールされている場合、レイアウトにいくつかの修正を行う", - "feature_helptext_sponsorBlockIntegration": "SponsorBlock 拡張機能がインストールされている場合、この機能を有効にすると、サイトのレイアウトに問題が発生するのを防ぐためにいくつかの修正が行われます。\n\nこの機能は拡張機能がインストールされていない場合には効果がありませんので、その場合は有効にしたままにしておくことができます。", - "feature_desc_themeSongIntegration": "ThemeSong 拡張機能がインストールされている場合、いくつかのスタイリング問題を修正する", - "feature_helptext_themeSongIntegration": "ThemeSong 拡張機能がインストールされている場合、この機能を有効にすると、サイトのスタイリングに問題が発生するのを防ぐためにいくつかの修正が行われます。\n\nこの機能は拡張機能がインストールされていない場合には効果がありませんので、その場合は有効にしたままにしておくことができます。", - "feature_desc_themeSongLightness": "現在の ThemeSong テーマから派生したアクセントカラーの明るさ", - "feature_helptext_themeSongLightness": "ThemeSong 拡張機能の設定に応じて、この機能を使用して、現在のテーマから派生したアクセントカラーの明るさを調整できます。\n\nThemeSong 拡張機能がインストールされていない場合、この機能は効果がありません。", - "feature_desc_openPluginList": "インストールされているプラグインのリストを開く", - "feature_btn_openPluginList": "リストを開く", - "feature_btn_openPluginList_running": "開いています...", - "feature_desc_initTimeout": "機能が初期化されるまでの待機時間(エラー状態と見なされる前に)", - "feature_helptext_initTimeout": "これは、機能が初期化されるまでの待機時間(秒単位)です。\nこれはスクリプトの動作に大きな影響を与えませんが、プラグインの初期化に時間がかかる場合は、この値を増やしてみてください。", + "feature_desc_disableDarkReaderSites": "レイアウト問題を修正するために Dark Reader 拡張機能を無効にするサイト", + "feature_helptext_disableDarkReaderSites": "Dark Reader 拡張機能はサイトのレイアウトに問題を引き起こす可能性があります。\nこの機能を使用すると、それらの問題を防ぐために特定のサイトまたはすべてのサイトで Dark Reader を無効にできます。\n\n拡張機能がインストールされていない場合、この機能は効果がありませんので、有効にしたままにしておくことができます。", + "feature_desc_sponsorBlockIntegration": "SponsorBlock がインストールされている場合、レイアウトにいくつかの修正を行う", + "feature_helptext_sponsorBlockIntegration": "SponsorBlock 拡張機能がインストールされている場合、この機能を有効にすると、サイトのレイアウトに問題が発生するのを防ぐためにいくつかの修正が行われます。\n\nこの機能は拡張機能がインストールされていない場合には効果がありませんので、その場合は有効にしたままにしておくことができます。", + "feature_desc_themeSongIntegration": "ThemeSong 拡張機能がインストールされている場合、いくつかのスタイリング問題を修正する", + "feature_helptext_themeSongIntegration": "ThemeSong 拡張機能がインストールされている場合、この機能を有効にすると、サイトのスタイリングに問題が発生するのを防ぐためにいくつかの修正が行われます。\n\nこの機能は拡張機能がインストールされていない場合には効果がありませんので、その場合は有効にしたままにしておくことができます。", + "feature_desc_themeSongLightness": "現在の ThemeSong テーマから派生したアクセントカラーの明るさ", + "feature_helptext_themeSongLightness": "ThemeSong 拡張機能の設定に応じて、この機能を使用して、現在のテーマから派生したアクセントカラーの明るさを調整できます。\n\nThemeSong 拡張機能がインストールされていない場合、この機能は効果がありません。", + "feature_desc_openPluginList": "インストールされているプラグインのリストを開く", + "feature_btn_openPluginList": "リストを開く", + "feature_btn_openPluginList_running": "開いています...", + "feature_desc_initTimeout": "機能が初期化されるまでの待機時間(エラー状態と見なされる前に)", + "feature_helptext_initTimeout": "これは、機能が初期化されるまでの待機時間(秒単位)です。\nこれはスクリプトの動作に大きな影響を与えませんが、プラグインの初期化に時間がかかる場合は、この値を増やしてみてください。", - "feature_desc_locale": "言語", - "feature_desc_localeFallback": "翻訳が欠落している場合に英語をフォールバック言語として使用する(翻訳を提供している場合は無効にする)", - "feature_desc_versionCheck": "バージョンチェック", - "feature_helptext_versionCheck": "この機能は 24 時間ごとに更新をチェックし、新しいバージョンが利用可能な場合に通知し、スクリプトを手動で更新することができます。\nユーザースクリプトマネージャー拡張機能がスクリプトを自動的に更新する場合、この機能を無効にすることができます。", - "feature_desc_checkVersionNow": "手動で新しいバージョンをチェックする", - "feature_btn_checkVersionNow": "今すぐチェック", - "feature_btn_checkVersionNow_running": "チェック中...", - "feature_desc_logLevel": "ログレベル", - "feature_helptext_logLevel": "これを変更するのは、問題が発生した結果としてデバッグ目的でのみ必要です。\n問題が発生した場合にのみ、ここでログレベルを増やし、ブラウザの JavaScript コンソールを開いて(通常は Ctrl + Shift + K)そのログのスクリーンショットを GitHub の課題に添付してください。", - "feature_desc_toastDuration": "カスタムトースト通知を表示する秒数 - 0 で完全に無効にする", - "feature_desc_showToastOnGenericError": "一般的なエラーが発生したときに通知を表示する?", - "feature_helptext_showToastOnGenericError": "スクリプトでエラーが発生し、それが一部の機能の正常な動作を妨げる場合、通知が表示され、そのことを通知します。\n問題が頻繁に発生する場合は、JavaScript コンソールからエラーをコピーして(通常は F12 メニューにあります)GitHub で問題を開いてください。", - "feature_desc_resetConfig": "すべての設定をデフォルト値にリセットする", - "feature_btn_resetConfig": "設定をリセット", - "feature_btn_resetConfig_running": "リセット中...", - "feature_desc_advancedMode": "詳細設定を表示する(メニューを再読み込み)", - "feature_helptext_advancedMode": "これを有効にすると、メニューが再読み込まれ、デフォルトで非表示になっている詳細設定が表示されます。\nこれは、スクリプトの動作をより深くカスタマイズしたい場合に便利です。メニューが過密になっていることを気にしない場合に役立ちます。" - } + "feature_desc_locale": "言語", + "feature_desc_localeFallback": "翻訳が欠落している場合に英語をフォールバック言語として使用する(翻訳を提供している場合は無効にする)", + "feature_desc_versionCheck": "バージョンチェック", + "feature_helptext_versionCheck": "この機能は 24 時間ごとに更新をチェックし、新しいバージョンが利用可能な場合に通知し、スクリプトを手動で更新することができます。\nユーザースクリプトマネージャー拡張機能がスクリプトを自動的に更新する場合、この機能を無効にすることができます。", + "feature_desc_checkVersionNow": "手動で新しいバージョンをチェックする", + "feature_btn_checkVersionNow": "今すぐチェック", + "feature_btn_checkVersionNow_running": "チェック中...", + "feature_desc_logLevel": "ログレベル", + "feature_helptext_logLevel": "これを変更するのは、問題が発生した結果としてデバッグ目的でのみ必要です。\n問題が発生した場合にのみ、ここでログレベルを増やし、ブラウザの JavaScript コンソールを開いて(通常は Ctrl + Shift + K)そのログのスクリーンショットを GitHub の課題に添付してください。", + "feature_desc_toastDuration": "カスタムトースト通知を表示する秒数 - 0 で完全に無効にする", + "feature_desc_showToastOnGenericError": "一般的なエラーが発生したときに通知を表示する?", + "feature_helptext_showToastOnGenericError": "スクリプトでエラーが発生し、それが一部の機能の正常な動作を妨げる場合、通知が表示され、そのことを通知します。\n問題が頻繁に発生する場合は、JavaScript コンソールからエラーをコピーして(通常は F12 メニューにあります)GitHub で問題を開いてください。", + "feature_desc_resetConfig": "すべての設定をデフォルト値にリセットする", + "feature_btn_resetConfig": "設定をリセット", + "feature_btn_resetConfig_running": "リセット中...", + "feature_desc_advancedMode": "詳細設定を表示する(メニューを再読み込み)", + "feature_helptext_advancedMode": "これを有効にすると、メニューが再読み込まれ、デフォルトで非表示になっている詳細設定が表示されます。\nこれは、スクリプトの動作をより深くカスタマイズしたい場合に便利です。メニューが過密になっていることを気にしない場合に役立ちます。" } diff --git a/assets/translations/pt-BR.json b/assets/translations/pt-BR.json index b8127b7eea..50bc30d8ce 100644 --- a/assets/translations/pt-BR.json +++ b/assets/translations/pt-BR.json @@ -1,356 +1,362 @@ { - "translations": { - "config_menu_option": "Configuração do %1", - "config_menu_title": "%1 - Configuração", - "changelog_menu_title": "%1 - Registro de alterações", - "export_menu_title": "%1 - Exportar configuração", - "import_menu_title": "%1 - Importar configuração", - "open_menu_tooltip": "Abra o menu de configuração do %1", - "close_menu_tooltip": "Clique para fechar o menu", - "reload_hint": "Por favor, recarregue a página para aplicar suas alterações", - "reload_now": "Recarregar agora", - "reload_tooltip": "Recarregue a página", - "feature_requires_reload": "Alterar este recurso requer uma recarga da página", - "version_tooltip": "Versão %1 (compilação %2) - clique para abrir o registro de alterações", - "bytm_config_export_import_title": "Exportar ou Importar Configuração", - "bytm_config_import_desc": "Cole a configuração que você deseja importar no campo abaixo e clique no botão de importação:", - "bytm_config_export_desc": "Copie o texto a seguir para exportar sua configuração. Aviso: pode conter dados sensíveis.", - "export_import": "Exportar/Importar", - "export": "Exportar", - "export_hint": "Copie o texto a seguir para exportar sua configuração:", - "click_to_reveal": "(clique para revelar)", - "click_to_reveal_sensitive_info": "(clique para revelar informações sensíveis)", - "export_tooltip": "Exporte sua configuração atual", - "import": "Importar", - "import_hint": "Cole a configuração que você deseja importar no campo abaixo e clique no botão de importação:", - "import_tooltip": "Importe uma configuração que você exportou anteriormente", - "start_import_tooltip": "Clique para importar os dados que você colou acima", - "import_error_invalid": "Os dados importados são inválidos", - "import_error_no_format_version": "Os dados importados não contêm uma versão de formato", - "import_error_no_data": "O objeto importado não contém nenhum dado", - "import_error_wrong_format_version": "Os dados importados estão em uma versão de formato não suportada (esperada %1 ou inferior, mas %2)", - "import_success": "Os dados foram importados com sucesso", - "import_success_confirm_reload": "Os dados foram importados com sucesso.\nVocê deseja recarregar a página agora para aplicar as alterações?", - "reset_config_confirm": "Você realmente deseja redefinir todas as configurações para seus valores padrão?\nA página será recarregada automaticamente.", - "copy": "Copiar", - "copy_to_clipboard": "Copiar para a área de transferência", - "copy_to_clipboard_error": "Não foi possível copiar o texto para a área de transferência. Por favor, copie manualmente daqui:\n%1", - "copy_config_tooltip": "Copie a configuração para a área de transferência", - "copied": "Copiado!", - "copied_to_clipboard": "Copiado para a área de transferência!", - "copy_hidden": "Copiar oculto", - "copy_hidden_tooltip": "Clique para copiar o valor oculto - estes são dados sensíveis ⚠️", - "open_github": "Abrir %1 no GitHub", - "open_discord": "Junte-se ao meu servidor Discord", - "open_greasyfork": "Abrir %1 no GreasyFork", - "open_openuserjs": "Abrir %1 no OpenUserJS", - "lang_changed_prompt_reload": "O idioma foi alterado.\nVocê deseja recarregar a página agora para aplicar as alterações?", - "search_placeholder": "Pesquisar...", - "search_clear": "Limpar pesquisa", + "meta": { + "langName": "Português", + "langNameEnglish": "Portuguese (Brazil)", + "countryName": "Brasil", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "Configuração do %1", + "config_menu_title": "%1 - Configuração", + "changelog_menu_title": "%1 - Registro de alterações", + "export_menu_title": "%1 - Exportar configuração", + "import_menu_title": "%1 - Importar configuração", + "open_menu_tooltip": "Abra o menu de configuração do %1", + "close_menu_tooltip": "Clique para fechar o menu", + "reload_hint": "Por favor, recarregue a página para aplicar suas alterações", + "reload_now": "Recarregar agora", + "reload_tooltip": "Recarregue a página", + "feature_requires_reload": "Alterar este recurso requer uma recarga da página", + "version_tooltip": "Versão %1 (compilação %2) - clique para abrir o registro de alterações", + "bytm_config_export_import_title": "Exportar ou Importar Configuração", + "bytm_config_import_desc": "Cole a configuração que você deseja importar no campo abaixo e clique no botão de importação:", + "bytm_config_export_desc": "Copie o texto a seguir para exportar sua configuração. Aviso: pode conter dados sensíveis.", + "export_import": "Exportar/Importar", + "export": "Exportar", + "export_hint": "Copie o texto a seguir para exportar sua configuração:", + "click_to_reveal": "(clique para revelar)", + "click_to_reveal_sensitive_info": "(clique para revelar informações sensíveis)", + "export_tooltip": "Exporte sua configuração atual", + "import": "Importar", + "import_hint": "Cole a configuração que você deseja importar no campo abaixo e clique no botão de importação:", + "import_tooltip": "Importe uma configuração que você exportou anteriormente", + "start_import_tooltip": "Clique para importar os dados que você colou acima", + "import_error_invalid": "Os dados importados são inválidos", + "import_error_no_format_version": "Os dados importados não contêm uma versão de formato", + "import_error_no_data": "O objeto importado não contém nenhum dado", + "import_error_wrong_format_version": "Os dados importados estão em uma versão de formato não suportada (esperada %1 ou inferior, mas %2)", + "import_success": "Os dados foram importados com sucesso", + "import_success_confirm_reload": "Os dados foram importados com sucesso.\nVocê deseja recarregar a página agora para aplicar as alterações?", + "reset_config_confirm": "Você realmente deseja redefinir todas as configurações para seus valores padrão?\nA página será recarregada automaticamente.", + "copy": "Copiar", + "copy_to_clipboard": "Copiar para a área de transferência", + "copy_to_clipboard_error": "Não foi possível copiar o texto para a área de transferência. Por favor, copie manualmente daqui:\n%1", + "copy_config_tooltip": "Copie a configuração para a área de transferência", + "copied": "Copiado!", + "copied_to_clipboard": "Copiado para a área de transferência!", + "copy_hidden": "Copiar oculto", + "copy_hidden_tooltip": "Clique para copiar o valor oculto - estes são dados sensíveis ⚠️", + "open_github": "Abrir %1 no GitHub", + "open_discord": "Junte-se ao meu servidor Discord", + "open_greasyfork": "Abrir %1 no GreasyFork", + "open_openuserjs": "Abrir %1 no OpenUserJS", + "lang_changed_prompt_reload": "O idioma foi alterado.\nVocê deseja recarregar a página agora para aplicar as alterações?", + "search_placeholder": "Pesquisar...", + "search_clear": "Limpar pesquisa", - "reset": "Redefinir", - "close": "Fechar", - "log_level_debug": "Depurar (mais)", - "log_level_info": "Informações (apenas importantes)", - "toggled_on": "Habilitado", - "toggled_off": "Desabilitado", - "trigger_btn_action": "Ação", - "trigger_btn_action_running": "Executando...", - "new_entry": "Nova entrada", - "new_entry_tooltip": "Clique para criar uma nova entrada", - "remove_entry": "Remover esta entrada", - "edit_entry": "Editar esta entrada", - "remove_from_queue": "Remover esta música da fila", - "delete_from_list": "Excluir esta música da lista", - "couldnt_remove_from_queue": "Não foi possível remover esta música da fila", - "couldnt_delete_from_list": "Não foi possível excluir esta música da lista", - "clear_list": "Limpar a lista", - "clear_list_confirm": "Você realmente deseja limpar a lista e deixar apenas a música que está tocando atualmente?", - "scroll_to_playing": "Rolar para a música que está tocando atualmente", - "scroll_to_bottom": "Clique para rolar para o final", - "volume_tooltip": "Volume: %1% (Sensibilidade: %2%)", - "volume_shared_tooltip": "Volume compartilhado entre guias - desative no menu de configuração", - "middle_click_open_tab": "Clique com o botão do meio para abrir em uma nova guia", - "example_toast": "Exemplo de notificação", - "generic_error_toast_encountered_error_type": "Encontrou %1", - "generic_error_toast_click_for_details": "Clique para detalhes", - "error": "Erro", - "generic_error_dialog_message": "Encontrou um erro.", - "generic_error_dialog_open_console_note": "Se este erro continuar ocorrendo, abra o console JavaScript (geralmente com Ctrl + Shift + K) e anexe uma captura de tela da mensagem de erro **inteira** que começa com %1 a um novo [problema do GitHub.](%2)", - "active_mode_display": "Modo %1", - "active_mode_tooltip-1": "O %1 está atualmente ativo", - "active_mode_tooltip-n": "Os %1 estão atualmente ativos", - "dev_mode": "Modo de desenvolvedor", - "dev_mode_short": "Dev", - "advanced_mode": "Modo avançado", - "advanced_mode_short": "Avançado", - "experimental_feature": "Recurso experimental", + "reset": "Redefinir", + "close": "Fechar", + "log_level_debug": "Depurar (mais)", + "log_level_info": "Informações (apenas importantes)", + "toggled_on": "Habilitado", + "toggled_off": "Desabilitado", + "trigger_btn_action": "Ação", + "trigger_btn_action_running": "Executando...", + "new_entry": "Nova entrada", + "new_entry_tooltip": "Clique para criar uma nova entrada", + "remove_entry": "Remover esta entrada", + "edit_entry": "Editar esta entrada", + "remove_from_queue": "Remover esta música da fila", + "delete_from_list": "Excluir esta música da lista", + "couldnt_remove_from_queue": "Não foi possível remover esta música da fila", + "couldnt_delete_from_list": "Não foi possível excluir esta música da lista", + "clear_list": "Limpar a lista", + "clear_list_confirm": "Você realmente deseja limpar a lista e deixar apenas a música que está tocando atualmente?", + "scroll_to_playing": "Rolar para a música que está tocando atualmente", + "scroll_to_bottom": "Clique para rolar para o final", + "volume_tooltip": "Volume: %1% (Sensibilidade: %2%)", + "volume_shared_tooltip": "Volume compartilhado entre guias - desative no menu de configuração", + "middle_click_open_tab": "Clique com o botão do meio para abrir em uma nova guia", + "example_toast": "Exemplo de notificação", + "generic_error_toast_encountered_error_type": "Encontrou %1", + "generic_error_toast_click_for_details": "Clique para detalhes", + "error": "Erro", + "generic_error_dialog_message": "Encontrou um erro.", + "generic_error_dialog_open_console_note": "Se este erro continuar ocorrendo, abra o console JavaScript (geralmente com Ctrl + Shift + K) e anexe uma captura de tela da mensagem de erro **inteira** que começa com %1 a um novo [problema do GitHub.](%2)", + "active_mode_display": "Modo %1", + "active_mode_tooltip-1": "O %1 está atualmente ativo", + "active_mode_tooltip-n": "Os %1 estão atualmente ativos", + "dev_mode": "Modo de desenvolvedor", + "dev_mode_short": "Dev", + "advanced_mode": "Modo avançado", + "advanced_mode_short": "Avançado", + "experimental_feature": "Recurso experimental", - "open_lyrics_search_prompt": "Digite o título da música e o artista para pesquisar as letras:", - "lyrics_loading": "Carregando URL das letras...", - "lyrics_rate_limited-1": "Você está sendo limitado.\nAguarde alguns segundos antes de solicitar mais letras.", - "lyrics_rate_limited-n": "Você está sendo limitado.\nAguarde %1 segundos antes de solicitar mais letras.", - "lyrics_not_found_confirm_open_search": "Não foi possível encontrar uma página de letras para esta música.\nVocê deseja abrir genius.com para pesquisar manualmente?", - "lyrics_not_found_click_open_search": "Não foi possível encontrar a URL das letras - clique para abrir a pesquisa manual de letras", - "lyrics_clear_cache_confirm_prompt-1": "O cache de letras atualmente tem %1 entrada.\nVocê realmente deseja excluí-las?", - "lyrics_clear_cache_confirm_prompt-n": "O cache de letras atualmente tem %1 entradas.\nVocê realmente deseja excluí-las?", - "lyrics_clear_cache_success": "O cache de letras foi excluído com sucesso.", - "lyrics_cache_changed_clear_confirm": "Você alterou configurações que afetam os dados no cache de letras, o que quebra as pesquisas de URL de letras.\nVocê deseja limpar o cache agora?", + "open_lyrics_search_prompt": "Digite o título da música e o artista para pesquisar as letras:", + "lyrics_loading": "Carregando URL das letras...", + "lyrics_rate_limited-1": "Você está sendo limitado.\nAguarde alguns segundos antes de solicitar mais letras.", + "lyrics_rate_limited-n": "Você está sendo limitado.\nAguarde %1 segundos antes de solicitar mais letras.", + "lyrics_not_found_confirm_open_search": "Não foi possível encontrar uma página de letras para esta música.\nVocê deseja abrir genius.com para pesquisar manualmente?", + "lyrics_not_found_click_open_search": "Não foi possível encontrar a URL das letras - clique para abrir a pesquisa manual de letras", + "lyrics_clear_cache_confirm_prompt-1": "O cache de letras atualmente tem %1 entrada.\nVocê realmente deseja excluí-las?", + "lyrics_clear_cache_confirm_prompt-n": "O cache de letras atualmente tem %1 entradas.\nVocê realmente deseja excluí-las?", + "lyrics_clear_cache_success": "O cache de letras foi excluído com sucesso.", + "lyrics_cache_changed_clear_confirm": "Você alterou configurações que afetam os dados no cache de letras, o que quebra as pesquisas de URL de letras.\nVocê deseja limpar o cache agora?", - "hotkey_input_click_to_change": "Clique para alterar", - "hotkey_input_click_to_change_tooltip": "%1 - Atualmente definido para: %2 - Digite qualquer combinação de teclas para alterar. Nota: alguns leitores de tela podem bloquear certas combinações de teclas.", - "hotkey_input_click_to_reset_tooltip": "Redefinir para a última combinação de teclas salva", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Shift", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "Nenhum", + "hotkey_input_click_to_change": "Clique para alterar", + "hotkey_input_click_to_change_tooltip": "%1 - Atualmente definido para: %2 - Digite qualquer combinação de teclas para alterar. Nota: alguns leitores de tela podem bloquear certas combinações de teclas.", + "hotkey_input_click_to_reset_tooltip": "Redefinir para a última combinação de teclas salva", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Shift", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "Nenhum", - "welcome_menu_title": "Bem-vindo ao %1!", - "config_menu": "Configuração", - "open_config_menu_tooltip": "Clique para abrir o menu de configuração", - "open_changelog": "Registro de alterações", - "open_changelog_tooltip": "Clique para abrir o registro de alterações", - "feature_help_button_tooltip": "Clique para obter mais informações sobre o seguinte recurso: \"%1\"", - "welcome_text_line_1": "Obrigado por instalar!", - "welcome_text_line_2": "Espero que você goste de usar o %1 tanto quanto eu gostei de fazê-lo 😃", - "welcome_text_line_3": "Se você gosta do %1, por favor, deixe uma avaliação no %2GreasyFork%3 ou %4OpenUserJS%5", - "welcome_text_line_4": "Meu trabalho depende de doações, então considere %1doar ❤️%2", - "welcome_text_line_5": "Encontrou um bug ou quer sugerir um recurso? Por favor, %1abra um problema no GitHub%2", + "welcome_menu_title": "Bem-vindo ao %1!", + "config_menu": "Configuração", + "open_config_menu_tooltip": "Clique para abrir o menu de configuração", + "open_changelog": "Registro de alterações", + "open_changelog_tooltip": "Clique para abrir o registro de alterações", + "feature_help_button_tooltip": "Clique para obter mais informações sobre o seguinte recurso: \"%1\"", + "welcome_text_line_1": "Obrigado por instalar!", + "welcome_text_line_2": "Espero que você goste de usar o %1 tanto quanto eu gostei de fazê-lo 😃", + "welcome_text_line_3": "Se você gosta do %1, por favor, deixe uma avaliação no %2GreasyFork%3 ou %4OpenUserJS%5", + "welcome_text_line_4": "Meu trabalho depende de doações, então considere %1doar ❤️%2", + "welcome_text_line_5": "Encontrou um bug ou quer sugerir um recurso? Por favor, %1abra um problema no GitHub%2", - "list_button_placement_queue_only": "Apenas na fila de reprodução", - "list_button_placement_everywhere": "Em toda lista de músicas", + "list_button_placement_queue_only": "Apenas na fila de reprodução", + "list_button_placement_everywhere": "Em toda lista de músicas", - "site_selection_both_sites": "Ambos os sites", - "site_selection_only_yt": "Apenas YouTube", - "site_selection_only_ytm": "Apenas YouTube Music", - "site_selection_none": "Nenhum (desativado)", + "site_selection_both_sites": "Ambos os sites", + "site_selection_only_yt": "Apenas YouTube", + "site_selection_only_ytm": "Apenas YouTube Music", + "site_selection_none": "Nenhum (desativado)", - "new_version_available": "Uma nova versão do %1 está disponível!\nVocê tem a versão %2 instalada e pode atualizar para a versão %3", - "open_update_page_install_manually": "Instalar em %1", - "disable_update_check": "Desativar verificação de atualizações", - "reenable_in_config_menu": "(pode ser reativado no menu de configuração)", - "close_and_ignore_for_24h": "Fechar e ignorar por 24 horas", - "close_and_ignore_until_reenabled": "Fechar e ignorar até ser reativado", - "expand_release_notes": "Clique para expandir as notas de lançamento mais recentes", - "collapse_release_notes": "Clique para recolher as notas de lançamento mais recentes", - "no_updates_found": "Nenhuma atualização encontrada.", + "new_version_available": "Uma nova versão do %1 está disponível!\nVocê tem a versão %2 instalada e pode atualizar para a versão %3", + "open_update_page_install_manually": "Instalar em %1", + "disable_update_check": "Desativar verificação de atualizações", + "reenable_in_config_menu": "(pode ser reativado no menu de configuração)", + "close_and_ignore_for_24h": "Fechar e ignorar por 24 horas", + "close_and_ignore_until_reenabled": "Fechar e ignorar até ser reativado", + "expand_release_notes": "Clique para expandir as notas de lançamento mais recentes", + "collapse_release_notes": "Clique para recolher as notas de lançamento mais recentes", + "no_new_version_found": "Nenhuma nova versão encontrada.", - "thumbnail_overlay_behavior_never": "Nunca", - "thumbnail_overlay_behavior_videos_only": "Apenas para vídeos", - "thumbnail_overlay_behavior_songs_only": "Apenas para músicas", - "thumbnail_overlay_behavior_always": "Sempre", - "thumbnail_overlay_toggle_btn_tooltip_hide": "Desativar a sobreposição da miniatura - clique do meio ou shift-clique para abrir em uma nova guia", - "thumbnail_overlay_toggle_btn_tooltip_show": "Ativar a sobreposição da miniatura - clique do meio ou shift-clique para abrir em uma nova guia", - "thumbnail_overlay_indicator_tooltip": "A sobreposição da miniatura está atualmente ativa", - "thumbnail_overlay_image_fit_crop": "Cortar se necessário", - "thumbnail_overlay_image_fit_full": "Mostrar a imagem completa", - "thumbnail_overlay_image_fit_stretch": "Esticar para ajustar", + "thumbnail_overlay_behavior_never": "Nunca", + "thumbnail_overlay_behavior_videos_only": "Apenas para vídeos", + "thumbnail_overlay_behavior_songs_only": "Apenas para músicas", + "thumbnail_overlay_behavior_always": "Sempre", + "thumbnail_overlay_toggle_btn_tooltip_hide": "Desativar a sobreposição da miniatura - clique do meio ou shift-clique para abrir em uma nova guia", + "thumbnail_overlay_toggle_btn_tooltip_show": "Ativar a sobreposição da miniatura - clique do meio ou shift-clique para abrir em uma nova guia", + "thumbnail_overlay_indicator_tooltip": "A sobreposição da miniatura está atualmente ativa", + "thumbnail_overlay_image_fit_crop": "Cortar se necessário", + "thumbnail_overlay_image_fit_full": "Mostrar a imagem completa", + "thumbnail_overlay_image_fit_stretch": "Esticar para ajustar", - "auto_like_channels_dialog_title": "Canais auto-curtidos", - "auto_like_channels_dialog_desc": "Aqui você pode ver quais canais você definiu para auto-curtir e pode editá-los, ativar, desativar e removê-los.\nVocê também pode criar manualmente entradas, embora seja mais fácil apenas visitar a página do canal e clicar no botão lá.", - "auto_like": "Auto-curtir", - "auto_like_button_tooltip_enabled": "Clique para desativar o auto-curtir. Shift-clique para abrir o diálogo de gerenciamento.", - "auto_like_button_tooltip_disabled": "Clique para ativar o auto-curtir. Shift-clique para abrir o diálogo de gerenciamento.", - "auto_like_channel_toggle_tooltip": "Alternar auto-curtir para o canal %1", - "add_auto_like_channel_id_prompt": "Digite o ID do usuário (@Nome / UC...) ou a URL completa do canal que você deseja auto-curtir.\nPressione \"cancelar\" para sair.", - "add_auto_like_channel_invalid_id": "O ID do usuário inserido é inválido.\nCertifique-se de copiar a URL do canal inteira! Deve conter uma parte como \"channel/UC...\" ou \"/@...\"", - "add_auto_like_channel_already_exists_prompt_new_name": "Um canal com esse ID já está na lista.\nVocê deseja alterar o nome?", - "add_auto_like_channel_name_prompt": "Digite o nome do canal.\nPressione \"cancelar\" para sair.", - "auto_like_channel_edit_name_prompt": "Digite o novo nome para este canal.\nPressione \"cancelar\" para sair.", - "auto_like_channel_edit_id_prompt": "Digite o novo ID do usuário (@Nome / UC...) ou a URL completa para este canal.\nPressione \"cancelar\" para sair.", - "auto_like_enabled_toast": "Auto-curtir ativado", - "auto_like_disabled_toast": "Auto-curtir desativado", - "auto_liked_a_channels_song": "Música curtida por %1", - "auto_liked_a_channels_video": "Vídeo curtido por %1", - "auto_like_click_to_configure": "Clique para configurar", - "auto_like_export_or_import_tooltip": "Exporte ou importe seus canais auto-curtidos", - "auto_like_export_import_title": "Exportar ou Importar Canais Auto-curtidos", - "auto_like_export_desc": "Copie o texto a seguir para exportar seus canais auto-curtidos.", - "auto_like_import_desc": "Cole os canais auto-curtidos que você deseja importar no campo abaixo e clique no botão de importação:", + "auto_like_channels_dialog_title": "Canais auto-curtidos", + "auto_like_channels_dialog_desc": "Aqui você pode ver quais canais você definiu para auto-curtir e pode editá-los, ativar, desativar e removê-los.\nVocê também pode criar manualmente entradas, embora seja mais fácil apenas visitar a página do canal e clicar no botão lá.", + "auto_like": "Auto-curtir", + "auto_like_button_tooltip_enabled": "Clique para desativar o auto-curtir. Shift-clique para abrir o diálogo de gerenciamento.", + "auto_like_button_tooltip_disabled": "Clique para ativar o auto-curtir. Shift-clique para abrir o diálogo de gerenciamento.", + "auto_like_channel_toggle_tooltip": "Alternar auto-curtir para o canal %1", + "add_auto_like_channel_id_prompt": "Digite o ID do usuário (@Nome / UC...) ou a URL completa do canal que você deseja auto-curtir.\nPressione \"cancelar\" para sair.", + "add_auto_like_channel_invalid_id": "O ID do usuário inserido é inválido.\nCertifique-se de copiar a URL do canal inteira! Deve conter uma parte como \"channel/UC...\" ou \"/@...\"", + "add_auto_like_channel_already_exists_prompt_new_name": "Um canal com esse ID já está na lista.\nVocê deseja alterar o nome?", + "add_auto_like_channel_name_prompt": "Digite o nome do canal.\nPressione \"cancelar\" para sair.", + "auto_like_channel_edit_name_prompt": "Digite o novo nome para este canal.\nPressione \"cancelar\" para sair.", + "auto_like_channel_edit_id_prompt": "Digite o novo ID do usuário (@Nome / UC...) ou a URL completa para este canal.\nPressione \"cancelar\" para sair.", + "auto_like_enabled_toast": "Auto-curtir ativado", + "auto_like_disabled_toast": "Auto-curtir desativado", + "auto_liked_a_channels_song": "Música curtida por %1", + "auto_liked_a_channels_video": "Vídeo curtido por %1", + "auto_like_click_to_configure": "Clique para configurar", + "auto_like_export_or_import_tooltip": "Exporte ou importe seus canais auto-curtidos", + "auto_like_export_import_title": "Exportar ou Importar Canais Auto-curtidos", + "auto_like_export_desc": "Copie o texto a seguir para exportar seus canais auto-curtidos.", + "auto_like_import_desc": "Cole os canais auto-curtidos que você deseja importar no campo abaixo e clique no botão de importação:", - "prompt_confirm": "Confirmar", - "prompt_close": "Fechar", - "prompt_cancel": "Cancelar", + "prompt_confirm": "Confirmar", + "prompt_close": "Fechar", + "prompt_cancel": "Cancelar", - "click_to_confirm_tooltip": "Clique para confirmar", - "click_to_close_tooltip": "Clique para fechar", - "click_to_cancel_tooltip": "Clique para cancelar", + "click_to_confirm_tooltip": "Clique para confirmar", + "click_to_close_tooltip": "Clique para fechar", + "click_to_cancel_tooltip": "Clique para cancelar", - "vote_label_likes-1": "%1 curtida", - "vote_label_likes-n": "%1 curtidas", - "vote_label_dislikes-1": "%1 descurtida", - "vote_label_dislikes-n": "%1 descurtidas", + "vote_label_likes-1": "%1 curtida", + "vote_label_likes-n": "%1 curtidas", + "vote_label_dislikes-1": "%1 descurtida", + "vote_label_dislikes-n": "%1 descurtidas", - "vote_ratio_disabled": "Desativado", - "vote_ratio_green_red": "Verde e vermelho", - "vote_ratio_blue_gray": "Azul e cinza", + "vote_ratio_disabled": "Desativado", + "vote_ratio_green_red": "Verde e vermelho", + "vote_ratio_blue_gray": "Azul e cinza", - "votes_format_short": "Curto", - "votes_format_long": "Longo", + "votes_format_short": "Curto", + "votes_format_long": "Longo", - "unit_entries-1": "entrada", - "unit_entries-n": "entradas", + "unit_entries-1": "entrada", + "unit_entries-n": "entradas", - "unit_days-1": "dia", - "unit_days-n": "dias", + "unit_days-1": "dia", + "unit_days-n": "dias", - "color_lightness_darker": "Mais escuro", - "color_lightness_normal": "Normal", - "color_lightness_lighter": "Mais claro", + "color_lightness_darker": "Mais escuro", + "color_lightness_normal": "Normal", + "color_lightness_lighter": "Mais claro", - "plugin_list_title": "Lista de plugins", - "plugin_list_no_plugins": "Nenhum plugin está atualmente instalado.\nVisite %1esta página%2 para mais informações.", - "plugin_list_no_plugins_tooltip": "Nenhum plugin está atualmente instalado.", - "plugin_list_permissions_header": "Permissões:", + "plugin_list_title": "Lista de plugins", + "plugin_list_no_plugins": "Nenhum plugin está atualmente instalado.\nVisite %1esta página%2 para mais informações.", + "plugin_list_no_plugins_tooltip": "Nenhum plugin está atualmente instalado.", + "plugin_list_permissions_header": "Permissões:", - "plugin_link_type_source": "Repositório", - "plugin_link_type_other": "Outro / Página inicial", - "plugin_link_type_bug": "Reportar um bug", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "Repositório", + "plugin_link_type_other": "Outro / Página inicial", + "plugin_link_type_bug": "Reportar um bug", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "Este plugin pode ler a configuração do recurso", - "plugin_intent_description_WriteFeatureConfig": "Este plugin pode escrever na configuração do recurso", - "plugin_intent_description_SeeHiddenConfigValues": "Este plugin tem acesso a valores de configuração ocultos", - "plugin_intent_description_WriteLyricsCache": "Este plugin pode escrever no cache de letras", - "plugin_intent_description_WriteTranslations": "Este plugin pode adicionar novas traduções e sobrescrever as existentes", - "plugin_intent_description_CreateModalDialogs": "Este plugin pode criar diálogos modais", - "plugin_intent_description_ReadAutoLikeData": "Este plugin pode ler dados de auto-curtir", - "plugin_intent_description_WriteAutoLikeData": "Este plugin pode escrever em dados de auto-curtir", + "plugin_intent_description_ReadFeatureConfig": "Este plugin pode ler a configuração do recurso", + "plugin_intent_description_WriteFeatureConfig": "Este plugin pode escrever na configuração do recurso", + "plugin_intent_description_SeeHiddenConfigValues": "Este plugin tem acesso a valores de configuração ocultos", + "plugin_intent_description_WriteLyricsCache": "Este plugin pode escrever no cache de letras", + "plugin_intent_description_WriteTranslations": "Este plugin pode adicionar novas traduções e sobrescrever as existentes", + "plugin_intent_description_CreateModalDialogs": "Este plugin pode criar diálogos modais", + "plugin_intent_description_ReadAutoLikeData": "Este plugin pode ler dados de auto-curtir", + "plugin_intent_description_WriteAutoLikeData": "Este plugin pode escrever em dados de auto-curtir", - "plugin_validation_error_no_property": "Nenhuma propriedade '%1' com o tipo '%2'", - "plugin_validation_error_invalid_property-1": "Propriedade '%1' com valor '%2' é inválida. Valor de exemplo: %3", - "plugin_validation_error_invalid_property-n": "Propriedade '%1' com valor '%2' é inválida. Valores de exemplo: %3", + "plugin_validation_error_no_property": "Nenhuma propriedade '%1' com o tipo '%2'", + "plugin_validation_error_invalid_property-1": "Propriedade '%1' com valor '%2' é inválida. Valor de exemplo: %3", + "plugin_validation_error_invalid_property-n": "Propriedade '%1' com valor '%2' é inválida. Valores de exemplo: %3", - "feature_category_layout": "Layout", - "feature_category_volume": "Volume", - "feature_category_songLists": "Listas de músicas", - "feature_category_behavior": "Comportamento", - "feature_category_input": "Entrada", - "feature_category_lyrics": "Letras", - "feature_category_integrations": "Integrações", - "feature_category_plugins": "Plugins", - "feature_category_general": "Geral", + "feature_category_layout": "Layout", + "feature_category_volume": "Volume", + "feature_category_songLists": "Listas de músicas", + "feature_category_behavior": "Comportamento", + "feature_category_input": "Entrada", + "feature_category_lyrics": "Letras", + "feature_category_integrations": "Integrações", + "feature_category_plugins": "Plugins", + "feature_category_general": "Geral", - "feature_desc_watermarkEnabled": "Mostrar uma marca d'água sob o logotipo do site que abre este menu de configuração", - "feature_helptext_watermarkEnabled": "Se isso estiver desativado, você ainda pode abrir o menu de configuração clicando na opção no menu que abre quando você clica em sua foto de perfil no canto superior direito.\nNo entanto, será mais difícil encontrar o easter egg ;)", - "feature_desc_removeShareTrackingParam": "Remover o parâmetro de rastreamento \"?si\" do URL nos menus de compartilhamento", - "feature_helptext_removeShareTrackingParam": "Para fins de análise, o YouTube adiciona um parâmetro de rastreamento ao final do URL que você pode copiar no menu de compartilhamento. Embora não seja diretamente prejudicial, ele torna o URL mais longo e dá ao YouTube mais informações sobre você e as pessoas para quem você envia o link.", - "feature_desc_removeShareTrackingParamSites": "Em quais sites o parâmetro de rastreamento de compartilhamento deve ser removido?", - "feature_desc_numKeysSkipToTime": "Ative a capacidade de pular para um horário específico no vídeo pressionando uma tecla numérica (0-9)", - "feature_desc_fixSpacing": "Corrigir problemas de espaçamento no layout", - "feature_helptext_fixSpacing": "Há vários locais na interface do usuário onde o espaçamento entre os elementos é inconsistente. Este recurso corrige esses problemas.", - "feature_desc_thumbnailOverlayBehavior": "Quando substituir automaticamente o elemento de vídeo pela miniatura na maior resolução", - "feature_helptext_thumbnailOverlayBehavior": "Se você optar por substituir o vídeo pelo thumbnail, você pode escolher quando isso deve acontecer.\nSe você escolher \"sempre\", o vídeo será substituído pelo thumbnail assim que ele for carregado.\nSe você escolher \"apenas para vídeos\", o vídeo será substituído pelo thumbnail apenas se o elemento de vídeo for um vídeo e não uma música.\nSe você escolher \"apenas para músicas\", o vídeo será substituído pelo thumbnail apenas se o elemento de vídeo for uma música e não um vídeo.", - "feature_desc_thumbnailOverlayToggleBtnShown": "Adicione um botão aos controles de mídia para alternar manualmente a miniatura", - "feature_helptext_thumbnailOverlayToggleBtnShown": "Este botão permitirá que você alterne manualmente a miniatura ligada e desligada. Isso não é afetado se a sobreposição estiver definida como \"nunca mostrada\".\nAssim que um novo vídeo ou música começar a ser reproduzido, o estado padrão será restaurado.\nMantenha pressionada a tecla shift enquanto clica ou pressione o botão do meio do mouse para abrir a miniatura da mais alta qualidade em uma nova guia.", - "feature_desc_thumbnailOverlayShowIndicator": "Mostrar um indicador na miniatura enquanto ela estiver ativa?", - "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacidade do indicador da miniatura", - "feature_desc_thumbnailOverlayImageFit": "Como ajustar a miniatura sobre o elemento de vídeo", - "feature_desc_hideCursorOnIdle": "Ocultar o cursor após alguns segundos de inatividade sobre o vídeo", - "feature_desc_hideCursorOnIdleDelay": "Quantos segundos de inatividade antes de ocultar o cursor?", - "feature_desc_fixHdrIssues": "Corrigir problemas de renderização ao usar um GPU e monitor compatíveis com HDR", - "feature_desc_showVotes": "Mostrar a quantidade de curtidas e descurtidas na música que está tocando atualmente", - "feature_helptext_showVotes": "Este recurso é alimentado pelo Return YouTube Dislike e mostrará a quantidade aproximada de curtidas e descurtidas na música que está tocando atualmente.", - "feature_desc_numbersFormat": "Como os números devem ser formatados?", + "feature_desc_watermarkEnabled": "Mostrar uma marca d'água sob o logotipo do site que abre este menu de configuração", + "feature_helptext_watermarkEnabled": "Se isso estiver desativado, você ainda pode abrir o menu de configuração clicando na opção no menu que abre quando você clica em sua foto de perfil no canto superior direito.\nNo entanto, será mais difícil encontrar o easter egg ;)", + "feature_desc_removeShareTrackingParam": "Remover o parâmetro de rastreamento \"?si\" do URL nos menus de compartilhamento", + "feature_helptext_removeShareTrackingParam": "Para fins de análise, o YouTube adiciona um parâmetro de rastreamento ao final do URL que você pode copiar no menu de compartilhamento. Embora não seja diretamente prejudicial, ele torna o URL mais longo e dá ao YouTube mais informações sobre você e as pessoas para quem você envia o link.", + "feature_desc_removeShareTrackingParamSites": "Em quais sites o parâmetro de rastreamento de compartilhamento deve ser removido?", + "feature_desc_numKeysSkipToTime": "Ative a capacidade de pular para um horário específico no vídeo pressionando uma tecla numérica (0-9)", + "feature_desc_fixSpacing": "Corrigir problemas de espaçamento no layout", + "feature_helptext_fixSpacing": "Há vários locais na interface do usuário onde o espaçamento entre os elementos é inconsistente. Este recurso corrige esses problemas.", + "feature_desc_thumbnailOverlayBehavior": "Quando substituir automaticamente o elemento de vídeo pela miniatura na maior resolução", + "feature_helptext_thumbnailOverlayBehavior": "Se você optar por substituir o vídeo pelo thumbnail, você pode escolher quando isso deve acontecer.\nSe você escolher \"sempre\", o vídeo será substituído pelo thumbnail assim que ele for carregado.\nSe você escolher \"apenas para vídeos\", o vídeo será substituído pelo thumbnail apenas se o elemento de vídeo for um vídeo e não uma música.\nSe você escolher \"apenas para músicas\", o vídeo será substituído pelo thumbnail apenas se o elemento de vídeo for uma música e não um vídeo.", + "feature_desc_thumbnailOverlayToggleBtnShown": "Adicione um botão aos controles de mídia para alternar manualmente a miniatura", + "feature_helptext_thumbnailOverlayToggleBtnShown": "Este botão permitirá que você alterne manualmente a miniatura ligada e desligada. Isso não é afetado se a sobreposição estiver definida como \"nunca mostrada\".\nAssim que um novo vídeo ou música começar a ser reproduzido, o estado padrão será restaurado.\nMantenha pressionada a tecla shift enquanto clica ou pressione o botão do meio do mouse para abrir a miniatura da mais alta qualidade em uma nova guia.", + "feature_desc_thumbnailOverlayShowIndicator": "Mostrar um indicador na miniatura enquanto ela estiver ativa?", + "feature_desc_thumbnailOverlayIndicatorOpacity": "Opacidade do indicador da miniatura", + "feature_desc_thumbnailOverlayImageFit": "Como ajustar a miniatura sobre o elemento de vídeo", + "feature_desc_hideCursorOnIdle": "Ocultar o cursor após alguns segundos de inatividade sobre o vídeo", + "feature_desc_hideCursorOnIdleDelay": "Quantos segundos de inatividade antes de ocultar o cursor?", + "feature_desc_fixHdrIssues": "Corrigir problemas de renderização ao usar um GPU e monitor compatíveis com HDR", + "feature_desc_showVotes": "Mostrar a quantidade de curtidas e descurtidas na música que está tocando atualmente", + "feature_helptext_showVotes": "Este recurso é alimentado pelo Return YouTube Dislike e mostrará a quantidade aproximada de curtidas e descurtidas na música que está tocando atualmente.", + "feature_desc_numbersFormat": "Como os números devem ser formatados?", - "feature_desc_volumeSliderLabel": "Adicionar um rótulo de porcentagem ao lado do controle de volume", - "feature_desc_volumeSliderSize": "A largura do controle deslizante de volume em pixels", - "feature_desc_volumeSliderStep": "Sensibilidade do controle deslizante de volume (por quantos porcento o volume pode ser alterado de cada vez)", - "feature_desc_volumeSliderScrollStep": "Sensibilidade da roda do mouse para o controle deslizante de volume em porcentagem - ajusta para o valor de sensibilidade mais próximo de cima", - "feature_helptext_volumeSliderScrollStep": "Por quantos porcento o volume deve ser alterado ao rolar o controle deslizante de volume com a roda do mouse.\nIsso deve ser um múltiplo da sensibilidade do controle deslizante de volume, caso contrário, haverá pequenos saltos irregulares no volume ao rolar.", - "feature_desc_volumeSharedBetweenTabs": "Deve o volume ser compartilhado entre guias e lembrado entre sessões?", - "feature_helptext_volumeSharedBetweenTabs": "Se você alterar o volume em uma guia, o nível de volume será definido para o mesmo valor em todas as outras guias que têm este recurso ativado.\nEste valor será lembrado e restaurado entre sessões, até ser desativado.", - "feature_desc_setInitialTabVolume": "Definir o nível de volume para um valor específico ao abrir o site", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Este recurso é incompatível com o recurso \"Volume compartilhado entre guias\" e será ignorado ao usar o recurso de volume compartilhado!", - "feature_desc_initialTabVolumeLevel": "O valor para definir o nível de volume ao abrir o site", + "feature_desc_volumeSliderLabel": "Adicionar um rótulo de porcentagem ao lado do controle de volume", + "feature_desc_volumeSliderSize": "A largura do controle deslizante de volume em pixels", + "feature_desc_volumeSliderStep": "Sensibilidade do controle deslizante de volume (por quantos porcento o volume pode ser alterado de cada vez)", + "feature_desc_volumeSliderScrollStep": "Sensibilidade da roda do mouse para o controle deslizante de volume em porcentagem - ajusta para o valor de sensibilidade mais próximo de cima", + "feature_helptext_volumeSliderScrollStep": "Por quantos porcento o volume deve ser alterado ao rolar o controle deslizante de volume com a roda do mouse.\nIsso deve ser um múltiplo da sensibilidade do controle deslizante de volume, caso contrário, haverá pequenos saltos irregulares no volume ao rolar.", + "feature_desc_volumeSharedBetweenTabs": "Deve o volume ser compartilhado entre guias e lembrado entre sessões?", + "feature_helptext_volumeSharedBetweenTabs": "Se você alterar o volume em uma guia, o nível de volume será definido para o mesmo valor em todas as outras guias que têm este recurso ativado.\nEste valor será lembrado e restaurado entre sessões, até ser desativado.", + "feature_desc_setInitialTabVolume": "Definir o nível de volume para um valor específico ao abrir o site", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "Este recurso é incompatível com o recurso \"Volume compartilhado entre guias\" e será ignorado ao usar o recurso de volume compartilhado!", + "feature_desc_initialTabVolumeLevel": "O valor para definir o nível de volume ao abrir o site", - "feature_desc_lyricsQueueButton": "Adicionar um botão a cada música em uma lista para abrir sua página de letras", - "feature_desc_deleteFromQueueButton": "Adicionar um botão a cada música em uma lista para removê-la rapidamente", - "feature_desc_listButtonsPlacement": "Onde os botões da lista devem aparecer?", - "feature_helptext_listButtonsPlacement": "Existem várias listas de músicas no site, como páginas de álbuns, listas de reprodução e a fila de reprodução atual.\nCom esta opção, você pode escolher onde os botões da lista devem aparecer.", - "feature_desc_scrollToActiveSongBtn": "Adicione um botão acima da fila para rolar para a música que está tocando atualmente", - "feature_desc_clearQueueBtn": "Adicione um botão acima da fila de reprodução ou lista de reprodução atual para limpar rapidamente", + "feature_desc_lyricsQueueButton": "Adicionar um botão a cada música em uma lista para abrir sua página de letras", + "feature_desc_deleteFromQueueButton": "Adicionar um botão a cada música em uma lista para removê-la rapidamente", + "feature_desc_listButtonsPlacement": "Onde os botões da lista devem aparecer?", + "feature_helptext_listButtonsPlacement": "Existem várias listas de músicas no site, como páginas de álbuns, listas de reprodução e a fila de reprodução atual.\nCom esta opção, você pode escolher onde os botões da lista devem aparecer.", + "feature_desc_scrollToActiveSongBtn": "Adicione um botão acima da fila para rolar para a música que está tocando atualmente", + "feature_desc_clearQueueBtn": "Adicione um botão acima da fila de reprodução ou lista de reprodução atual para limpar rapidamente", - "feature_desc_disableBeforeUnloadPopup": "Evite a janela de confirmação que aparece ao tentar sair do site enquanto uma música está tocando", - "feature_helptext_disableBeforeUnloadPopup": "Ao tentar sair do site enquanto uma música está tocando, uma janela de confirmação pode aparecer, pedindo que você confirme que deseja sair do site. Pode dizer algo como \"você tem dados não salvos\" ou \"este site está perguntando se você deseja fechá-lo\".\nEste recurso desativa completamente essa janela de confirmação.", - "feature_desc_closeToastsTimeout": "Após quantos segundos fechar as notificações permanentes - 0 para fechá-las apenas manualmente (comportamento padrão)", - "feature_helptext_closeToastsTimeout": "A maioria das notificações que aparecem no canto inferior esquerdo fechará automaticamente após 3 segundos, com exceção de certas, como ao curtir uma música.\nEste recurso permite definir um tempo para fechar as notificações permanentes.\nO outro tipo de notificações permanecerá inalterado.\nDefina isso como 0 para o comportamento padrão de não fechar as notificações permanentes.", - "feature_desc_rememberSongTime": "Lembre-se do tempo da última música ao recarregar ou restaurar a guia", - "feature_helptext_rememberSongTime-1": "Às vezes, ao recarregar a página ou restaurá-la após fechá-la acidentalmente, você deseja retomar a audição no mesmo ponto. Este recurso permite que você faça isso.\nPara registrar o tempo da música, você precisa reproduzi-la por %1 segundo, então seu tempo será lembrado e restaurável por um curto período.", - "feature_helptext_rememberSongTime-n": "Às vezes, ao recarregar a página ou restaurá-la após fechá-la acidentalmente, você deseja retomar a audição no mesmo ponto. Este recurso permite que você faça isso.\nPara registrar o tempo da música, você precisa reproduzi-la por %1 segundos, então seu tempo será lembrado e restaurável por um curto período.", - "feature_desc_rememberSongTimeSites": "Em quais sites o tempo da música deve ser lembrado e restaurado?", - "feature_desc_rememberSongTimeDuration": "Por quanto tempo em segundos lembrar o tempo da música após ela ter sido reproduzida pela última vez", - "feature_desc_rememberSongTimeReduction": "Quantos segundos subtrair ao restaurar o tempo de uma música lembrada", - "feature_helptext_rememberSongTimeReduction": "Ao restaurar o tempo de uma música que foi lembrada, este valor de segundos será subtraído do tempo lembrado para que você possa reouvir a parte que foi interrompida.", - "feature_desc_rememberSongTimeMinPlayTime": "Quantidade mínima de segundos que uma música precisa ser reproduzida para que seu tempo seja lembrado", + "feature_desc_disableBeforeUnloadPopup": "Evite a janela de confirmação que aparece ao tentar sair do site enquanto uma música está tocando", + "feature_helptext_disableBeforeUnloadPopup": "Ao tentar sair do site enquanto uma música está tocando, uma janela de confirmação pode aparecer, pedindo que você confirme que deseja sair do site. Pode dizer algo como \"você tem dados não salvos\" ou \"este site está perguntando se você deseja fechá-lo\".\nEste recurso desativa completamente essa janela de confirmação.", + "feature_desc_closeToastsTimeout": "Após quantos segundos fechar as notificações permanentes - 0 para fechá-las apenas manualmente (comportamento padrão)", + "feature_helptext_closeToastsTimeout": "A maioria das notificações que aparecem no canto inferior esquerdo fechará automaticamente após 3 segundos, com exceção de certas, como ao curtir uma música.\nEste recurso permite definir um tempo para fechar as notificações permanentes.\nO outro tipo de notificações permanecerá inalterado.\nDefina isso como 0 para o comportamento padrão de não fechar as notificações permanentes.", + "feature_desc_rememberSongTime": "Lembre-se do tempo da última música ao recarregar ou restaurar a guia", + "feature_helptext_rememberSongTime-1": "Às vezes, ao recarregar a página ou restaurá-la após fechá-la acidentalmente, você deseja retomar a audição no mesmo ponto. Este recurso permite que você faça isso.\nPara registrar o tempo da música, você precisa reproduzi-la por %1 segundo, então seu tempo será lembrado e restaurável por um curto período.", + "feature_helptext_rememberSongTime-n": "Às vezes, ao recarregar a página ou restaurá-la após fechá-la acidentalmente, você deseja retomar a audição no mesmo ponto. Este recurso permite que você faça isso.\nPara registrar o tempo da música, você precisa reproduzi-la por %1 segundos, então seu tempo será lembrado e restaurável por um curto período.", + "feature_desc_rememberSongTimeSites": "Em quais sites o tempo da música deve ser lembrado e restaurado?", + "feature_desc_rememberSongTimeDuration": "Por quanto tempo em segundos lembrar o tempo da música após ela ter sido reproduzida pela última vez", + "feature_desc_rememberSongTimeReduction": "Quantos segundos subtrair ao restaurar o tempo de uma música lembrada", + "feature_helptext_rememberSongTimeReduction": "Ao restaurar o tempo de uma música que foi lembrada, este valor de segundos será subtraído do tempo lembrado para que você possa reouvir a parte que foi interrompida.", + "feature_desc_rememberSongTimeMinPlayTime": "Quantidade mínima de segundos que uma música precisa ser reproduzida para que seu tempo seja lembrado", - "feature_desc_arrowKeySupport": "Use as teclas de seta para pular para a próxima ou anterior música na fila", - "feature_helptext_arrowKeySupport": "Normalmente, você só pode pular para frente e para trás por um intervalo fixo de 10 segundos com as teclas \"H\" e \"L\". Este recurso permite que você use as teclas de seta também.\nPara alterar a quantidade de segundos a pular, use a opção abaixo.", - "feature_desc_arrowKeySkipBy": "Por quantos segundos pular ao usar as teclas de seta", - "feature_desc_switchBetweenSites": "Adicione um atalho para alternar entre os sites YT e YTM em um vídeo / música", - "feature_helptext_switchBetweenSites": "Pressionar este atalho alternará para o outro site se você estiver no YouTube ou YouTube Music, mantendo-se na mesma música / vídeo.", - "feature_desc_switchSitesHotkey": "Qual tecla de atalho precisa ser pressionada para alternar entre os sites?", - "feature_desc_anchorImprovements": "Adicione e melhore os links em toda a página para que as coisas possam ser abertas em uma nova guia com mais facilidade", - "feature_helptext_anchorImprovements": "Alguns elementos na página só podem ser clicados com o botão esquerdo do mouse, o que significa que você não pode abri-los em uma nova guia clicando com o botão do meio ou através do menu de contexto usando shift + clique com o botão direito. Este recurso adiciona links a muitos deles ou os aumenta para facilitar o clique.", - "feature_desc_autoLikeChannels": "Curtir automaticamente todas as músicas e vídeos de certos canais", - "feature_helpText_autoLikeChannels": "Depois de ativado, você pode ativar esse recurso para determinados canais abrindo sua página e clicando no botão de alternância. Depois, qualquer música que você tocar desse canal será curtida automaticamente.\nUse a opção abaixo para abrir um diálogo para gerenciar os canais.", - "feature_desc_autoLikeChannelToggleBtn": "Adicionar um botão a cada página do canal para ativar ou desativar o auto-curtir", - "feature_desc_autoLikePlayerBarToggleBtn": "Adicionar um botão aos controles de mídia para ativar ou desativar o auto-curtir", - "feature_desc_autoLikeTimeout": "Quantos segundos uma música precisa tocar antes de ser auto-curtida", - "feature_desc_autoLikeShowToast": "Mostrar uma notificação de brinde quando uma música é auto-curtida", - "feature_desc_autoLikeOpenMgmtDialog": "Abrir o diálogo para gerenciar os canais auto-curtidos", - "feature_btn_autoLikeOpenMgmtDialog": "Abrir diálogo", - "feature_btn_autoLikeOpenMgmtDialog_running": "Abrindo...", + "feature_desc_arrowKeySupport": "Use as teclas de seta para pular para a próxima ou anterior música na fila", + "feature_helptext_arrowKeySupport": "Normalmente, você só pode pular para frente e para trás por um intervalo fixo de 10 segundos com as teclas \"H\" e \"L\". Este recurso permite que você use as teclas de seta também.\nPara alterar a quantidade de segundos a pular, use a opção abaixo.", + "feature_desc_arrowKeySkipBy": "Por quantos segundos pular ao usar as teclas de seta", + "feature_desc_switchBetweenSites": "Adicione um atalho para alternar entre os sites YT e YTM em um vídeo / música", + "feature_helptext_switchBetweenSites": "Pressionar este atalho alternará para o outro site se você estiver no YouTube ou YouTube Music, mantendo-se na mesma música / vídeo.", + "feature_desc_switchSitesHotkey": "Qual tecla de atalho precisa ser pressionada para alternar entre os sites?", + "feature_desc_anchorImprovements": "Adicione e melhore os links em toda a página para que as coisas possam ser abertas em uma nova guia com mais facilidade", + "feature_helptext_anchorImprovements": "Alguns elementos na página só podem ser clicados com o botão esquerdo do mouse, o que significa que você não pode abri-los em uma nova guia clicando com o botão do meio ou através do menu de contexto usando shift + clique com o botão direito. Este recurso adiciona links a muitos deles ou os aumenta para facilitar o clique.", + "feature_desc_autoLikeChannels": "Curtir automaticamente todas as músicas e vídeos de certos canais", + "feature_helpText_autoLikeChannels": "Depois de ativado, você pode ativar esse recurso para determinados canais abrindo sua página e clicando no botão de alternância. Depois, qualquer música que você tocar desse canal será curtida automaticamente.\nUse a opção abaixo para abrir um diálogo para gerenciar os canais.", + "feature_desc_autoLikeChannelToggleBtn": "Adicionar um botão a cada página do canal para ativar ou desativar o auto-curtir", + "feature_desc_autoLikePlayerBarToggleBtn": "Adicionar um botão aos controles de mídia para ativar ou desativar o auto-curtir", + "feature_desc_autoLikeTimeout": "Quantos segundos uma música precisa tocar antes de ser auto-curtida", + "feature_desc_autoLikeShowToast": "Mostrar uma notificação de brinde quando uma música é auto-curtida", + "feature_desc_autoLikeOpenMgmtDialog": "Abrir o diálogo para gerenciar os canais auto-curtidos", + "feature_btn_autoLikeOpenMgmtDialog": "Abrir diálogo", + "feature_btn_autoLikeOpenMgmtDialog_running": "Abrindo...", - "feature_desc_geniusLyrics": "Adicione um botão aos controles de mídia da música que está tocando atualmente para abrir suas letras em genius.com", - "feature_desc_errorOnLyricsNotFound": "Mostrar um erro quando a página de letras para a música que está tocando atualmente não pôde ser encontrada", - "feature_desc_geniUrlBase": "URL base da sua instância geniURL, veja https://github.com/Sv443/geniURL", - "feature_helptext_geniUrlBase": "Se você tiver sua própria instância do geniURL em execução (por exemplo, para evitar limites de taxa), você pode inserir sua URL base aqui para usá-la para o botão de letras do genius.com.\nSe você não sabe o que é isso, pode deixar esta opção como está.", - "feature_desc_geniUrlToken": "Token de autenticação para sua instância geniURL", - "feature_helptext_geniUrlToken": "Para evitar limites de taxa, você pode fornecer um token de autenticação que também está definido no arquivo .env de sua instância geniURL.", - "feature_desc_lyricsCacheMaxSize": "Quantidade máxima de letras para manter no cache", - "feature_helptext_lyricsCacheMaxSize": "As letras das músicas que você ouve são armazenadas em um cache para reduzir a quantidade de solicitações ao provedor de letras.\nEste recurso permite que você defina a quantidade máxima de letras a serem mantidas no cache.\nQuando o limite for atingido, a entrada mais antiga será removida para dar espaço a novas.", - "feature_desc_lyricsCacheTTL": "Quantidade máxima de dias para manter uma entrada de letras no cache", - "feature_helptext_lyricsCacheTTL": "O cache que as letras são armazenadas excluirá automaticamente elas após esse tempo para garantir que as atualizações da fonte sejam buscadas mais cedo ou mais tarde.\nSe você quiser garantir que sempre tenha as letras mais recentes, defina isso para um valor baixo, como 4 dias.", - "feature_desc_clearLyricsCache": "Limpar o cache de letras manualmente", - "feature_helptext_clearLyricsCache": "Se as letras que estão no cache local estiverem desatualizadas ou você apenas quiser liberar espaço, você pode limpar o cache manualmente pressionando este botão.", - "feature_btn_clearLyricsCache": "Limpar cache", - "feature_btn_clearLyricsCache_running": "Limpando...", - "feature_desc_advancedLyricsFilter": "Experimental: Ativar filtragem avançada para a pesquisa de letras", - "feature_helptext_advancedLyricsFilter": "A filtragem avançada inclui várias camadas de filtros que visam tornar as pesquisas de letras mais confiáveis.\nEsses filtros podem não funcionar bem para músicas em seu idioma e músicas e artistas com títulos menos únicos em geral.\nAviso: Este recurso ainda é experimental e pode não funcionar melhor do que a pesquisa de letras padrão. Certifique-se de confirmar o prompt que aparece se você alterou esta configuração.", + "feature_desc_geniusLyrics": "Adicione um botão aos controles de mídia da música que está tocando atualmente para abrir suas letras em genius.com", + "feature_desc_errorOnLyricsNotFound": "Mostrar um erro quando a página de letras para a música que está tocando atualmente não pôde ser encontrada", + "feature_desc_geniUrlBase": "URL base da sua instância geniURL, veja https://github.com/Sv443/geniURL", + "feature_helptext_geniUrlBase": "Se você tiver sua própria instância do geniURL em execução (por exemplo, para evitar limites de taxa), você pode inserir sua URL base aqui para usá-la para o botão de letras do genius.com.\nSe você não sabe o que é isso, pode deixar esta opção como está.", + "feature_desc_geniUrlToken": "Token de autenticação para sua instância geniURL", + "feature_helptext_geniUrlToken": "Para evitar limites de taxa, você pode fornecer um token de autenticação que também está definido no arquivo .env de sua instância geniURL.", + "feature_desc_lyricsCacheMaxSize": "Quantidade máxima de letras para manter no cache", + "feature_helptext_lyricsCacheMaxSize": "As letras das músicas que você ouve são armazenadas em um cache para reduzir a quantidade de solicitações ao provedor de letras.\nEste recurso permite que você defina a quantidade máxima de letras a serem mantidas no cache.\nQuando o limite for atingido, a entrada mais antiga será removida para dar espaço a novas.", + "feature_desc_lyricsCacheTTL": "Quantidade máxima de dias para manter uma entrada de letras no cache", + "feature_helptext_lyricsCacheTTL": "O cache que as letras são armazenadas excluirá automaticamente elas após esse tempo para garantir que as atualizações da fonte sejam buscadas mais cedo ou mais tarde.\nSe você quiser garantir que sempre tenha as letras mais recentes, defina isso para um valor baixo, como 4 dias.", + "feature_desc_clearLyricsCache": "Limpar o cache de letras manualmente", + "feature_helptext_clearLyricsCache": "Se as letras que estão no cache local estiverem desatualizadas ou você apenas quiser liberar espaço, você pode limpar o cache manualmente pressionando este botão.", + "feature_btn_clearLyricsCache": "Limpar cache", + "feature_btn_clearLyricsCache_running": "Limpando...", + "feature_desc_advancedLyricsFilter": "Experimental: Ativar filtragem avançada para a pesquisa de letras", + "feature_helptext_advancedLyricsFilter": "A filtragem avançada inclui várias camadas de filtros que visam tornar as pesquisas de letras mais confiáveis.\nEsses filtros podem não funcionar bem para músicas em seu idioma e músicas e artistas com títulos menos únicos em geral.\nAviso: Este recurso ainda é experimental e pode não funcionar melhor do que a pesquisa de letras padrão. Certifique-se de confirmar o prompt que aparece se você alterou esta configuração.", - "feature_desc_disableDarkReaderSites": "Em quais sites o Dark Reader deve ser desativado para corrigir problemas de layout?", - "feature_helptext_disableDarkReaderSites": "A extensão Dark Reader pode causar problemas com o layout do site.\nEste recurso permite desativar o Dark Reader em determinados ou todos os sites para evitar esses problemas.\n\nSe a extensão não estiver instalada, este recurso não terá efeito e pode ser deixado ativado.", - "feature_desc_sponsorBlockIntegration": "Faça algumas correções no layout caso o SponsorBlock esteja instalado", - "feature_helptext_sponsorBlockIntegration": "Se você tiver a extensão SponsorBlock instalada, este recurso fará algumas correções no layout do site para evitar problemas que possam ocorrer.\n\nEste recurso não terá efeito se a extensão não estiver instalada e pode ser deixado ativado nesse caso.", - "feature_desc_themeSongIntegration": "Corrigir alguns problemas de estilo se a extensão ThemeSong estiver instalada", - "feature_helptext_themeSongIntegration": "Se a extensão ThemeSong estiver instalada, este recurso fará algumas correções no layout do site para evitar problemas que possam ocorrer.\n\nEste recurso não terá efeito se a extensão não estiver instalada e pode ser deixado ativado nesse caso.", - "feature_desc_themeSongLightness": "Quão claro os acentos derivados do tema atual do ThemeSong devem ser", - "feature_helptext_themeSongLightness": "Dependendo das configurações que você escolheu para a extensão ThemeSong, este recurso permite ajustar a claridade das cores de destaque que são derivadas do tema atual.\n\nEste recurso não terá efeito se a extensão ThemeSong não estiver instalada.", - "feature_desc_openPluginList": "Abrir a lista de plugins que você instalou", - "feature_btn_openPluginList": "Abrir lista", - "feature_btn_openPluginList_running": "Abrindo...", - "feature_desc_initTimeout": "Quantos segundos esperar para que os recursos se inicializem antes de considerá-los provavelmente em um estado de erro", - "feature_helptext_initTimeout": "Este é o tempo em segundos que o script esperará para que os recursos se inicializem antes de considerá-los provavelmente em um estado de erro.\nIsso não afetará significativamente o comportamento do script, mas se um de seus plugins não puder se inicializar a tempo, você deve tentar aumentar esse valor.", + "feature_desc_disableDarkReaderSites": "Em quais sites o Dark Reader deve ser desativado para corrigir problemas de layout?", + "feature_helptext_disableDarkReaderSites": "A extensão Dark Reader pode causar problemas com o layout do site.\nEste recurso permite desativar o Dark Reader em determinados ou todos os sites para evitar esses problemas.\n\nSe a extensão não estiver instalada, este recurso não terá efeito e pode ser deixado ativado.", + "feature_desc_sponsorBlockIntegration": "Faça algumas correções no layout caso o SponsorBlock esteja instalado", + "feature_helptext_sponsorBlockIntegration": "Se você tiver a extensão SponsorBlock instalada, este recurso fará algumas correções no layout do site para evitar problemas que possam ocorrer.\n\nEste recurso não terá efeito se a extensão não estiver instalada e pode ser deixado ativado nesse caso.", + "feature_desc_themeSongIntegration": "Corrigir alguns problemas de estilo se a extensão ThemeSong estiver instalada", + "feature_helptext_themeSongIntegration": "Se a extensão ThemeSong estiver instalada, este recurso fará algumas correções no layout do site para evitar problemas que possam ocorrer.\n\nEste recurso não terá efeito se a extensão não estiver instalada e pode ser deixado ativado nesse caso.", + "feature_desc_themeSongLightness": "Quão claro os acentos derivados do tema atual do ThemeSong devem ser", + "feature_helptext_themeSongLightness": "Dependendo das configurações que você escolheu para a extensão ThemeSong, este recurso permite ajustar a claridade das cores de destaque que são derivadas do tema atual.\n\nEste recurso não terá efeito se a extensão ThemeSong não estiver instalada.", + "feature_desc_openPluginList": "Abrir a lista de plugins que você instalou", + "feature_btn_openPluginList": "Abrir lista", + "feature_btn_openPluginList_running": "Abrindo...", + "feature_desc_initTimeout": "Quantos segundos esperar para que os recursos se inicializem antes de considerá-los provavelmente em um estado de erro", + "feature_helptext_initTimeout": "Este é o tempo em segundos que o script esperará para que os recursos se inicializem antes de considerá-los provavelmente em um estado de erro.\nIsso não afetará significativamente o comportamento do script, mas se um de seus plugins não puder se inicializar a tempo, você deve tentar aumentar esse valor.", - "feature_desc_locale": "Idioma", - "feature_desc_localeFallback": "Utilizar o inglês para traduções em falta (desativar se contribuir para traduções)", - "feature_desc_versionCheck": "Verificar atualizações", - "feature_helptext_versionCheck": "Este recurso verifica atualizações a cada 24 horas, notifica você se uma nova versão estiver disponível e permite que você atualize o script manualmente.\nSe o seu gerenciador de scripts de usuário atualiza scripts automaticamente, você pode desativar este recurso.", - "feature_desc_checkVersionNow": "Verificar manualmente uma nova versão", - "feature_btn_checkVersionNow": "Verificar agora", - "feature_btn_checkVersionNow_running": "Verificando...", - "feature_desc_logLevel": "Quanta informação registrar no console", - "feature_helptext_logLevel": "Alterar isso é realmente necessário apenas para fins de depuração como resultado de experimentar um problema.\nSe você tiver um, você pode aumentar o nível de log aqui, abrir o console JavaScript do seu navegador (geralmente com Ctrl + Shift + K) e anexar capturas de tela desse log em um problema do GitHub.", - "feature_desc_toastDuration": "Por quantos segundos as notificações personalizadas devem ser exibidas - 0 para desativá-las completamente", - "feature_desc_showToastOnGenericError": "Mostrar uma notificação quando ocorrer um erro genérico?", - "feature_helptext_showToastOnGenericError": "Se ocorrer um erro no script que impeça partes dele de funcionar corretamente, uma notificação será exibida para informá-lo sobre isso.\nSe você encontrar um problema com frequência, copie o erro do console JavaScript (geralmente no menu F12) e abra um problema no GitHub.", - "feature_desc_resetConfig": "Redefinir todas as configurações para seus valores padrão", - "feature_btn_resetConfig": "Redefinir configurações", - "feature_btn_resetConfig_running": "Redefinindo...", - "feature_desc_advancedMode": "Mostrar configurações avançadas (isso recarrega o menu)", - "feature_helptext_advancedMode": "Depois de ativar isso, o menu recarregará e mostrará configurações avançadas que estão ocultas por padrão.\nIsso é útil se você deseja personalizar mais profundamente o comportamento do script e não se importa com um menu lotado." - } + "feature_desc_locale": "Idioma", + "feature_desc_localeFallback": "Utilizar o inglês para traduções em falta (desativar se contribuir para traduções)", + "feature_desc_versionCheck": "Verificar atualizações", + "feature_helptext_versionCheck": "Este recurso verifica atualizações a cada 24 horas, notifica você se uma nova versão estiver disponível e permite que você atualize o script manualmente.\nSe o seu gerenciador de scripts de usuário atualiza scripts automaticamente, você pode desativar este recurso.", + "feature_desc_checkVersionNow": "Verificar manualmente uma nova versão", + "feature_btn_checkVersionNow": "Verificar agora", + "feature_btn_checkVersionNow_running": "Verificando...", + "feature_desc_logLevel": "Quanta informação registrar no console", + "feature_helptext_logLevel": "Alterar isso é realmente necessário apenas para fins de depuração como resultado de experimentar um problema.\nSe você tiver um, você pode aumentar o nível de log aqui, abrir o console JavaScript do seu navegador (geralmente com Ctrl + Shift + K) e anexar capturas de tela desse log em um problema do GitHub.", + "feature_desc_toastDuration": "Por quantos segundos as notificações personalizadas devem ser exibidas - 0 para desativá-las completamente", + "feature_desc_showToastOnGenericError": "Mostrar uma notificação quando ocorrer um erro genérico?", + "feature_helptext_showToastOnGenericError": "Se ocorrer um erro no script que impeça partes dele de funcionar corretamente, uma notificação será exibida para informá-lo sobre isso.\nSe você encontrar um problema com frequência, copie o erro do console JavaScript (geralmente no menu F12) e abra um problema no GitHub.", + "feature_desc_resetConfig": "Redefinir todas as configurações para seus valores padrão", + "feature_btn_resetConfig": "Redefinir configurações", + "feature_btn_resetConfig_running": "Redefinindo...", + "feature_desc_advancedMode": "Mostrar configurações avançadas (isso recarrega o menu)", + "feature_helptext_advancedMode": "Depois de ativar isso, o menu recarregará e mostrará configurações avançadas que estão ocultas por padrão.\nIsso é útil se você deseja personalizar mais profundamente o comportamento do script e não se importa com um menu lotado." } diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json index 4d64e70086..941ffe51b0 100644 --- a/assets/translations/zh-CN.json +++ b/assets/translations/zh-CN.json @@ -1,356 +1,362 @@ { - "translations": { - "config_menu_option": "%1 配置", - "config_menu_title": "%1 - 配置", - "changelog_menu_title": "%1 - 更新日志", - "export_menu_title": "%1 - 导出配置", - "import_menu_title": "%1 - 导入配置", - "open_menu_tooltip": "打开 %1 的配置菜单", - "close_menu_tooltip": "点击关闭菜单", - "reload_hint": "请重新加载页面以应用更改", - "reload_now": "立即重新加载", - "reload_tooltip": "重新加载页面", - "feature_requires_reload": "更改此功能需要重新加载页面", - "version_tooltip": "版本 %1 (构建 %2) - 点击打开更新日志", - "bytm_config_export_import_title": "导出或导入配置", - "bytm_config_import_desc": "将要导入的配置粘贴到下面的字段中,然后点击导入按钮:", - "bytm_config_export_desc": "复制以下文本以导出您的配置。警告:它可能包含敏感数据。", - "export_import": "导出/导入", - "export": "导出", - "export_hint": "复制以下文本以导出您的配置。\n警告:它可能包含敏感数据。", - "click_to_reveal": "(点击以显示)", - "click_to_reveal_sensitive_info": "(点击以显示敏感信息)", - "export_tooltip": "导出当前配置", - "import": "导入", - "import_hint": "将要导入的配置粘贴到下面的字段中,然后点击导入按钮:", - "import_tooltip": "导入您先前导出的配置", - "start_import_tooltip": "点击以导入您在上面粘贴的数据", - "import_error_invalid": "导入的数据无效", - "import_error_no_format_version": "导入的数据不包含格式版本", - "import_error_no_data": "导入的对象不包含任何数据", - "import_error_wrong_format_version": "导入的数据格式版本不受支持(期望 %1 或更低版本,但得到 %2)", - "import_success": "成功导入数据", - "import_success_confirm_reload": "成功导入数据。\n您是否要重新加载页面以应用更改?", - "reset_config_confirm": "您是否确实要将所有设置重置为默认值?\n页面将自动重新加载。", - "copy": "复制", - "copy_to_clipboard": "复制到剪贴板", - "copy_to_clipboard_error": "无法将文本复制到剪贴板。请手动从这里复制:\n%1", - "copy_config_tooltip": "将配置复制到剪贴板", - "copied": "已复制!", - "copied_to_clipboard": "已复制到剪贴板!", - "copy_hidden": "复制隐藏", - "copy_hidden_tooltip": "点击复制隐藏值 - 这是敏感数据 ⚠️", - "open_github": "在 GitHub 上打开 %1", - "open_discord": "加入我的 Discord 服务器", - "open_greasyfork": "在 GreasyFork 上打开 %1", - "open_openuserjs": "在 OpenUserJS 上打开 %1", - "lang_changed_prompt_reload": "语言已更改。\n您是否要重新加载页面以应用更改?", - "search_placeholder": "搜索...", - "search_clear": "清除搜索", + "meta": { + "langName": "简体中文", + "langNameEnglish": "Chinese (Simplified)", + "countryName": "中国", + "authors": [ + "Sv443" + ] + }, + "config_menu_option": "%1 配置", + "config_menu_title": "%1 - 配置", + "changelog_menu_title": "%1 - 更新日志", + "export_menu_title": "%1 - 导出配置", + "import_menu_title": "%1 - 导入配置", + "open_menu_tooltip": "打开 %1 的配置菜单", + "close_menu_tooltip": "点击关闭菜单", + "reload_hint": "请重新加载页面以应用更改", + "reload_now": "立即重新加载", + "reload_tooltip": "重新加载页面", + "feature_requires_reload": "更改此功能需要重新加载页面", + "version_tooltip": "版本 %1 (构建 %2) - 点击打开更新日志", + "bytm_config_export_import_title": "导出或导入配置", + "bytm_config_import_desc": "将要导入的配置粘贴到下面的字段中,然后点击导入按钮:", + "bytm_config_export_desc": "复制以下文本以导出您的配置。警告:它可能包含敏感数据。", + "export_import": "导出/导入", + "export": "导出", + "export_hint": "复制以下文本以导出您的配置。\n警告:它可能包含敏感数据。", + "click_to_reveal": "(点击以显示)", + "click_to_reveal_sensitive_info": "(点击以显示敏感信息)", + "export_tooltip": "导出当前配置", + "import": "导入", + "import_hint": "将要导入的配置粘贴到下面的字段中,然后点击导入按钮:", + "import_tooltip": "导入您先前导出的配置", + "start_import_tooltip": "点击以导入您在上面粘贴的数据", + "import_error_invalid": "导入的数据无效", + "import_error_no_format_version": "导入的数据不包含格式版本", + "import_error_no_data": "导入的对象不包含任何数据", + "import_error_wrong_format_version": "导入的数据格式版本不受支持(期望 %1 或更低版本,但得到 %2)", + "import_success": "成功导入数据", + "import_success_confirm_reload": "成功导入数据。\n您是否要重新加载页面以应用更改?", + "reset_config_confirm": "您是否确实要将所有设置重置为默认值?\n页面将自动重新加载。", + "copy": "复制", + "copy_to_clipboard": "复制到剪贴板", + "copy_to_clipboard_error": "无法将文本复制到剪贴板。请手动从这里复制:\n%1", + "copy_config_tooltip": "将配置复制到剪贴板", + "copied": "已复制!", + "copied_to_clipboard": "已复制到剪贴板!", + "copy_hidden": "复制隐藏", + "copy_hidden_tooltip": "点击复制隐藏值 - 这是敏感数据 ⚠️", + "open_github": "在 GitHub 上打开 %1", + "open_discord": "加入我的 Discord 服务器", + "open_greasyfork": "在 GreasyFork 上打开 %1", + "open_openuserjs": "在 OpenUserJS 上打开 %1", + "lang_changed_prompt_reload": "语言已更改。\n您是否要重新加载页面以应用更改?", + "search_placeholder": "搜索...", + "search_clear": "清除搜索", - "reset": "重置", - "close": "关闭", - "log_level_debug": "调试(最多)", - "log_level_info": "信息(仅重要)", - "toggled_on": "开", - "toggled_off": "关", - "trigger_btn_action": "触发", - "trigger_btn_action_running": "运行...", - "new_entry": "新条目", - "new_entry_tooltip": "点击创建新条目", - "remove_entry": "删除此条目", - "edit_entry": "编辑此条目", - "remove_from_queue": "从队列中删除此歌曲", - "delete_from_list": "从列表中删除此歌曲", - "couldnt_remove_from_queue": "无法从队列中删除此歌曲", - "couldnt_delete_from_list": "无法从列表中删除此歌曲", - "clear_list": "清空列表", - "clear_list_confirm": "您是否真的要清空列表并仅保留当前播放的歌曲?", - "scroll_to_playing": "滚动到当前播放的歌曲", - "scroll_to_bottom": "点击滚动到底部", - "volume_tooltip": "音量:%1%(灵敏度:%2%)", - "volume_shared_tooltip": "音量级别在标签页之间共享 - 在配置菜单中禁用", - "middle_click_open_tab": "中键点击打开新标签页", - "example_toast": "示例通知", - "generic_error_toast_encountered_error_type": "遇到 %1", - "generic_error_toast_click_for_details": "点击查看详情", - "error": "错误", - "generic_error_dialog_message": "遇到错误。", - "generic_error_dialog_open_console_note": "如果此错误继续发生,请打开 JavaScript 控制台(通常使用 Ctrl + Shift + K)并将以 %1 开头的 **整个** 错误消息的屏幕截图附加到新的 [GitHub 问题](%2)。", - "active_mode_display": "%1 模式", - "active_mode_tooltip-1": "%1 当前处于活动状态", - "active_mode_tooltip-n": "%1 当前处于活动状态", - "dev_mode": "开发者模式", - "dev_mode_short": "开发者", - "advanced_mode": "高级模式", - "advanced_mode_short": "高级", - "experimental_feature": "实验性功能", + "reset": "重置", + "close": "关闭", + "log_level_debug": "调试(最多)", + "log_level_info": "信息(仅重要)", + "toggled_on": "开", + "toggled_off": "关", + "trigger_btn_action": "触发", + "trigger_btn_action_running": "运行...", + "new_entry": "新条目", + "new_entry_tooltip": "点击创建新条目", + "remove_entry": "删除此条目", + "edit_entry": "编辑此条目", + "remove_from_queue": "从队列中删除此歌曲", + "delete_from_list": "从列表中删除此歌曲", + "couldnt_remove_from_queue": "无法从队列中删除此歌曲", + "couldnt_delete_from_list": "无法从列表中删除此歌曲", + "clear_list": "清空列表", + "clear_list_confirm": "您是否真的要清空列表并仅保留当前播放的歌曲?", + "scroll_to_playing": "滚动到当前播放的歌曲", + "scroll_to_bottom": "点击滚动到底部", + "volume_tooltip": "音量:%1%(灵敏度:%2%)", + "volume_shared_tooltip": "音量级别在标签页之间共享 - 在配置菜单中禁用", + "middle_click_open_tab": "中键点击打开新标签页", + "example_toast": "示例通知", + "generic_error_toast_encountered_error_type": "遇到 %1", + "generic_error_toast_click_for_details": "点击查看详情", + "error": "错误", + "generic_error_dialog_message": "遇到错误。", + "generic_error_dialog_open_console_note": "如果此错误继续发生,请打开 JavaScript 控制台(通常使用 Ctrl + Shift + K)并将以 %1 开头的 **整个** 错误消息的屏幕截图附加到新的 [GitHub 问题](%2)。", + "active_mode_display": "%1 模式", + "active_mode_tooltip-1": "%1 当前处于活动状态", + "active_mode_tooltip-n": "%1 当前处于活动状态", + "dev_mode": "开发者模式", + "dev_mode_short": "开发者", + "advanced_mode": "高级模式", + "advanced_mode_short": "高级", + "experimental_feature": "实验性功能", - "open_lyrics_search_prompt": "输入歌曲标题和艺术家以搜索歌词:", - "lyrics_loading": "正在加载歌词 URL...", - "lyrics_rate_limited-1": "您的请求正在被限制。\n请等待几秒钟再请求更多歌词。", - "lyrics_rate_limited-n": "您的请求正在被限制。\n请等待 %1 秒再请求更多歌词。", - "lyrics_not_found_confirm_open_search": "找不到这首歌的歌词页面。\n您是否要打开 genius.com 手动搜索?", - "lyrics_not_found_click_open_search": "找不到歌词 URL - 点击打开手动歌词搜索", - "lyrics_clear_cache_confirm_prompt-1": "歌词缓存当前有 %1 条记录。\n您是否真的要删除它们?", - "lyrics_clear_cache_confirm_prompt-n": "歌词缓存当前有 %1 条记录。\n您是否真的要删除它们?", - "lyrics_clear_cache_success": "歌词缓存已成功清除。", - "lyrics_cache_changed_clear_confirm": "您已更改了影响歌词缓存中数据的设置,这会破坏歌词 URL 查找。\n您是否要现在清除缓存?", + "open_lyrics_search_prompt": "输入歌曲标题和艺术家以搜索歌词:", + "lyrics_loading": "正在加载歌词 URL...", + "lyrics_rate_limited-1": "您的请求正在被限制。\n请等待几秒钟再请求更多歌词。", + "lyrics_rate_limited-n": "您的请求正在被限制。\n请等待 %1 秒再请求更多歌词。", + "lyrics_not_found_confirm_open_search": "找不到这首歌的歌词页面。\n您是否要打开 genius.com 手动搜索?", + "lyrics_not_found_click_open_search": "找不到歌词 URL - 点击打开手动歌词搜索", + "lyrics_clear_cache_confirm_prompt-1": "歌词缓存当前有 %1 条记录。\n您是否真的要删除它们?", + "lyrics_clear_cache_confirm_prompt-n": "歌词缓存当前有 %1 条记录。\n您是否真的要删除它们?", + "lyrics_clear_cache_success": "歌词缓存已成功清除。", + "lyrics_cache_changed_clear_confirm": "您已更改了影响歌词缓存中数据的设置,这会破坏歌词 URL 查找。\n您是否要现在清除缓存?", - "hotkey_input_click_to_change": "点击更改", - "hotkey_input_click_to_change_tooltip": "%1 - 当前设置为:%2 - 输入任何键组合以更改。注意:某些屏幕阅读器可能会阻止某些键组合。", - "hotkey_input_click_to_reset_tooltip": "重置为上次保存的键组合", - "hotkey_key_ctrl": "Ctrl", - "hotkey_key_shift": "Shift", - "hotkey_key_mac_option": "Option", - "hotkey_key_alt": "Alt", - "hotkey_key_none": "未选择热键", + "hotkey_input_click_to_change": "点击更改", + "hotkey_input_click_to_change_tooltip": "%1 - 当前设置为:%2 - 输入任何键组合以更改。注意:某些屏幕阅读器可能会阻止某些键组合。", + "hotkey_input_click_to_reset_tooltip": "重置为上次保存的键组合", + "hotkey_key_ctrl": "Ctrl", + "hotkey_key_shift": "Shift", + "hotkey_key_mac_option": "Option", + "hotkey_key_alt": "Alt", + "hotkey_key_none": "未选择热键", - "welcome_menu_title": "欢迎使用 %1!", - "config_menu": "配置菜单", - "open_config_menu_tooltip": "点击打开配置菜单", - "open_changelog": "更新日志", - "open_changelog_tooltip": "点击打开更新日志", - "feature_help_button_tooltip": "点击获取有关以下功能的更多信息:\"%1\"", - "welcome_text_line_1": "感谢您安装!", - "welcome_text_line_2": "我希望您使用 %1 的过程中能够愉快 😃", - "welcome_text_line_3": "如果您喜欢 %1,请在 %2GreasyFork%3 或 %4OpenUserJS%5 上留下评分", - "welcome_text_line_4": "我的工作依赖于捐赠,所以请考虑 %1捐赠 ❤️%2", - "welcome_text_line_5": "发现了一个错误或想要建议一个功能?请 %1在 GitHub 上打开一个问题%2", + "welcome_menu_title": "欢迎使用 %1!", + "config_menu": "配置菜单", + "open_config_menu_tooltip": "点击打开配置菜单", + "open_changelog": "更新日志", + "open_changelog_tooltip": "点击打开更新日志", + "feature_help_button_tooltip": "点击获取有关以下功能的更多信息:\"%1\"", + "welcome_text_line_1": "感谢您安装!", + "welcome_text_line_2": "我希望您使用 %1 的过程中能够愉快 😃", + "welcome_text_line_3": "如果您喜欢 %1,请在 %2GreasyFork%3 或 %4OpenUserJS%5 上留下评分", + "welcome_text_line_4": "我的工作依赖于捐赠,所以请考虑 %1捐赠 ❤️%2", + "welcome_text_line_5": "发现了一个错误或想要建议一个功能?请 %1在 GitHub 上打开一个问题%2", - "list_button_placement_queue_only": "仅在队列中", - "list_button_placement_everywhere": "在每首歌曲列表中", + "list_button_placement_queue_only": "仅在队列中", + "list_button_placement_everywhere": "在每首歌曲列表中", - "site_selection_both_sites": "所有网站", - "site_selection_only_yt": "仅 YouTube", - "site_selection_only_ytm": "仅 YouTube Music", - "site_selection_none": "无(禁用)", + "site_selection_both_sites": "所有网站", + "site_selection_only_yt": "仅 YouTube", + "site_selection_only_ytm": "仅 YouTube Music", + "site_selection_none": "无(禁用)", - "new_version_available": "有新版本可用!\n您当前安装的版本是 %1,您可以更新到版本 %2", - "open_update_page_install_manually": "在 %1 上手动安装", - "disable_update_check": "禁用自动更新检查", - "reenable_in_config_menu": "(可以在配置菜单中重新启用)", - "close_and_ignore_for_24h": "关闭并忽略 24 小时", - "close_and_ignore_until_reenabled": "关闭并忽略,直到重新启用", - "expand_release_notes": "点击展开最新的更新日志", - "collapse_release_notes": "点击折叠最新的更新日志", - "no_updates_found": "未找到更新。", + "new_version_available": "有新版本可用!\n您当前安装的版本是 %1,您可以更新到版本 %2", + "open_update_page_install_manually": "在 %1 上手动安装", + "disable_update_check": "禁用自动更新检查", + "reenable_in_config_menu": "(可以在配置菜单中重新启用)", + "close_and_ignore_for_24h": "关闭并忽略 24 小时", + "close_and_ignore_until_reenabled": "关闭并忽略,直到重新启用", + "expand_release_notes": "点击展开最新的更新日志", + "collapse_release_notes": "点击折叠最新的更新日志", + "no_new_version_found": "未找到新版本。", - "thumbnail_overlay_behavior_never": "从不", - "thumbnail_overlay_behavior_videos_only": "仅视频", - "thumbnail_overlay_behavior_songs_only": "仅歌曲", - "thumbnail_overlay_behavior_always": "总是", - "thumbnail_overlay_toggle_btn_tooltip_hide": "禁用缩略图叠加 - 中键单击或 shift + 单击以在新标签页中打开", - "thumbnail_overlay_toggle_btn_tooltip_show": "启用缩略图叠加 - 中键单击或 shift + 单击以在新标签页中打开", - "thumbnail_overlay_indicator_tooltip": "缩略图叠加当前处于活动状态", - "thumbnail_overlay_image_fit_crop": "裁剪", - "thumbnail_overlay_image_fit_full": "显示完整图像", - "thumbnail_overlay_image_fit_stretch": "拉伸以适应", + "thumbnail_overlay_behavior_never": "从不", + "thumbnail_overlay_behavior_videos_only": "仅视频", + "thumbnail_overlay_behavior_songs_only": "仅歌曲", + "thumbnail_overlay_behavior_always": "总是", + "thumbnail_overlay_toggle_btn_tooltip_hide": "禁用缩略图叠加 - 中键单击或 shift + 单击以在新标签页中打开", + "thumbnail_overlay_toggle_btn_tooltip_show": "启用缩略图叠加 - 中键单击或 shift + 单击以在新标签页中打开", + "thumbnail_overlay_indicator_tooltip": "缩略图叠加当前处于活动状态", + "thumbnail_overlay_image_fit_crop": "裁剪", + "thumbnail_overlay_image_fit_full": "显示完整图像", + "thumbnail_overlay_image_fit_stretch": "拉伸以适应", - "auto_like_channels_dialog_title": "自动喜欢的频道", - "auto_like_channels_dialog_desc": "在这里,您可以查看您设置为自动喜欢的频道,并对其进行编辑、启用、禁用和删除。\n您还可以手动创建条目,尽管最好的方法是访问频道页面并在那里单击按钮。", - "auto_like": "自动喜欢", - "auto_like_button_tooltip_enabled": "点击以禁用自动喜欢。Shift + 单击以打开管理对话框。", - "auto_like_button_tooltip_disabled": "点击以启用自动喜欢。Shift + 单击以打开管理对话框。", - "auto_like_channel_toggle_tooltip": "切换频道 %1 的自动喜欢", - "add_auto_like_channel_id_prompt": "输入您要自动喜欢的频道的用户 ID(@Name / UC...)或完整 URL。\n按“取消”退出。", - "add_auto_like_channel_invalid_id": "输入的用户 ID 无效。\n请确保您复制了整个频道 URL!它应该包含类似于“channel/UC...”或“/@...”的部分", - "add_auto_like_channel_already_exists_prompt_new_name": "列表中已经存在具有该 ID 的频道。\n您是否要更改名称?", - "add_auto_like_channel_name_prompt": "输入频道的名称。\n按“取消”退出。", - "auto_like_channel_edit_name_prompt": "输入此频道的新名称。\n按“取消”退出。", - "auto_like_channel_edit_id_prompt": "输入此频道的新用户 ID(@Name / UC...)或完整 URL。\n按“取消”退出。", - "auto_like_enabled_toast": "自动喜欢已启用", - "auto_like_disabled_toast": "自动喜欢已禁用", - "auto_liked_a_channels_song": "喜欢了 %1 的歌曲", - "auto_liked_a_channels_video": "喜欢了 %1 的视频", - "auto_like_click_to_configure": "点击以配置", - "auto_like_export_or_import_tooltip": "导出或导入您的自动喜欢的频道", - "auto_like_export_import_title": "导出或导入自动喜欢的频道", - "auto_like_export_desc": "复制以下文本以导出您的自动喜欢的频道。", - "auto_like_import_desc": "将要导入的自动喜欢的频道粘贴到下面的字段中,然后点击导入按钮:", + "auto_like_channels_dialog_title": "自动喜欢的频道", + "auto_like_channels_dialog_desc": "在这里,您可以查看您设置为自动喜欢的频道,并对其进行编辑、启用、禁用和删除。\n您还可以手动创建条目,尽管最好的方法是访问频道页面并在那里单击按钮。", + "auto_like": "自动喜欢", + "auto_like_button_tooltip_enabled": "点击以禁用自动喜欢。Shift + 单击以打开管理对话框。", + "auto_like_button_tooltip_disabled": "点击以启用自动喜欢。Shift + 单击以打开管理对话框。", + "auto_like_channel_toggle_tooltip": "切换频道 %1 的自动喜欢", + "add_auto_like_channel_id_prompt": "输入您要自动喜欢的频道的用户 ID(@Name / UC...)或完整 URL。\n按“取消”退出。", + "add_auto_like_channel_invalid_id": "输入的用户 ID 无效。\n请确保您复制了整个频道 URL!它应该包含类似于“channel/UC...”或“/@...”的部分", + "add_auto_like_channel_already_exists_prompt_new_name": "列表中已经存在具有该 ID 的频道。\n您是否要更改名称?", + "add_auto_like_channel_name_prompt": "输入频道的名称。\n按“取消”退出。", + "auto_like_channel_edit_name_prompt": "输入此频道的新名称。\n按“取消”退出。", + "auto_like_channel_edit_id_prompt": "输入此频道的新用户 ID(@Name / UC...)或完整 URL。\n按“取消”退出。", + "auto_like_enabled_toast": "自动喜欢已启用", + "auto_like_disabled_toast": "自动喜欢已禁用", + "auto_liked_a_channels_song": "喜欢了 %1 的歌曲", + "auto_liked_a_channels_video": "喜欢了 %1 的视频", + "auto_like_click_to_configure": "点击以配置", + "auto_like_export_or_import_tooltip": "导出或导入您的自动喜欢的频道", + "auto_like_export_import_title": "导出或导入自动喜欢的频道", + "auto_like_export_desc": "复制以下文本以导出您的自动喜欢的频道。", + "auto_like_import_desc": "将要导入的自动喜欢的频道粘贴到下面的字段中,然后点击导入按钮:", - "prompt_confirm": "确认", - "prompt_close": "关闭", - "prompt_cancel": "取消", + "prompt_confirm": "确认", + "prompt_close": "关闭", + "prompt_cancel": "取消", - "click_to_confirm_tooltip": "点击以确认", - "click_to_close_tooltip": "点击关闭", - "click_to_cancel_tooltip": "点击取消", + "click_to_confirm_tooltip": "点击以确认", + "click_to_close_tooltip": "点击关闭", + "click_to_cancel_tooltip": "点击取消", - "vote_label_likes-1": "%1 个赞", - "vote_label_likes-n": "%1 个赞", - "vote_label_dislikes-1": "%1 个踩", - "vote_label_dislikes-n": "%1 个踩", + "vote_label_likes-1": "%1 个赞", + "vote_label_likes-n": "%1 个赞", + "vote_label_dislikes-1": "%1 个踩", + "vote_label_dislikes-n": "%1 个踩", - "vote_ratio_disabled": "禁用", - "vote_ratio_green_red": "绿色和红色", - "vote_ratio_blue_gray": "蓝色和灰色", + "vote_ratio_disabled": "禁用", + "vote_ratio_green_red": "绿色和红色", + "vote_ratio_blue_gray": "蓝色和灰色", - "votes_format_short": "短", - "votes_format_long": "长", + "votes_format_short": "短", + "votes_format_long": "长", - "unit_entries-1": "条记录", - "unit_entries-n": "条记录", + "unit_entries-1": "条记录", + "unit_entries-n": "条记录", - "unit_days-1": "天", - "unit_days-n": "天", + "unit_days-1": "天", + "unit_days-n": "天", - "color_lightness_darker": "更暗", - "color_lightness_normal": "正常", - "color_lightness_lighter": "更亮", + "color_lightness_darker": "更暗", + "color_lightness_normal": "正常", + "color_lightness_lighter": "更亮", - "plugin_list_title": "插件列表", - "plugin_list_no_plugins": "当前未安装任何插件。\n访问 %1此页面%2 以获取更多信息。", - "plugin_list_no_plugins_tooltip": "当前未安装任何插件。", - "plugin_list_permissions_header": "权限:", + "plugin_list_title": "插件列表", + "plugin_list_no_plugins": "当前未安装任何插件。\n访问 %1此页面%2 以获取更多信息。", + "plugin_list_no_plugins_tooltip": "当前未安装任何插件。", + "plugin_list_permissions_header": "权限:", - "plugin_link_type_source": "存储库", - "plugin_link_type_other": "其他 / 主页", - "plugin_link_type_bug": "报告错误", - "plugin_link_type_greasyfork": "GreasyFork", - "plugin_link_type_openuserjs": "OpenUserJS", + "plugin_link_type_source": "存储库", + "plugin_link_type_other": "其他 / 主页", + "plugin_link_type_bug": "报告错误", + "plugin_link_type_greasyfork": "GreasyFork", + "plugin_link_type_openuserjs": "OpenUserJS", - "plugin_intent_description_ReadFeatureConfig": "此插件可以读取功能配置", - "plugin_intent_description_WriteFeatureConfig": "此插件可以写入功能配置", - "plugin_intent_description_SeeHiddenConfigValues": "此插件可以访问隐藏的配置值", - "plugin_intent_description_WriteLyricsCache": "此插件可以写入歌词缓存", - "plugin_intent_description_WriteTranslations": "此插件可以添加新的翻译并覆盖现有的翻译", - "plugin_intent_description_CreateModalDialogs": "此插件可以创建模态对话框", - "plugin_intent_description_ReadAutoLikeData": "此插件可以读取自动喜欢的数据", - "plugin_intent_description_WriteAutoLikeData": "此插件可以写入自动喜欢的数据", + "plugin_intent_description_ReadFeatureConfig": "此插件可以读取功能配置", + "plugin_intent_description_WriteFeatureConfig": "此插件可以写入功能配置", + "plugin_intent_description_SeeHiddenConfigValues": "此插件可以访问隐藏的配置值", + "plugin_intent_description_WriteLyricsCache": "此插件可以写入歌词缓存", + "plugin_intent_description_WriteTranslations": "此插件可以添加新的翻译并覆盖现有的翻译", + "plugin_intent_description_CreateModalDialogs": "此插件可以创建模态对话框", + "plugin_intent_description_ReadAutoLikeData": "此插件可以读取自动喜欢的数据", + "plugin_intent_description_WriteAutoLikeData": "此插件可以写入自动喜欢的数据", - "plugin_validation_error_no_property": "没有类型为 '%2' 的属性 '%1'", - "plugin_validation_error_invalid_property-1": "属性 '%1' 的值 '%2' 无效。示例值:%3", - "plugin_validation_error_invalid_property-n": "属性 '%1' 的值 '%2' 无效。示例值:%3", + "plugin_validation_error_no_property": "没有类型为 '%2' 的属性 '%1'", + "plugin_validation_error_invalid_property-1": "属性 '%1' 的值 '%2' 无效。示例值:%3", + "plugin_validation_error_invalid_property-n": "属性 '%1' 的值 '%2' 无效。示例值:%3", - "feature_category_layout": "布局", - "feature_category_volume": "音量", - "feature_category_songLists": "歌曲列表", - "feature_category_behavior": "行为", - "feature_category_input": "输入", - "feature_category_lyrics": "歌词", - "feature_category_integrations": "集成", - "feature_category_plugins": "插件", - "feature_category_general": "一般的", + "feature_category_layout": "布局", + "feature_category_volume": "音量", + "feature_category_songLists": "歌曲列表", + "feature_category_behavior": "行为", + "feature_category_input": "输入", + "feature_category_lyrics": "歌词", + "feature_category_integrations": "集成", + "feature_category_plugins": "插件", + "feature_category_general": "一般的", - "feature_desc_watermarkEnabled": "在网站标志下方显示一个水印,以打开此配置菜单", - "feature_helptext_watermarkEnabled": "如果禁用此功能,您仍然可以通过单击右上角的个人资料图片打开配置菜单中的选项。\n但是,要找到彩蛋将会更难 ;)", - "feature_desc_removeShareTrackingParam": "从共享菜单的 URL 中移除跟踪参数(\"?si\")", - "feature_helptext_removeShareTrackingParam": "出于分析目的,YouTube 在您可以复制的共享菜单中的 URL 末尾添加了一个跟踪参数。虽然不会直接有害,但它会使 URL 变得更长,并且会向 YouTube 提供有关您和您发送链接的人的更多信息。", - "feature_desc_removeShareTrackingParamSites": "在哪些网站上应该删除共享跟踪参数?", - "feature_desc_numKeysSkipToTime": "启用按数字键(0-9)跳转到视频中的特定时间", - "feature_desc_fixSpacing": "修复布局中的间距问题", - "feature_helptext_fixSpacing": "在用户界面中有各种位置的元素之间的间距不一致。此功能修复了这些问题。", - "feature_desc_thumbnailOverlayBehavior": "何时自动用最高分辨率的缩略图替换视频元素", - "feature_helptext_thumbnailOverlayBehavior": "此功能允许您在播放视频或歌曲时自动显示缩略图。\n这将不会节省任何带宽,因为视频仍将在后台加载和播放!", - "feature_desc_thumbnailOverlayToggleBtnShown": "在媒体控件中添加一个按钮,以手动切换缩略图", - "feature_helptext_thumbnailOverlayToggleBtnShown": "这个按钮允许你手动开关缩略图。一旦开始播放新的视频或歌曲,就会恢复默认状态。点击时按住 shift 或按下鼠标中键,可在新标签页中打开最高质量的缩略图。", - "feature_desc_thumbnailOverlayShowIndicator": "在缩略图上显示一个指示器,表示它是活动的吗?", - "feature_desc_thumbnailOverlayIndicatorOpacity": "缩略图指示器的不透明度", - "feature_desc_thumbnailOverlayImageFit": "如何将缩略图图像适配到视频元素上", - "feature_desc_hideCursorOnIdle": "在视频上几秒钟不活动后隐藏光标", - "feature_desc_hideCursorOnIdleDelay": "在几秒钟的不活动后隐藏光标?", - "feature_desc_fixHdrIssues": "在 HDR 兼容的 GPU 和显示器上防止一些渲染问题", - "feature_desc_showVotes": "显示当前播放的歌曲的喜欢和不喜欢的数量", - "feature_helptext_showVotes": "此功能由 Return YouTube Dislike 提供支持,将显示当前播放的歌曲的大致喜欢和不喜欢的数量。", - "feature_desc_numbersFormat": "数字应该如何格式化?", + "feature_desc_watermarkEnabled": "在网站标志下方显示一个水印,以打开此配置菜单", + "feature_helptext_watermarkEnabled": "如果禁用此功能,您仍然可以通过单击右上角的个人资料图片打开配置菜单中的选项。\n但是,要找到彩蛋将会更难 ;)", + "feature_desc_removeShareTrackingParam": "从共享菜单的 URL 中移除跟踪参数(\"?si\")", + "feature_helptext_removeShareTrackingParam": "出于分析目的,YouTube 在您可以复制的共享菜单中的 URL 末尾添加了一个跟踪参数。虽然不会直接有害,但它会使 URL 变得更长,并且会向 YouTube 提供有关您和您发送链接的人的更多信息。", + "feature_desc_removeShareTrackingParamSites": "在哪些网站上应该删除共享跟踪参数?", + "feature_desc_numKeysSkipToTime": "启用按数字键(0-9)跳转到视频中的特定时间", + "feature_desc_fixSpacing": "修复布局中的间距问题", + "feature_helptext_fixSpacing": "在用户界面中有各种位置的元素之间的间距不一致。此功能修复了这些问题。", + "feature_desc_thumbnailOverlayBehavior": "何时自动用最高分辨率的缩略图替换视频元素", + "feature_helptext_thumbnailOverlayBehavior": "此功能允许您在播放视频或歌曲时自动显示缩略图。\n这将不会节省任何带宽,因为视频仍将在后台加载和播放!", + "feature_desc_thumbnailOverlayToggleBtnShown": "在媒体控件中添加一个按钮,以手动切换缩略图", + "feature_helptext_thumbnailOverlayToggleBtnShown": "这个按钮允许你手动开关缩略图。一旦开始播放新的视频或歌曲,就会恢复默认状态。点击时按住 shift 或按下鼠标中键,可在新标签页中打开最高质量的缩略图。", + "feature_desc_thumbnailOverlayShowIndicator": "在缩略图上显示一个指示器,表示它是活动的吗?", + "feature_desc_thumbnailOverlayIndicatorOpacity": "缩略图指示器的不透明度", + "feature_desc_thumbnailOverlayImageFit": "如何将缩略图图像适配到视频元素上", + "feature_desc_hideCursorOnIdle": "在视频上几秒钟不活动后隐藏光标", + "feature_desc_hideCursorOnIdleDelay": "在几秒钟的不活动后隐藏光标?", + "feature_desc_fixHdrIssues": "在 HDR 兼容的 GPU 和显示器上防止一些渲染问题", + "feature_desc_showVotes": "显示当前播放的歌曲的喜欢和不喜欢的数量", + "feature_helptext_showVotes": "此功能由 Return YouTube Dislike 提供支持,将显示当前播放的歌曲的大致喜欢和不喜欢的数量。", + "feature_desc_numbersFormat": "数字应该如何格式化?", - "feature_desc_volumeSliderLabel": "在音量滑块旁边添加百分比标签", - "feature_desc_volumeSliderSize": "音量滑块的宽度(像素)", - "feature_desc_volumeSliderStep": "音量滑块灵敏度(音量每次可以改变多少百分比)", - "feature_desc_volumeSliderScrollStep": "音量滑块滚动步长", - "feature_helptext_volumeSliderScrollStep": "当使用鼠标滚轮滚动音量滑块时,音量应该改变多少百分比。\n这应该是音量滑块灵敏度的倍数,否则在滚动音量时会出现小的不规则跳跃。", - "feature_desc_volumeSharedBetweenTabs": "音量级别在标签页之间共享", - "feature_helptext_volumeSharedBetweenTabs": "如果您在一个标签页中更改了音量,则所有其他启用此功能的标签页中的音量级别将设置为相同的值。\n此值将在会话之间记住并恢复,直到禁用。", - "feature_desc_setInitialTabVolume": "打开网站时将音量级别设置为特定值", - "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "此功能与 \"音量级别在标签页之间共享\" 功能不兼容,将在使用共享音量功能时被忽略!", - "feature_desc_initialTabVolumeLevel": "打开网站时将音量级别设置为的值", + "feature_desc_volumeSliderLabel": "在音量滑块旁边添加百分比标签", + "feature_desc_volumeSliderSize": "音量滑块的宽度(像素)", + "feature_desc_volumeSliderStep": "音量滑块灵敏度(音量每次可以改变多少百分比)", + "feature_desc_volumeSliderScrollStep": "音量滑块滚动步长", + "feature_helptext_volumeSliderScrollStep": "当使用鼠标滚轮滚动音量滑块时,音量应该改变多少百分比。\n这应该是音量滑块灵敏度的倍数,否则在滚动音量时会出现小的不规则跳跃。", + "feature_desc_volumeSharedBetweenTabs": "音量级别在标签页之间共享", + "feature_helptext_volumeSharedBetweenTabs": "如果您在一个标签页中更改了音量,则所有其他启用此功能的标签页中的音量级别将设置为相同的值。\n此值将在会话之间记住并恢复,直到禁用。", + "feature_desc_setInitialTabVolume": "打开网站时将音量级别设置为特定值", + "feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible": "此功能与 \"音量级别在标签页之间共享\" 功能不兼容,将在使用共享音量功能时被忽略!", + "feature_desc_initialTabVolumeLevel": "打开网站时将音量级别设置为的值", - "feature_desc_lyricsQueueButton": "在每首歌曲的列表中添加一个按钮,以打开其歌词页面", - "feature_desc_deleteFromQueueButton": "在每首歌曲的列表中添加一个按钮,以快速删除它", - "feature_desc_listButtonsPlacement": "列表按钮应该显示在哪里?", - "feature_helptext_listButtonsPlacement": "网站上有各种歌曲列表,如专辑页面、播放列表和当前播放队列。\n使用此选项,您可以选择列表按钮应该显示在哪里。", - "feature_desc_scrollToActiveSongBtn": "在队列中添加一个按钮,以滚动到当前播放的歌曲", - "feature_desc_clearQueueBtn": "在当前播放的队列或播放列表上方添加一个按钮,以快速清除它", + "feature_desc_lyricsQueueButton": "在每首歌曲的列表中添加一个按钮,以打开其歌词页面", + "feature_desc_deleteFromQueueButton": "在每首歌曲的列表中添加一个按钮,以快速删除它", + "feature_desc_listButtonsPlacement": "列表按钮应该显示在哪里?", + "feature_helptext_listButtonsPlacement": "网站上有各种歌曲列表,如专辑页面、播放列表和当前播放队列。\n使用此选项,您可以选择列表按钮应该显示在哪里。", + "feature_desc_scrollToActiveSongBtn": "在队列中添加一个按钮,以滚动到当前播放的歌曲", + "feature_desc_clearQueueBtn": "在当前播放的队列或播放列表上方添加一个按钮,以快速清除它", - "feature_desc_disableBeforeUnloadPopup": "防止在播放歌曲时尝试离开网站时出现的确认弹出窗口", - "feature_helptext_disableBeforeUnloadPopup": "当尝试在正在播放的歌曲中几秒钟后离开网站时,将出现一个弹出窗口,询问您是否要离开网站。它可能会说类似于 \"您有未保存的数据\" 或 \"此网站正在询问您是否要关闭它\"。\n此功能完全禁用了该弹出窗口。", - "feature_desc_closeToastsTimeout": "多少秒后关闭永久通知 - 0 仅手动关闭(默认行为)", - "feature_helptext_closeToastsTimeout": "出现在左下角的大多数弹出窗口将在 3 秒后自动关闭,但有一些例外,例如喜欢一首歌时。\n此功能允许您设置永久弹出窗口关闭的时间。\n其他类型的弹出窗口不受影响。\n将此设置为 0 以使用默认行为,即不关闭永久通知。", - "feature_desc_rememberSongTime": "记住重新加载或恢复标签时的最后一首歌的时间", - "feature_helptext_rememberSongTime-1": "有时在重新加载页面或意外关闭后恢复它时,您希望在相同的位置继续听歌。此功能允许您这样做。\n为了记录歌曲的时间,您需要播放它 %1 秒,然后它的时间将被记住,并在短时间内可以恢复。", - "feature_helptext_rememberSongTime-n": "有时在重新加载页面或意外关闭后恢复它时,您希望在相同的位置继续听歌。此功能允许您这样做。\n为了记录歌曲的时间,您需要播放它 %1 秒,然后它的时间将被记住,并在短时间内可以恢复。", - "feature_desc_rememberSongTimeSites": "在哪些网站上应该记住和恢复歌曲时间?", - "feature_desc_rememberSongTimeDuration": "记住歌曲的时间多长时间?", - "feature_desc_rememberSongTimeReduction": "恢复记住的歌曲时间时要减去多少秒", - "feature_helptext_rememberSongTimeReduction": "当恢复记住的时间时,将从记住的时间中减去这么多秒,以便您可以重新听到被中断的部分。", - "feature_desc_rememberSongTimeMinPlayTime": "歌曲需要播放多少秒才能记住其时间", + "feature_desc_disableBeforeUnloadPopup": "防止在播放歌曲时尝试离开网站时出现的确认弹出窗口", + "feature_helptext_disableBeforeUnloadPopup": "当尝试在正在播放的歌曲中几秒钟后离开网站时,将出现一个弹出窗口,询问您是否要离开网站。它可能会说类似于 \"您有未保存的数据\" 或 \"此网站正在询问您是否要关闭它\"。\n此功能完全禁用了该弹出窗口。", + "feature_desc_closeToastsTimeout": "多少秒后关闭永久通知 - 0 仅手动关闭(默认行为)", + "feature_helptext_closeToastsTimeout": "出现在左下角的大多数弹出窗口将在 3 秒后自动关闭,但有一些例外,例如喜欢一首歌时。\n此功能允许您设置永久弹出窗口关闭的时间。\n其他类型的弹出窗口不受影响。\n将此设置为 0 以使用默认行为,即不关闭永久通知。", + "feature_desc_rememberSongTime": "记住重新加载或恢复标签时的最后一首歌的时间", + "feature_helptext_rememberSongTime-1": "有时在重新加载页面或意外关闭后恢复它时,您希望在相同的位置继续听歌。此功能允许您这样做。\n为了记录歌曲的时间,您需要播放它 %1 秒,然后它的时间将被记住,并在短时间内可以恢复。", + "feature_helptext_rememberSongTime-n": "有时在重新加载页面或意外关闭后恢复它时,您希望在相同的位置继续听歌。此功能允许您这样做。\n为了记录歌曲的时间,您需要播放它 %1 秒,然后它的时间将被记住,并在短时间内可以恢复。", + "feature_desc_rememberSongTimeSites": "在哪些网站上应该记住和恢复歌曲时间?", + "feature_desc_rememberSongTimeDuration": "记住歌曲的时间多长时间?", + "feature_desc_rememberSongTimeReduction": "恢复记住的歌曲时间时要减去多少秒", + "feature_helptext_rememberSongTimeReduction": "当恢复记住的时间时,将从记住的时间中减去这么多秒,以便您可以重新听到被中断的部分。", + "feature_desc_rememberSongTimeMinPlayTime": "歌曲需要播放多少秒才能记住其时间", - "feature_desc_arrowKeySupport": "使用箭头键在当前播放的歌曲中前进和后退", - "feature_helptext_arrowKeySupport": "通常,您只能使用 \"H\" 和 \"L\" 键以固定的 10 秒间隔前进和后退。此功能允许您也使用箭头键。\n要更改要跳过的秒数,请使用下面的选项。", - "feature_desc_arrowKeySkipBy": "使用箭头键跳过多少秒", - "feature_desc_switchBetweenSites": "在视频 / 歌曲上添加一个热键,以在 YT 和 YTM 网站之间切换", - "feature_helptext_switchBetweenSites": "按下此热键将在 YT 或 YTM 上切换到另一个站点,同时保持在同一视频 / 歌曲上。", - "feature_desc_switchSitesHotkey": "需要按下哪个热键才能切换网站?", - "feature_desc_anchorImprovements": "在页面上添加和改进链接,以便更容易在新标签页中打开", - "feature_helptext_anchorImprovements": "页面上的一些元素只能使用鼠标左键单击,这意味着您无法通过中键单击或使用 shift + 右键单击的上下文菜单在新标签页中打开它们。此功能添加了很多链接或扩大了现有的链接,以使单击更容易。", - "feature_desc_autoLikeChannels": "自动喜欢某些频道的所有歌曲和视频", - "feature_helpText_autoLikeChannels": "启用后,您可以通过打开其页面并单击切换按钮来为某些频道启用此功能。之后,您播放的该频道的任何歌曲都将自动喜欢。\n使用下面的选项打开对话框以管理频道。", - "feature_desc_autoLikeChannelToggleBtn": "在每个频道页面上添加一个按钮,以启用或禁用自动喜欢", - "feature_desc_autoLikePlayerBarToggleBtn": "在媒体控件中添加一个按钮,以启用或禁用自动喜欢", - "feature_desc_autoLikeTimeout": "多少秒后自动喜欢一首歌曲", - "feature_desc_autoLikeShowToast": "当自动喜欢一首歌曲时显示一个通知", - "feature_desc_autoLikeOpenMgmtDialog": "打开对话框以管理自动喜欢的频道", - "feature_btn_autoLikeOpenMgmtDialog": "打开对话框", - "feature_btn_autoLikeOpenMgmtDialog_running": "正在打开...", + "feature_desc_arrowKeySupport": "使用箭头键在当前播放的歌曲中前进和后退", + "feature_helptext_arrowKeySupport": "通常,您只能使用 \"H\" 和 \"L\" 键以固定的 10 秒间隔前进和后退。此功能允许您也使用箭头键。\n要更改要跳过的秒数,请使用下面的选项。", + "feature_desc_arrowKeySkipBy": "使用箭头键跳过多少秒", + "feature_desc_switchBetweenSites": "在视频 / 歌曲上添加一个热键,以在 YT 和 YTM 网站之间切换", + "feature_helptext_switchBetweenSites": "按下此热键将在 YT 或 YTM 上切换到另一个站点,同时保持在同一视频 / 歌曲上。", + "feature_desc_switchSitesHotkey": "需要按下哪个热键才能切换网站?", + "feature_desc_anchorImprovements": "在页面上添加和改进链接,以便更容易在新标签页中打开", + "feature_helptext_anchorImprovements": "页面上的一些元素只能使用鼠标左键单击,这意味着您无法通过中键单击或使用 shift + 右键单击的上下文菜单在新标签页中打开它们。此功能添加了很多链接或扩大了现有的链接,以使单击更容易。", + "feature_desc_autoLikeChannels": "自动喜欢某些频道的所有歌曲和视频", + "feature_helpText_autoLikeChannels": "启用后,您可以通过打开其页面并单击切换按钮来为某些频道启用此功能。之后,您播放的该频道的任何歌曲都将自动喜欢。\n使用下面的选项打开对话框以管理频道。", + "feature_desc_autoLikeChannelToggleBtn": "在每个频道页面上添加一个按钮,以启用或禁用自动喜欢", + "feature_desc_autoLikePlayerBarToggleBtn": "在媒体控件中添加一个按钮,以启用或禁用自动喜欢", + "feature_desc_autoLikeTimeout": "多少秒后自动喜欢一首歌曲", + "feature_desc_autoLikeShowToast": "当自动喜欢一首歌曲时显示一个通知", + "feature_desc_autoLikeOpenMgmtDialog": "打开对话框以管理自动喜欢的频道", + "feature_btn_autoLikeOpenMgmtDialog": "打开对话框", + "feature_btn_autoLikeOpenMgmtDialog_running": "正在打开...", - "feature_desc_geniusLyrics": "在当前播放的歌曲的媒体控件中添加一个按钮,以在 genius.com 上打开其歌词", - "feature_desc_errorOnLyricsNotFound": "当找不到当前播放歌曲的歌词页面时显示一个错误", - "feature_desc_geniUrlBase": "geniURL 实例的基本 URL,请参阅 https://github.com/Sv443/geniURL", - "feature_helptext_geniUrlBase": "如果您有自己运行的 geniURL 实例(例如为了绕过速率限制),您可以在此处输入其基本 URL 以在 genius.com 歌词按钮中使用它。\n如果您不知道这是什么,您可以将此选项保留为默认值。", - "feature_desc_geniUrlToken": "您的 geniURL 实例的身份验证令牌", - "feature_helptext_geniUrlToken": "为了绕过速率限制,您可以提供一个身份验证令牌,该令牌也在您的 geniURL 实例的 .env 文件中定义。", - "feature_desc_lyricsCacheMaxSize": "在缓存中保留的歌词的最大数量", - "feature_helptext_lyricsCacheMaxSize": "您听的歌曲的歌词存储在缓存中,以减少对歌词提供者的请求。\n此功能允许您设置要在缓存中保留的歌词的最大数量。\n当达到限制时,将删除最旧的条目以为任何新条目腾出空间。", - "feature_desc_lyricsCacheTTL": "在缓存中保留歌词条目的最大天数", - "feature_helptext_lyricsCacheTTL": "歌词存储在缓存中,以减少对歌词提供者的请求。\n此功能允许您设置要在缓存中保留的歌词的最大天数。\n如果您想确保始终拥有最新的歌词,请将此设置为较低的值,例如 4 天。", - "feature_desc_clearLyricsCache": "手动清除歌词缓存", - "feature_helptext_clearLyricsCache": "如果本地缓存中的歌词已过时,或者您只是想释放一些空间,您可以通过按下此按钮手动清除缓存。", - "feature_btn_clearLyricsCache": "清除缓存", - "feature_btn_clearLyricsCache_running": "正在清除...", - "feature_desc_advancedLyricsFilter": "实验性:启用歌词搜索的高级过滤", - "feature_helptext_advancedLyricsFilter": "高级过滤包括多层过滤器,旨在使歌词查找更可靠。\n这些过滤器可能不适用于您的语言的歌曲,以及标题不太独特的歌曲和艺术家。\n警告:此功能仍处于实验阶段,可能实际上并不比默认歌词查找更好。确保确认更改此设置时出现的提示。", + "feature_desc_geniusLyrics": "在当前播放的歌曲的媒体控件中添加一个按钮,以在 genius.com 上打开其歌词", + "feature_desc_errorOnLyricsNotFound": "当找不到当前播放歌曲的歌词页面时显示一个错误", + "feature_desc_geniUrlBase": "geniURL 实例的基本 URL,请参阅 https://github.com/Sv443/geniURL", + "feature_helptext_geniUrlBase": "如果您有自己运行的 geniURL 实例(例如为了绕过速率限制),您可以在此处输入其基本 URL 以在 genius.com 歌词按钮中使用它。\n如果您不知道这是什么,您可以将此选项保留为默认值。", + "feature_desc_geniUrlToken": "您的 geniURL 实例的身份验证令牌", + "feature_helptext_geniUrlToken": "为了绕过速率限制,您可以提供一个身份验证令牌,该令牌也在您的 geniURL 实例的 .env 文件中定义。", + "feature_desc_lyricsCacheMaxSize": "在缓存中保留的歌词的最大数量", + "feature_helptext_lyricsCacheMaxSize": "您听的歌曲的歌词存储在缓存中,以减少对歌词提供者的请求。\n此功能允许您设置要在缓存中保留的歌词的最大数量。\n当达到限制时,将删除最旧的条目以为任何新条目腾出空间。", + "feature_desc_lyricsCacheTTL": "在缓存中保留歌词条目的最大天数", + "feature_helptext_lyricsCacheTTL": "歌词存储在缓存中,以减少对歌词提供者的请求。\n此功能允许您设置要在缓存中保留的歌词的最大天数。\n如果您想确保始终拥有最新的歌词,请将此设置为较低的值,例如 4 天。", + "feature_desc_clearLyricsCache": "手动清除歌词缓存", + "feature_helptext_clearLyricsCache": "如果本地缓存中的歌词已过时,或者您只是想释放一些空间,您可以通过按下此按钮手动清除缓存。", + "feature_btn_clearLyricsCache": "清除缓存", + "feature_btn_clearLyricsCache_running": "正在清除...", + "feature_desc_advancedLyricsFilter": "实验性:启用歌词搜索的高级过滤", + "feature_helptext_advancedLyricsFilter": "高级过滤包括多层过滤器,旨在使歌词查找更可靠。\n这些过滤器可能不适用于您的语言的歌曲,以及标题不太独特的歌曲和艺术家。\n警告:此功能仍处于实验阶段,可能实际上并不比默认歌词查找更好。确保确认更改此设置时出现的提示。", - "feature_desc_disableDarkReaderSites": "在哪些网站上应该禁用 Dark Reader 扩展以修复布局问题?", - "feature_helptext_disableDarkReaderSites": "Dark Reader 扩展可能会导致网站布局问题。\n此功能允许您在某些或所有网站上禁用 Dark Reader 以防止这些问题。\n\n如果未安装扩展程序,则此功能将不起作用,可以保持激活状态。", - "feature_desc_sponsorBlockIntegration": "如果安装了 SponsorBlock 扩展,则对布局进行一些修复", - "feature_helptext_sponsorBlockIntegration": "如果安装了 SponsorBlock 扩展,则此功能将对网站的布局进行一些修复,以防止可能发生的问题。\n\n如果未安装扩展程序,则此功能将不起作用,可以保持激活状态。", - "feature_desc_themeSongIntegration": "如果安装了 ThemeSong 扩展,则修复一些样式问题", - "feature_helptext_themeSongIntegration": "如果安装了 ThemeSong 扩展但此功能已关闭(反之亦然),您可能会注意到网站上的样式损坏。\n\n请始终确保此功能反映了扩展程序是否已安装!", - "feature_desc_themeSongLightness": "从当前 ThemeSong 主题派生的强调颜色应该有多亮", - "feature_helptext_themeSongLightness": "根据您为 ThemeSong 扩展选择的设置,此功能允许您调整从当前主题派生的强调颜色的亮度。\n\n如果未安装 ThemeSong 扩展,此功能将不起作用。", - "feature_desc_openPluginList": "打开您安装的插件列表", - "feature_btn_openPluginList": "打开列表", - "feature_btn_openPluginList_running": "正在打开...", - "feature_desc_initTimeout": "在考虑它们可能处于错误状态之前等待功能初始化的时间", - "feature_helptext_initTimeout": "这是脚本等待功能初始化的时间,以考虑它们可能处于错误状态。\n这不会对脚本的行为产生重大影响,但如果您的插件之一无法及时初始化,您应该尝试增加此值。", + "feature_desc_disableDarkReaderSites": "在哪些网站上应该禁用 Dark Reader 扩展以修复布局问题?", + "feature_helptext_disableDarkReaderSites": "Dark Reader 扩展可能会导致网站布局问题。\n此功能允许您在某些或所有网站上禁用 Dark Reader 以防止这些问题。\n\n如果未安装扩展程序,则此功能将不起作用,可以保持激活状态。", + "feature_desc_sponsorBlockIntegration": "如果安装了 SponsorBlock 扩展,则对布局进行一些修复", + "feature_helptext_sponsorBlockIntegration": "如果安装了 SponsorBlock 扩展,则此功能将对网站的布局进行一些修复,以防止可能发生的问题。\n\n如果未安装扩展程序,则此功能将不起作用,可以保持激活状态。", + "feature_desc_themeSongIntegration": "如果安装了 ThemeSong 扩展,则修复一些样式问题", + "feature_helptext_themeSongIntegration": "如果安装了 ThemeSong 扩展但此功能已关闭(反之亦然),您可能会注意到网站上的样式损坏。\n\n请始终确保此功能反映了扩展程序是否已安装!", + "feature_desc_themeSongLightness": "从当前 ThemeSong 主题派生的强调颜色应该有多亮", + "feature_helptext_themeSongLightness": "根据您为 ThemeSong 扩展选择的设置,此功能允许您调整从当前主题派生的强调颜色的亮度。\n\n如果未安装 ThemeSong 扩展,此功能将不起作用。", + "feature_desc_openPluginList": "打开您安装的插件列表", + "feature_btn_openPluginList": "打开列表", + "feature_btn_openPluginList_running": "正在打开...", + "feature_desc_initTimeout": "在考虑它们可能处于错误状态之前等待功能初始化的时间", + "feature_helptext_initTimeout": "这是脚本等待功能初始化的时间,以考虑它们可能处于错误状态。\n这不会对脚本的行为产生重大影响,但如果您的插件之一无法及时初始化,您应该尝试增加此值。", - "feature_desc_locale": "语言", - "feature_desc_localeFallback": "如果找不到翻译,请使用英语(如果您提供翻译,请禁用)。", - "feature_desc_versionCheck": "检查更新", - "feature_helptext_versionCheck": "此功能每 24 小时检查更新,如果有新版本可用,会通知您并允许您手动更新脚本。\n如果您的用户脚本管理器扩展自动更新脚本,您可以禁用此功能。", - "feature_desc_checkVersionNow": "手动检查新版本", - "feature_btn_checkVersionNow": "立即检查", - "feature_btn_checkVersionNow_running": "正在检查...", - "feature_desc_logLevel": "更改扩展程序的日志级别", - "feature_helptext_logLevel": "更改这个只是为了调试目的,因为遇到了问题。\n如果您有一个,您可以在这里增加日志级别,打开您的浏览器的 JavaScript 控制台(通常是 Ctrl + Shift + K)并在 GitHub 问题中附上那个日志的截图。", - "feature_desc_toastDuration": "自定义提示通知应显示多少秒 - 0 以完全禁用它们", - "feature_desc_showToastOnGenericError": "当发生错误时显示通知?", - "feature_helptext_showToastOnGenericError": "如果脚本中发生错误,阻止其部分工作的通知将显示给您。\n如果您经常遇到问题,请从 JavaScript 控制台(通常在 F12 菜单中)复制错误,并在 GitHub 上打开一个问题。", - "feature_desc_resetConfig": "重置所有设置为默认值", - "feature_btn_resetConfig": "重置设置", - "feature_btn_resetConfig_running": "正在重置...", - "feature_desc_advancedMode": "显示高级设置(重新加载菜单)", - "feature_helptext_advancedMode": "启用此功能后,菜单将重新加载并显示默认情况下隐藏的高级设置。\n如果您想更深入地定制脚本的行为并不在意过于拥挤的菜单,这将很有用。" - } + "feature_desc_locale": "语言", + "feature_desc_localeFallback": "如果找不到翻译,请使用英语(如果您提供翻译,请禁用)。", + "feature_desc_versionCheck": "检查更新", + "feature_helptext_versionCheck": "此功能每 24 小时检查更新,如果有新版本可用,会通知您并允许您手动更新脚本。\n如果您的用户脚本管理器扩展自动更新脚本,您可以禁用此功能。", + "feature_desc_checkVersionNow": "手动检查新版本", + "feature_btn_checkVersionNow": "立即检查", + "feature_btn_checkVersionNow_running": "正在检查...", + "feature_desc_logLevel": "更改扩展程序的日志级别", + "feature_helptext_logLevel": "更改这个只是为了调试目的,因为遇到了问题。\n如果您有一个,您可以在这里增加日志级别,打开您的浏览器的 JavaScript 控制台(通常是 Ctrl + Shift + K)并在 GitHub 问题中附上那个日志的截图。", + "feature_desc_toastDuration": "自定义提示通知应显示多少秒 - 0 以完全禁用它们", + "feature_desc_showToastOnGenericError": "当发生错误时显示通知?", + "feature_helptext_showToastOnGenericError": "如果脚本中发生错误,阻止其部分工作的通知将显示给您。\n如果您经常遇到问题,请从 JavaScript 控制台(通常在 F12 菜单中)复制错误,并在 GitHub 上打开一个问题。", + "feature_desc_resetConfig": "重置所有设置为默认值", + "feature_btn_resetConfig": "重置设置", + "feature_btn_resetConfig_running": "正在重置...", + "feature_desc_advancedMode": "显示高级设置(重新加载菜单)", + "feature_helptext_advancedMode": "启用此功能后,菜单将重新加载并显示默认情况下隐藏的高级设置。\n如果您想更深入地定制脚本的行为并不在意过于拥挤的菜单,这将很有用。" } diff --git a/changelog.md b/changelog.md index ea8699e9fb..682b87047e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,41 @@
+ +## 2.3.0 - TODO: + + + + +
+
+ ## 2.2.0 - **Changes:** @@ -22,7 +57,7 @@ - `en-UK` -> `en-GB` - `ja-JA` -> `ja-JP` - Enabled Subresource Integrity (SRI) hashes for external resources to increase security -- **Plugin Changes:** +- **Plugin Changes:** - Migration guide: - Since locale codes now have the format `xx-YY` and two were renamed, all plugins must implement those changes @@ -92,7 +127,7 @@ - Added Storybook for easier and faster development of components - Removed the `@updateURL` and `@downloadURL` directives because their use is controversial and the script has a built-in update check now - Migrated to pnpm for faster compilation times - - Moved `NanoEmitter` class over to the [UserUtils library](https://github.com/Sv443-Network/UserUtils#nanoemitter) (it is still re-exported by the plugin interface as before) + - Moved `NanoEmitter` class over to the [UserUtils library](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#nanoemitter) (it is still re-exported by the plugin interface as before) - Made `getThumbnailUrl()` and `getBestThumbnailUrl()` use the domain `youtube.com` to prevent cross-origin issues - Added custom error instances `LyricsError` and `PluginError` for better error handling using `instanceof` - Changed the feature identifier key for `showVotesFormat` to `numbersFormat` as it is now generic and available to plugins through the `formatNumber()` function @@ -140,7 +175,7 @@ - `bytm:siteEvent:pathChanged` - emitted whenever the URL path (`location.pathname`) changes - Now the event `bytm:siteEvent:fullscreenToggled` is only emitted once per fullscreen change - Renamed event `bytm:initPlugins` to `bytm:registerPlugin` to be more consistent - - Changed `event` property returned by `registerPlugin()` from nanoevents Emitter to NanoEmitter instance (see [the UserUtils docs](https://github.com/Sv443-Network/UserUtils#nanoemitter)) + - Changed `event` property returned by `registerPlugin()` from nanoevents Emitter to NanoEmitter instance (see [the UserUtils docs](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#nanoemitter)) In practice this changes nothing, but it benefits from plugins having access to the additional methods `once()` for immediately unsubscribing from an event after it was emitted once and `unsubscribeAll()` to remove all event listeners. @@ -178,7 +213,7 @@ - **Internal Changes:** - Improved script performance - - Implemented new [SelectorObserver](https://github.com/Sv443-Network/UserUtils#selectorobserver) instances to improve overall performance by quite a lot + - Implemented new [SelectorObserver](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#selectorobserver) instances to improve overall performance by quite a lot - Implemented rising-edge debounce for SelectorObserver instances to massively improve responsiveness - Added a cache to save lyrics in. Up to 1000 of the most listened to songs are saved throughout sessions for 30 days to save time and reduce server load. - Implemented new class BytmDialog for less duplicate code, better maintainability, the ability to make more menus easier and for them to have better accessibility @@ -186,7 +221,7 @@ - Expanded plugin interface - Added function to register plugins (see [contributing guide](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#registerplugin)) All plugins that are not registered will have restricted access to the BetterYTM API (subject to change in the future). - - Plugins are now given access to the classes [`BytmDialog`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#bytmdialog) and [`NanoEmitter`](https://github.com/Sv443-Network/UserUtils#nanoemitter), and the functions [`onInteraction()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#oninteraction), [`getThumbnailUrl()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#getthumbnailurl), [`getBestThumbnailUrl()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#getbestthumbnailurl) [`createHotkeyInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createhotkeyinput), [`createToggleInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createtoggleinput) and [`createCircularBtn()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createcircularbtn) + - Plugins are now given access to the classes [`BytmDialog`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#bytmdialog) and [`NanoEmitter`](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#nanoemitter), and the functions [`onInteraction()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#oninteraction), [`getThumbnailUrl()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#getthumbnailurl), [`getBestThumbnailUrl()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#getbestthumbnailurl) [`createHotkeyInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createhotkeyinput), [`createToggleInput()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createtoggleinput) and [`createCircularBtn()`](https://github.com/Sv443/BetterYTM/blob/main/contributing.md#createcircularbtn) - Added an experimental fuzzy filtering algorithm when fetching lyrics to eventually yield more accurate results (hidden behind advanced mode because it's far from perfect) - Resource URL versioning was improved, so all versions from now on will still work in the future when the URLs potentially change diff --git a/contributing.md b/contributing.md index b7fcdbecb5..842d04610e 100644 --- a/contributing.md +++ b/contributing.md @@ -1,12 +1,16 @@ -## BetterYTM - Contributing Guide +# BetterYTM - Contributing Guide Thank you for your interest in contributing to BetterYTM! This guide will help you get started with contributing to the project. -If you have any questions or need help, feel free to contact me, [see my homepage](https://sv443.net/) for contact info. + +It contains boatloads of information on internal workings, how to set up the project for local development, how to submit translations and how to develop a plugin that interfaces with BetterYTM. +Also, each folder of the project should contain a `README.md` file that further explains the contents of the folder and how to work with them in much more detail. + +If you have any questions or need help, feel free to contact me ([visit my homepage](https://sv443.net/) for contact info). Or you can also [join my Discord server](https://dc.sv443.net) to get in touch or get help.
-### Table of contents: +## Table of contents: - [Submitting translations](#submitting-translations) - [Adding translations for a new language](#adding-translations-for-a-new-language) - [Editing an existing translation](#editing-an-existing-translation) @@ -19,43 +23,47 @@ Or you can also [join my Discord server](https://dc.sv443.net) to get in touch o - [Developing a plugin that interfaces with BetterYTM](#developing-a-plugin-that-interfaces-with-betterytm) - [Shimming for TypeScript without errors & with autocomplete](#shimming-for-typescript-without-errors--with-autocomplete) - [Global functions and classes on the plugin interface](#global-functions-and-classes) + - [CSS selectors and variables for use in plugins](#css-selectors-and-variables-for-use-in-plugins)

-### Submitting translations: +## Submitting translations: Thank you so much for your interest in translating BetterYTM! Before submitting a translation, please check on [this document](https://github.com/Sv443/BetterYTM/tree/develop/assets/translations) if the language you want to translate to has already been translated and how many strings are still missing.
-#### Adding translations for a new language: +### Adding translations for a new language: > [!IMPORTANT] > > **Please make sure you always select the `develop` branch when translating, as the `main` branch is only used for releases.** To submit a translation, please follow these steps: -1. Copy the contents of the default translation file [`assets/translations/en-US.json`](./assets/translations/en-US.json) -2. Replace the `en-US` part of the file name with the language code and locale code of the language you want to translate to +1. Select the `develop` branch to translate for the latest version of BetterYTM. + If you are setting up the project for local development, also make sure you have checked out the `develop` branch. +2. Copy the contents of the default translation file [`assets/translations/en-US.json`](./assets/translations/en-US.json) +3. Replace the `en-US` part of the file name with the language code and locale code of the language you want to translate to You can find [a list of these BCP 47 codes here.](https://www.techonthenet.com/js/language_tags.php) The final locale code should always be in the format `language-COUNTRY` (e.g. `en-US`, `en-GB`, ...) -3. Translate the strings inside the file, while making sure not to change the keys on the left side of the colon and to preserve the placeholders with the format %n (where n is any number starting at 1). - If you don't want to finish it in one go, please remove the extra keys before submitting the file. They can always be added back by running the command `pnpm run tr-format -p -o=language-COUNTRY` (see [this section](#editing-an-existing-translation) for more info). -4. If you like, you may also create a translation for the [`README-summary.md`](./README-summary.md) file for display on the userscript distribution sites +4. Translate the strings inside the file, while making sure not to change the keys on the left side of the colon and to preserve the placeholders with the format %n (where n is any number starting at 1). + If you don't want to finish it in one go, please remove the extra keys before submitting the file. They can always be added back by running the command `pnpm tr-format -p -o=language-COUNTRY` (see [this section](#editing-an-existing-translation) for more info). +5. If you like, you may also create a translation for the [`README-summary.md`](./README-summary.md) file for display on the userscript distribution sites Please duplicate the file `README-summary.md` and call it `README-summary-xx-YY.md` and place it in the [`assets/translations/`](./assets/translations/) folder. -5. If you want to submit a pull request with the translated file: +6. If you want to submit a pull request with the translated file: 1. Duplicate the `en-US.json` file in the folder [`assets/translations/`](./assets/translations/) by keeping the format `languageCode_localeCode.json` 2. Edit it to your translated version and keep the left side of the colon unchanged 3. Create the mapping in `assets/locales.json` by copying the English one and editing it (please make sure it's alphabetically ordered) - 4. Add your name to the respective `authors` property in [`assets/locales.json`](./assets/locales.json) - 5. Test your changes by following [this section](#setting-up-the-project-for-local-development), then submit your pull request -6. Alternatively send it to me directly, [see my homepage](https://sv443.net/) for contact info + 4. Add the path to the JSON file to `assets/resources.json` by following the format of the others and also alphabetical order + 5. Add your name to the respective `authors` properties in [`assets/locales.json`](./assets/locales.json) and the translation file + 6. Test your changes by following [this section](#setting-up-the-project-for-local-development), then submit your pull request +7. Alternatively send it to me directly, [see my homepage](https://sv443.net/) for contact info Make sure you also add your language to the contents of [`assets/locales.json`](./assets/locales.json)
-#### Editing an existing translation: +### Editing an existing translation: > [!IMPORTANT] > > **Please make sure you always select the `develop` branch when translating, as the `main` branch is only used for releases.** @@ -63,106 +71,109 @@ To edit an existing translation, please follow these steps: 1. Set up the project for local development by following [this section](#setting-up-the-project-for-local-development) Make sure you have forked the repository and cloned your fork instead of cloning the original repository. 2. Find the file for the language you want to edit in the folder [`assets/translations/`](./assets/translations/) -3. Run the command `pnpm run tr-format -p -o=language-COUNTRY, where `language-COUNTRY` is the part of the file name before the `.json` extension +3. Run the command `pnpm tr-format -p -o=language-COUNTRY, where `language-COUNTRY` is the part of the file name before the `.json` extension This will prepare the file for translation by providing the missing keys once in English and once without any value and also formatting the file to have the same structure as the base file `en-US.json` 4. Edit the strings inside the file, while making sure not to change the keys on the left side of the colon and to preserve the placeholders with the format %n (where n is any number starting at 1). 5. Make sure there are no duplicate keys in the file -6. Run the command `pnpm run tr-format -o=language-COUNTRY` to make sure the file is formatted correctly -7. Test for syntax errors and update translation progress with the command `pnpm run tr-progress` +6. Run the command `pnpm tr-format -o=language-COUNTRY` to make sure the file is formatted correctly +7. Test for syntax errors and update translation progress with the command `pnpm tr-progress` 8. Open the file [`assets/translations/README.md`](./assets/translations/README.md) to see if you're still missing any untranslated keys (you don't have to translate them all, but it would of course be nice) 9. I highly encourage you to test your changes to see if the wording fits into the respective context by following [this section](#setting-up-the-project-for-local-development) -10. Submit your pull request by [clicking here](https://github.com/Sv443/BetterYTM/compare/) and setting the `compare:` dropdown to your fork +10. Submit your pull request by [clicking here](https://github.com/Sv443/BetterYTM/compare/) and setting the `compare:` dropdown to your fork and the `base:` dropdown to `develop` + Make sure to describe your changes in the pull request and reference the issue you are fixing, if applicable 11. Check that the CI checks just above the comment box pass and then wait for the pull request to be reviewed and merged


-### Setting up the project for local development: -#### Requirements: +## Setting up the project for local development: +### Requirements: 1. Have current versions of Node.js, npm and Git installed 2. Install pnpm by running `npm i -g pnpm` 3. Clone this repository - If you plan on contributing to the project, please [click here to fork it](https://github.com/Sv443/BetterYTM/fork) and clone your fork instead. - Make sure to clone or fork from the `develop` branch since the `main` branch is only used for releases! + If you plan on contributing to the project through Git, please [click here to fork it](https://github.com/Sv443/BetterYTM/fork) and clone your fork instead. 4. Switch to the `develop` branch by running `git checkout -b develop` in the project root. + **I will only accept pull requests that were created from and to the `develop` branch**, since there is usually a newer version in progress on that branch compared to `main` Skip this step if you are using your own forked repository. 5. Open a terminal in the project root and run `pnpm i` 6. Copy the file `.env.template` to `.env` and modify the variables inside to your needs. -7. Now you can run `pnpm run dev` to build the userscript and host it on a development server or check out the other commands below +7. Now you can run `pnpm dev` or `pnpm dev-cdn` to build the userscript and host it on a development server or check out the other commands below
-#### These are the CLI commands available after setting up the project: +### These are the CLI commands available after setting up the project: - **`pnpm i`** - Run once to install dependencies -- **`pnpm run dev`** + Run once to install dependencies. +- **`pnpm dev`** This is the command you want to use to locally develop and test BetterYTM. - It watches for any changes, then rebuilds and serves the userscript on port 8710, so it can be updated live if set up correctly in the userscript manager (see [extras](#extras)). - Once it has finished building, a link will be printed to the console. Open it to install the userscript. + It watches for any changes, then rebuilds and serves the userscript on port 8710, so it can be updated live if set up correctly in the userscript manager ([see extras](#extras)). You can also configure request logging and more in `.env` and `src/tools/serve.ts`, just make sure to restart the dev server after changing anything. -- **`pnpm run build-prod`** + + This command uses the local server as the assetSource, so that all changes are immediately reflected in the built userscript. Note that this also means the server needs to keep running for the userscript to work. If it's not running, you will run into weird errors because none of the necessary assets are able to be fetched. + + Once the build is finished, a link will be printed to the console. Open it to install the userscript. +- **`pnpm dev-cdn`** + Works exactly like `pnpm dev`, but uses the default CDN as the asset source. + Practically, this means the server doesn't have to be constantly running. + But this also means that changes to the assets won't be reflected in the userscript until committed, pushed and the script is rebuilt. +- **`pnpm build-prod`** Builds the userscript for production for all hosts with their respective options already set. Outputs the files using a suffix predefined in the `package.json` file. Use this to build the userscript for distribution on all host/CDN platforms. -- **`pnpm run build `** +- **`pnpm build `** Builds the userscript with custom options Arguments: - `--config-mode=` - The mode to build in. Can be either `production` or `development` (default) - `--config-branch=` - The GitHub branch to target. Can be any branch name, but should be `main` for production and `develop` for development (default) - `--config-host=` - The host to build for. Can be either `github` (default), `greasyfork` or `openuserjs` - - `--config-assetSource=` - Where to get the resource files from. Can be either `local` or `github` (default) + - `--config-assetSource=` - Where to get the resource files from. Can be either `local`, `jsdelivr` (default) or `github` - `--config-suffix=` - Suffix to add just before the `.user.js` extension. Defaults to an empty string Shorthand commands: - - `pnpm run build-prod-base` - Used for building for production, targets the main branch and the public asset source. - Sets `--config-mode=production` and `--config-branch=main` and `--config-assetSource=github` - - `pnpm run build-dev` - Builds a preview version, targeting the develop branch and the public asset source so no local dev environment is needed. - Sets `--config-mode=development`, `--config-branch=develop` and `--config-assetSource=github` - - `pnpm run preview` - Same as `pnpm run build-prod`, but sets `--config-host=github` and `--config-assetSource=local`, then starts the dev server for a few seconds so the extension that's waiting for file changes can update the script and assets -- **`pnpm run lint`** + - `pnpm build-prod-base` - Used for building for production, targets the main branch and the public asset source. + Sets `--config-mode=production` and `--config-branch=main` and `--config-assetSource=jsdelivr` + - `pnpm build-dev` - Builds a preview version, targeting the develop branch and the public asset source so no local dev environment is needed. + Sets `--config-mode=development`, `--config-branch=develop` and `--config-assetSource=jsdelivr` + - `pnpm preview` - Same as `pnpm build-prod`, but sets `--config-host=github` and `--config-assetSource=local`, then starts the dev server for a few seconds so the extension that's waiting for file changes can update the script and assets +- **`pnpm lint`** Builds the userscript with the TypeScript compiler and lints it with ESLint. Doesn't verify the functionality of the script, only checks for syntax and TypeScript errors! -- **`pnpm run storybook`** +- **`pnpm storybook`** Starts Storybook for developing and testing components. After launching, it will automatically open in your default browser. -- **`pnpm run gen-readme`** +- **`pnpm gen-readme`** Updates the README files by inserting different parts of generated sections into them. -- **`pnpm run tr-changed `** +- **`pnpm tr-changed `** Removes the provided keys (comma-separated) from all translation files but `en-US.json` - This is useful when the translation for one or more keys has changed and needs to be regenerated for all locales with `pnpm run tr-format -p` -- **`pnpm run tr-progress`** + This is useful when the translation for one or more keys has changed and needs to be regenerated for all locales with `pnpm tr-format -p` +- **`pnpm tr-progress`** Checks all translation files for missing strings and updates the progress table in `assets/translations/README.md` Will also be run automatically after every script build. -- **`pnpm run tr-format `** +- **`pnpm tr-format `** Reformats all translation files so they match that of the base file `en-US.json` This includes sorting keys and adding the same empty lines and indentation. Arguments: - `--prep` or `-p` - Prepares the files for translation via GitHub Copilot by providing the missing key once in English and once without any value - `--only=""` or `-o=""` - Only applies formatting to the files of the specified locales. Has to be a quoted, case-sensitive, comma separated list! (e.g. `-o="fr-FR,de-DE"` or `-o="pt-BR"`) - `--include-based` or `-b` - Also includes files which have a base locale specified -- **`pnpm run tr-prep`** - Shorthand for `pnpm run tr-format --prep` (see above) -- **`pnpm run --silent invisible ""`** +- **`pnpm tr-prep`** + Shorthand for `pnpm tr-format --prep` (see above). +- **`pnpm --silent invisible ""`** Runs the passed command as a child process without giving any console output. (`--` and double quotes are required!) Remove `--silent` to see pnpm's info and error messages. -- **`pnpm run node-ts `** - Runs the TypeScript file at the given path using the regular node binary and the [ts-node ESM loader.](https://www.npmjs.com/package/ts-node#node-flags-and-other-tools) +- **`pnpm node-ts `** + Runs the TypeScript file at the given path using the regular node binary and [tsx.](https://tsx.is/) Also enables source map support and disables experimental warnings. -- **`pnpm run dep-cruise`** - Runs dependency-cruiser to show problems with the dependency tree like circular, missing or orphaned dependencies. -- **`pnpm run dep-graph`** - Generates a dependency graph of the project, visually showing the dependencies between files and problems with the dependency tree. - Requires the [Graphviz](https://www.graphviz.org/download/) binary `dot` to be in the system PATH. > [!NOTE] > > When you are using npm (as opposed to `pnpm`), read the following carefully: -> You will need to use a lone ` -- ` between the command name and the arguments, for example: `pnpm run tr-format -- -p -o="de-DE"` +> You will need to use a lone ` -- ` between the command name and the arguments, for example: `pnpm tr-format -- -p -o="de-DE"` > This is so npm can tell the difference between arguments passed to it versus arguments passed to the script it is running.
-#### Extras: -- When using ViolentMonkey, after letting the command `pnpm run dev` run in the background, open [`http://localhost:8710/BetterYTM.user.js`](http://localhost:8710/BetterYTM.user.js) and select the `Track local file` option. +### Extras: +- When using ViolentMonkey, after letting the command `pnpm dev` or `pnpm dev-cdn` run in the background, open [`http://localhost:8710/BetterYTM.user.js`](http://localhost:8710/BetterYTM.user.js) and select the `Track local file` option. This makes it so the userscript automatically updates when the code changes. Note: the tab needs to stay open on Firefox or the script will not update itself. - To link any local JS file (like a work-in-progress library) in the userscript, add a `"link": "/path/to/script.umd.js"` property to the respective library in [`assets/require.json`](./assets/require.json) (relative or absolute path) @@ -171,7 +182,7 @@ To edit an existing translation, please follow these steps:
-#### Getting started working on the project: +### Getting started working on the project: After setting the project up for local development ([see above](#setting-up-the-project-for-local-development)), you can start working on the project. The main files you will be working with are: - [`src/index.ts`](./src/index.ts) - The main entry point for the userscript and where all features are initialized @@ -184,18 +195,24 @@ The main files you will be working with are:
-#### Procedure for specific tasks: +### Procedure for specific tasks: +Here are some well explained procedures for common tasks. +If you need help with these, don't hesitate to reach out to me (see the top of this file). + - Adding a new feature: 1. Add your feature to the `FeatureConfig` type in [`src/types.ts`](./src/types.ts) (after choosing a fitting category for it) 2. Add your feature and its properties to the `featInfo` object in [`src/features/index.ts`](./src/features/index.ts), under the correct category 3. Create an async initialization function for your feature in the respective category's file inside the `src/features` folder 4. Add the init function to the `onDomLoad` function in [`src/index.ts`](./src/index.ts), under the correct "domain guard condition" and category by following the format of the other features - Adding an asset (image, icon, stylesheet, translation file and misc. other files): - 1. Add the asset to the `assets` folder in the root of the project, under the correct subfolder - 2. Add the asset to the [`assets/resources.json`](./assets/resources.json) file by following the format of the other entries. - If the path begins with a slash, it will start at the project root (where package.json is), otherwise it will start at the `assets` folder. - 3. The asset will be immediately available in the userscript after the next build and the `@resource` directive will automatically point at the locally served asset or the GitHub CDN, depending on the build mode. - 4. **When committing, make sure to commit the assets first, then rebuild the userscript and make a second commit.** + 1. Check out [`assets/README.md`](./assets/README.md) for information on all asset formats + 2. Add the asset to the `assets` folder in the root of the project, under the correct subfolder + 3. Add the asset to the [`assets/resources.json`](./assets/resources.json) file by following the format of the other entries. + If the path begins with a slash, it will start at the project root (where package.json is), otherwise it will start at the `assets` folder. + The path string or all values in the object of each resource will be passed through the function `resolveResourceVal()` in [`src/tools/post-build.ts`](./src/tools/post-build.ts) to resolve placeholders like `$BRANCH`. View all replacements by looking up that function. + 4. The asset will be immediately available in the userscript after the next build and the `@resource` directive will automatically point at the locally served asset or the GitHub CDN, depending on the build mode and if the asset key matches a pattern in `alwaysExternalAssetPatterns` in the `assets/resources.json` file. + 5. If the asset is an SVG icon, it will be included in the file `assets/spritesheet.svg` so it can be referenced without needing to be fetched every time it's used. This spritesheet needs to be committed before the build, find out why in the next step. + 6. **When committing, make sure to commit the assets first, then rebuild the userscript and make a second commit.** This needs to be done because the build script at `src/tools/post-build.ts` will use the *previous* commit hash to create version-independent URLs for the assets. These will continue to work in the future, instead of pointing to an ever-changing branch where files could be moved, renamed or deleted at any time. - Adding a new site event: 1. Add your event to the `SiteEventsMap` type in [`src/siteEvents.ts`](./src/siteEvents.ts) @@ -211,7 +228,7 @@ The main files you will be working with are: 3. Add a re-export inside the file [`src/components/index.ts`](./src/components/index.ts) 4. If you want to expose the component to plugins, add it to the `globalFuncs` variable in [`src/interface.ts`](./src/interface.ts) under the category `Components` 5. Write some documentation for the component inside this file (`contributing.md`), under the [global functions and classes section](#global-functions-and-classes) by following the format of the other documented components. -- Adding a locale (language & regional dialect): +- Adding a locale (language & country code): 1. Add the locale code and info about the locale to the file [`assets/locales.json`](./assets/locales.json) by following the format of the other entries. Please make sure the alphabetical order is kept. You can find [a list of BCP 47 codes here.](https://www.techonthenet.com/js/language_tags.php) @@ -223,23 +240,26 @@ The main files you will be working with are: 2. Run `pnpm i` so the version is updated in the lockfile 3. Update the `changelog.md` with the new version and an exhaustive list of changes that were made 4. Make sure all files are committed before the built userscript is, so the next build will have the correct build number - 5. Run `pnpm run build-prod` to build the userscript for all hosts + 5. Run `pnpm build-prod` to build the userscript for all hosts 6. Commit and push the built files 7. Create a new release on GitHub with a tag that follows the format of the previous releases, a copy of the relevant section in the changelog and an install button that points to the built userscript on GitHub (make sure it uses the version tag in its URL to ensure the correct version is installed) 8. Update the userscript on GreasyFork and OpenUserJS from the built files GreasyFork also needs the relevant section of the changelog, but the internal and plugin changes should be trimmed out 9. Send an announcement in the Discord server linking to the install pages and the changelog, with a summary of the most important changes + 10. Update the [BYTM plugin template](https://github.com/Sv443/BetterYTM-Plugin-Template) by setting the BYTM submodule to the *exact* commit matching the release and making sure everything else is compatible with the changes of the latest BYTM version + 11. Create a release in the BYTM plugin template repository with the same tag as the BYTM release, following the format of the previous releases


-### Developing a plugin that interfaces with BetterYTM: +## Developing a plugin that interfaces with BetterYTM: BetterYTM has a built-in interface based on events and exposed global constants and functions that allows other "plugin" userscripts to benefit from its features. If you want your plugin to be displayed in the readme and possibly inside the userscript itself, please [submit an issue using the plugin submission template](https://github.com/Sv443/BetterYTM/issues/new/choose) -> [!TIP] +> [!TIP] +> > Want to quickly get started with a plugin? **Check out the [official plugin template.](https://github.com/Sv443/BetterYTM-Plugin-Template)** > It is based on Vite and TypeScript and has all the necessary setup for you to start developing a plugin right away. > It also contains some small examples to show you how to interact with the BYTM API. @@ -270,7 +290,7 @@ These are the ways to interact with BetterYTM; through constants, events and glo You can find all properties that are available and their types in the `declare global` block of [`src/types.ts`](src/types.ts) - Dynamic interaction is done through events that are dispatched on the `unsafeWindow` object. - They all have the prefix `bytm:eventName` and are all dispatched with the `CustomEvent` interface, meaning their data can be read using the `detail` property. + They all have the prefix `bytm:eventName` and are all dispatched with the [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent) interface, meaning their data can be read using the `detail` property. You can find all events that are available and their types in [`src/interface.ts`](src/interface.ts) Additionally BetterYTM has an internal system called SiteEvents. They are dispatched using the format `bytm:siteEvent:eventName` @@ -295,7 +315,7 @@ If you need specific events to be added or modified, please [submit an issue.](h
Static interaction Example (click to expand) -#### Example: +### Example: ```ts const BYTM = unsafeWindow.BYTM; @@ -310,7 +330,7 @@ console.log(`BetterYTM's version is '${BYTM.version} #${BYTM.buildNumber}'`);
Dynamic interaction examples (click to expand) -#### Basic format: +### Basic format: ```ts unsafeWindow.addEventListener("bytm:eventName", (event) => { // can have any type, but usually it's an object or undefined @@ -328,7 +348,7 @@ unsafeWindow.addEventListener("bytm:siteEvent:eventName", (event) => { }); ``` -#### Practical Example: +### Practical Example: ```ts // listening to generic events: unsafeWindow.addEventListener("bytm:ready", () => { @@ -362,33 +382,39 @@ unsafeWindow.addEventListener("bytm:siteEvent:queueChanged", (event) => {

-### Shimming for TypeScript without errors & with autocomplete: +## Shimming for TypeScript without errors & with autocomplete: In order for TypeScript to not throw errors while creating a plugin, you need to shim the types for BYTM. To do this, create a .d.ts file (for example `bytm.d.ts`) and add the following code: ```ts +import type { BytmObject, PluginDef, PluginRegisterResult } from "./bytm/src/types.js"; + declare global { interface Window { - BYTM: { - // add types here - }; + BYTM: BytmObject; + } + + // Enter BYTM's custom events you need in here so they are available on the `window` object and typed correctly. + // When adding new events, you can basically copy them from `type InterfaceEvents` in `src/interface.ts` after wrapping them in the `CustomEvent` type. + interface WindowEventMap { + "bytm:registerPlugin": CustomEvent<(def: PluginDef) => PluginRegisterResult>; } } ``` -You may specify all types that you need in this file. -To find which types BetterYTM exposes, check out the `declare global` block in [`src/types.ts`](src/types.ts) -You may also just copy it entirely, as long as all the imports also exist in your project. -An easy way to do this might be to include BetterYTM as a Git submodule, as long as you ***stick to only using type imports*** - +What is shown above assumes you have the BetterYTM source code in a folder called `bytm`. An easy way to do this is through [Git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules). +The [official plugin template](https://github.com/Sv443/BetterYTM-Plugin-Template) includes all of this setup already so you can either use it as a reference or create your plugin based on that template. + +You may instead also just copy all necessary types over from the BetterYTM source code. However, this is not recommended as it may lead to inconsistencies between the plugin and BetterYTM and can just be a painful amount of work. And this could also easily violate the [plugin sublicense](./license-for-plugins.txt), making your plugin ineligible for official recognition.

-### Global functions and classes: +## Global functions and classes: These are the global functions and classes that are exposed by BetterYTM through the `unsafeWindow.BYTM` object. The usage and example blocks on each are written in TypeScript but can be used in JavaScript as well, after removing all type annotations. -> [!IMPORTANT] +> [!IMPORTANT] +> > Authenticated functions are marked with 🔒 and need to be passed a per-session and per-plugin authentication token. It can be acquired by calling [registerPlugin()](#registerplugin)
@@ -400,6 +426,7 @@ The usage and example blocks on each are written in TypeScript but can be used i - [getDomain()](#getdomain) - Returns the current domain of the page as a constant string (either "yt" or "ytm") - [getResourceUrl()](#getresourceurl) - Returns a `blob:` URL provided by the local userscript extension for the specified BYTM resource file - [getSessionId()](#getsessionid) - Returns the unique session ID that is generated on every started session + - [reloadTab()](#reloadtab) - Reloads the current tab while preserving video time and volume and making features like initial tab volume lower priority - DOM: - [BytmDialog](#bytmdialog) - A class for creating and managing modal, fully customizable dialogs - [ExImDialog](#eximdialog) - Subclass of BytmDialog for allowing users to export and import serializable data @@ -411,6 +438,8 @@ The usage and example blocks on each are written in TypeScript but can be used i - [getThumbnailUrl()](#getthumbnailurl) - Returns the URL to the thumbnail of the currently playing video - [getBestThumbnailUrl()](#getbestthumbnailurl) - Returns the URL to the best quality thumbnail of the currently playing video - [waitVideoElementReady()](#waitvideoelementready) - Waits for the video element to be queryable in the DOM - has to be called after `bytm:observersReady` + - [getVideoElement()](#getvideoelement) - Returns the video element on the current page or null if there is none (on both YT and YTM) + - [getVideoSelector()](#getvideoselector) - Returns the CSS selector for the video element (on both YT and YTM) - [getCurrentMediaType()](#getcurrentmediatype) - (On YTM only) returns the type of media that is currently playing (either "video" or "song") - Components: - [createHotkeyInput()](#createhotkeyinput) - Creates a hotkey input element @@ -433,6 +462,7 @@ The usage and example blocks on each are written in TypeScript but can be used i - Feature config: - [getFeatures()](#getfeatures) 🔒 - Returns the current BYTM feature configuration object - [saveFeatures()](#savefeatures) 🔒 - Overwrites the current BYTM feature configuration object with the provided one + - [getDefaultFeatures()](#getdefaultfeatures) - Returns the default feature configuration object - Lyrics: - [fetchLyricsUrlTop()](#fetchlyricsurltop) - Fetches the URL to the lyrics page for the specified song - [getLyricsCacheEntry()](#getlyricscacheentry) - Tries to find a URL entry in the in-memory cache for the specified song @@ -448,7 +478,7 @@ The usage and example blocks on each are written in TypeScript but can be used i

-> #### registerPlugin() +> ### registerPlugin() > Usage: > ```ts > unsafeWindow.BYTM.registerPlugin(pluginDef: PluginDef): PluginRegisterResult @@ -501,7 +531,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > }, > // The intents (permissions) the plugin needs to be granted to be able to use certain functions. > // Search for "enum PluginIntent" in "src/types.ts" to see all available values, then sum all of them together to get the final intents number. -> // If you have BYTM as a dependency/submodule, you can import the enum and add the values like so: `PluginIntent.Foo | PluginIntent.Bar` +> // If you have BYTM as a dependency/submodule, you can import the enum and join the values like so: `PluginIntent.Foo | PluginIntent.Bar` > intents: 18, // required > contributors: [ // (optional) > { // (optional) @@ -563,7 +593,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getPluginInfo() +> ### getPluginInfo() > Usages: > ```ts > unsafeWindow.BYTM.getPluginInfo(token: string | undefined, name: string, namespace: string): PluginInfo | undefined @@ -600,7 +630,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getDomain() +> ### getDomain() > Usage: > ```ts > unsafeWindow.BYTM.getDomain(): "yt" | "ytm" @@ -630,7 +660,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getResourceUrl() +> ### getResourceUrl() > Usage: > ```ts > unsafeWindow.BYTM.getResourceUrl(): Promise @@ -659,7 +689,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getSessionId() +> ### getSessionId() > Usage: > ```ts > unsafeWindow.BYTM.getSessionId(): string | null @@ -687,10 +717,22 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### setInnerHtml() +> ### reloadTab() +> Usage: +> ```ts +> unsafeWindow.BYTM.reloadTab(): Promise +> ``` +> +> Description: +> Reloads the current tab while preserving video time and volume and making features like initial tab volume lower priority. +> The tab will be reloaded, whether the video element is queryable in the DOM or not, but without video time and volume restoration in the latter case. + +
+ +> ### setInnerHtml() > Usage: > ```ts -> unsafeWindow.BYTM.setInnerHtml(element: HTMLElement, html: string): void +> unsafeWindow.BYTM.setInnerHtml(element: HTMLElement, html?: Stringifiable | null): void > ``` > > Description: @@ -700,7 +742,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > > Arguments: > - `element` - The element to set the innerHTML property of -> - `html` - The HTML string to sanitize and set as the innerHTML property +> - `html` - The HTML string to sanitize and set as the innerHTML property - if set to `undefined` or `null`, the innerHTML will be cleared > >
Example (click to expand) > @@ -714,7 +756,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### addSelectorListener() +> ### addSelectorListener() > Usage: > ```ts > unsafeWindow.BYTM.addSelectorListener(observerName: ObserverName, selector: string, options: SelectorListenerOptions): void @@ -726,7 +768,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > > The instances are chained together in a way that the least specific observer is the parent of the more specific ones. > This is done to limit the amount of checks that need to be run, especially on pages with a lot of dynamic content and if `continuous` listeners are used. -> See the [UserUtils SelectorObserver documentation](https://github.com/Sv443-Network/UserUtils#selectorobserver) for more info and example code. +> See the [UserUtils SelectorObserver documentation](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#selectorobserver) for more info and example code. > > ⚠️ Due to this chained architecture, the selector you pass can only start with an element that is a child of the observer's base element. > If you provide a selector that starts higher up or directly on the base element, the listener will never be called. @@ -735,7 +777,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > Arguments: > - `observerName` - The name of the SelectorObserver instance to add the listener to. You can find all available instances and which base element they observe in the file [`src/observers.ts`](src/observers.ts) > - `selector` - The CSS selector to observe for changes. -> - `options` - The options for the listener. See the [UserUtils SelectorObserver documentation](https://github.com/Sv443-Network/UserUtils#selectorobserver) +> - `options` - The options for the listener. See the [UserUtils SelectorObserver documentation](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#selectorobserver) > >
Example (click to expand) > @@ -755,7 +797,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### onInteraction() +> ### onInteraction() > Usage: > ```ts > unsafeWindow.BYTM.onInteraction( @@ -797,7 +839,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getVideoTime() +> ### getVideoTime() > Usage: > ```ts > unsafeWindow.BYTM.getVideoTime(precision?: number): Promise @@ -811,13 +853,27 @@ The usage and example blocks on each are written in TypeScript but can be used i > In order for that edge case not to throw an error, the function would need to be called in response to a user interaction event (e.g. click) due to the strict automated interaction policy in browsers, otherwise an error can be thrown. > Resolves with a number of seconds or `null` if the time couldn't be determined. > +> ⚠️ If the video element isn't available yet (like when not on the `/watch` page), the Promise will only resolve once the user navigates to the video page. +> To solve this, you could use [`Promise.race()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/race) in conjunction with [UserUtils' `pauseFor()` function](https://github.com/Sv443-Network/UserUtils/blob/main/docs.md#pausefor) to implement a timeout (see example). +> You can also use the site event `bytm:siteEvent:pathChanged` to listen for navigation changes to know at which point this function should resolve quickly. +> >
Example (click to expand) > > ```ts +> const { UserUtils } = unsafeWindow.BYTM; +> > try { -> // get the video time with 3 decimal digits -> const videoTime = await unsafeWindow.BYTM.getVideoTime(3); -> console.log(`The video time is ${videoTime}s`); +> const videoTime: number | undefined | null = await Promise.race([ +> // get the video time with 3 decimal digits: +> unsafeWindow.BYTM.getVideoTime(3), +> // resolve with undefined if the video time couldn't be determined after 5 seconds: +> UserUtils.pauseFor(5000), +> ]); +> +> if(typeof videoTime === "number") +> console.log(`The video time is ${videoTime}s`); +> else +> console.error("Couldn't get the video time"); > } > catch(err) { > console.error("Couldn't get the video time, probably due to automated interaction restrictions"); @@ -827,7 +883,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getThumbnailUrl() +> ### getThumbnailUrl() > Usage: > ```ts > unsafeWindow.BYTM.getThumbnailUrl( @@ -838,11 +894,13 @@ The usage and example blocks on each are written in TypeScript but can be used i > > Description: > Returns the URL to the thumbnail of the video with the specified watch/video ID and quality (resolution). -> If an index number is passed, 0 will return a very low resolution thumbnail and 1-3 will return a very low resolution frame from the video (if available). +> If an index number is passed, 0 will return a very low resolution thumbnail and 1-3 will return a very low resolution preview frame from the video (if available). +> For some videos, different qualities and indexes are not available. That is why using [`getBestThumbnailUrl()`](#getbestthumbnailurl) is recommended in most cases. > > Arguments: > - `watchID` - The watch/video ID of the video to get the thumbnail for -> - `qualityOrIndex` - The quality or index of the thumbnail to get. Possible quality strings sorted by highest resolution first: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default`. If no quality is specified, `maxresdefault` (highest resolution) is used. +> - `qualityOrIndex` - The quality or index of the thumbnail to get. If no quality is specified, `maxresdefault` (highest resolution) is used. +> Quality values sorted by highest res first: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default`. > >
Example (click to expand) > @@ -854,7 +912,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getBestThumbnailUrl() +> ### getBestThumbnailUrl() > Usage: > ```ts > unsafeWindow.BYTM.getBestThumbnailUrl(watchID: string): Promise @@ -863,9 +921,12 @@ The usage and example blocks on each are written in TypeScript but can be used i > Description: > Returns the URL to the best resolution thumbnail of the video with the specified watch/video ID. > Will sequentially try to get the highest quality thumbnail available until one is found. -> Resolution priority list: `maxresdefault.jpg` > `sddefault.jpg` > `hqdefault.jpg` > `0.jpg` +> Resolution priority list: `maxresdefault` > `sddefault` > `hqdefault` > `0` > -> If no thumbnail is found, the Promise will resolve with `undefined` +> This check is done by sending a HEAD request to each thumbnail URL and checking if the response status is 200. +> Other qualities are not checked since that would be overkill (sending 4 requests is already a lot). +> +> If no thumbnail is found after `0` is checked, the Promise will resolve with `undefined` > May throw if an error occurs while fetching the thumbnails. > > Arguments: @@ -887,7 +948,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### waitVideoElementReady() +> ### waitVideoElementReady() > Usage: > ```ts > unsafeWindow.BYTM.waitVideoElementReady(): Promise @@ -899,6 +960,9 @@ The usage and example blocks on each are written in TypeScript but can be used i > If the video element already exists, the Promise will resolve immediately. > This function has to be called after the `bytm:observersReady` event has been dispatched. > +> If the user lingers on a page that doesn't have a video element, the Promise will only resolve after they navigate to the `/watch` page. +> To curb this, you can listen to the site event `bytm:siteEvent:pathChanged` to know when the user navigates to a page where the video element is available, or use a timeout (see [`getVideoTime()`](#getvideotime) for an example) +> >
Example (click to expand) > > ```ts @@ -911,7 +975,55 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getCurrentMediaType() +> ### getVideoElement() +> Usage: +> ```ts +> unsafeWindow.BYTM.getVideoElement(): HTMLVideoElement | null +> ``` +> +> Description: +> Returns the video element on the current page or `null` if there is none. +> Works on both YouTube and YouTube Music. +> Contrary to [`waitVideoElementReady()`](#waitvideoelementready), this function will not wait until the video element is queryable in the DOM. +> +>
Example (click to expand) +> +> ```ts +> // get the current video time and volume: +> const videoElem = unsafeWindow.BYTM.getVideoElement(); +> if(videoElem.readyState && videoElem.readyState >= 2) +> console.log("Video time:", videoElem.currentTime, "Video volume:", videoElem.volume); +> else +> console.error("The video element is not ready yet"); +> ``` +>
+ +
+ +> ### getVideoSelector() +> Usage: +> ```ts +> unsafeWindow.BYTM.getVideoSelector(): string +> ``` +> +> Description: +> Returns the CSS selector for the video element on the current page. +> Works on both YouTube and YouTube Music. +> +>
Example (click to expand) +> +> ```ts +> // add CSS to an element that exists within the video element on both YT and YTM: +> const videoSelector = unsafeWindow.BYTM.getVideoSelector(); +> const css = `${videoSelector} #my-element { border: 2px solid red; }`; +> const styleElem = unsafeWindow.BYTM.UserUtils.addGlobalStyle(css); +> styleElem.id = "my-element-style"; +> ``` +>
+ +
+ +> ### getCurrentMediaType() > Usage: > ```ts > unsafeWindow.BYTM.getCurrentMediaType(): "video" | "song" @@ -920,7 +1032,8 @@ The usage and example blocks on each are written in TypeScript but can be used i > Description: > Returns the type of media that is currently playing (works on YTM only). > It will return `"video"` for videos (manually uploaded to YT - plays an actual video) and `"song"` for songs (automatic YTM releases - only displays a static, square image). -> Throws an error if [`waitVideoElementReady()`](#waitvideoelementready) hasn't been awaited yet or the function is called on YT. +> Throws an error if [`waitVideoElementReady()`](#waitvideoelementready) hasn't been awaited yet. +> On YT, this function will always return `"video"`. > >
Example (click to expand) > @@ -935,7 +1048,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### setLocale() +> ### setLocale() > Usage: > ```ts > unsafeWindow.BYTM.setLocale(token: string | undefined, locale: string): void @@ -947,7 +1060,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > > Arguments: > - `token` - The private token that was returned when the plugin was registered (if not provided, the function will do nothing). -> - `locale` - The locale to set. Refer to the file [`assets/locales.json`](assets/locales.json) for a list of available locales. +> - `locale` - The locale to set. Refer to the keys of the object in [`assets/locales.json`](assets/locales.json) for a list of available locales. > >
Example (click to expand) > @@ -958,7 +1071,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getLocale() +> ### getLocale() > Usage: > ```ts > unsafeWindow.BYTM.getLocale(): string @@ -966,6 +1079,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > > Description: > Returns the currently set locale. +> Can be any key of the object in [`assets/locales.json`](assets/locales.json). > >
Example (click to expand) > @@ -980,7 +1094,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### hasKey() +> ### hasKey() > Usage: > ```ts > unsafeWindow.BYTM.hasKey(key: string): boolean @@ -1002,7 +1116,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### hasKeyFor() +> ### hasKeyFor() > Usage: > ```ts > unsafeWindow.BYTM.hasKeyFor(locale: string, key: string): boolean @@ -1012,7 +1126,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > Returns true if the specified translation key exists in the specified locale. > > Arguments: -> - `locale` - The locale to check for the translation key in. +> - `locale` - The locale to check for the translation key in - refer to the keys of the object in [`assets/locales.json`](assets/locales.json). > - `key` - The key of the translation to check for. > >
Example (click to expand) @@ -1025,7 +1139,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### t() +> ### t() > Usage: > ```ts > unsafeWindow.BYTM.t(key: TFuncKey, ...values: Stringifiable[]): string @@ -1050,7 +1164,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### tp() +> ### tp() > Usage: > ```ts > unsafeWindow.BYTM.tp(key: TFuncKey, num: number | unknown[] | NodeList, ...values: Stringifiable[]): string @@ -1090,7 +1204,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### tl() +> ### tl() > Usage: > ```ts > unsafeWindow.BYTM.tl(locale: string, key: TFuncKey, ...values: Stringifiable[]): string @@ -1099,18 +1213,17 @@ The usage and example blocks on each are written in TypeScript but can be used i > Description: > Returns the translation for the provided translation key and locale. > Useful to get the translation for a specific locale without changing the currently set locale. -> To see a list of possible translation values, check the file [`assets/translations/en-US.json`](assets/translations/en-US.json) > > Arguments: -> - `locale` - The locale to get the translation for. -> - `translationKey` - The key of the translation to get. +> - `locale` - The locale to get the translation for - refer to the keys of the object in [`assets/locales.json`](assets/locales.json). +> - `translationKey` - The key of the translation to get - find all translation keys in [`assets/translations/en-US.json`](assets/translations/en-US.json). > - `...values` - A spread parameter of values that can be converted to strings to replace the numbered placeholders in the translation with. > > For an example, see [`t()`](#t) which behaves in the same way, but uses the currently set locale instead of a specified one.
-> #### tlp() +> ### tlp() > Usage: > ```ts > unsafeWindow.BYTM.tlp(locale: string, key: TFuncKey, num: number | unknown[] | NodeList, ...values: Stringifiable[]): string @@ -1119,11 +1232,10 @@ The usage and example blocks on each are written in TypeScript but can be used i > Description: > Returns the translation for the provided translation key, including pluralization identifier and locale. > Useful to get the translation for a specific locale without changing the currently set locale. -> To see a list of possible translation values, check the file [`assets/translations/en-US.json`](assets/translations/en-US.json) > > Arguments: -> - `locale` - The locale to get the translation for. -> - `key` - The key of the translation to get. +> - `locale` - The locale to get the translation for - refer to the keys of the object in [`assets/locales.json`](assets/locales.json). +> - `key` - The key of the translation to get - find all translation keys in [`assets/translations/en-US.json`](assets/translations/en-US.json). > - `num` - The number of items to determine the pluralization identifier from. Can also be an array or NodeList. > - `...values` - A spread parameter of values that can be converted to strings to replace the numbered placeholders in the translation with. > @@ -1131,17 +1243,20 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### getFeatures() +> ### getFeatures() > Usage: > ```ts -> unsafeWindow.BYTM.getFeatures(token: string | undefined): FeatureConfig +> unsafeWindow.BYTM.getFeatures(token: string | undefined): FeatureConfig | undefined > ``` > > Description: > Returns the current feature configuration object synchronously from memory. > To see the structure of the object, check out the type `FeatureConfig` in the file [`src/types.ts`](src/types.ts) > If features are set to be hidden using `valueHidden: true`, their value will always be `undefined` in the returned object. -> In the future, an intent could grant access to the hidden values, but for now, they are only accessible to BetterYTM itself. +> In the future, a plugin intent (see [`registerPlugin()`](#registerplugin)) could grant access to the hidden values, but for now, they are only accessible to BetterYTM itself. +> +> Arguments: +> - `token` - The private token that was returned when the plugin was registered (if not provided, the function will return undefined). > >
Example (click to expand) > @@ -1153,7 +1268,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### saveFeatures() +> ### saveFeatures() > Usage: > ```ts > unsafeWindow.BYTM.saveFeatures(token: string | undefined, config: FeatureConfig): Promise @@ -1163,6 +1278,9 @@ The usage and example blocks on each are written in TypeScript but can be used i > Overwrites the current feature configuration object with the provided one. > The object in memory is updated synchronously, while the one in GM storage is updated asynchronously once the Promise resolves. > +> ⚠️ No validation is done on the provided object, so make sure it has all the required properties or you're gonna break stuff. +> A good way to ensure that is to spread your modifications over the result of a call to [`getFeatures()`](#getfeatures) or [`getDefaultFeatures()`](#getdefaultfeatures) +> > Arguments: > - `token` - The private token that was returned when the plugin was registered (if not provided, the function will do nothing). > - `config` - The full config object to save. If properties are missing, BYTM will break! @@ -1177,8 +1295,8 @@ The usage and example blocks on each are written in TypeScript but can be used i > > const promise = unsafeWindow.BYTM.saveFeatures(myToken, newConfig); > // new config is now saved in memory, but not yet in GM storage -> // so this already returns the updated config: -> console.log(unsafeWindow.BYTM.getFeatures()); +> // so this will already return the updated config: +> console.log(unsafeWindow.BYTM.getFeatures(myToken)); > > await promise; > // now the data is saved persistently in GM storage and the page can @@ -1191,7 +1309,20 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### fetchLyricsUrlTop() +> ### getDefaultFeatures() +> Usage: +> ```ts +> unsafeWindow.BYTM.getDefaultFeatures(): FeatureConfig +> ``` +> +> Description: +> Returns the default feature configuration object as a copy. +> This object is used as a fallback if the user's configuration is missing properties or when the user first installs BYTM. +> Since all hidden properties have not been populated with the user's sensitive data, this function will return the full object. + +
+ +> ### fetchLyricsUrlTop() > Usage: > ```ts > unsafeWindow.BYTM.fetchLyricsUrlTop(artist: string, song: string): Promise @@ -1212,22 +1343,24 @@ The usage and example blocks on each are written in TypeScript but can be used i >
Example (click to expand) > > ```ts -> async function getLyricsUrl() { -> const lyricsUrl = await unsafeWindow.BYTM.fetchLyricsUrlTop("Michael Jackson", "Thriller"); +> async function getLyricsUrl(artists: string, song: string) { +> const artistsSan = unsafeWindow.BYTM.sanitizeArtists(artists); +> const songSan = unsafeWindow.BYTM.sanitizeSong(song); +> const lyricsUrl = await unsafeWindow.BYTM.fetchLyricsUrlTop(artistsSan, songSan); > > if(lyricsUrl) -> console.log(`The lyrics URL for Michael Jackson's Thriller is '${lyricsUrl}'`); +> console.log(`The lyrics URL for '${artist} - ${song}' is:`, lyricsUrl); > else -> console.log("Couldn't find the lyrics URL for this song"); +> console.log("Couldn't find the lyrics URL"); > } > -> getLyricsUrl(); +> await getLyricsUrl("Michael Jackson", "Thriller"); > ``` >

-> #### getLyricsCacheEntry() +> ### getLyricsCacheEntry() > Usage: > ```ts > unsafeWindow.BYTM.getLyricsCacheEntry(artists: string, song: string): LyricsCacheEntry | undefined @@ -1247,22 +1380,24 @@ The usage and example blocks on each are written in TypeScript but can be used i >
Example (click to expand) > > ```ts -> function tryToGetLyricsUrl() { -> const lyricsEntry = unsafeWindow.BYTM.getLyricsCacheEntry("Michael Jackson", "Thriller"); +> function tryToGetLyricsUrl(artists: string, song: string) { +> const artistsSan = unsafeWindow.BYTM.sanitizeArtists(artists); +> const songSan = unsafeWindow.BYTM.sanitizeSong(song); +> const cacheEntry = await unsafeWindow.BYTM.getLyricsCacheEntry(artistsSan, songSan); > -> if(lyricsEntry) -> console.log(`The lyrics URL for Michael Jackson's Thriller is '${lyricsEntry.url}'`); +> if(cacheEntry) +> console.log(`The lyrics URL for '${artist} - ${song}' is:`, cacheEntry.url); > else -> console.log("Couldn't find the lyrics URL for this song in cache"); +> console.log("Couldn't find the lyrics URL in the cache"); > } > -> tryToGetLyricsUrl(); +> await tryToGetLyricsUrl("Michael Jackson", "Thriller"); > ``` >

-> #### sanitizeArtists() +> ### sanitizeArtists() > Usage: > ```ts > unsafeWindow.BYTM.sanitizeArtists(artists: string): string @@ -1270,7 +1405,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > > Description: > Sanitizes the specified artist string to be used in fetching a lyrics URL. -> This tries to strip out special characters and co-artist names, separated by a comma or ampersand. +> This tries to strip out special characters and co-artist names, separated by a comma, bullet or ampersand. > Returns (hopefully) a single artist name with leading and trailing whitespaces trimmed. > > Arguments: @@ -1280,14 +1415,14 @@ The usage and example blocks on each are written in TypeScript but can be used i > > ```ts > // usually artist strings will only have one of these characters but this is just an example -> const sanitizedArtists = unsafeWindow.BYTM.sanitizeArtists(" Michael Jackson • Paul McCartney & Freddy Mercury, Frank Sinatra"); +> const sanitizedArtists = unsafeWindow.BYTM.sanitizeArtists(" Michael Jackson • Paul McCartney & Freddy Mercury, Frank Sinatra "); > console.log(sanitizedArtists); // "Michael Jackson" > ``` >

-> #### sanitizeSong() +> ### sanitizeSong() > Usage: > ```ts > unsafeWindow.BYTM.sanitizeSong(songName: string): string @@ -1295,8 +1430,8 @@ The usage and example blocks on each are written in TypeScript but can be used i > > Description: > Sanitizes the specified song title string to be used in fetching a lyrics URL. -> This tries to strip out special characters and everything inside regular and square parentheses like `(Foo Remix)`. -> Returns (hopefully) a song title with leading and trailing whitespaces trimmed. +> This tries to strip out special characters and everything inside regular and square parentheses like `(Foo Remix)` or `[Bar Cover]`. +> Also trims all leading and trailing whitespaces. > > Arguments: > - `songName` - The string of the song title to sanitize. @@ -1304,14 +1439,14 @@ The usage and example blocks on each are written in TypeScript but can be used i >
Example (click to expand) > > ```ts -> const sanitizedSong = unsafeWindow.BYTM.sanitizeSong(" Thriller (Freddy Mercury Cover) [Tommy Cash Remix]"); +> const sanitizedSong = unsafeWindow.BYTM.sanitizeSong(" Thriller (Tommy Cash Remix) [Freddy Mercury Cover] "); > console.log(sanitizedSong); // "Thriller" > ``` >

-> #### getAutoLikeData() +> ### getAutoLikeData() > Usage: > ```ts > unsafeWindow.BYTM.getAutoLikeData(token: string | undefined): AutoLikeData @@ -1341,7 +1476,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### saveAutoLikeData() +> ### saveAutoLikeData() > Usage: > ```ts > unsafeWindow.BYTM.saveAutoLikeData(token: string | undefined, data: AutoLikeData): Promise @@ -1379,7 +1514,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### fetchVideoVotes() +> ### fetchVideoVotes() > Usage: > ```ts > unsafeWindow.BYTM.fetchVideoVotes(videoId: string): Promise @@ -1412,7 +1547,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### BytmDialog +> ### BytmDialog > Usage: > ```ts > new unsafeWindow.BYTM.BytmDialog(options: BytmDialogOptions): BytmDialog @@ -1542,7 +1677,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### ExImDialog +> ### ExImDialog > Usage: > ```ts > new unsafeWindow.BYTM.ExImDialog(options: ExImDialogOptions): ExImDialog @@ -1636,7 +1771,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### MarkdownDialog +> ### MarkdownDialog > Usage: > ```ts > new unsafeWindow.BYTM.MarkdownDialog(options: MarkdownDialogOptions): MarkdownDialog @@ -1689,7 +1824,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### createHotkeyInput() +> ### createHotkeyInput() > Usage: > ```ts > unsafeWindow.BYTM.createHotkeyInput(inputProps: { @@ -1718,7 +1853,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### createToggleInput() +> ### createToggleInput() > Usage: > ```ts > unsafeWindow.BYTM.createToggleInput(toggleProps: { @@ -1753,7 +1888,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### createCircularBtn() +> ### createCircularBtn() > Usage: > ```ts > unsafeWindow.BYTM.createCircularBtn(props: CircularBtnProps): Promise @@ -1787,7 +1922,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### createLongBtn() +> ### createLongBtn() > Usage: > ```ts > unsafeWindow.BYTM.createLongBtn(props: LongBtnProps): Promise @@ -1855,7 +1990,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### createRipple() +> ### createRipple() > Usages: > ```ts > unsafeWindow.BYTM.createRipple(rippleElement: TElement, props?: RippleProps): TElement @@ -1868,7 +2003,7 @@ The usage and example blocks on each are written in TypeScript but can be used i > The exact speed values and variable names and locations can be found in [`src/components/ripple.css`](./src/components/ripple.css) > > Properties: -> - `speed?: string` - The speed of the ripple effect. Can be "faster", "fast", "normal", "slow" or "slower" (defaults to "normal") +> - `speed?: string` - The speed of the ripple effect. Can be "fastest", "fast", "normal", "slow" or "slowest" (defaults to "normal") > >
Example (click to expand) > @@ -1881,7 +2016,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### showToast() +> ### showToast() > Usages: > ```ts > unsafeWindow.BYTM.showToast(props: ToastProps): Promise; @@ -1917,7 +2052,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### showIconToast() +> ### showIconToast() > Usage: > ```ts > unsafeWindow.BYTM.showIconToast(props: IconToastProps): Promise @@ -1959,7 +2094,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### showPrompt() +> ### showPrompt() > Usage: > ```ts > unsafeWindow.BYTM.showPrompt(props: ShowPromptProps): Promise @@ -2028,7 +2163,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### formatNumber() +> ### formatNumber() > Usage: > ```ts > unsafeWindow.BYTM.formatNumber(num: number, notation?: "short" | "long"): string @@ -2056,7 +2191,7 @@ The usage and example blocks on each are written in TypeScript but can be used i
-> #### NanoEmitter +> ### NanoEmitter > Usage: > ```ts > new unsafeWindow.BYTM.NanoEmitter(settings: NanoEmitterSettings): NanoEmitter @@ -2123,5 +2258,31 @@ The usage and example blocks on each are written in TypeScript but can be used i > ``` >
+

+ +## CSS selectors and variables for use in plugins: +Just like the JS functions and variables, BetterYTM also provides a set of CSS selectors and variables that can be used to assist when styling elements in a plugin. + +These are the available selectors: +| Selector | Description | +| :--- | :--- | +| `.bytm-dom-yt` | Applied to the root (HTML) element, only when on YouTube | +| `.bytm-dom-ytm` | Applied to the root (HTML) element, only when on YT Music | +| `.bytm-no-select` | Prevents text selection on the element and all its children | +| `.bytm-generic-btn` | Needs to be applied to a button's wrapper element to give it the generic BetterYTM button appearance | +| `.bytm-generic-btn-img` | Needs to be given to the button's svg icon or img element so it gets styled correctly | +| `#bytm-dialog-container` | This is the container that holds all dialog elements | + +
+ +And these are the available CSS variables: +| Variable | Type | Description | +| `--bytm-font-monospace` | `font` | Monospace fonts used for code blocks and similar elements | +| `--bytm-themed-icon-col` | `hex-color` | Default color for icons that respects the page theme (on YT) | +| `--bytm-themed-bg-col` | `hex-color` | Default background color that prefers the theme set by the ThemeSong extension (on YTM only) | +| `--bytm-global-inner-height` | `px` | Inner height of the viewport | +| `--bytm-global-outer-height` | `px` | Outer height of the viewport | +| `--bytm-global-inner-width` | `px` | Inner width of the viewport | +| `--bytm-global-outer-width` | `px` | Outer width of the viewport |





diff --git a/dist/BetterYTM.css b/dist/BetterYTM.css index a955cb0e1b..be5d89d302 100644 --- a/dist/BetterYTM.css +++ b/dist/BetterYTM.css @@ -1,182 +1,3 @@ -/* #region general */ -:root { - --bytm-locale-color: #5ea2ec; - --bytm-advanced-mode-color: #ceb663; - --bytm-experimental-col: #cc83e5; - --bytm-warning-col: #e5a63b; - --bytm-error-col: #fa5b5b; - --bytm-reload-col: #77e064; -} - -/* TODO: after cfg menu migration, leave only dialog styles here */ -#bytm-cfg-dialog-bg, -#bytm-cfg-menu-bg { - --bytm-dialog-width-max: 1150px; - --bytm-menu-width-max: 1150px; - --bytm-dialog-height-max: 800px; - --bytm-menu-height-max: 800px; -} - -/* Have to do it like this since various extensions use a pretty damn high z-index and since I haven't implemented top layer for dialogs yet (https://developer.mozilla.org/en-US/docs/Glossary/Top_layer) */ -.bytm-dom-yt .bytm-dialog-bg, -.bytm-dom-yt .bytm-menu-bg { - z-index: 10042; -} - -.bytm-dom-yt .bytm-dialog, -.bytm-dom-yt .bytm-menu { - z-index: 10069; -} - -.bytm-dialog-body p { - overflow-wrap: break-word; -} - -.bytm-dialog-body details summary { - cursor: pointer; - font-style: italic; -} - -.bytm-secondary-label { - padding-left: 12px; - font-size: 1.3rem; -} - -/* #region version notification */ - -#bytm-version-notif-dialog-btns { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - margin-top: 20px; -} - -#bytm-disable-update-check-wrapper { - display: flex; - align-items: center; - font-size: 1.5rem; - margin-top: 35px; -} - -#bytm-disable-update-check-wrapper label { - padding-left: 12px; -} - -#bytm-version-notif-changelog-cont { - max-height: calc(max(var(--calc-max-height) - 280px, 0px)); - overflow-y: auto; - margin: 10px 0px; -} - -#bytm-version-notif-changelog-details { - margin-top: 15px; -} - -.bytm-disable-update-check-toggle-label-wrapper { - display: flex; - flex-direction: column; - justify-content: flex-start; -} - -/* #region cfg menu */ - -.bytm-ftconf-adv-copy-btn { - margin: 0px 10px; -} - -.bytm-adorn-icon { - display: inline-flex; - align-items: center; -} - -.bytm-adorn-icon[title] { - cursor: help; -} - -/* #region cfg menu adornments */ - -.bytm-locale-icon svg path { - fill: var(--bytm-locale-color, #fff); -} - -.bytm-advanced-mode-icon svg path { - fill: var(--bytm-advanced-mode-color, #fff); -} - -.bytm-experimental-icon svg path { - fill: var(--bytm-experimental-col, #fff); -} - -.bytm-warning-icon svg { - width: 24px; - height: 24px; -} - -.bytm-warning-icon svg path { - fill: var(--bytm-warning-col, #fff); -} - -.bytm-reload-icon svg path { - fill: var(--bytm-reload-col, #fff); -} - -/* #region welcome dialog */ - -#bytm-welcome-menu-title-wrapper { - display: flex; - flex-direction: row; - align-items: center; -} - -#bytm-welcome-menu-title-logo { - width: 32px; - height: 32px; - margin-right: 20px; -} - -#bytm-welcome-menu-content-wrapper { - overflow-y: auto; -} - -#bytm-welcome-menu-locale-cont { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; -} - -#bytm-welcome-menu-locale-img { - width: 80px; - height: 80px; - margin-bottom: 10px; -} - -#bytm-welcome-menu-text-cont { - margin-top: 16px; -} - -#bytm-welcome-menu-text { - font-size: 1.6rem; - line-height: 20px; -} - -#bytm-welcome-menu-locale-select { - font-size: 1.6rem; -} - -#bytm-welcome-menu-footer-cont { - display: flex; - justify-content: space-between; - border-radius: 0px 0px var(--bytm-menu-border-radius) - var(--bytm-menu-border-radius); -} - -#bytm-auto-like-channels-dialog-body { - padding-left: 0; - padding-right: 0; -} - .bytm-dialog-bg { --bytm-dialog-bg: #333333; --bytm-dialog-bg-highlight: #252525; @@ -215,12 +36,12 @@ .bytm-dialog.align-top { top: 0; - transform: translate(-50%, 40px); + transform: translate(-50%, 20px); } .bytm-dialog.align-bottom { top: 100%; - transform: translate(-50%, -100%); + transform: translate(-50%, calc(-100% - 20px)); } .bytm-dialog-body { @@ -648,108 +469,125 @@ hr { margin-right: 6px; } -/* some variables are not defined on purpose to allow plugin authors to more easily provide overrides */ +.bytm-toggle-input-wrapper { + --toggle-height: 20px; + --toggle-width: 40px; + --toggle-knob-offset: 4px; + --toggle-color-on: #3683d4; + --toggle-color-off: #666; + --toggle-knob-color: #f5f5f5; -/* use `body` prefix for more specificity: */ -body .bytm-ripple { - position: relative; - overflow: hidden; - width: var(--bytm-ripple-width, auto); - height: var(--bytm-ripple-height, auto); + display: flex; + align-items: center; } -body .bytm-ripple.faster { - --bytm-ripple-anim-duration: 0.15s; +.bytm-toggle-input-wrapper .bytm-toggle-input-label { + cursor: pointer; + font-size: 1.5rem; + padding: 3px 12px; } -body .bytm-ripple.fast { - --bytm-ripple-anim-duration: 0.35s; +/* sauce: https://danklammer.com/articles/simple-css-toggle-switch/ */ + +.bytm-toggle-input { + display: flex; + align-items: center; } -body .bytm-ripple.slow { - --bytm-ripple-anim-duration: 0.75s; +.bytm-toggle-input input { + appearance: none; + display: inline-block; + width: var(--toggle-width); + height: var(--toggle-height); + position: relative; + border-radius: 50px; + overflow: hidden; + outline: none; + border: none; + margin: 0; + padding: var(--toggle-knob-offset); + cursor: pointer; + background-color: var(--toggle-color-off); + transition: + justify-content 0.2s ease, + background-color 0.2s ease; } -body .bytm-ripple.slower { - --bytm-ripple-anim-duration: 1s; +.bytm-toggle-input input[data-toggled="true"] { + background-color: var(--toggle-color-on); } -.bytm-ripple-area { - --bytm-ripple-min-size: 100px; - position: absolute; - background: var(--bytm-ripple-color, rgba(255, 255, 255, 0.175)); - transform: translate(-50%, -50%); - pointer-events: none; +.bytm-toggle-input input .bytm-toggle-input-knob { + --toggle-knob-calc-width: calc( + var(--toggle-height) - (var(--toggle-knob-offset) * 2) + ); + --toggle-knob-calc-height: calc( + var(--toggle-height) - (var(--toggle-knob-offset) * 2) + ); + width: var(--toggle-knob-calc-width); + height: var(--toggle-knob-calc-height); + background-color: var(--toggle-knob-color); border-radius: 50%; - animation: bytm-scale-ripple var(--bytm-ripple-anim-duration, 0.55s) - cubic-bezier(0.375, 0.33, 0.225, 0.93); + position: absolute; + top: 50%; + transform: translateY(-50%); + left: var(--toggle-knob-offset); + transition: left 0.2s ease; } -@keyframes bytm-scale-ripple { - 0% { - width: 0; - height: 0; - opacity: 1; - } - 100% { - /* Variable is added to .bytm-ripple by JS at runtime since there's no better way of getting the parent's width inside of here */ - width: calc( - max( - var(--bytm-ripple-cont-width, var(--bytm-ripple-min-size)) * 2, - var(--bytm-ripple-min-size) - ) - ); - height: calc( - max( - var(--bytm-ripple-cont-width, var(--bytm-ripple-min-size)) * 2, - var(--bytm-ripple-min-size) - ) - ); - opacity: 0; - } +.bytm-toggle-input input[data-toggled="true"] .bytm-toggle-input-knob { + left: calc( + var(--toggle-width) - var(--toggle-knob-offset) - + var(--toggle-knob-calc-width) + ); } -.bytm-exim-dialog-panes-cont { +#bytm-prompt-dialog-header { display: flex; flex-direction: row; - justify-content: space-between; - flex-wrap: nowrap; - height: 100%; - overflow: hidden; + align-items: center; } -.bytm-exim-dialog-pane { - display: flex; - flex-direction: column; - gap: 12px; - width: 48%; +#bytm-prompt-dialog-header svg { + width: 24px; + height: 24px; } -.bytm-exim-dialog-center-btn-cont { - display: flex; - justify-content: center; +#bytm-prompt-dialog-upper-cont, +#bytm-prompt-dialog-message { + font-size: 1.6rem; + white-space: pre-wrap; } -.bytm-exim-dialog-pane textarea { - height: 120px; - resize: none; +.bytm-prompt-type-prompt #bytm-prompt-dialog-message { + margin-bottom: 15px; } -.bytm-hotkey-wrapper { +#bytm-prompt-dialog-input { + font-size: 1.6rem; + width: 100%; +} + +#bytm-prompt-dialog-button-wrapper { display: flex; flex-direction: row; - align-items: center; justify-content: flex-end; + align-items: end; } -.bytm-hotkey-reset { - font-size: 0.9em; - margin-right: 10px; +#bytm-prompt-dialog-buttons-cont { + display: flex; + flex-direction: row; + gap: 15px; } -.bytm-hotkey-info { - font-size: 0.9em; - white-space: nowrap; +#bytm-prompt-dialog-buttons-cont button { + cursor: pointer; +} + +.bytm-prompt-dialog-button { + padding: 2px 6px; + font-size: 1.45rem; } :root { @@ -843,130 +681,236 @@ body .bytm-ripple.slower { font-weight: 400; } -.bytm-toggle-input-wrapper { - --toggle-height: 20px; - --toggle-width: 40px; - --toggle-knob-offset: 4px; - --toggle-color-on: #3683d4; - --toggle-color-off: #666; - --toggle-knob-color: #f5f5f5; +/* some variables are not defined on purpose to allow plugin authors to more easily provide overrides */ + +/* use `body` prefix for more specificity: */ +body .bytm-ripple { + position: relative; + overflow: hidden; + width: var(--bytm-ripple-width, auto); + height: var(--bytm-ripple-height, auto); +} + +body .bytm-ripple.fastest { + --bytm-ripple-anim-duration: 0.15s; +} + +body .bytm-ripple.fast { + --bytm-ripple-anim-duration: 0.35s; +} + +body .bytm-ripple.slow { + --bytm-ripple-anim-duration: 0.75s; +} + +body .bytm-ripple.slowest { + --bytm-ripple-anim-duration: 1s; +} + +.bytm-ripple-area { + --bytm-ripple-min-size: 100px; + /* --bytm-ripple-cont-width is added at runtime since there's no better way of getting the parent's width */ + --bytm-ripple-expanded-size: calc( + max( + var(--bytm-ripple-cont-width, var(--bytm-ripple-min-size)) * 2, + var(--bytm-ripple-min-size) + ) + ); + position: absolute; + background: var(--bytm-ripple-color, rgba(255, 255, 255, 0.175)); + transform: translate(-50%, -50%); + pointer-events: none; + border-radius: 50%; + animation: bytm-scale-ripple var(--bytm-ripple-anim-duration, 0.55s) + cubic-bezier(0.375, 0.33, 0.225, 0.93); +} + +@keyframes bytm-scale-ripple { + 0% { + width: 0; + height: 0; + opacity: 1; + } + 100% { + width: var(--bytm-ripple-expanded-size, 100%); + height: var(--bytm-ripple-expanded-size, 100%); + opacity: 0; + } +} + +.bytm-exim-dialog-panes-cont { + display: flex; + flex-direction: row; + justify-content: space-between; + flex-wrap: nowrap; + height: 100%; + overflow: hidden; +} + +.bytm-exim-dialog-pane { + display: flex; + flex-direction: column; + gap: 12px; + width: 48%; +} + +.bytm-exim-dialog-center-btn-cont { + display: flex; + justify-content: center; +} +.bytm-exim-dialog-pane textarea { + height: 120px; + resize: none; +} + +.bytm-auto-like-channels-footer-wrapper { + display: flex; + justify-content: space-between; +} + +:root { + --bytm-auto-like-btn-color: #bf87f0; +} + +#bytm-auto-like-channels-list { + display: flex; + flex-direction: column; + overflow-x: hidden; + overflow-y: auto; + white-space: nowrap; + text-overflow: ellipsis; + /* idk how else to get the scroll bar to show up correctly */ + max-height: calc(min(100vh - 40px, var(--bytm-dialog-height-max)) - 300px); +} + +.bytm-auto-like-channels-desc { + padding: 0px 15px; + white-space: pre-wrap; + font-size: 1.6rem; + margin-bottom: 10px; +} + +.bytm-auto-like-channels-search-cont { display: flex; + flex-direction: row; align-items: center; + gap: 5px; + padding: 0px 15px; + margin-bottom: 10px; } -.bytm-toggle-input-wrapper .bytm-toggle-input-label { - cursor: pointer; - font-size: 1.5rem; - padding: 3px 12px; +.bytm-auto-like-channels-searchbar { + min-width: calc(min(250px, 50%)); } -/* sauce: https://danklammer.com/articles/simple-css-toggle-switch/ */ +.bytm-auto-like-channel-row-left-cont { + display: flex; + align-items: center; + gap: 10px; +} -.bytm-toggle-input { +.bytm-auto-like-channel-row { display: flex; + justify-content: space-between; align-items: center; + padding: 8px 15px; + transition: background-color 0.15s ease-out; } -.bytm-toggle-input input { - appearance: none; - display: inline-block; - width: var(--toggle-width); - height: var(--toggle-height); - position: relative; - border-radius: 50px; - overflow: hidden; - outline: none; - border: none; - margin: 0; - padding: var(--toggle-knob-offset); - cursor: pointer; - background-color: var(--toggle-color-off); - transition: - justify-content 0.2s ease, - background-color 0.2s ease; +.bytm-auto-like-channel-row.hidden { + display: none; } -.bytm-toggle-input input[data-toggled="true"] { - background-color: var(--toggle-color-on); +.bytm-auto-like-channel-row:hover { + background-color: var(--bytm-menu-bg-highlight); } -.bytm-toggle-input input .bytm-toggle-input-knob { - --toggle-knob-calc-width: calc( - var(--toggle-height) - (var(--toggle-knob-offset) * 2) - ); - --toggle-knob-calc-height: calc( - var(--toggle-height) - (var(--toggle-knob-offset) * 2) - ); - width: var(--toggle-knob-calc-width); - height: var(--toggle-knob-calc-height); - background-color: var(--toggle-knob-color); - border-radius: 50%; - position: absolute; - top: 50%; - transform: translateY(-50%); - left: var(--toggle-knob-offset); - transition: left 0.2s ease; +.bytm-auto-like-channel-row + .bytm-toggle-input-wrapper.bytm-auto-like-channel-toggle { + --toggle-height: 18px !important; + --toggle-width: 36px !important; +} + +.bytm-auto-like-channel-row .bytm-generic-btn { + --bytm-generic-btn-width: 30px; + --bytm-generic-btn-height: 30px; +} + +.bytm-auto-like-channel-name-label { + display: flex; + flex-direction: row; + align-items: center; } -.bytm-toggle-input input[data-toggled="true"] .bytm-toggle-input-knob { - left: calc( - var(--toggle-width) - var(--toggle-knob-offset) - - var(--toggle-knob-calc-width) - ); +.bytm-auto-like-channel-id { + cursor: text; + font-family: var(--bytm-font-monospace); + font-size: 1.25rem; + color: #aaa; + margin-left: 10px; } -.bytm-auto-like-channels-footer-wrapper { - display: flex; - justify-content: space-between; +/* add more specificity to override the default styles */ + +.bytm-generic-btn.long.bytm-auto-like-toggle-btn { + margin-right: 8px; + border: 1px solid var(--bytm-auto-like-btn-color); + box-sizing: border-box; + background-color: transparent; + transition: border-color 0.2s ease; + animation: none; } -#bytm-prompt-dialog-header { - display: flex; - flex-direction: row; - align-items: center; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn.left-margin { + margin: 0 0 0 8px; } -#bytm-prompt-dialog-header svg { - width: 24px; - height: 24px; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn.right-margin { + margin: 0 8px 0 0; } -#bytm-prompt-dialog-upper-cont, -#bytm-prompt-dialog-message { - font-size: 1.6rem; - white-space: pre-wrap; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn:hover { + background-color: rgba(255, 255, 255, 0.1); } -.bytm-prompt-type-prompt #bytm-prompt-dialog-message { - margin-bottom: 15px; +body.bytm-dom-ytm + ytmusic-subscribe-button-renderer + #button-shape-subscribe:hover + > button { + background-color: rgba(255, 255, 255, 0.1) !important; } -#bytm-prompt-dialog-input { - font-size: 1.6rem; - width: 100%; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active { + background-color: rgba(255, 255, 255, 0.2); } -#bytm-prompt-dialog-button-wrapper { - display: flex; - flex-direction: row; - justify-content: flex-end; - align-items: end; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active, +.bytm-generic-btn.long.bytm-auto-like-toggle-btn.toggled { + border-color: rgba(255, 255, 255, 0.2); } -#bytm-prompt-dialog-buttons-cont { - display: flex; - flex-direction: row; - gap: 15px; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn svg path { + fill: var(--bytm-auto-like-btn-color); + transition: fill 0.2s ease; } -#bytm-prompt-dialog-buttons-cont button { - cursor: pointer; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active svg path, +.bytm-generic-btn.long.bytm-auto-like-toggle-btn.toggled svg path { + fill: var(--ytmusic-subscribe-button-color, rgba(255, 255, 255, 0.7)); } -.bytm-prompt-dialog-button { - padding: 2px 6px; - font-size: 1.45rem; +.bytm-generic-btn.long.bytm-auto-like-toggle-btn .bytm-generic-long-btn-txt { + font-weight: 500; + color: var(--bytm-auto-like-btn-color); + transition: color 0.2s ease; +} + +.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active + .bytm-generic-long-btn-txt, +.bytm-generic-btn.long.bytm-auto-like-toggle-btn.toggled + .bytm-generic-long-btn-txt { + color: var(--ytmusic-subscribe-button-color, rgba(255, 255, 255, 0.7)); } #bytm-plugin-list-dialog-bg { @@ -1097,6 +1041,23 @@ body .bytm-ripple.slower { cursor: help; } +.bytm-hotkey-wrapper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; +} + +.bytm-hotkey-reset { + font-size: 0.9em; + margin-right: 10px; +} + +.bytm-hotkey-info { + font-size: 0.9em; + white-space: nowrap; +} + :root { --bytm-menu-bg-highlight: #252525; } @@ -1360,7 +1321,7 @@ body .bytm-ripple.slower { font-size: 20px; margin-top: 32px; margin-bottom: 8px; - padding: 0px 15px; + padding: 0px 20px; } .bytm-ftconf-category-header:first-of-type { @@ -1595,150 +1556,6 @@ hr { border: revert; } -:root { - --bytm-auto-like-btn-color: #bf87f0; -} - -#bytm-auto-like-channels-list { - display: flex; - flex-direction: column; - overflow-x: hidden; - overflow-y: auto; - white-space: nowrap; - text-overflow: ellipsis; - /* idk how else to get the scroll bar to show up correctly */ - max-height: calc(min(100vh - 40px, var(--bytm-dialog-height-max)) - 300px); -} - -.bytm-auto-like-channels-desc { - padding: 0px 15px; - white-space: pre-wrap; - font-size: 1.6rem; - margin-bottom: 10px; -} - -.bytm-auto-like-channels-search-cont { - display: flex; - flex-direction: row; - align-items: center; - gap: 5px; - padding: 0px 15px; - margin-bottom: 10px; -} - -.bytm-auto-like-channels-searchbar { - min-width: calc(min(250px, 50%)); -} - -.bytm-auto-like-channel-row-left-cont { - display: flex; - align-items: center; - gap: 10px; -} - -.bytm-auto-like-channel-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 15px; - transition: background-color 0.15s ease-out; -} - -.bytm-auto-like-channel-row.hidden { - display: none; -} - -.bytm-auto-like-channel-row:hover { - background-color: var(--bytm-menu-bg-highlight); -} - -.bytm-auto-like-channel-row - .bytm-toggle-input-wrapper.bytm-auto-like-channel-toggle { - --toggle-height: 18px !important; - --toggle-width: 36px !important; -} - -.bytm-auto-like-channel-row .bytm-generic-btn { - --bytm-generic-btn-width: 30px; - --bytm-generic-btn-height: 30px; -} - -.bytm-auto-like-channel-name-label { - display: flex; - flex-direction: row; - align-items: center; -} - -.bytm-auto-like-channel-id { - cursor: text; - font-family: var(--bytm-font-monospace); - font-size: 1.25rem; - color: #aaa; - margin-left: 10px; -} - -/* add more specificity to override the default styles */ - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn { - margin-right: 8px; - border: 1px solid var(--bytm-auto-like-btn-color); - box-sizing: border-box; - background-color: transparent; - transition: border-color 0.2s ease; - animation: none; -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn.left-margin { - margin: 0 0 0 8px; -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn.right-margin { - margin: 0 8px 0 0; -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn:hover { - background-color: rgba(255, 255, 255, 0.1); -} - -body.bytm-dom-ytm - ytmusic-subscribe-button-renderer - #button-shape-subscribe:hover - > button { - background-color: rgba(255, 255, 255, 0.1) !important; -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active { - background-color: rgba(255, 255, 255, 0.2); -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active, -.bytm-generic-btn.long.bytm-auto-like-toggle-btn.toggled { - border-color: rgba(255, 255, 255, 0.2); -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn svg path { - fill: var(--bytm-auto-like-btn-color); - transition: fill 0.2s ease; -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active svg path, -.bytm-generic-btn.long.bytm-auto-like-toggle-btn.toggled svg path { - fill: var(--ytmusic-subscribe-button-color, rgba(255, 255, 255, 0.7)); -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn .bytm-generic-long-btn-txt { - font-weight: 500; - color: var(--bytm-auto-like-btn-color); - transition: color 0.2s ease; -} - -.bytm-generic-btn.long.bytm-auto-like-toggle-btn:active - .bytm-generic-long-btn-txt, -.bytm-generic-btn.long.bytm-auto-like-toggle-btn.toggled - .bytm-generic-long-btn-txt { - color: var(--ytmusic-subscribe-button-color, rgba(255, 255, 255, 0.7)); -} - /* #region misc */ :root { @@ -1746,6 +1563,14 @@ body.bytm-dom-ytm --bytm-font-size-monospace: 1.4rem; } +html:not([dark]) { + --bytm-themed-icon-col: #000; +} + +html[dark] { + --bytm-themed-icon-col: #fff; +} + .bytm-disable-scroll { overflow: hidden !important; } @@ -1860,6 +1685,11 @@ body.bytm-dom-ytm height: 24px; } +.bytm-dom-yt .bytm-generic-btn-img path { + /* YT has both dark and light theme, YTM only dark */ + fill: var(--bytm-themed-icon-col, #fff); +} + .bytm-spinner { animation: rotate 1.2s linear infinite; } @@ -2206,7 +2036,7 @@ ytmusic-app-layout[player-ui-state="FULLSCREEN"] .bytm-vote-label { } :root { - --bytm-themed-bg: var(--ts-playerpage-color, #030303); + --bytm-themed-bg-col: var(--ts-playerpage-color, #030303); } #ts-visualizer-container #ThemeSong-Visualizer canvas { @@ -2225,7 +2055,7 @@ ytmusic-app-layout[player-ui-state="FULLSCREEN"] .bytm-vote-label { background: linear-gradient( 90deg, rgba(0, 0, 0, 0) 0%, - var(--bytm-themed-bg) 15% + var(--bytm-themed-bg-col) 15% ); display: none; position: absolute; @@ -2304,7 +2134,7 @@ ytmusic-responsive-list-item-renderer.bytm-has-queue-btns:focus-within .bytm-vol-slider-label { --bytm-slider-label-bg-col: var( --ytmusic-player-bar-background, - var(--bytm-themed-bg) + var(--bytm-themed-bg-col) ); position: absolute; top: 50%; diff --git a/dist/BetterYTM.user.js b/dist/BetterYTM.user.js index eb13185dd6..3a8de83ddd 100644 --- a/dist/BetterYTM.user.js +++ b/dist/BetterYTM.user.js @@ -3,15 +3,29 @@ // @namespace https://github.com/Sv443/BetterYTM // @version 2.2.0 // @description Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™ +// @homepageURL https://github.com/Sv443/BetterYTM#readme +// @supportURL https://github.com/Sv443/BetterYTM/issues +// @license AGPL-3.0-only +// @author Sv443 +// @copyright Sv443 (https://github.com/Sv443) +// @icon https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/images/logo/logo_dev_48.png +// @match https://music.youtube.com/* +// @match https://www.youtube.com/* +// @run-at document-start // @description:de-DE Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™ // @description:de Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™ // @description:de-AT Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™ // @description:de-CH Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™ +// @description:de-LI Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™ +// @description:de-LU Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™ // @description:en-US Configurable layout and user experience improvements for YouTube Music™ and YouTube™ // @description:en Configurable layout and user experience improvements for YouTube Music™ and YouTube™ // @description:en-CA Configurable layout and user experience improvements for YouTube Music™ and YouTube™ -// @description:en-AU Configurable layout and user experience improvements for YouTube Music™ and YouTube™ // @description:en-GB Configurable layout and user experience improvements for YouTube Music™ and YouTube™ +// @description:en-AU Configurable layout and user experience improvements for YouTube Music™ and YouTube™ +// @description:en-IE Configurable layout and user experience improvements for YouTube Music™ and YouTube™ +// @description:en-NZ Configurable layout and user experience improvements for YouTube Music™ and YouTube™ +// @description:en-ZA Configurable layout and user experience improvements for YouTube Music™ and YouTube™ // @description:es-ES Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™ // @description:es Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™ // @description:es-MX Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™ @@ -26,7 +40,6 @@ // @description:hi-NP YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार // @description:ja-JP YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上 // @description:ja YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上 -// @description:ja-JP YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上 // @description:pt-BR Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™ // @description:pt Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™ // @description:pt-PT Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™ @@ -34,20 +47,13 @@ // @description:zh YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进 // @description:zh-TW YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进 // @description:zh-HK YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进 -// @homepageURL https://github.com/Sv443/BetterYTM#readme -// @supportURL https://github.com/Sv443/BetterYTM/issues -// @license AGPL-3.0-only -// @author Sv443 -// @copyright Sv443 (https://github.com/Sv443) -// @icon https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/logo/logo_48.png -// @match https://music.youtube.com/* -// @match https://www.youtube.com/* -// @run-at document-start +// @description:zh-SG YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进 // @connect api.sv443.net // @connect github.com // @connect raw.githubusercontent.com // @connect youtube.com // @connect returnyoutubedislikeapi.com +// @noframes // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue @@ -56,66 +62,24 @@ // @grant GM.xmlHttpRequest // @grant GM.openInTab // @grant unsafeWindow -// @noframes -// @resource css-above_queue_btns https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/aboveQueueBtns.css#sha256=AbBUD23ut57NcUG8d560d6ZPMiQviB8itV4N2AqUsQ4= -// @resource css-anchor_improvements https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/anchorImprovements.css#sha256=9WSAxeL1Tiv7ZCKrocNrvrQNWSbVY8/bv6wzf0lJ9pg= -// @resource css-auto_like https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/autoLike.css#sha256=A4O2rPsBXjugn0EnF5e1L68Kn3KR7Qm9nlwqe8XWMTM= -// @resource css-bundle https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/dist/BetterYTM.css#sha256=8V9r57Xa1Su/vp31nYoWhTeZ5SGmPZxvY/daJFltNvw= -// @resource css-fix_hdr https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/fixHDR.css#sha256=4GeuAroKiRD1asFe6cQ1UiSg+p82Jyl/8JeWXLNTgL8= -// @resource css-fix_playerpage_theming https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/fixPlayerPageTheming.css#sha256=7xS+bvp7TJFdzyKztER8xYtsLhinTU1dAdmzuO057p0= -// @resource css-fix_spacing https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/fixSpacing.css#sha256=T57yRp87wz/ye3i4MTRh/o7cFaQsUom4yjG/Kp4eevM= -// @resource css-fix_sponsorblock https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/fixSponsorBlock.css#sha256=KY3RepJ8BaLPTM2n1+irvZUJCLlC0i2EiLzKRgz6z38= -// @resource css-hide_themesong_logo https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/hideThemeSongLogo.css#sha256=Nvr0CaLm23d5dNlJ2bOaxLw2cHfH8KBnpPCbmbXgnOE= -// @resource css-show_votes https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/showVotes.css#sha256=Zxm4eBmg6GtFMCnzWRZXW08nr4pwk6aUCMb8x8xIsJU= -// @resource css-vol_slider_size https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/style/volSliderSize.css#sha256=WKE/i2XjuE2HYfOkZ9N4QtEgKsWdrhMXZyb2cc+iAAk= -// @resource doc-changelog https://raw.githubusercontent.com/Sv443/BetterYTM/main/changelog.md#sha256=EYkZbxmzX8e2+GteAZnfygqvSrRjeBtW5JYY5/ahp0E= -// @resource font-cousine_ttf https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/fonts/Cousine/Cousine-Regular.ttf#sha256=3NUmAE/P7E7COiHKRY6mER4eDURxwK3icEknEBtdzts= -// @resource font-cousine_woff https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/fonts/Cousine/Cousine-Regular.woff#sha256=VEi7Zy63C3H0/+UD2MOkk35FMufP94uORI0flA24cJM= -// @resource font-cousine_woff2 https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/fonts/Cousine/Cousine-Regular.woff2#sha256=Rw3vpRf3bTAujjOoevj5BUUvlDFTkZb7zH3URXvGmW0= -// @resource icon-advanced_mode https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/plus_circle_small.svg#sha256=EUkGEgNKO99AHbhk88W1RkuFgE00+GXIC3QWLnWXJCc= -// @resource icon-alert https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/alert.svg#sha256=V+B+Z2U0yau2fB+XExY94Ic5Wg/p+mZgXqDjZ3lhg78= -// @resource icon-arrow_down https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/arrow_down.svg#sha256=jtTMiYlQtVkZu6FHGH1MI1lezXoWg0uViAi1mEoKKQQ= -// @resource icon-auto_like https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/auto_like.svg#sha256=z/a/NhDg4oPj4/KXP2uPzs+V38UT3UxM8YhZe71Sh5I= -// @resource icon-auto_like_enabled https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/auto_like_enabled.svg#sha256=PxQhv4EAdXb2BkcXYgQ5TJKC49pjzY0kyDY/rj9+lYY= -// @resource icon-clear_list https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/clear_list.svg#sha256=1EU5h0xw5NkFAbUU59kfrr1Ke9Ui6LJ9zHQhEBMgl+0= -// @resource icon-copy https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/copy.svg#sha256=66sj77i0AwuB7kcuWV95MKz8gJ5eWrQ/VTqpMvWjt1w= -// @resource icon-delete https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/delete.svg#sha256=v6S0FrsvJ45xIF+TvzeSp/l8E8Eyh0KatBEfxuazvhk= -// @resource icon-edit https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/edit.svg#sha256=Hck4kGmeoloSWIWkbjWHnKEvdhJtIFY5O9KRTgl3mVo= -// @resource icon-error https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/error.svg#sha256=9+7VNYGb7S76hDl6n01vk01CRW9XULWIDbXFpW+p/Rg= -// @resource icon-experimental https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/beaker_small.svg#sha256=8mMm6igfnnHFqBylMN8TV2Ww6YeOfWG0Oq49iR9A1Ik= -// @resource icon-globe https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/globe.svg#sha256=G+NKmM6y7Ank5z/EOV4gj0tQszLoOpeWucNterW8Yek= -// @resource icon-globe_small https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/globe_small.svg#sha256=8KB7wookZWWuE8cPSH2WpwgNGxddjVeKTlyKrhFmaLM= -// @resource icon-help https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/help.svg#sha256=a8lO+sznaGFnbQfjbOyCydVtuzJO9NBRHUbDCIUe6CY= -// @resource icon-image https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/image.svg#sha256=9lB+dguBi9QBiqkNj52wv7eRS50oNaa6Y200mEZIL8Y= -// @resource icon-image_filled https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/image_filled.svg#sha256=mCFBRDA3BVvwc2xN6lGH5eVSE+VjWfj/BPYE6LizAXs= -// @resource icon-link https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/link.svg#sha256=8YThvRBuJ4/yNkgATIkxQH1BdYjCK5k2/XRi36u7p/c= -// @resource icon-lyrics https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/lyrics.svg#sha256=+L4fFYsWiaM8gxfP1G1anRHYgfggPmt9mWeThTltCF4= -// @resource icon-prompt https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/help.svg#sha256=a8lO+sznaGFnbQfjbOyCydVtuzJO9NBRHUbDCIUe6CY= -// @resource icon-reload https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/refresh.svg#sha256=NArBWzah/U42KJnYhe8oXTYRJxn2Je1ILMVQBQAxeSw= -// @resource icon-restore_time https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/restore_time.svg#sha256=4enl1dD8022iAN5flRyXLJ6FcGD+w9ClMCW/kf8AY7E= -// @resource icon-skip_to https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/skip_to.svg#sha256=UtsFutOl7n+QwiO1ZQGyGBj5lsyGrULgKvoAmFjDGa8= -// @resource icon-spinner https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/spinner.svg#sha256=pj8Ms7+/GPWA/hpl7pkbtzdGvL2fgIvk241iWUO/AQg= -// @resource icon-upload https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/icons/upload.svg#sha256=ufaBZkMv8RaLvoNdY2nG6egcIezB9fkgiNN/Xv0ZvJU= -// @resource img-close https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/close.png#sha256=XIZfYT+0R0kQuDR3AKaKu3F/+timMz6JMvA1wdhUpkA= -// @resource img-discord https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/external/discord.png#sha256=w/7aw0lgtdS9s5wnW4S70H2ww8x5C6H3Bc2EulWnMi0= -// @resource img-github https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/external/github.png#sha256=MPwTJpkyHrsQ0PtuKL2lyoHXMmESsT8S9i7g9K3szwE= -// @resource img-greasyfork https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/external/greasyfork.png#sha256=C8CI6iPrIpyUbXPbcpdTN2R+XiQ9aL0LI6thE+7vEcY= -// @resource img-logo https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/logo/logo_48.png#sha256=+DoIcM002UevjTGvAxHMULo2VGHcYjy8TWla3xeNdNs= -// @resource img-logo_dev https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/logo/logo_dev_48.png#sha256=bX5hzhFwROh3PLZu68ZPfL+pJF1HBnxkT7AoB7VnkEE= -// @resource img-openuserjs https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/images/external/openuserjs.png#sha256=LYfN7+1yfmagwPk+EfhKE6mZCOPJ8GB2cu5tKI/J+Vo= -// @resource trans-de-DE https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/de-DE.json#sha256=RYz0Y8nqFHx7USa2NTNRFMAN7ncr6aHZjgqsckeYtEI= -// @resource trans-en-GB https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/en-GB.json#sha256=zYvmENtA7hDHA98ZiuImMWEwgTvwWuU5936xb+wN8KE= -// @resource trans-en-US https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/en-US.json#sha256=DD1nvuPEKTwpBwWL2alTepdAdwwC2Ax4AQAwOLlPC1I= -// @resource trans-es-ES https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/es-ES.json#sha256=66Fmuwqp6Uwo5MoEZxLhSzXP8BdkveBctNfHU4SbbBY= -// @resource trans-fr-FR https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/fr-FR.json#sha256=4O2PhBxGrWqz/mJlBaab1Lzcs/P1opPMvWIt8fajJPU= -// @resource trans-hi-IN https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/hi-IN.json#sha256=IJIvrwoQPJ4YLm8aWZNVf1C4rocM9CbtPKHsGcbSUtc= -// @resource trans-ja-JP https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/ja-JP.json#sha256=IvJDZL5K1cXB5uRabCv9I373rGByKP0pwOvmR47PM/s= -// @resource trans-pt-BR https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/pt-BR.json#sha256=A42lpxuHH0FG6JqZldqjOzELjSgzzZ3ugOcHKo/3bHU= -// @resource trans-zh-CN https://raw.githubusercontent.com/Sv443/BetterYTM/e2a684dd/assets/translations/zh-CN.json#sha256=kJPPd29sljo7qxmi9NEA3A/yhdKHtxYHU9K0r9to3g4= -// @require https://cdn.jsdelivr.net/npm/@sv443-network/userutils@8.3.1/dist/index.global.js +// @resource css-above_queue_btns https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/aboveQueueBtns.css#sha256=S+Pkz9xm785iQ5mmX+Z0RZgaCgPvCHqVIKOTX9r/Nt8= +// @resource css-above_queue_btns_sticky https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/aboveQueueBtnsSticky.css#sha256=BJX5ju+5L+4asy16iF3XOuiJUlPg5KNXkcGennJWGB0= +// @resource css-anchor_improvements https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/anchorImprovements.css#sha256=9WSAxeL1Tiv7ZCKrocNrvrQNWSbVY8/bv6wzf0lJ9pg= +// @resource css-auto_like https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/autoLike.css#sha256=A4O2rPsBXjugn0EnF5e1L68Kn3KR7Qm9nlwqe8XWMTM= +// @resource css-bundle https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/dist/BetterYTM.css#sha256=kFSriGJ1TQTRp4PtgDS889JJpYJcVDC5eNrH9DAYcZM= +// @resource css-fix_hdr https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/fixHDR.css#sha256=4GeuAroKiRD1asFe6cQ1UiSg+p82Jyl/8JeWXLNTgL8= +// @resource css-fix_playerpage_theming https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/fixPlayerPageTheming.css#sha256=7xS+bvp7TJFdzyKztER8xYtsLhinTU1dAdmzuO057p0= +// @resource css-fix_spacing https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/fixSpacing.css#sha256=T57yRp87wz/ye3i4MTRh/o7cFaQsUom4yjG/Kp4eevM= +// @resource css-fix_sponsorblock https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/fixSponsorBlock.css#sha256=KY3RepJ8BaLPTM2n1+irvZUJCLlC0i2EiLzKRgz6z38= +// @resource css-hide_themesong_logo https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/hideThemeSongLogo.css#sha256=Nvr0CaLm23d5dNlJ2bOaxLw2cHfH8KBnpPCbmbXgnOE= +// @resource css-show_votes https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/showVotes.css#sha256=Zxm4eBmg6GtFMCnzWRZXW08nr4pwk6aUCMb8x8xIsJU= +// @resource css-vol_slider_size https://cdn.jsdelivr.net/gh/Sv443/BetterYTM@4892ba35/assets/style/volSliderSize.css#sha256=WKE/i2XjuE2HYfOkZ9N4QtEgKsWdrhMXZyb2cc+iAAk= +// @require https://cdn.jsdelivr.net/npm/@sv443-network/userutils@9.0.4/dist/index.global.js // @require https://cdn.jsdelivr.net/npm/marked@12.0.2/lib/marked.umd.js // @require https://cdn.jsdelivr.net/npm/compare-versions@6.1.0/lib/umd/index.js // @require https://cdn.jsdelivr.net/npm/dompurify@3.1.6 +// @grant GM.registerMenuCommand +// @grant GM.listValues // ==/UserScript== /* ▄▄▄ ▄ ▄▄▄▄▄▄ ▄ @@ -131,7616 +95,7775 @@ I welcome every contribution on GitHub! /* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */ /* C&D this 🖕 */ -(function (UserUtils, DOMPurify, compareVersions, marked) { - 'use strict'; - - function _interopNamespaceDefault(e) { - var n = Object.create(null); - if (e) { - Object.keys(e).forEach(function (k) { - if (k !== 'default') { - var d = Object.getOwnPropertyDescriptor(e, k); - Object.defineProperty(n, k, d.get ? d : { - enumerable: true, - get: function () { return e[k]; } - }); - } - }); +(function(UserUtils,DOMPurify,marked,compareVersions){'use strict';function _interopNamespaceDefault(e){var n=Object.create(null);if(e){Object.keys(e).forEach(function(k){if(k!=='default'){var d=Object.getOwnPropertyDescriptor(e,k);Object.defineProperty(n,k,d.get?d:{enumerable:true,get:function(){return e[k]}});}})}n.default=e;return Object.freeze(n)}var UserUtils__namespace=/*#__PURE__*/_interopNamespaceDefault(UserUtils);var compareVersions__namespace=/*#__PURE__*/_interopNamespaceDefault(compareVersions);var alwaysExternalAssetPatterns = [ + "^icon-", + "^img-", + "^font-", + "^doc-", + "^trans-" +]; +var resources = { + "css-above_queue_btns": "style/aboveQueueBtns.css", + "css-above_queue_btns_sticky": "style/aboveQueueBtnsSticky.css", + "css-anchor_improvements": "style/anchorImprovements.css", + "css-auto_like": "style/autoLike.css", + "css-fix_hdr": "style/fixHDR.css", + "css-fix_playerpage_theming": "style/fixPlayerPageTheming.css", + "css-fix_spacing": "style/fixSpacing.css", + "css-fix_sponsorblock": "style/fixSponsorBlock.css", + "css-hide_themesong_logo": "style/hideThemeSongLogo.css", + "css-show_votes": "style/showVotes.css", + "css-vol_slider_size": "style/volSliderSize.css", + "doc-license": { + path: "/LICENSE.txt", + ref: "$BRANCH", + integrity: false + }, + "doc-svg_spritesheet": "spritesheet.svg", + "font-cousine_ttf": "fonts/Cousine/Cousine-Regular.ttf", + "font-cousine_woff": "fonts/Cousine/Cousine-Regular.woff", + "font-cousine_woff2": "fonts/Cousine/Cousine-Regular.woff2", + "icon-advanced_mode": "icons/plus_circle_small.svg", + "icon-alert": "icons/alert.svg", + "icon-arrow_down": "icons/arrow_down.svg", + "icon-auto_like_enabled": "icons/auto_like_enabled.svg", + "icon-auto_like": "icons/auto_like.svg", + "icon-clear_list": "icons/clear_list.svg", + "icon-copy": "icons/copy.svg", + "icon-delete": "icons/delete.svg", + "icon-edit": "icons/edit.svg", + "icon-error": "icons/error.svg", + "icon-experimental": "icons/beaker_small.svg", + "icon-globe_small": "icons/globe_small.svg", + "icon-globe": "icons/globe.svg", + "icon-help": "icons/help.svg", + "icon-image_filled": "icons/image_filled.svg", + "icon-image": "icons/image.svg", + "icon-link": "icons/link.svg", + "icon-lyrics": "icons/lyrics.svg", + "icon-prompt": "icons/help.svg", + "icon-reload": "icons/refresh.svg", + "icon-restore_time": "icons/restore_time.svg", + "icon-skip_to": "icons/skip_to.svg", + "icon-speed": "icons/speed.svg", + "icon-spinner": "icons/spinner.svg", + "icon-upload": "icons/upload.svg", + "img-close": "images/close.png", + "img-discord": "images/external/discord.png", + "img-github": "images/external/github.png", + "img-greasyfork": "images/external/greasyfork.png", + "img-logo_dev": "images/logo/logo_dev_48.png", + "img-logo": "images/logo/logo_48.png", + "img-openuserjs": "images/external/openuserjs.png", + "trans-de-DE": "translations/de-DE.json", + "trans-en-US": "translations/en-US.json", + "trans-en-GB": "translations/en-GB.json", + "trans-es-ES": "translations/es-ES.json", + "trans-fr-FR": "translations/fr-FR.json", + "trans-hi-IN": "translations/hi-IN.json", + "trans-ja-JP": "translations/ja-JP.json", + "trans-pt-BR": "translations/pt-BR.json", + "trans-zh-CN": "translations/zh-CN.json" +}; +var resourcesJson = { + alwaysExternalAssetPatterns: alwaysExternalAssetPatterns, + resources: resources +};var locales = { + "de-DE": { + name: "Deutsch (Deutschland)", + nameEnglish: "German (Germany)", + emoji: "🇩🇪", + userscriptDesc: "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™", + authors: [ + "Sv443" + ], + altLocales: [ + "de", + "de-AT", + "de-CH", + "de-LI", + "de-LU" + ] +}, + "en-US": { + name: "English (United States)", + nameEnglish: "English (United States)", + emoji: "🇺🇸", + userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™", + authors: [ + "Sv443" + ], + altLocales: [ + "en", + "en-CA" + ] +}, + "en-GB": { + name: "English (Great Britain)", + nameEnglish: "English (Great Britain)", + emoji: "🇬🇧", + userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™", + authors: [ + "Sv443" + ], + altLocales: [ + "en-AU", + "en-IE", + "en-NZ", + "en-ZA" + ] +}, + "es-ES": { + name: "Español (España)", + nameEnglish: "Spanish (Spain)", + emoji: "🇪🇸", + userscriptDesc: "Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™", + authors: [ + "Sv443" + ], + altLocales: [ + "es", + "es-MX" + ] +}, + "fr-FR": { + name: "Français (France)", + nameEnglish: "French (France)", + emoji: "🇫🇷", + userscriptDesc: "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™", + authors: [ + "Sv443" + ], + altLocales: [ + "fr", + "fr-CA", + "fr-BE", + "fr-CH", + "fr-LU" + ] +}, + "hi-IN": { + name: "हिंदी (भारत)", + nameEnglish: "Hindi (India)", + emoji: "🇮🇳", + userscriptDesc: "YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार", + authors: [ + "Sv443" + ], + altLocales: [ + "hi", + "hi-NP" + ] +}, + "ja-JP": { + name: "日本語 (日本)", + nameEnglish: "Japanese (Japan)", + emoji: "🇯🇵", + userscriptDesc: "YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上", + authors: [ + "Sv443" + ], + altLocales: [ + "ja" + ] +}, + "pt-BR": { + name: "Português (Brasil)", + nameEnglish: "Portuguese (Brazil)", + emoji: "🇧🇷", + userscriptDesc: "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™", + authors: [ + "Sv443" + ], + altLocales: [ + "pt", + "pt-PT" + ] +}, + "zh-CN": { + name: "中文(简化,中国)", + nameEnglish: "Chinese (Simplified, China)", + emoji: "🇨🇳", + userscriptDesc: "YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进", + authors: [ + "Sv443" + ], + altLocales: [ + "zh", + "zh-TW", + "zh-HK", + "zh-SG" + ] +} +};// I know TS enums are impure but it doesn't really matter here, plus imo they are cooler than pure enums anyway +var LogLevel; +(function (LogLevel) { + LogLevel[LogLevel["Debug"] = 0] = "Debug"; + LogLevel[LogLevel["Info"] = 1] = "Info"; +})(LogLevel || (LogLevel = {})); +//#region plugins +/** + * Intents (permissions) BYTM has to grant your plugin for it to be able to access certain features. + * TODO: this feature is unfinished, but you should still specify the intents your plugin needs. + * Never request more permissions than you need, as this is a bad practice and can lead to your plugin being rejected. + */ +var PluginIntent; +(function (PluginIntent) { + /** Plugin can read the feature configuration */ + PluginIntent[PluginIntent["ReadFeatureConfig"] = 1] = "ReadFeatureConfig"; + /** Plugin can write to the feature configuration */ + PluginIntent[PluginIntent["WriteFeatureConfig"] = 2] = "WriteFeatureConfig"; + /** Plugin has access to hidden config values */ + PluginIntent[PluginIntent["SeeHiddenConfigValues"] = 4] = "SeeHiddenConfigValues"; + /** Plugin can write to the lyrics cache */ + PluginIntent[PluginIntent["WriteLyricsCache"] = 8] = "WriteLyricsCache"; + /** Plugin can add new translations and overwrite existing ones */ + PluginIntent[PluginIntent["WriteTranslations"] = 16] = "WriteTranslations"; + /** Plugin can create modal dialogs */ + PluginIntent[PluginIntent["CreateModalDialogs"] = 32] = "CreateModalDialogs"; + /** Plugin can read auto-like data */ + PluginIntent[PluginIntent["ReadAutoLikeData"] = 64] = "ReadAutoLikeData"; + /** Plugin can write to auto-like data */ + PluginIntent[PluginIntent["WriteAutoLikeData"] = 128] = "WriteAutoLikeData"; +})(PluginIntent || (PluginIntent = {}));// these strings will have their values replaced by the post-build script: +const modeRaw = "development"; +const branchRaw = "develop"; +const hostRaw = "github"; +const buildNumberRaw = "4892ba35"; +const assetSourceRaw = "jsdelivr"; +const devServerPortRaw = "8710"; +const getRawVal = (rawVal, defaultVal) => (rawVal.match(/^#{{.+}}$/) ? defaultVal : rawVal); +/** Path to the GitHub repo */ +const repo = "Sv443/BetterYTM"; +/** The mode in which the script was built (production or development) */ +const mode = getRawVal(modeRaw, "production"); +/** The branch to use in various URLs that point to the GitHub repo */ +const branch = getRawVal(branchRaw, "main"); +/** Which host the userscript was installed from */ +const host = getRawVal(hostRaw, "github"); +/** The build number of the userscript */ +const buildNumber = getRawVal(buildNumberRaw, "BUILD_ERROR!"); +/** The source of the assets - github, jsdelivr or local */ +const assetSource = getRawVal(assetSourceRaw, "jsdelivr"); +/** The port of the dev server */ +const devServerPort = Number(getRawVal(devServerPortRaw, 8710)); +/** URL to the changelog file */ +const changelogUrl = `https://raw.githubusercontent.com/${repo}/${buildNumber !== null && buildNumber !== void 0 ? buildNumber : branch}/changelog.md`; +/** The URL search parameters at the earliest possible time */ +const initialParams = new URL(location.href).searchParams; +/** Names of platforms by key of {@linkcode host} */ +const platformNames = { + github: "GitHub", + greasyfork: "GreasyFork", + openuserjs: "OpenUserJS", +}; +/** Default compression format used throughout BYTM */ +const compressionFormat = "deflate-raw"; +/** Whether sessionStorage is available and working */ +const sessionStorageAvailable = typeof (sessionStorage === null || sessionStorage === void 0 ? void 0 : sessionStorage.setItem) === "function" + && (() => { + try { + const key = `_bytm_test_${UserUtils.randomId(6, 36, false, true)}`; + sessionStorage.setItem(key, "test"); + sessionStorage.removeItem(key); + return true; } - n.default = e; - return Object.freeze(n); + catch (_a) { + return false; + } + })(); +/** + * Fallback and initial value of how much info should be logged to the devtools console + * 0 = Debug (show everything) or 1 = Info (show only important stuff) + */ +const defaultLogLevel = mode === "production" ? LogLevel.Info : LogLevel.Debug; +/** Info about the userscript, parsed from the userscript header (tools/post-build.js) */ +const scriptInfo = { + name: GM.info.script.name, + version: GM.info.script.version, + namespace: GM.info.script.namespace, +};let canCompress$2 = true; +const lyricsCacheMgr = new UserUtils.DataStore({ + id: "bytm-lyrics-cache", + defaultData: { + cache: [], + }, + formatVersion: 1, + encodeData: (data) => canCompress$2 ? UserUtils.compress(data, compressionFormat, "string") : data, + decodeData: (data) => canCompress$2 ? UserUtils.decompress(data, compressionFormat, "string") : data, +}); +async function initLyricsCache() { + canCompress$2 = await compressionSupported(); + const data = await lyricsCacheMgr.loadData(); + log(`Initialized lyrics cache with ${data.cache.length} entries:`, data); + emitInterface("bytm:lyricsCacheReady"); + return data; +} +/** + * Returns the cache entry for the passed artist and song, or undefined if it doesn't exist yet + * {@linkcode artist} and {@linkcode song} need to be sanitized first! + * @param refreshEntry If true, the timestamp of the entry will be set to the current time + */ +function getLyricsCacheEntry(artist, song, refreshEntry = true) { + const { cache } = lyricsCacheMgr.getData(); + const entry = cache.find(e => e.artist === artist && e.song === song); + if (entry && Date.now() - (entry === null || entry === void 0 ? void 0 : entry.added) > getFeature("lyricsCacheTTL") * 1000 * 60 * 60 * 24) { + deleteLyricsCacheEntry(artist, song); + return undefined; + } + // refresh timestamp of the entry by mutating cache + if (entry && refreshEntry) + updateLyricsCacheEntry(artist, song); + return entry; +} +/** Updates the "last viewed" timestamp of the cache entry for the passed artist and song */ +function updateLyricsCacheEntry(artist, song) { + const { cache } = lyricsCacheMgr.getData(); + const idx = cache.findIndex(e => e.artist === artist && e.song === song); + if (idx !== -1) { + const newEntry = cache.splice(idx, 1)[0]; + newEntry.viewed = Date.now(); + lyricsCacheMgr.setData({ cache: [newEntry, ...cache] }); + } +} +/** Deletes the cache entry for the passed artist and song */ +function deleteLyricsCacheEntry(artist, song) { + const { cache } = lyricsCacheMgr.getData(); + const idx = cache.findIndex(e => e.artist === artist && e.song === song); + if (idx !== -1) { + cache.splice(idx, 1); + lyricsCacheMgr.setData({ cache }); } +} +/** Clears the lyrics cache locally and clears it in persistent storage */ +function clearLyricsCache() { + emitInterface("bytm:lyricsCacheCleared"); + return lyricsCacheMgr.setData({ cache: [] }); +} +/** Returns the full lyrics cache array */ +function getLyricsCache() { + return lyricsCacheMgr.getData().cache; +} +/** + * Adds the provided "best" (non-penalized) entry into the lyrics URL cache, synchronously to RAM and asynchronously to GM storage + * {@linkcode artist} and {@linkcode song} need to be sanitized first! + */ +function addLyricsCacheEntryBest(artist, song, url) { + // refresh entry if it exists and don't overwrite / duplicate it + const cachedEntry = getLyricsCacheEntry(artist, song, true); + if (cachedEntry) + return; + const { cache } = lyricsCacheMgr.getData(); + const entry = { + artist, song, url, viewed: Date.now(), added: Date.now(), + }; + cache.push(entry); + cache.sort((a, b) => b.viewed - a.viewed); + // always keep the cache <= max size + cache.splice(getFeature("lyricsCacheMaxSize")); + log("Added cache entry for best result", artist, "-", song, "\n", entry); + emitInterface("bytm:lyricsCacheEntryAdded", { entry, type: "best" }); + return lyricsCacheMgr.setData({ cache }); +}/****************************************************************************** +Copyright (c) Microsoft Corporation. - var UserUtils__namespace = /*#__PURE__*/_interopNamespaceDefault(UserUtils); - var compareVersions__namespace = /*#__PURE__*/_interopNamespaceDefault(compareVersions); +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. - // I know TS enums are impure but it doesn't really matter here, plus imo they are cooler than pure enums anyway - var LogLevel; - (function (LogLevel) { - LogLevel[LogLevel["Debug"] = 0] = "Debug"; - LogLevel[LogLevel["Info"] = 1] = "Info"; - })(LogLevel || (LogLevel = {})); - //#region plugins - /** - * Intents (permissions) BYTM has to grant your plugin for it to be able to access certain features. - * TODO: this feature is unfinished, but you should still specify the intents your plugin needs. - * Never request more permissions than you need, as this is a bad practice and can lead to your plugin being rejected. - */ - var PluginIntent; - (function (PluginIntent) { - /** Plugin can read the feature configuration */ - PluginIntent[PluginIntent["ReadFeatureConfig"] = 1] = "ReadFeatureConfig"; - /** Plugin can write to the feature configuration */ - PluginIntent[PluginIntent["WriteFeatureConfig"] = 2] = "WriteFeatureConfig"; - /** Plugin has access to hidden config values */ - PluginIntent[PluginIntent["SeeHiddenConfigValues"] = 4] = "SeeHiddenConfigValues"; - /** Plugin can write to the lyrics cache */ - PluginIntent[PluginIntent["WriteLyricsCache"] = 8] = "WriteLyricsCache"; - /** Plugin can add new translations and overwrite existing ones */ - PluginIntent[PluginIntent["WriteTranslations"] = 16] = "WriteTranslations"; - /** Plugin can create modal dialogs */ - PluginIntent[PluginIntent["CreateModalDialogs"] = 32] = "CreateModalDialogs"; - /** Plugin can read auto-like data */ - PluginIntent[PluginIntent["ReadAutoLikeData"] = 64] = "ReadAutoLikeData"; - /** Plugin can write to auto-like data */ - PluginIntent[PluginIntent["WriteAutoLikeData"] = 128] = "WriteAutoLikeData"; - })(PluginIntent || (PluginIntent = {})); +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ +/* global Reflect, Promise, SuppressedError, Symbol */ - // these variables will have their values replaced by the build script: - const modeRaw = "production"; - const branchRaw = "main"; - const hostRaw = "github"; - const buildNumberRaw = "e2a684dd"; - /** The mode in which the script was built (production or development) */ - const mode = (modeRaw.match(/^#{{.+}}$/) ? "production" : modeRaw); - /** The branch to use in various URLs that point to the GitHub repo */ - const branch = (branchRaw.match(/^#{{.+}}$/) ? "main" : branchRaw); - /** Path to the GitHub repo */ - const repo = "Sv443/BetterYTM"; - /** Which host the userscript was installed from */ - const host = (hostRaw.match(/^#{{.+}}$/) ? "github" : hostRaw); - /** The build number of the userscript */ - const buildNumber = (buildNumberRaw.match(/^#{{.+}}$/) ? "BUILD_ERROR!" : buildNumberRaw); // asserted as generic string instead of literal - /** The URL search parameters at the earliest possible time */ - const initialParams = new URL(location.href).searchParams; - /** Names of platforms by key of {@linkcode host} */ - const platformNames = { - github: "GitHub", - greasyfork: "GreasyFork", - openuserjs: "OpenUserJS", - }; - /** Default compression format used throughout BYTM */ - const compressionFormat = "deflate-raw"; - /** Whether sessionStorage is available and working */ - const sessionStorageAvailable = typeof (sessionStorage === null || sessionStorage === void 0 ? void 0 : sessionStorage.setItem) === "function" - && (() => { - try { - const key = `_bytm_test_${UserUtils.randomId(6, 36)}`; - sessionStorage.setItem(key, "test"); - sessionStorage.removeItem(key); - return true; - } - catch (_a) { - return false; - } - })(); - /** - * Fallback and initial value of how much info should be logged to the devtools console - * 0 = Debug (show everything) or 1 = Info (show only important stuff) - */ - const defaultLogLevel = mode === "production" ? LogLevel.Info : LogLevel.Debug; - /** Info about the userscript, parsed from the userscript header (tools/post-build.js) */ - const scriptInfo = { - name: GM.info.script.name, - version: GM.info.script.version, - namespace: GM.info.script.namespace, - }; - let canCompress$2 = true; - const lyricsCacheMgr = new UserUtils.DataStore({ - id: "bytm-lyrics-cache", - defaultData: { - cache: [], - }, - formatVersion: 1, - encodeData: (data) => canCompress$2 ? UserUtils.compress(data, compressionFormat, "string") : data, - decodeData: (data) => canCompress$2 ? UserUtils.decompress(data, compressionFormat, "string") : data, - }); - async function initLyricsCache() { - canCompress$2 = await compressionSupported(); - const data = await lyricsCacheMgr.loadData(); - log(`Initialized lyrics cache with ${data.cache.length} entries:`, data); - emitInterface("bytm:lyricsCacheReady"); - return data; - } - /** - * Returns the cache entry for the passed artist and song, or undefined if it doesn't exist yet - * {@linkcode artist} and {@linkcode song} need to be sanitized first! - * @param refreshEntry If true, the timestamp of the entry will be set to the current time - */ - function getLyricsCacheEntry(artist, song, refreshEntry = true) { - const { cache } = lyricsCacheMgr.getData(); - const entry = cache.find(e => e.artist === artist && e.song === song); - if (entry && Date.now() - (entry === null || entry === void 0 ? void 0 : entry.added) > getFeature("lyricsCacheTTL") * 1000 * 60 * 60 * 24) { - deleteLyricsCacheEntry(artist, song); - return undefined; - } - // refresh timestamp of the entry by mutating cache - if (entry && refreshEntry) - updateLyricsCacheEntry(artist, song); - return entry; - } - /** Updates the "last viewed" timestamp of the cache entry for the passed artist and song */ - function updateLyricsCacheEntry(artist, song) { - const { cache } = lyricsCacheMgr.getData(); - const idx = cache.findIndex(e => e.artist === artist && e.song === song); - if (idx !== -1) { - const newEntry = cache.splice(idx, 1)[0]; - newEntry.viewed = Date.now(); - lyricsCacheMgr.setData({ cache: [newEntry, ...cache] }); - } - } - /** Deletes the cache entry for the passed artist and song */ - function deleteLyricsCacheEntry(artist, song) { - const { cache } = lyricsCacheMgr.getData(); - const idx = cache.findIndex(e => e.artist === artist && e.song === song); - if (idx !== -1) { - cache.splice(idx, 1); - lyricsCacheMgr.setData({ cache }); +function __rest(s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +} + +function __values(o) { + var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; + if (m) return m.call(o); + if (o && typeof o.length === "number") return { + next: function () { + if (o && i >= o.length) o = void 0; + return { value: o && o[i++], done: !o }; } + }; + throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); +} + +function __asyncValues(o) { + if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); + var m = o[Symbol.asyncIterator], i; + return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); + function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } + function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } +} + +typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { + var e = new Error(message); + return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; +};/** Contains the identifiers of all initialized and loaded translation locales */ +const initializedLocales = new Set(); +/** The currently active locale */ +let activeLocale = "en-US"; +UserUtils.tr.addTransform(UserUtils.tr.transforms.percent); +UserUtils.tr.addTransform(UserUtils.tr.transforms.templateLiteral); +/** Initializes the translations */ +async function initTranslations(locale) { + if (initializedLocales.has(locale)) + return; + initializedLocales.add(locale); + try { + const transFile = await fetchLocaleJson(locale); + let fallbackTrans = {}; + if (getFeature("localeFallback")) + fallbackTrans = await fetchLocaleJson("en-US"); + // merge with base translations if specified + const baseTransFile = typeof (transFile === null || transFile === void 0 ? void 0 : transFile.meta) === "object" && "base" in transFile.meta && typeof transFile.meta.base === "string" + ? await fetchLocaleJson(transFile.base) + : undefined; + const translations = Object.assign(Object.assign(Object.assign({}, (fallbackTrans !== null && fallbackTrans !== void 0 ? fallbackTrans : {})), (baseTransFile !== null && baseTransFile !== void 0 ? baseTransFile : {})), transFile); + const _a = translations.meta, { authors: _authors } = _a, meta = __rest(_a, ["authors"]), trans = __rest(translations, ["meta"]); + UserUtils.tr.addTranslations(locale, Object.assign(Object.assign({}, meta), trans)); + info(`Loaded translations for locale '${locale}'`); } - /** Clears the lyrics cache locally and clears it in persistent storage */ - function clearLyricsCache() { - emitInterface("bytm:lyricsCacheCleared"); - return lyricsCacheMgr.setData({ cache: [] }); - } - /** Returns the full lyrics cache array */ - function getLyricsCache() { - return lyricsCacheMgr.getData().cache; + catch (err) { + const errStr = `Couldn't load translations for locale '${locale}'`; + error(errStr, err); + throw new Error(errStr); } - /** - * Adds the provided "best" (non-penalized) entry into the lyrics URL cache, synchronously to RAM and asynchronously to GM storage - * {@linkcode artist} and {@linkcode song} need to be sanitized first! - */ - function addLyricsCacheEntryBest(artist, song, url) { - // refresh entry if it exists and don't overwrite / duplicate it - const cachedEntry = getLyricsCacheEntry(artist, song, true); - if (cachedEntry) - return; - const { cache } = lyricsCacheMgr.getData(); - const entry = { - artist, song, url, viewed: Date.now(), added: Date.now(), - }; - cache.push(entry); - cache.sort((a, b) => b.viewed - a.viewed); - // always keep the cache <= max size - cache.splice(getFeature("lyricsCacheMaxSize")); - log("Added cache entry for best result", artist, "-", song, "\n", entry); - emitInterface("bytm:lyricsCacheEntryAdded", { entry, type: "best" }); - return lyricsCacheMgr.setData({ cache }); +} +/** Fetches the translation JSON file of the passed locale */ +async function fetchLocaleJson(locale) { + const url = await getResourceUrl(`trans-${locale}`); + const res = await UserUtils.fetchAdvanced(url); + if (res.status < 200 || res.status >= 300) + throw new Error(`Failed to fetch translation file for locale '${locale}'`); + return await res.json(); +} +/** Sets the current language for translations */ +function setLocale(locale) { + activeLocale = locale; + setGlobalProp("locale", locale); + emitInterface("bytm:setLocale", { locale }); +} +/** Returns the currently set language */ +function getLocale() { + return activeLocale; +} +/** Returns whether the given translation key exists in the current locale */ +function hasKey(key) { + return hasKeyFor(getLocale(), key); +} +/** Returns whether the given translation key exists in the given locale */ +function hasKeyFor(locale, key) { + var _a; + return typeof ((_a = UserUtils.tr.getTranslations(locale)) === null || _a === void 0 ? void 0 : _a[key]) === "string"; +} +/** Returns the translated string for the given key, after optionally inserting values */ +function t(key, ...values) { + return tl(activeLocale, key, ...values); +} +/** + * Returns the translated string for the given {@linkcode key} with an added pluralization identifier based on the passed {@linkcode num} + * Also inserts the passed {@linkcode values} into the translation at the markers `%1`, `%2`, etc. + * Tries to fall back to the non-pluralized syntax if no translation was found + */ +function tp(key, num, ...values) { + return tlp(getLocale(), key, num, ...values); +} +/** Returns the translated string for the given key in the specified locale, after optionally inserting values */ +function tl(locale, key, ...values) { + return UserUtils.tr.for(locale, key, ...values); +} +/** + * Returns the translated string for the given {@linkcode key} in the given {@linkcode locale} with an added pluralization identifier based on the passed {@linkcode num} + * Also inserts the passed {@linkcode values} into the translation at the markers `%1`, `%2`, etc. + * Tries to fall back to the non-pluralized syntax if no translation was found + */ +function tlp(locale, key, num, ...values) { + if (typeof num !== "number") + num = num.length; + const plNum = num === 1 ? "1" : "n"; + const trans = tl(locale, `${key}-${plNum}`, ...values); + if (trans === key) + return t(key, ...values); + return trans; +}// hoist the class declaration because either rollup or babel is being a hoe +/** Whether the dialog system has been initialized */ +let dialogsInitialized = false; +/** Container element for all BytmDialog elements */ +let dialogContainer; +// TODO: remove export as soon as config menu is migrated to use BytmDialog +/** ID of the last opened (top-most) dialog */ +let currentDialogId = null; +/** IDs of all currently open dialogs, top-most first */ +const openDialogs = []; +/** TODO: remove as soon as config menu is migrated to use BytmDialog */ +const setCurrentDialogId = (id) => currentDialogId = id; +/** Creates and manages a modal dialog element */ +class BytmDialog extends UserUtils.NanoEmitter { + constructor(options) { + super(); + Object.defineProperty(this, "options", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "id", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + Object.defineProperty(this, "dialogOpen", { + enumerable: true, + configurable: true, + writable: true, + value: false + }); + Object.defineProperty(this, "dialogMounted", { + enumerable: true, + configurable: true, + writable: true, + value: false + }); + BytmDialog.initDialogs(); + this.options = Object.assign({ closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, destroyOnClose: false, unmountOnClose: true, removeListenersOnDestroy: true, smallHeader: false, verticalAlign: "center" }, options); + this.id = options.id; } - - /** Contains the identifiers of all initialized and loaded translation locales */ - const initializedLocales = new Set(); - /** Initializes the translations */ - async function initTranslations(locale) { - var _a, _b; - if (initializedLocales.has(locale)) + //#region public + /** Call after DOMContentLoaded to pre-render the dialog and invisibly mount it in the DOM */ + async mount() { + if (this.dialogMounted) return; - initializedLocales.add(locale); + this.dialogMounted = true; + const bgElem = document.createElement("div"); + bgElem.id = `bytm-${this.id}-dialog-bg`; + bgElem.classList.add("bytm-dialog-bg"); + if (this.options.closeOnBgClick) + bgElem.ariaLabel = bgElem.title = t("close_menu_tooltip"); + bgElem.style.setProperty("--bytm-dialog-width-max", `${this.options.width}px`); + bgElem.style.setProperty("--bytm-dialog-height-max", `${this.options.height}px`); + bgElem.style.visibility = "hidden"; + bgElem.style.display = "none"; + bgElem.inert = true; try { - const transFile = await fetchLocaleJson(locale); - let fallbackTrans = {}; - if (getFeature("localeFallback")) - fallbackTrans = await fetchLocaleJson("en-US"); - // merge with base translations if specified - const baseTransFile = transFile.base ? await fetchLocaleJson(transFile.base) : undefined; - const translations = Object.assign(Object.assign(Object.assign({}, ((_a = fallbackTrans === null || fallbackTrans === void 0 ? void 0 : fallbackTrans.translations) !== null && _a !== void 0 ? _a : {})), ((_b = baseTransFile === null || baseTransFile === void 0 ? void 0 : baseTransFile.translations) !== null && _b !== void 0 ? _b : {})), transFile.translations); - UserUtils.tr.addLanguage(locale, translations); - info(`Loaded translations for locale '${locale}'`); + bgElem.appendChild(await this.getDialogContent()); + if (dialogContainer) + dialogContainer.appendChild(bgElem); + else + document.addEventListener("DOMContentLoaded", () => dialogContainer === null || dialogContainer === void 0 ? void 0 : dialogContainer.appendChild(bgElem)); } - catch (err) { - const errStr = `Couldn't load translations for locale '${locale}'`; - error(errStr, err); - throw new Error(errStr); + catch (e) { + return error("Failed to render dialog content:", e); } + this.attachListeners(bgElem); + this.events.emit("render"); + return bgElem; } - /** Fetches the translation JSON file of the passed locale */ - async function fetchLocaleJson(locale) { - const url = await getResourceUrl(`trans-${locale}`); - const res = await UserUtils.fetchAdvanced(url); - if (res.status < 200 || res.status >= 300) - throw new Error(`Failed to fetch translation file for locale '${locale}'`); - return await res.json(); - } - /** Sets the current language for translations */ - function setLocale(locale) { - UserUtils.tr.setLanguage(locale); - setGlobalProp("locale", locale); - emitInterface("bytm:setLocale", { locale }); - } - /** Returns the currently set language */ - function getLocale() { - return UserUtils.tr.getLanguage(); - } - /** Returns whether the given translation key exists in the current locale */ - function hasKey(key) { - return hasKeyFor(getLocale(), key); - } - /** Returns whether the given translation key exists in the given locale */ - function hasKeyFor(locale, key) { + /** Closes the dialog and clears all its contents (unmounts elements from the DOM) in preparation for a new rendering call */ + unmount() { var _a; - return typeof ((_a = UserUtils.tr.getTranslations(locale)) === null || _a === void 0 ? void 0 : _a[key]) === "string"; - } - /** Returns the translated string for the given key, after optionally inserting values */ - function t(key, ...values) { - return UserUtils.tr(key, ...values); - } - /** - * Returns the translated string for the given {@linkcode key} with an added pluralization identifier based on the passed {@linkcode num} - * Also inserts the passed {@linkcode values} into the translation at the markers `%1`, `%2`, etc. - * Tries to fall back to the non-pluralized syntax if no translation was found - */ - function tp(key, num, ...values) { - return tlp(getLocale(), key, num, ...values); + this.close(); + this.dialogMounted = false; + const clearSelectors = [ + `#bytm-${this.id}-dialog-bg`, + ]; + for (const sel of clearSelectors) { + const elem = document.querySelector(sel); + (elem === null || elem === void 0 ? void 0 : elem.hasChildNodes()) && clearInner(elem); + (_a = document.querySelector(sel)) === null || _a === void 0 ? void 0 : _a.remove(); + } + this.events.emit("clear"); } - /** Returns the translated string for the given key in the specified locale, after optionally inserting values */ - function tl(locale, key, ...values) { - return UserUtils.tr.forLang(locale, key, ...values); + /** Clears the DOM of the dialog and then renders it again */ + async remount() { + this.unmount(); + await this.mount(); } /** - * Returns the translated string for the given {@linkcode key} in the given {@linkcode locale} with an added pluralization identifier based on the passed {@linkcode num} - * Also inserts the passed {@linkcode values} into the translation at the markers `%1`, `%2`, etc. - * Tries to fall back to the non-pluralized syntax if no translation was found + * Opens the dialog - also mounts it if it hasn't been mounted yet + * Prevents default action and immediate propagation of the passed event */ - function tlp(locale, key, num, ...values) { - if (typeof num !== "number") - num = num.length; - const plNum = num === 1 ? "1" : "n"; - const trans = tl(locale, `${key}-${plNum}`, ...values); - if (trans === key) - return t(key, ...values); - return trans; - } - - // hoist the class declaration because either rollup or babel is being a hoe - /** Whether the dialog system has been initialized */ - let dialogsInitialized = false; - /** Container element for all BytmDialog elements */ - let dialogContainer; - // TODO: remove export as soon as config menu is migrated to use BytmDialog - /** ID of the last opened (top-most) dialog */ - let currentDialogId = null; - /** IDs of all currently open dialogs, top-most first */ - const openDialogs = []; - /** TODO: remove as soon as config menu is migrated to use BytmDialog */ - const setCurrentDialogId = (id) => currentDialogId = id; - /** Creates and manages a modal dialog element */ - class BytmDialog extends UserUtils.NanoEmitter { - constructor(options) { - super(); - Object.defineProperty(this, "options", { - enumerable: true, - configurable: true, - writable: true, - value: void 0 - }); - Object.defineProperty(this, "id", { - enumerable: true, - configurable: true, - writable: true, - value: void 0 - }); - Object.defineProperty(this, "dialogOpen", { - enumerable: true, - configurable: true, - writable: true, - value: false - }); - Object.defineProperty(this, "dialogMounted", { - enumerable: true, - configurable: true, - writable: true, - value: false - }); - BytmDialog.initDialogs(); - this.options = Object.assign({ closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, destroyOnClose: false, unmountOnClose: true, removeListenersOnDestroy: true, smallHeader: false, verticalAlign: "center" }, options); - this.id = options.id; - } - //#region public - /** Call after DOMContentLoaded to pre-render the dialog and invisibly mount it in the DOM */ - async mount() { - if (this.dialogMounted) - return; - this.dialogMounted = true; - const bgElem = document.createElement("div"); - bgElem.id = `bytm-${this.id}-dialog-bg`; - bgElem.classList.add("bytm-dialog-bg"); - if (this.options.closeOnBgClick) - bgElem.ariaLabel = bgElem.title = t("close_menu_tooltip"); - bgElem.style.setProperty("--bytm-dialog-width-max", `${this.options.width}px`); - bgElem.style.setProperty("--bytm-dialog-height-max", `${this.options.height}px`); - bgElem.style.visibility = "hidden"; - bgElem.style.display = "none"; - bgElem.inert = true; - try { - bgElem.appendChild(await this.getDialogContent()); - if (dialogContainer) - dialogContainer.appendChild(bgElem); - else - document.addEventListener("DOMContentLoaded", () => dialogContainer === null || dialogContainer === void 0 ? void 0 : dialogContainer.appendChild(bgElem)); - } - catch (e) { - return error("Failed to render dialog content:", e); - } - this.attachListeners(bgElem); - this.events.emit("render"); - return bgElem; - } - /** Closes the dialog and clears all its contents (unmounts elements from the DOM) in preparation for a new rendering call */ - unmount() { - var _a; - this.close(); - this.dialogMounted = false; - const clearSelectors = [ - `#bytm-${this.id}-dialog-bg`, - ]; - for (const sel of clearSelectors) { - const elem = document.querySelector(sel); - (elem === null || elem === void 0 ? void 0 : elem.hasChildNodes()) && clearInner(elem); - (_a = document.querySelector(sel)) === null || _a === void 0 ? void 0 : _a.remove(); - } - this.events.emit("clear"); - } - /** Clears the DOM of the dialog and then renders it again */ - async remount() { - this.unmount(); - await this.mount(); - } - /** - * Opens the dialog - also mounts it if it hasn't been mounted yet - * Prevents default action and immediate propagation of the passed event - */ - async open(e) { - var _a; - e === null || e === void 0 ? void 0 : e.preventDefault(); - e === null || e === void 0 ? void 0 : e.stopImmediatePropagation(); - if (this.isOpen()) - return; - this.dialogOpen = true; - if (openDialogs.includes(this.id)) { - openDialogs.splice(openDialogs.indexOf(this.id), 1); - currentDialogId = (_a = openDialogs[0]) !== null && _a !== void 0 ? _a : null; - this.removeBgInert(); - this.close(); - throw new Error(`A dialog with the same ID of '${this.id}' already exists and is open!`); - } - if (!this.isMounted()) - await this.mount(); - this.setBgInert(); - const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); - if (!dialogBg) - return warn(`Couldn't find background element for dialog with ID '${this.id}'`); - dialogBg.style.visibility = "visible"; - dialogBg.style.display = "block"; - currentDialogId = this.id; - openDialogs.unshift(this.id); - this.events.emit("open"); - emitInterface("bytm:dialogOpened", this); - emitInterface(`bytm:dialogOpened:${this.id}`, this); - return dialogBg; - } - /** Closes the dialog - prevents default action and immediate propagation of the passed event */ - close(e) { - var _a; - e === null || e === void 0 ? void 0 : e.preventDefault(); - e === null || e === void 0 ? void 0 : e.stopImmediatePropagation(); - if (!this.isOpen()) - return; - this.dialogOpen = false; - const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); - if (!dialogBg) - return warn(`Couldn't find background element for dialog with ID '${this.id}'`); - dialogBg.style.visibility = "hidden"; - dialogBg.style.display = "none"; + async open(e) { + var _a; + e === null || e === void 0 ? void 0 : e.preventDefault(); + e === null || e === void 0 ? void 0 : e.stopImmediatePropagation(); + if (this.isOpen()) + return; + this.dialogOpen = true; + if (openDialogs.includes(this.id)) { openDialogs.splice(openDialogs.indexOf(this.id), 1); currentDialogId = (_a = openDialogs[0]) !== null && _a !== void 0 ? _a : null; this.removeBgInert(); - this.events.emit("close"); - emitInterface("bytm:dialogClosed", this); - emitInterface(`bytm:dialogClosed:${this.id}`, this); - if (this.options.destroyOnClose) - this.destroy(); - // don't destroy *and* unmount at the same time - else if (this.options.unmountOnClose) - this.unmount(); - this.removeBgInert(); - } - /** Returns true if the dialog is currently open */ - isOpen() { - return this.dialogOpen; - } - /** Returns true if the dialog is currently mounted */ - isMounted() { - return this.dialogMounted; + this.close(); + throw new Error(`A dialog with the same ID of '${this.id}' already exists and is open!`); } - /** Clears the DOM of the dialog and removes all event listeners */ - destroy() { + if (!this.isMounted()) + await this.mount(); + this.setBgInert(); + const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); + if (!dialogBg) + return warn(`Couldn't find background element for dialog with ID '${this.id}'`); + dialogBg.style.visibility = "visible"; + dialogBg.style.display = "block"; + currentDialogId = this.id; + openDialogs.unshift(this.id); + this.events.emit("open"); + emitInterface("bytm:dialogOpened", this); + emitInterface(`bytm:dialogOpened:${this.id}`, this); + return dialogBg; + } + /** Closes the dialog - prevents default action and immediate propagation of the passed event */ + close(e) { + var _a; + e === null || e === void 0 ? void 0 : e.preventDefault(); + e === null || e === void 0 ? void 0 : e.stopImmediatePropagation(); + if (!this.isOpen()) + return; + this.dialogOpen = false; + const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); + if (!dialogBg) + return warn(`Couldn't find background element for dialog with ID '${this.id}'`); + dialogBg.style.visibility = "hidden"; + dialogBg.style.display = "none"; + openDialogs.splice(openDialogs.indexOf(this.id), 1); + currentDialogId = (_a = openDialogs[0]) !== null && _a !== void 0 ? _a : null; + this.removeBgInert(); + this.events.emit("close"); + emitInterface("bytm:dialogClosed", this); + emitInterface(`bytm:dialogClosed:${this.id}`, this); + if (this.options.destroyOnClose) + this.destroy(); + // don't destroy *and* unmount at the same time + else if (this.options.unmountOnClose) this.unmount(); - this.events.emit("destroy"); - this.options.removeListenersOnDestroy && this.unsubscribeAll(); - } - //#region static - /** Initializes the dialog system */ - static initDialogs() { - if (dialogsInitialized) - return; - dialogsInitialized = true; - const createContainer = () => { - const bytmDialogCont = dialogContainer = document.createElement("div"); - bytmDialogCont.id = "bytm-dialog-container"; - document.body.appendChild(bytmDialogCont); - }; - if (!domLoaded) - document.addEventListener("DOMContentLoaded", createContainer); - else - createContainer(); - } - /** Returns the ID of the top-most dialog (the dialog that has been opened last) */ - static getCurrentDialogId() { - return currentDialogId; - } - /** Returns the IDs of all currently open dialogs, top-most first */ - static getOpenDialogs() { - return openDialogs; - } - //#region protected - /** Sets this dialog and the body to be inert and makes sure the top-most dialog is not inert. If no other dialogs are open, the body is not set to be inert. */ - removeBgInert() { - var _a, _b, _c; - // make sure the new top-most dialog is not inert - if (currentDialogId) { - // special treatment for the old config menu, as always - if (currentDialogId === "cfg-menu") - (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); - else - (_b = document.querySelector(`#bytm-${currentDialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.removeAttribute("inert"); - } - // remove the scroll lock and inert attribute on the body if no dialogs are open - if (openDialogs.length === 0) { - document.body.classList.remove("bytm-disable-scroll"); - (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.removeAttribute("inert"); - } - const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); - dialogBg === null || dialogBg === void 0 ? void 0 : dialogBg.setAttribute("inert", "true"); - } - /** Sets this dialog to be not inert and the body and all other dialogs to be inert */ - setBgInert() { - var _a, _b, _c; - // make sure all other dialogs are inert - for (const dialogId of openDialogs) { - if (dialogId !== this.id) { - // special treatment for the old config menu, as always - if (dialogId === "cfg-menu") - (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); - else - (_b = document.querySelector(`#bytm-${dialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.setAttribute("inert", "true"); - } - } - // make sure body is inert and scroll is locked - document.body.classList.add("bytm-disable-scroll"); - (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.setAttribute("inert", "true"); - const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); - dialogBg === null || dialogBg === void 0 ? void 0 : dialogBg.removeAttribute("inert"); - } - /** Called on every {@linkcode mount()} to attach all generic event listeners */ - attachListeners(bgElem) { - if (this.options.closeOnBgClick) { - bgElem.addEventListener("click", (e) => { - var _a; - if (this.isOpen() && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === `bytm-${this.id}-dialog-bg`) - this.close(e); - }); - } - if (this.options.closeOnEscPress) { - document.body.addEventListener("keydown", (e) => { - if (e.key === "Escape" && this.isOpen() && BytmDialog.getCurrentDialogId() === this.id) - this.close(e); - }); - } - } - /** Returns the dialog content element and all its children */ - async getDialogContent() { - var _a, _b, _c, _d; - const header = (_b = (_a = this.options).renderHeader) === null || _b === void 0 ? void 0 : _b.call(_a); - const footer = (_d = (_c = this.options).renderFooter) === null || _d === void 0 ? void 0 : _d.call(_c); - const dialogWrapperEl = document.createElement("div"); - dialogWrapperEl.id = `bytm-${this.id}-dialog`; - dialogWrapperEl.classList.add("bytm-dialog"); - dialogWrapperEl.ariaLabel = dialogWrapperEl.title = ""; - dialogWrapperEl.role = "dialog"; - dialogWrapperEl.setAttribute("aria-labelledby", `bytm-${this.id}-dialog-title`); - dialogWrapperEl.setAttribute("aria-describedby", `bytm-${this.id}-dialog-body`); - if (this.options.verticalAlign !== "center") - dialogWrapperEl.classList.add(`align-${this.options.verticalAlign}`); - //#region header - const headerWrapperEl = document.createElement("div"); - headerWrapperEl.classList.add("bytm-dialog-header"); - this.options.small && headerWrapperEl.classList.add("small"); - if (header) { - const headerTitleWrapperEl = document.createElement("div"); - headerTitleWrapperEl.id = `bytm-${this.id}-dialog-title`; - headerTitleWrapperEl.classList.add("bytm-dialog-title-wrapper"); - headerTitleWrapperEl.role = "heading"; - headerTitleWrapperEl.ariaLevel = "1"; - headerTitleWrapperEl.appendChild(header instanceof Promise ? await header : header); - headerWrapperEl.appendChild(headerTitleWrapperEl); - } - else { - // insert element to pad the header height - const padEl = document.createElement("div"); - padEl.classList.add("bytm-dialog-header-pad"); - this.options.small && padEl.classList.add("small"); - headerWrapperEl.appendChild(padEl); - } - if (this.options.closeBtnEnabled) { - const closeBtnEl = document.createElement("img"); - closeBtnEl.classList.add("bytm-dialog-close"); - this.options.small && closeBtnEl.classList.add("small"); - closeBtnEl.src = await getResourceUrl("img-close"); - closeBtnEl.role = "button"; - closeBtnEl.tabIndex = 0; - closeBtnEl.alt = closeBtnEl.title = closeBtnEl.ariaLabel = t("close_menu_tooltip"); - onInteraction(closeBtnEl, () => this.close()); - headerWrapperEl.appendChild(closeBtnEl); - } - dialogWrapperEl.appendChild(headerWrapperEl); - //#region body - const dialogBodyElem = document.createElement("div"); - dialogBodyElem.id = `bytm-${this.id}-dialog-body`; - dialogBodyElem.classList.add("bytm-dialog-body"); - this.options.small && dialogBodyElem.classList.add("small"); - const body = this.options.renderBody(); - dialogBodyElem.appendChild(body instanceof Promise ? await body : body); - dialogWrapperEl.appendChild(dialogBodyElem); - //#region footer - if (footer) { - const footerWrapper = document.createElement("div"); - footerWrapper.classList.add("bytm-dialog-footer-cont"); - this.options.small && footerWrapper.classList.add("small"); - dialogWrapperEl.appendChild(footerWrapper); - footerWrapper.appendChild(footer instanceof Promise ? await footer : footer); - } - return dialogWrapperEl; - } - } - - /****************************************************************************** - Copyright (c) Microsoft Corporation. - - Permission to use, copy, modify, and/or distribute this software for any - purpose with or without fee is hereby granted. - - THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH - REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY - AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, - INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM - LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR - OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR - PERFORMANCE OF THIS SOFTWARE. - ***************************************************************************** */ - /* global Reflect, Promise, SuppressedError, Symbol */ - - - function __rest(s, e) { - var t = {}; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) - t[p] = s[p]; - if (s != null && typeof Object.getOwnPropertySymbols === "function") - for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { - if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) - t[p[i]] = s[p[i]]; - } - return t; - } - - function __values(o) { - var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; - if (m) return m.call(o); - if (o && typeof o.length === "number") return { - next: function () { - if (o && i >= o.length) o = void 0; - return { value: o && o[i++], done: !o }; - } + this.removeBgInert(); + } + /** Returns true if the dialog is currently open */ + isOpen() { + return this.dialogOpen; + } + /** Returns true if the dialog is currently mounted */ + isMounted() { + return this.dialogMounted; + } + /** Clears the DOM of the dialog and removes all event listeners */ + destroy() { + this.unmount(); + this.events.emit("destroy"); + this.options.removeListenersOnDestroy && this.unsubscribeAll(); + } + //#region static + /** Initializes the dialog system */ + static initDialogs() { + if (dialogsInitialized) + return; + dialogsInitialized = true; + const createContainer = () => { + const bytmDialogCont = dialogContainer = document.createElement("div"); + bytmDialogCont.id = "bytm-dialog-container"; + document.body.appendChild(bytmDialogCont); }; - throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); + if (!domLoaded) + document.addEventListener("DOMContentLoaded", createContainer); + else + createContainer(); } - - function __asyncValues(o) { - if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); - var m = o[Symbol.asyncIterator], i; - return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i); - function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; } - function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); } + /** Returns the ID of the top-most dialog (the dialog that has been opened last) */ + static getCurrentDialogId() { + return currentDialogId; } - - typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { - var e = new Error(message); - return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; - }; - - /** - * Creates an element with a ripple effect on click. - * @param clickEl If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created. - * @returns The passed element or the newly created element with the ripple effect. - */ - function createRipple(rippleElement, properties) { - const props = Object.assign({ speed: "normal" }, properties); - const rippleEl = rippleElement !== null && rippleElement !== void 0 ? rippleElement : document.createElement("div"); - rippleEl.classList.add("bytm-ripple", props.speed); - const updateRippleWidth = () => rippleEl.style.setProperty("--bytm-ripple-cont-width", `${rippleEl.clientWidth}px`); - rippleEl.addEventListener("mousedown", (e) => { - updateRippleWidth(); - const x = e.clientX - rippleEl.getBoundingClientRect().left; - const y = e.clientY - rippleEl.getBoundingClientRect().top; - const rippleAreaEl = document.createElement("span"); - rippleAreaEl.classList.add("bytm-ripple-area"); - rippleAreaEl.style.left = `${Math.round(x)}px`; - rippleAreaEl.style.top = `${Math.round(y)}px`; - if (rippleEl.firstChild) - rippleEl.insertBefore(rippleAreaEl, rippleEl.firstChild); - else - rippleEl.appendChild(rippleAreaEl); - rippleAreaEl.addEventListener("animationend", () => rippleAreaEl.remove()); - }); - updateRippleWidth(); - return rippleEl; + /** Returns the IDs of all currently open dialogs, top-most first */ + static getOpenDialogs() { + return openDialogs; } - - /** - * Creates a generic, circular button element. - * If `href` is provided, the button will be an anchor element. - * If `onClick` is provided, the button will be a div element. - * Provide either `resourceName` or `src` to specify the icon inside the button. - */ - async function createCircularBtn(_a) { - var { title, ripple = true } = _a, rest = __rest(_a, ["title", "ripple"]); - let btnElem; - if ("href" in rest && rest.href) { - btnElem = document.createElement("a"); - btnElem.href = rest.href; - btnElem.role = "button"; - btnElem.target = "_blank"; - btnElem.rel = "noopener noreferrer"; + //#region protected + /** Sets this dialog and the body to be inert and makes sure the top-most dialog is not inert. If no other dialogs are open, the body is not set to be inert. */ + removeBgInert() { + var _a, _b, _c; + // make sure the new top-most dialog is not inert + if (currentDialogId) { + // special treatment for the old config menu, as always + if (currentDialogId === "cfg-menu") + (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); + else + (_b = document.querySelector(`#bytm-${currentDialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.removeAttribute("inert"); } - else if ("onClick" in rest && rest.onClick) { - btnElem = document.createElement("div"); - rest.onClick && onInteraction(btnElem, rest.onClick); + // remove the scroll lock and inert attribute on the body if no dialogs are open + if (openDialogs.length === 0) { + document.body.classList.remove("bytm-disable-scroll"); + (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.removeAttribute("inert"); } - else - throw new TypeError("Either 'href' or 'onClick' must be provided"); - btnElem.classList.add("bytm-generic-btn"); - btnElem.ariaLabel = btnElem.title = title; - btnElem.tabIndex = 0; - btnElem.role = "button"; - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-generic-btn-img"); - imgElem.src = "src" in rest - ? rest.src instanceof Promise - ? await rest.src - : rest.src - : await getResourceUrl(rest.resourceName); - btnElem.appendChild(imgElem); - return ripple ? createRipple(btnElem) : btnElem; + const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); + dialogBg === null || dialogBg === void 0 ? void 0 : dialogBg.setAttribute("inert", "true"); } - - const interactionKeys = ["Enter", " ", "Space"]; - /** - * Adds generic, accessible interaction listeners to the passed element. - * All listeners have the default behavior prevented and stop propagation (for keyboard events this only applies as long as the captured key is included in {@linkcode interactionKeys}). - * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners - */ - function onInteraction(elem, listener, listenerOptions) { - const _a = listenerOptions !== null && listenerOptions !== void 0 ? listenerOptions : {}, { preventDefault = true, stopPropagation = true } = _a, listenerOpts = __rest(_a, ["preventDefault", "stopPropagation"]); - const proxListener = (e) => { - if (e instanceof KeyboardEvent) { - if (interactionKeys.includes(e.key)) { - preventDefault && e.preventDefault(); - stopPropagation && e.stopPropagation(); - } + /** Sets this dialog to be not inert and the body and all other dialogs to be inert */ + setBgInert() { + var _a, _b, _c; + // make sure all other dialogs are inert + for (const dialogId of openDialogs) { + if (dialogId !== this.id) { + // special treatment for the old config menu, as always + if (dialogId === "cfg-menu") + (_a = document.querySelector("#bytm-cfg-menu-bg")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); else - return; - } - else if (e instanceof MouseEvent) { - preventDefault && e.preventDefault(); - stopPropagation && e.stopPropagation(); + (_b = document.querySelector(`#bytm-${dialogId}-dialog-bg`)) === null || _b === void 0 ? void 0 : _b.setAttribute("inert", "true"); } - // clean up the other listener that isn't automatically removed if `once` is set - (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOpts); - (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOpts); - listener(e); - }; - elem.addEventListener("click", proxListener, listenerOpts); - elem.addEventListener("keydown", proxListener, listenerOpts); - } - - //#region class - /** Generic dialog for exporting and importing any string of data */ - class ExImDialog extends BytmDialog { - constructor(options) { - super(Object.assign({ renderHeader: () => ExImDialog.renderHeader(options), renderBody: () => ExImDialog.renderBody(options), renderFooter: undefined, closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, unmountOnClose: true, small: true }, options)); } + // make sure body is inert and scroll is locked + document.body.classList.add("bytm-disable-scroll"); + (_c = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _c === void 0 ? void 0 : _c.setAttribute("inert", "true"); + const dialogBg = document.querySelector(`#bytm-${this.id}-dialog-bg`); + dialogBg === null || dialogBg === void 0 ? void 0 : dialogBg.removeAttribute("inert"); + } + /** Called on every {@linkcode mount()} to attach all generic event listeners */ + attachListeners(bgElem) { + if (this.options.closeOnBgClick) { + bgElem.addEventListener("click", (e) => { + var _a; + if (this.isOpen() && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === `bytm-${this.id}-dialog-bg`) + this.close(e); + }); + } + if (this.options.closeOnEscPress) { + document.body.addEventListener("keydown", (e) => { + if (e.key === "Escape" && this.isOpen() && BytmDialog.getCurrentDialogId() === this.id) + this.close(e); + }); + } + } + /** Returns the dialog content element and all its children */ + async getDialogContent() { + var _a, _b, _c, _d; + const header = (_b = (_a = this.options).renderHeader) === null || _b === void 0 ? void 0 : _b.call(_a); + const footer = (_d = (_c = this.options).renderFooter) === null || _d === void 0 ? void 0 : _d.call(_c); + const dialogWrapperEl = document.createElement("div"); + dialogWrapperEl.id = `bytm-${this.id}-dialog`; + dialogWrapperEl.classList.add("bytm-dialog"); + dialogWrapperEl.ariaLabel = dialogWrapperEl.title = ""; + dialogWrapperEl.role = "dialog"; + dialogWrapperEl.setAttribute("aria-labelledby", `bytm-${this.id}-dialog-title`); + dialogWrapperEl.setAttribute("aria-describedby", `bytm-${this.id}-dialog-body`); + if (this.options.verticalAlign !== "center") + dialogWrapperEl.classList.add(`align-${this.options.verticalAlign}`); //#region header - static async renderHeader(opts) { - const headerEl = document.createElement("h2"); - headerEl.classList.add("bytm-menu-title"); - headerEl.role = "heading"; - headerEl.ariaLevel = "1"; - headerEl.tabIndex = 0; - headerEl.textContent = headerEl.ariaLabel = typeof opts.title === "function" - ? await opts.title() - : opts.title; - return headerEl; + const headerWrapperEl = document.createElement("div"); + headerWrapperEl.classList.add("bytm-dialog-header"); + this.options.small && headerWrapperEl.classList.add("small"); + if (header) { + const headerTitleWrapperEl = document.createElement("div"); + headerTitleWrapperEl.id = `bytm-${this.id}-dialog-title`; + headerTitleWrapperEl.classList.add("bytm-dialog-title-wrapper"); + headerTitleWrapperEl.role = "heading"; + headerTitleWrapperEl.ariaLevel = "1"; + headerTitleWrapperEl.appendChild(header instanceof Promise ? await header : header); + headerWrapperEl.appendChild(headerTitleWrapperEl); } + else { + // insert element to pad the header height + const padEl = document.createElement("div"); + padEl.classList.add("bytm-dialog-header-pad"); + this.options.small && padEl.classList.add("small"); + headerWrapperEl.appendChild(padEl); + } + if (this.options.closeBtnEnabled) { + const closeBtnEl = document.createElement("img"); + closeBtnEl.classList.add("bytm-dialog-close"); + this.options.small && closeBtnEl.classList.add("small"); + closeBtnEl.src = await getResourceUrl("img-close"); + closeBtnEl.role = "button"; + closeBtnEl.tabIndex = 0; + closeBtnEl.alt = closeBtnEl.title = closeBtnEl.ariaLabel = t("close_menu_tooltip"); + onInteraction(closeBtnEl, () => this.close()); + headerWrapperEl.appendChild(closeBtnEl); + } + dialogWrapperEl.appendChild(headerWrapperEl); //#region body - static async renderBody(opts) { - const panesCont = document.createElement("div"); - panesCont.classList.add("bytm-exim-dialog-panes-cont"); - //#region export - const exportPane = document.createElement("div"); - exportPane.classList.add("bytm-exim-dialog-pane", "export"); - { - const descEl = document.createElement("p"); - descEl.classList.add("bytm-exim-dialog-desc"); - descEl.role = "note"; - descEl.tabIndex = 0; - descEl.textContent = descEl.ariaLabel = typeof opts.descExport === "function" - ? await opts.descExport() - : opts.descExport; - const dataEl = document.createElement("textarea"); - dataEl.classList.add("bytm-exim-dialog-data"); - dataEl.readOnly = true; - dataEl.tabIndex = 0; - dataEl.value = t("click_to_reveal"); - onInteraction(dataEl, async () => { - dataEl.value = typeof opts.exportData === "function" ? await opts.exportData() : opts.exportData; - }); - const exportCenterBtnCont = document.createElement("div"); - exportCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont"); - const copyBtn = createRipple(await createLongBtn({ - title: t("copy_to_clipboard"), - text: t("copy"), - resourceName: "icon-copy", - async onClick({ shiftKey }) { - const copyData = shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData; - copyToClipboard(typeof copyData === "function" ? await copyData() : copyData); - await showToast({ message: t("copied_to_clipboard") }); - }, - })); - exportCenterBtnCont.appendChild(copyBtn); - exportPane.append(descEl, dataEl, exportCenterBtnCont); - } - //#region import - const importPane = document.createElement("div"); - importPane.classList.add("bytm-exim-dialog-pane", "import"); - { - const descEl = document.createElement("p"); - descEl.classList.add("bytm-exim-dialog-desc"); - descEl.role = "note"; - descEl.tabIndex = 0; - descEl.textContent = descEl.ariaLabel = typeof opts.descImport === "function" - ? await opts.descImport() - : opts.descImport; - const dataEl = document.createElement("textarea"); - dataEl.classList.add("bytm-exim-dialog-data"); - dataEl.tabIndex = 0; - const importCenterBtnCont = document.createElement("div"); - importCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont"); - const importBtn = createRipple(await createLongBtn({ - title: t("start_import_tooltip"), - text: t("import"), - resourceName: "icon-upload", - onClick: () => opts.onImport(dataEl.value), - })); - importCenterBtnCont.appendChild(importBtn); - importPane.append(descEl, dataEl, importCenterBtnCont); - } - panesCont.append(exportPane, importPane); - return panesCont; + const dialogBodyElem = document.createElement("div"); + dialogBodyElem.id = `bytm-${this.id}-dialog-body`; + dialogBodyElem.classList.add("bytm-dialog-body"); + this.options.small && dialogBodyElem.classList.add("small"); + const body = this.options.renderBody(); + dialogBodyElem.appendChild(body instanceof Promise ? await body : body); + dialogWrapperEl.appendChild(dialogBodyElem); + //#region footer + if (footer) { + const footerWrapper = document.createElement("div"); + footerWrapper.classList.add("bytm-dialog-footer-cont"); + this.options.small && footerWrapper.classList.add("small"); + dialogWrapperEl.appendChild(footerWrapper); + footerWrapper.appendChild(footer instanceof Promise ? await footer : footer); + } + return dialogWrapperEl; + } +}/** Creates a simple toggle element */ +async function createToggleInput({ onChange, initialValue = false, id = UserUtils.randomId(6, 36), labelPos = "left", }) { + const wrapperEl = document.createElement("div"); + wrapperEl.classList.add("bytm-toggle-input-wrapper", "bytm-no-select"); + wrapperEl.role = "switch"; + wrapperEl.tabIndex = 0; + const labelEl = labelPos !== "off" && document.createElement("label"); + if (labelEl) { + labelEl.classList.add("bytm-toggle-input-label"); + labelEl.textContent = t(`toggled_${initialValue ? "on" : "off"}`); + if (id) + labelEl.htmlFor = `bytm-toggle-input-${id}`; + } + const toggleWrapperEl = document.createElement("div"); + toggleWrapperEl.classList.add("bytm-toggle-input"); + toggleWrapperEl.tabIndex = -1; + const toggleEl = document.createElement("input"); + toggleEl.type = "checkbox"; + toggleEl.checked = initialValue; + toggleEl.dataset.toggled = String(Boolean(initialValue)); + toggleEl.tabIndex = -1; + if (id) + toggleEl.id = `bytm-toggle-input-${id}`; + const toggleKnobEl = document.createElement("div"); + toggleKnobEl.classList.add("bytm-toggle-input-knob"); + // TODO: this doesn't make the knob show up on Chromium + setInnerHtml(toggleKnobEl, " "); + const toggleElClicked = (e) => { + e.preventDefault(); + e.stopPropagation(); + onChange(toggleEl.checked); + toggleEl.dataset.toggled = String(Boolean(toggleEl.checked)); + if (labelEl) + labelEl.textContent = t(`toggled_${toggleEl.checked ? "on" : "off"}`); + wrapperEl.ariaValueText = t(`toggled_${toggleEl.checked ? "on" : "off"}`); + }; + toggleEl.addEventListener("change", toggleElClicked); + wrapperEl.addEventListener("keydown", (e) => { + if (["Space", " ", "Enter"].includes(e.code)) { + toggleEl.checked = !toggleEl.checked; + toggleElClicked(e); } - } - - /** EventEmitter instance that is used to detect various changes to the site and userscript */ - const siteEvents = new UserUtils.NanoEmitter({ - publicEmit: true, }); - let observers = []; - let lastWatchId = null; - let lastPathname = null; - let lastFullscreen; - /** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */ - async function initSiteEvents() { - try { + toggleEl.appendChild(toggleKnobEl); + toggleWrapperEl.appendChild(toggleEl); + labelEl && labelPos === "left" && wrapperEl.appendChild(labelEl); + wrapperEl.appendChild(toggleWrapperEl); + labelEl && labelPos === "right" && wrapperEl.appendChild(labelEl); + return wrapperEl; +}var name = "betterytm"; +var userscriptName = "BetterYTM"; +var version = "2.2.0"; +var description = "Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™"; +var homepage = "https://github.com/Sv443/BetterYTM"; +var main = "./src/index.ts"; +var type = "module"; +var scripts = { + dev: "concurrently \"nodemon --exec pnpm run build-private-dev --config-assetSource=local\" \"pnpm run serve\"", + "dev-cdn": "concurrently \"nodemon --exec pnpm run build-private-dev\" \"pnpm run serve\"", + serve: "pnpm run node-ts ./src/tools/serve.ts", + lint: "eslint . && tsc --noEmit", + build: "rollup -c", + "build-private-dev": "rollup -c --config-mode development --config-host github --config-branch develop", + "build-dev": "rollup -c --config-mode development --config-host github --config-branch develop", + preview: "pnpm run build-prod-gh --config-assetSource=local && pnpm run serve --auto-exit-time=6", + "build-prod": "pnpm run build-prod-gh && pnpm run build-prod-gf && pnpm run build-prod-oujs", + "build-prod-base": "rollup -c --config-mode production --config-branch main", + "build-prod-gh": "pnpm run build-prod-base --config-host github", + "build-prod-gf": "pnpm run build-prod-base --config-host greasyfork --config-suffix _gf", + "build-prod-oujs": "pnpm run build-prod-base --config-host openuserjs --config-suffix _oujs", + "post-build": "pnpm run node-ts ./src/tools/post-build.ts", + "tr-changed": "pnpm run node-ts ./src/tools/tr-changed.ts", + "tr-progress": "pnpm run node-ts ./src/tools/tr-progress.ts", + "tr-format": "pnpm run node-ts ./src/tools/tr-format.ts", + "tr-prep": "pnpm run tr-format -p", + "gen-readme": "pnpm run node-ts ./src/tools/gen-readme.ts", + "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm", + invisible: "node --enable-source-maps src/tools/run-invisible.mjs", + test: "pnpm run node-ts ./test.ts", + knip: "knip", + storybook: "storybook dev -p 6006", + "build-storybook": "storybook build" +}; +var engines = { + node: ">=19", + pnpm: ">=6" +}; +var repository = { + type: "git", + url: "git+https://github.com/Sv443/BetterYTM.git" +}; +var author = { + name: "Sv443", + url: "https://github.com/Sv443" +}; +var license = "AGPL-3.0-only"; +var bugs = { + url: "https://github.com/Sv443/BetterYTM/issues" +}; +var funding = { + type: "github", + url: "https://github.com/sponsors/Sv443" +}; +var hosts = { + github: "https://github.com/Sv443/BetterYTM", + greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm", + openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM" +}; +var updates = { + github: "https://github.com/Sv443/BetterYTM/releases", + greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm", + openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM" +}; +var dependencies = { + "@sv443-network/userutils": "^9.0.4", + "compare-versions": "^6.1.0", + dompurify: "^3.1.6", + marked: "^12.0.2", + tslib: "^2.6.3" +}; +var devDependencies = { + "@chromatic-com/storybook": "^1.5.0", + "@eslint/eslintrc": "^3.1.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^11.1.6", + "@storybook/addon-essentials": "^8.1.10", + "@storybook/addon-interactions": "^8.1.10", + "@storybook/addon-links": "^8.1.10", + "@storybook/blocks": "^8.1.10", + "@storybook/html": "^8.1.10", + "@storybook/html-vite": "^8.1.10", + "@storybook/test": "^8.1.10", + "@types/cors": "^2.8.17", + "@types/dompurify": "^3.0.5", + "@types/express": "^4.17.21", + "@types/greasemonkey": "^4.0.7", + "@types/node": "^20.14.8", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@typescript-eslint/utils": "^8.0.0", + concurrently: "^9.0.1", + cors: "^2.8.5", + dotenv: "^16.4.5", + eslint: "^9.5.0", + "eslint-plugin-storybook": "^0.11.0", + express: "^4.19.2", + globals: "^15.6.0", + kleur: "^4.1.5", + knip: "^5.22.2", + nanoevents: "^9.0.0", + nodemon: "^3.1.4", + "open-cli": "^8.0.0", + pnpm: "^9.4.0", + rollup: "^4.18.0", + "rollup-plugin-execute": "^1.1.1", + "rollup-plugin-import-css": "^3.5.0", + storybook: "^8.1.10", + "storybook-dark-mode": "^4.0.2", + "ts-node": "^10.9.2", + tsx: "^4.19.2", + typescript: "^5.5.2" +}; +var browserslist = [ + "last 1 version", + "> 1%", + "not dead" +]; +var nodemonConfig = { + watch: [ + "src/**", + "assets/**", + "rollup.config.mjs", + ".env", + "changelog.md", + "package.json" + ], + ext: "ts,mts,js,jsx,mjs,json,html,css,svg,png", + ignore: [ + "dist/*", + "dev/*", + "*/stories/*", + "assets/**/spritesheet.svg" + ] +}; +var packageJson = { + name: name, + userscriptName: userscriptName, + version: version, + description: description, + homepage: homepage, + main: main, + type: type, + scripts: scripts, + engines: engines, + repository: repository, + author: author, + license: license, + bugs: bugs, + funding: funding, + hosts: hosts, + updates: updates, + dependencies: dependencies, + devDependencies: devDependencies, + browserslist: browserslist, + nodemonConfig: nodemonConfig +};/** EventEmitter instance that is used to detect various changes to the site and userscript */ +const siteEvents = new UserUtils.NanoEmitter({ + publicEmit: true, +}); +let observers = []; +let lastWatchId = null; +let lastPathname = null; +let lastFullscreen; +/** Creates MutationObservers that check if parts of the site have changed, then emit an event on the `siteEvents` instance. */ +async function initSiteEvents() { + try { + if (getDomain() === "ytm") { + //#region queue + // the queue container always exists so it doesn't need an extra init function + const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => { + if (addedNodes.length > 0 || removedNodes.length > 0) { + info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`); + emitSiteEvent("queueChanged", target); + } + }); + // only observe added or removed elements + addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", { + listener: (el) => { + queueObs.observe(el, { + childList: true, + }); + }, + }); + const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => { + if (addedNodes.length > 0 || removedNodes.length > 0) { + info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`); + emitSiteEvent("autoplayQueueChanged", target); + } + }); + addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", { + listener: (el) => { + autoplayObs.observe(el, { + childList: true, + }); + }, + }); + //#region player bar + let lastTitle = null; + addSelectorListener("playerBarInfo", "yt-formatted-string.title", { + continuous: true, + listener: (titleElem) => { + const oldTitle = lastTitle; + const newTitle = titleElem.textContent; + if (newTitle === lastTitle || !newTitle) + return; + lastTitle = newTitle; + info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`); + emitSiteEvent("songTitleChanged", newTitle, oldTitle); + runIntervalChecks(); + }, + }); + info("Successfully initialized SiteEvents observers"); + observers = observers.concat([ + queueObs, + autoplayObs, + ]); + //#region player + const playerFullscreenObs = new MutationObserver(([{ target }]) => { + var _a; + const isFullscreen = ((_a = target.getAttribute("player-ui-state")) === null || _a === void 0 ? void 0 : _a.toUpperCase()) === "FULLSCREEN"; + if (lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") { + emitSiteEvent("fullscreenToggled", isFullscreen); + lastFullscreen = isFullscreen; + } + }); if (getDomain() === "ytm") { - //#region queue - // the queue container always exists so it doesn't need an extra init function - const queueObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => { - if (addedNodes.length > 0 || removedNodes.length > 0) { - info(`Detected queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`); - emitSiteEvent("queueChanged", target); - } - }); - // only observe added or removed elements - addSelectorListener("sidePanel", "#contents.ytmusic-player-queue", { + const registerFullScreenObs = () => addSelectorListener("mainPanel", "ytmusic-player#player", { listener: (el) => { - queueObs.observe(el, { - childList: true, + playerFullscreenObs.observe(el, { + attributeFilter: ["player-ui-state"], }); }, }); - const autoplayObs = new MutationObserver(([{ addedNodes, removedNodes, target }]) => { - if (addedNodes.length > 0 || removedNodes.length > 0) { - info(`Detected autoplay queue change - added nodes: ${[...addedNodes.values()].length} - removed nodes: ${[...removedNodes.values()].length}`); - emitSiteEvent("autoplayQueueChanged", target); + if (globserversReady) + registerFullScreenObs(); + else + window.addEventListener("bytm:observersReady", registerFullScreenObs, { once: true }); + } + } + window.addEventListener("bytm:ready", () => { + runIntervalChecks(); + setInterval(runIntervalChecks, 100); + if (getDomain() === "ytm") { + addSelectorListener("mainPanel", "ytmusic-player #song-video #movie_player .ytp-title-text > a", { + listener(el) { + const urlRefObs = new MutationObserver(([{ target }]) => { + var _a; + if (!target || !((_a = target === null || target === void 0 ? void 0 : target.href) === null || _a === void 0 ? void 0 : _a.includes("/watch"))) + return; + const watchId = new URL(target.href).searchParams.get("v"); + checkWatchIdChange(watchId); + }); + urlRefObs.observe(el, { + attributeFilter: ["href"], + }); } }); - addSelectorListener("sidePanel", "ytmusic-player-queue #automix-contents", { - listener: (el) => { - autoplayObs.observe(el, { - childList: true, - }); - }, - }); - //#region player bar - let lastTitle = null; - addSelectorListener("playerBarInfo", "yt-formatted-string.title", { - continuous: true, - listener: (titleElem) => { - const oldTitle = lastTitle; - const newTitle = titleElem.textContent; - if (newTitle === lastTitle || !newTitle) - return; - lastTitle = newTitle; - info(`Detected song change - old title: "${oldTitle}" - new title: "${newTitle}"`); - emitSiteEvent("songTitleChanged", newTitle, oldTitle); - runIntervalChecks(); - }, - }); - info("Successfully initialized SiteEvents observers"); - observers = observers.concat([ - queueObs, - autoplayObs, - ]); - //#region player - const playerFullscreenObs = new MutationObserver(([{ target }]) => { - var _a; - const isFullscreen = ((_a = target.getAttribute("player-ui-state")) === null || _a === void 0 ? void 0 : _a.toUpperCase()) === "FULLSCREEN"; - if (lastFullscreen !== isFullscreen || typeof lastFullscreen === "undefined") { - emitSiteEvent("fullscreenToggled", isFullscreen); - lastFullscreen = isFullscreen; - } - }); - if (getDomain() === "ytm") { - const registerFullScreenObs = () => addSelectorListener("mainPanel", "ytmusic-player#player", { - listener: (el) => { - playerFullscreenObs.observe(el, { - attributeFilter: ["player-ui-state"], - }); - }, - }); - if (globserversReady) - registerFullScreenObs(); - else - window.addEventListener("bytm:observersReady", registerFullScreenObs, { once: true }); - } } - window.addEventListener("bytm:ready", () => { - runIntervalChecks(); - setInterval(runIntervalChecks, 100); - if (getDomain() === "ytm") { - addSelectorListener("mainPanel", "ytmusic-player #song-video #movie_player .ytp-title-text > a", { - listener(el) { - const urlRefObs = new MutationObserver(([{ target }]) => { - var _a; - if (!target || !((_a = target === null || target === void 0 ? void 0 : target.href) === null || _a === void 0 ? void 0 : _a.includes("/watch"))) - return; - const watchId = new URL(target.href).searchParams.get("v"); - checkWatchIdChange(watchId); - }); - urlRefObs.observe(el, { - attributeFilter: ["href"], - }); - } - }); - } - if (getDomain() === "ytm") { - setInterval(checkWatchIdChange, 250); - checkWatchIdChange(); - } - }, { - once: true, - }); - } - catch (err) { - error("Couldn't initialize site event observers due to an error:\n", err); - } - } - let bytmReady = false; - window.addEventListener("bytm:ready", () => bytmReady = true, { once: true }); - /** Emits a site event with the given key and arguments - if `bytm:ready` has not been emitted yet, all events will be queued until it is */ - function emitSiteEvent(key, ...args) { - try { - if (!bytmReady) { - window.addEventListener("bytm:ready", () => { - bytmReady = true; - emitSiteEvent(key, ...args); - }, { once: true }); - return; + if (getDomain() === "ytm") { + setInterval(checkWatchIdChange, 250); + checkWatchIdChange(); } - siteEvents.emit(key, ...args); - emitInterface(`bytm:siteEvent:${key}`, args); - } - catch (err) { - error(`Couldn't emit site event "${key}" due to an error:\n`, err); - } + }, { + once: true, + }); } - //#region other - /** Checks if the watch ID has changed and emits a `watchIdChanged` siteEvent if it has */ - function checkWatchIdChange(newId) { - const newWatchId = newId !== null && newId !== void 0 ? newId : new URL(location.href).searchParams.get("v"); - if (newWatchId && newWatchId !== lastWatchId) { - info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`); - emitSiteEvent("watchIdChanged", newWatchId, lastWatchId); - lastWatchId = newWatchId; - } + catch (err) { + error("Couldn't initialize site event observers due to an error:\n", err); } - /** Periodically called to check for changes in the URL and emit associated siteEvents */ - function runIntervalChecks() { - if (!lastWatchId) - checkWatchIdChange(); - if (location.pathname !== lastPathname) { - emitSiteEvent("pathChanged", String(location.pathname), lastPathname); - lastPathname = String(location.pathname); +} +let bytmReady = false; +window.addEventListener("bytm:ready", () => bytmReady = true, { once: true }); +/** Emits a site event with the given key and arguments - if `bytm:ready` has not been emitted yet, all events will be queued until it is */ +function emitSiteEvent(key, ...args) { + try { + if (!bytmReady) { + window.addEventListener("bytm:ready", () => { + bytmReady = true; + emitSiteEvent(key, ...args); + }, { once: true }); + return; } + siteEvents.emit(key, ...args); + emitInterface(`bytm:siteEvent:${key}`, args); } - - let otherHotkeyInputActive = false; - const reservedKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Meta", "Tab", "Space", " "]; - /** Creates a hotkey input element */ - function createHotkeyInput({ initialValue, onChange, createTitle }) { - var _a; - const initialHotkey = initialValue; - let currentHotkey; - if (!createTitle) - createTitle = (value) => value; - const wrapperElem = document.createElement("div"); - wrapperElem.classList.add("bytm-hotkey-wrapper"); - const infoElem = document.createElement("span"); - infoElem.classList.add("bytm-hotkey-info"); - const inputElem = document.createElement("button"); - inputElem.role = "button"; - inputElem.classList.add("bytm-ftconf-input", "bytm-hotkey-input", "bytm-btn"); - inputElem.dataset.state = "inactive"; - inputElem.innerText = (_a = initialValue === null || initialValue === void 0 ? void 0 : initialValue.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change"); - inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue)); - const resetElem = document.createElement("span"); - resetElem.classList.add("bytm-hotkey-reset", "bytm-link", "bytm-hidden"); - resetElem.role = "button"; - resetElem.tabIndex = 0; - resetElem.textContent = `(${t("reset")})`; - resetElem.ariaLabel = resetElem.title = t("hotkey_input_click_to_reset_tooltip"); - const deactivate = () => { - var _a; - if (!otherHotkeyInputActive) - return; - emitSiteEvent("hotkeyInputActive", false); - otherHotkeyInputActive = false; - const curHk = currentHotkey !== null && currentHotkey !== void 0 ? currentHotkey : initialValue; - inputElem.innerText = (_a = curHk === null || curHk === void 0 ? void 0 : curHk.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change"); - inputElem.dataset.state = "inactive"; - inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(curHk)); - setInnerHtml(infoElem, curHk ? getHotkeyInfoHtml(curHk) : ""); - }; - const activate = () => { - if (otherHotkeyInputActive) - return; - emitSiteEvent("hotkeyInputActive", true); - otherHotkeyInputActive = true; - inputElem.innerText = "< ... >"; - inputElem.dataset.state = "active"; - inputElem.ariaLabel = inputElem.title = t("click_to_cancel_tooltip"); - }; - const resetClicked = (e) => { - e.preventDefault(); - e.stopImmediatePropagation(); - onChange(initialValue); - currentHotkey = initialValue; - deactivate(); - inputElem.innerText = initialValue.code; - setInnerHtml(infoElem, getHotkeyInfoHtml(initialValue)); - resetElem.classList.add("bytm-hidden"); - inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue)); - }; - onInteraction(resetElem, resetClicked); - if (initialValue) - setInnerHtml(infoElem, getHotkeyInfoHtml(initialValue)); - let lastKeyDown; - document.addEventListener("keypress", (e) => { - if (inputElem.dataset.state === "inactive") - return; - if ((lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.code) === e.code && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.shift) === e.shiftKey && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.ctrl) === e.ctrlKey && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.alt) === e.altKey) - return; - e.preventDefault(); - e.stopImmediatePropagation(); - const hotkey = { - code: e.code, - shift: e.shiftKey, - ctrl: e.ctrlKey, - alt: e.altKey, - }; - inputElem.innerText = hotkey.code; - inputElem.dataset.state = "inactive"; - setInnerHtml(infoElem, getHotkeyInfoHtml(hotkey)); - inputElem.ariaLabel = inputElem.title = t("click_to_cancel_tooltip"); - onChange(hotkey); - currentHotkey = hotkey; - }); - document.addEventListener("keydown", (e) => { - if (reservedKeys.filter(k => k !== "Tab").includes(e.code)) - return; - if (inputElem.dataset.state !== "active") - return; - if (e.code === "Tab" || e.code === " " || e.code === "Space" || e.code === "Escape" || e.code === "Enter") { - deactivate(); - return; - } - if (["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight"].includes(e.code)) - return; - e.preventDefault(); - e.stopImmediatePropagation(); - const hotkey = { - code: e.code, - shift: e.shiftKey, - ctrl: e.ctrlKey, - alt: e.altKey, - }; - const keyChanged = (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.code) !== hotkey.code || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.shift) !== hotkey.shift || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.ctrl) !== hotkey.ctrl || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.alt) !== hotkey.alt; - lastKeyDown = hotkey; - onChange(hotkey); - currentHotkey = hotkey; - if (keyChanged) { - deactivate(); - resetElem.classList.remove("bytm-hidden"); - } - else - resetElem.classList.add("bytm-hidden"); - inputElem.innerText = hotkey.code; - inputElem.dataset.state = "inactive"; - setInnerHtml(infoElem, getHotkeyInfoHtml(hotkey)); - }); - siteEvents.on("cfgMenuClosed", deactivate); - inputElem.addEventListener("click", () => { - if (inputElem.dataset.state === "inactive") - activate(); - else - deactivate(); - }); - inputElem.addEventListener("keydown", (e) => { - if (reservedKeys.includes(e.code)) - return; - if (inputElem.dataset.state === "inactive") - activate(); - }); - wrapperElem.appendChild(resetElem); - wrapperElem.appendChild(infoElem); - wrapperElem.appendChild(inputElem); - return wrapperElem; - } - /** Returns HTML for the hotkey modifier keys info element */ - function getHotkeyInfoHtml(hotkey) { - const modifiers = []; - hotkey.ctrl && modifiers.push(`${t("hotkey_key_ctrl")}`); - hotkey.shift && modifiers.push(`${t("hotkey_key_shift")}`); - hotkey.alt && modifiers.push(`${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}`); - return `\ -
- - ${modifiers.reduce((a, c) => `${a ? a + " " : ""}${c}`, "")} - - - ${modifiers.length > 0 ? "+" : ""} - -
`; + catch (err) { + error(`Couldn't emit site event "${key}" due to an error:\n`, err); } - /** Converts a hotkey object to a string */ - function hotkeyToString(hotkey) { - if (!hotkey) - return t("hotkey_key_none"); - let str = ""; - if (hotkey.ctrl) - str += `${t("hotkey_key_ctrl")}+`; - if (hotkey.shift) - str += `${t("hotkey_key_shift")}+`; - if (hotkey.alt) - str += `${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}+`; - str += hotkey.code; - return str; +} +//#region other +/** Checks if the watch ID has changed and emits a `watchIdChanged` siteEvent if it has */ +function checkWatchIdChange(newId) { + const newWatchId = newId !== null && newId !== void 0 ? newId : new URL(location.href).searchParams.get("v"); + if (newWatchId && newWatchId !== lastWatchId) { + info(`Detected watch ID change - old ID: "${lastWatchId}" - new ID: "${newWatchId}"`); + emitSiteEvent("watchIdChanged", newWatchId, lastWatchId); + lastWatchId = newWatchId; } - - /** - * Creates a generic, circular, long button element with an icon and text. - * Has classes for the enabled and disabled states for easier styling. - * If `href` is provided, the button will be an anchor element. - * If `onClick` or `onToggle` is provided, the button will be a div element. - * Provide either `resourceName` or `src` to specify the icon inside the button. - */ - async function createLongBtn(_a) { - var _b; - var { title, text, iconPosition, ripple } = _a, rest = __rest(_a, ["title", "text", "iconPosition", "ripple"]); - if (["href", "onClick", "onToggle"].every((key) => !(key in rest))) - throw new TypeError("Either 'href', 'onClick' or 'onToggle' must be provided"); - let btnElem; - if ("href" in rest && rest.href) { - btnElem = document.createElement("a"); - btnElem.href = rest.href; - btnElem.role = "button"; - btnElem.target = "_blank"; - btnElem.rel = "noopener noreferrer"; - } - else - btnElem = document.createElement("div"); - if ("toggle" in rest && rest.toggle) { - btnElem.classList.add("bytm-toggle"); - if ("toggleInitialState" in rest && rest.toggleInitialState) - btnElem.classList.add("toggled"); - } - onInteraction(btnElem, (evt) => { - var _a; - if ("onClick" in rest) - rest.onClick(evt); - if ("toggle" in rest && rest.toggle && ((_a = rest.togglePredicate) !== null && _a !== void 0 ? _a : (() => true))(evt)) - rest.onToggle(btnElem.classList.toggle("toggled"), evt); +} +/** Periodically called to check for changes in the URL and emit associated siteEvents */ +function runIntervalChecks() { + if (!lastWatchId) + checkWatchIdChange(); + if (location.pathname !== lastPathname) { + emitSiteEvent("pathChanged", String(location.pathname), lastPathname); + lastPathname = String(location.pathname); + } +}let verNotifDialog = null; +/** Creates and/or returns the dialog to be shown when a new version is available */ +async function getVersionNotifDialog({ latestTag, }) { + if (!verNotifDialog) { + const changelogMdFull = await getChangelogMd(); + // I messed up because this should be 0 so the changelog will always need to have an extra div at the top for backwards compatibility + const changelogMd = changelogMdFull.split("
")[1]; + const changelogHtml = await parseMarkdown(changelogMd); + verNotifDialog = new BytmDialog({ + id: "version-notif", + width: 600, + height: 800, + closeBtnEnabled: false, + closeOnBgClick: false, + closeOnEscPress: true, + destroyOnClose: true, + small: true, + renderHeader: renderHeader$5, + renderBody: () => renderBody$6({ latestTag, changelogHtml }), }); - btnElem.classList.add("bytm-generic-btn", "long"); - btnElem.ariaLabel = btnElem.title = title; - btnElem.tabIndex = 0; - btnElem.role = "button"; - const imgElem = document.createElement("src" in rest ? "img" : "div"); - imgElem.classList.add("bytm-generic-btn-img", iconPosition !== null && iconPosition !== void 0 ? iconPosition : "left"); - if ("src" in rest) - imgElem.src = rest.src; - else - setInnerHtml(imgElem, (_b = await resourceAsString(rest.resourceName)) !== null && _b !== void 0 ? _b : ""); - const txtElem = document.createElement("span"); - txtElem.classList.add("bytm-generic-long-btn-txt", "bytm-no-select"); - txtElem.textContent = txtElem.ariaLabel = text; - iconPosition === "left" || !iconPosition && btnElem.appendChild(imgElem); - btnElem.appendChild(txtElem); - iconPosition === "right" && btnElem.appendChild(imgElem); - return ripple ? createRipple(btnElem, { speed: "normal" }) : btnElem; } - - var langMapping = { - "de-DE": { - name: "Deutsch (Deutschland)", - nameEnglish: "German", - emoji: "🇩🇪", - userscriptDesc: "Konfigurierbare Layout- und Benutzererfahrungs-Verbesserungen für YouTube Music™ und YouTube™", - authors: [ - "Sv443" - ], - altLocales: [ - "de", - "de-AT", - "de-CH" - ] - }, - "en-US": { - name: "English (United States)", - nameEnglish: "English (US)", - emoji: "🇺🇸", - userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™", - authors: [ - "Sv443" - ], - altLocales: [ - "en", - "en-CA", - "en-AU" - ] - }, - "en-GB": { - name: "English (Great Britain)", - nameEnglish: "English (GB)", - emoji: "🇬🇧", - userscriptDesc: "Configurable layout and user experience improvements for YouTube Music™ and YouTube™", - authors: [ - "Sv443" - ] - }, - "es-ES": { - name: "Español (España)", - nameEnglish: "Spanish", - emoji: "🇪🇸", - userscriptDesc: "Mejoras de diseño y experiencia de usuario configurables para YouTube Music™ y YouTube™", - authors: [ - "Sv443" - ], - altLocales: [ - "es", - "es-MX" - ] - }, - "fr-FR": { - name: "Français (France)", - nameEnglish: "French", - emoji: "🇫🇷", - userscriptDesc: "Améliorations de la mise en page et de l'expérience utilisateur configurables pour YouTube Music™ et YouTube™", - authors: [ - "Sv443" - ], - altLocales: [ - "fr", - "fr-CA", - "fr-BE", - "fr-CH", - "fr-LU" - ] - }, - "hi-IN": { - name: "हिंदी (भारत)", - nameEnglish: "Hindi", - emoji: "🇮🇳", - userscriptDesc: "YouTube Music™ और YouTube™ के लिए कॉन्फ़िगर करने योग्य लेआउट और उपयोगकर्ता अनुभव में सुधार", - authors: [ - "Sv443" - ], - altLocales: [ - "hi", - "hi-NP" - ] - }, - "ja-JP": { - name: "日本語 (日本)", - nameEnglish: "Japanese", - emoji: "🇯🇵", - userscriptDesc: "YouTube Music™ と YouTube™ の構成可能なレイアウトとユーザー エクスペリエンスの向上", - authors: [ - "Sv443" - ], - altLocales: [ - "ja", - "ja-JP" - ] - }, - "pt-BR": { - name: "Português (Brasil)", - nameEnglish: "Portuguese", - emoji: "🇧🇷", - userscriptDesc: "Melhorias configuráveis no layout e na experiência do usuário para o YouTube Music™ e o YouTube™", - authors: [ - "Sv443" - ], - altLocales: [ - "pt", - "pt-PT" - ] - }, - "zh-CN": { - name: "中文(简化,中国)", - nameEnglish: "Chinese (simpl.)", - emoji: "🇨🇳", - userscriptDesc: "YouTube Music™ 和 YouTube™ 的可配置布局和用户体验改进", - authors: [ - "Sv443" - ], - altLocales: [ - "zh", - "zh-TW", - "zh-HK" - ] + return verNotifDialog; +} +async function renderHeader$5() { + const logoEl = document.createElement("img"); + logoEl.classList.add("bytm-dialog-header-img", "bytm-no-select"); + logoEl.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); + logoEl.alt = "BetterYTM logo"; + return logoEl; +} +let disableUpdateCheck = false; +async function renderBody$6({ latestTag, changelogHtml, }) { + disableUpdateCheck = false; + const wrapperEl = document.createElement("div"); + const pEl = document.createElement("p"); + pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host]); + wrapperEl.appendChild(pEl); + const changelogDetailsEl = document.createElement("details"); + changelogDetailsEl.id = "bytm-version-notif-changelog-details"; + changelogDetailsEl.open = false; + const changelogSummaryEl = document.createElement("summary"); + changelogSummaryEl.role = "button"; + changelogSummaryEl.tabIndex = 0; + changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = t("expand_release_notes"); + changelogDetailsEl.appendChild(changelogSummaryEl); + changelogDetailsEl.addEventListener("toggle", () => { + changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = changelogDetailsEl.open ? t("collapse_release_notes") : t("expand_release_notes"); + }); + const changelogEl = document.createElement("p"); + changelogEl.id = "bytm-version-notif-changelog-cont"; + changelogEl.classList.add("bytm-markdown-container"); + setInnerHtml(changelogEl, changelogHtml); + changelogEl.querySelectorAll("a").forEach((a) => { + a.target = "_blank"; + a.rel = "noopener noreferrer"; + }); + changelogDetailsEl.appendChild(changelogEl); + wrapperEl.appendChild(changelogDetailsEl); + const disableUpdCheckEl = document.createElement("div"); + disableUpdCheckEl.id = "bytm-disable-update-check-wrapper"; + if (!getFeature("versionCheck")) + disableUpdateCheck = true; + const disableToggleEl = await createToggleInput({ + id: "disable-update-check", + initialValue: disableUpdateCheck, + labelPos: "off", + onChange(checked) { + disableUpdateCheck = checked; + if (checked) + btnClose.textContent = t("close_and_ignore_until_reenabled"); + else + btnClose.textContent = t("close_and_ignore_for_24h"); + }, + }); + const labelWrapperEl = document.createElement("div"); + labelWrapperEl.classList.add("bytm-disable-update-check-toggle-label-wrapper"); + const labelEl = document.createElement("label"); + labelEl.htmlFor = "bytm-toggle-disable-update-check"; + labelEl.textContent = t("disable_update_check"); + const secondaryLabelEl = document.createElement("span"); + secondaryLabelEl.classList.add("bytm-secondary-label"); + secondaryLabelEl.textContent = t("reenable_in_config_menu"); + labelWrapperEl.appendChild(labelEl); + labelWrapperEl.appendChild(secondaryLabelEl); + disableUpdCheckEl.appendChild(disableToggleEl); + disableUpdCheckEl.appendChild(labelWrapperEl); + wrapperEl.appendChild(disableUpdCheckEl); + verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", async () => { + const config = getFeatures(); + const recreateCfgMenu = config.versionCheck === disableUpdateCheck; + if (config.versionCheck && disableUpdateCheck) + config.versionCheck = false; + else if (!config.versionCheck && !disableUpdateCheck) + config.versionCheck = true; + await setFeatures(config); + recreateCfgMenu && emitSiteEvent("recreateCfgMenu"); + }); + const btnWrapper = document.createElement("div"); + btnWrapper.id = "bytm-version-notif-dialog-btns"; + const btnUpdate = document.createElement("button"); + btnUpdate.classList.add("bytm-btn"); + btnUpdate.tabIndex = 0; + btnUpdate.textContent = t("open_update_page_install_manually", platformNames[host]); + onInteraction(btnUpdate, () => { + window.open(packageJson.updates[host]); + verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close(); + }); + const btnClose = document.createElement("button"); + btnClose.classList.add("bytm-btn"); + btnClose.tabIndex = 0; + btnClose.textContent = t("close_and_ignore_for_24h"); + onInteraction(btnClose, () => verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close()); + btnWrapper.appendChild(btnUpdate); + btnWrapper.appendChild(btnClose); + wrapperEl.appendChild(btnWrapper); + return wrapperEl; +}//#region PromptDialog +let promptDialog = null; +class PromptDialog extends BytmDialog { + constructor(props) { + super({ + id: "prompt-dialog", + width: 500, + height: 400, + destroyOnClose: true, + closeBtnEnabled: true, + closeOnBgClick: props.type === "alert", + closeOnEscPress: true, + small: true, + renderHeader: () => this.renderHeader(props), + renderBody: () => this.renderBody(props), + renderFooter: () => this.renderFooter(props), + }); + this.on("render", this.focusOnRender); } - }; - - var resourcesJson = { - "css-above_queue_btns": "style/aboveQueueBtns.css", - "css-anchor_improvements": "style/anchorImprovements.css", - "css-auto_like": "style/autoLike.css", - "css-fix_hdr": "style/fixHDR.css", - "css-fix_playerpage_theming": "style/fixPlayerPageTheming.css", - "css-fix_spacing": "style/fixSpacing.css", - "css-fix_sponsorblock": "style/fixSponsorBlock.css", - "css-hide_themesong_logo": "style/hideThemeSongLogo.css", - "css-show_votes": "style/showVotes.css", - "css-vol_slider_size": "style/volSliderSize.css", - "doc-changelog": { - path: "/changelog.md", - ref: "main" - }, - "font-cousine_ttf": "fonts/Cousine/Cousine-Regular.ttf", - "font-cousine_woff": "fonts/Cousine/Cousine-Regular.woff", - "font-cousine_woff2": "fonts/Cousine/Cousine-Regular.woff2", - "icon-advanced_mode": "icons/plus_circle_small.svg", - "icon-alert": "icons/alert.svg", - "icon-arrow_down": "icons/arrow_down.svg", - "icon-auto_like_enabled": "icons/auto_like_enabled.svg", - "icon-auto_like": "icons/auto_like.svg", - "icon-clear_list": "icons/clear_list.svg", - "icon-copy": "icons/copy.svg", - "icon-delete": "icons/delete.svg", - "icon-edit": "icons/edit.svg", - "icon-error": "icons/error.svg", - "icon-experimental": "icons/beaker_small.svg", - "icon-globe_small": "icons/globe_small.svg", - "icon-globe": "icons/globe.svg", - "icon-help": "icons/help.svg", - "icon-image_filled": "icons/image_filled.svg", - "icon-image": "icons/image.svg", - "icon-link": "icons/link.svg", - "icon-lyrics": "icons/lyrics.svg", - "icon-prompt": "icons/help.svg", - "icon-reload": "icons/refresh.svg", - "icon-restore_time": "icons/restore_time.svg", - "icon-skip_to": "icons/skip_to.svg", - "icon-spinner": "icons/spinner.svg", - "icon-upload": "icons/upload.svg", - "img-close": "images/close.png", - "img-discord": "images/external/discord.png", - "img-github": "images/external/github.png", - "img-greasyfork": "images/external/greasyfork.png", - "img-logo_dev": "images/logo/logo_dev_48.png", - "img-logo": "images/logo/logo_48.png", - "img-openuserjs": "images/external/openuserjs.png" - }; - - //#region misc - let domain; - /** - * Returns the current domain as a constant string representation - * @throws Throws if script runs on an unexpected website - */ - function getDomain() { - if (domain) - return domain; - if (location.hostname.match(/^music\.youtube/)) - return domain = "ytm"; - else if (location.hostname.match(/youtube\./)) - return domain = "yt"; - else - throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header."); + emitResolve(val) { + this.events.emit("resolve", val); } - /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */ - function getSessionId() { - try { - if (!sessionStorageAvailable) - throw new Error("Session storage unavailable"); - let sesId = window.sessionStorage.getItem("_bytm-session-id"); - if (!sesId) - window.sessionStorage.setItem("_bytm-session-id", sesId = UserUtils.randomId(10, 36)); - return sesId; - } - catch (err) { - warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err); - return null; - } + async renderHeader({ type }) { + const headerEl = document.createElement("div"); + headerEl.id = "bytm-prompt-dialog-header"; + setInnerHtml(headerEl, await resourceAsString(type === "alert" ? "icon-alert" : "icon-prompt")); + return headerEl; } - let isCompressionSupported; - /** Tests whether compression via the predefined {@linkcode compressionFormat} is supported (only on the first call, then returns the cached result) */ - async function compressionSupported() { - if (typeof isCompressionSupported === "boolean") - return isCompressionSupported; - try { - await UserUtils.compress(".", compressionFormat, "string"); - return isCompressionSupported = true; - } - catch (_a) { - return isCompressionSupported = false; + async renderBody(_a) { + var { type, message } = _a, rest = __rest(_a, ["type", "message"]); + const contElem = document.createElement("div"); + contElem.classList.add(`bytm-prompt-type-${type}`); + const upperContElem = document.createElement("div"); + upperContElem.id = "bytm-prompt-dialog-upper-cont"; + contElem.appendChild(upperContElem); + const messageElem = document.createElement("p"); + messageElem.id = "bytm-prompt-dialog-message"; + messageElem.role = "alert"; + messageElem.ariaLive = "polite"; + messageElem.tabIndex = 0; + messageElem.textContent = String(message); + upperContElem.appendChild(messageElem); + if (type === "prompt") { + const inputElem = document.createElement("input"); + inputElem.id = "bytm-prompt-dialog-input"; + inputElem.type = "text"; + inputElem.autocomplete = "off"; + inputElem.spellcheck = false; + inputElem.value = "defaultValue" in rest && rest.defaultValue + ? await UserUtils.consumeStringGen(rest.defaultValue) + : ""; + const inputEnterListener = (e) => { + var _a, _b; + if (e.key === "Enter") { + inputElem.removeEventListener("keydown", inputEnterListener); + this.emitResolve((_b = (_a = inputElem === null || inputElem === void 0 ? void 0 : inputElem.value) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : null); + promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); + } + }; + inputElem.addEventListener("keydown", inputEnterListener); + promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.once("close", () => inputElem.removeEventListener("keydown", inputEnterListener)); + upperContElem.appendChild(inputElem); } + return contElem; } - /** Returns a string with the given array's items separated by a default separator (`", "` by default), with an optional different separator for the last item */ - function arrayWithSeparators(array, separator = ", ", lastSeparator) { - const arr = [...array]; - if (arr.length === 0) - return ""; - else if (arr.length <= 2) - return arr.join(lastSeparator); - else - return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)}`; - } - /** Returns the watch ID of the current video or null if not on a video page */ - function getWatchId() { - const { searchParams, pathname } = new URL(location.href); - return pathname.includes("/watch") ? searchParams.get("v") : null; - } - /** - * Returns the ID of the current channel in the format `@User` or `UC...` from URLs with the path `/@User`, `/@User/videos`, `/channel/UC...` or `/channel/UC.../videos` - * Returns null if the current page is not a channel page or there was an error parsing the URL - */ - function getCurrentChannelId() { - return parseChannelIdFromUrl(location.href); - } - /** Returns the channel ID from a URL or null if the URL is invalid */ - function parseChannelIdFromUrl(url) { - try { - const { pathname } = url instanceof URL ? url : new URL(url); - if (pathname.includes("/channel/")) - return sanitizeChannelId(pathname.split("/channel/")[1].split("/")[0]); - else if (pathname.includes("/@")) - return sanitizeChannelId(pathname.split("/@")[1].split("/")[0]); - else - return null; - } - catch (_a) { - return null; + async renderFooter(_a) { + var { type } = _a, rest = __rest(_a, ["type"]); + const buttonsWrapper = document.createElement("div"); + buttonsWrapper.id = "bytm-prompt-dialog-button-wrapper"; + const buttonsCont = document.createElement("div"); + buttonsCont.id = "bytm-prompt-dialog-buttons-cont"; + let confirmBtn; + if (type === "confirm" || type === "prompt") { + confirmBtn = document.createElement("button"); + confirmBtn.id = "bytm-prompt-dialog-confirm"; + confirmBtn.classList.add("bytm-prompt-dialog-button"); + confirmBtn.textContent = await this.consumePromptStringGen(type, rest.confirmBtnText, t("prompt_confirm")); + confirmBtn.ariaLabel = confirmBtn.title = await this.consumePromptStringGen(type, rest.confirmBtnTooltip, t("click_to_confirm_tooltip")); + confirmBtn.tabIndex = 0; + confirmBtn.addEventListener("click", () => { + var _a, _b, _c; + this.emitResolve(type === "confirm" ? true : (_c = (_b = (_a = (document.querySelector("#bytm-prompt-dialog-input"))) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : null); + promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); + }, { once: true }); } + const closeBtn = document.createElement("button"); + closeBtn.id = "bytm-prompt-dialog-close"; + closeBtn.classList.add("bytm-prompt-dialog-button"); + closeBtn.textContent = await this.consumePromptStringGen(type, rest.denyBtnText, t(type === "alert" ? "prompt_close" : "prompt_cancel")); + closeBtn.ariaLabel = closeBtn.title = await this.consumePromptStringGen(type, rest.denyBtnTooltip, t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")); + closeBtn.tabIndex = 0; + closeBtn.addEventListener("click", () => { + const resVals = { + alert: true, + confirm: false, + prompt: null, + }; + this.emitResolve(resVals[type]); + promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); + }, { once: true }); + confirmBtn && getOS() !== "mac" && buttonsCont.appendChild(confirmBtn); + buttonsCont.appendChild(closeBtn); + confirmBtn && getOS() === "mac" && buttonsCont.appendChild(confirmBtn); + buttonsWrapper.appendChild(buttonsCont); + return buttonsWrapper; + } + /** Converts a {@linkcode stringGen} (stringifiable value or sync or async function that returns a stringifiable value) to a string - uses {@linkcode fallback} as a fallback */ + async consumePromptStringGen(curPromptType, stringGen, fallback) { + if (typeof stringGen === "function") + return await stringGen(curPromptType); + return String(stringGen !== null && stringGen !== void 0 ? stringGen : fallback); + } + /** Called on render to focus on the confirm or cancel button or text input, depending on prompt type */ + focusOnRender() { + const inputElem = document.querySelector("#bytm-prompt-dialog-input"); + if (inputElem) + return inputElem.focus(); + let captureEnterKey = true; + document.addEventListener("keydown", (e) => { + var _a; + if (e.key === "Enter" && captureEnterKey) { + const confBtn = document.querySelector("#bytm-prompt-dialog-confirm"); + const closeBtn = document.querySelector("#bytm-prompt-dialog-close"); + if (confBtn || closeBtn) { + (_a = confBtn === null || confBtn === void 0 ? void 0 : confBtn.click()) !== null && _a !== void 0 ? _a : closeBtn === null || closeBtn === void 0 ? void 0 : closeBtn.click(); + captureEnterKey = false; + } + } + }, { capture: true, once: true }); } - /** Sanitizes a channel ID by adding a leading `@` if the ID doesn't start with `UC...` */ - function sanitizeChannelId(channelId) { - channelId = String(channelId).trim(); - return isValidChannelId(channelId) || channelId.startsWith("@") - ? channelId - : `@${channelId}`; - } - /** Tests whether a string is a valid channel ID in the format `@User` or `UC...` */ - function isValidChannelId(channelId) { - return channelId.match(/^(UC|@)[\w-]+$/) !== null; +} +/** Custom dialog to emulate and enhance the behavior of the native `confirm()`, `alert()`, and `prompt()` functions */ +function showPrompt(_a) { + var { type } = _a, rest = __rest(_a, ["type"]); + return new Promise((resolve) => { + if (BytmDialog.getOpenDialogs().includes("prompt-dialog")) + promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); + promptDialog = new PromptDialog(Object.assign({ type }, rest)); + promptDialog.once("render", () => { + addSelectorListener("bytmDialogContainer", `#bytm-prompt-dialog-${type === "alert" ? "close" : "confirm"}`, { + listener: (btn) => btn.focus(), + }); + }); + // make config menu inert while prompt dialog is open + promptDialog.once("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); }); + promptDialog.once("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); }); + let resolveVal; + const tryResolve = () => resolve(typeof resolveVal !== "undefined" ? resolveVal : false); + let closeUnsub; // eslint-disable-line prefer-const + const resolveUnsub = promptDialog.on("resolve", (val) => { + resolveUnsub(); + if (resolveVal !== undefined) + return; + resolveVal = val; + tryResolve(); + closeUnsub === null || closeUnsub === void 0 ? void 0 : closeUnsub(); + }); + closeUnsub = promptDialog.on("close", () => { + closeUnsub(); + if (resolveVal !== undefined) + return; + resolveVal = type === "alert"; + if (type === "prompt") + resolveVal = null; + tryResolve(); + resolveUnsub(); + }); + promptDialog.open(); + }); +}const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest"; +/** Initializes the version check feature */ +async function initVersionCheck() { + try { + if (getFeature("versionCheck") === false) + return info("Version check is disabled"); + const lastCheck = await GM.getValue("bytm-version-check", 0); + if (Date.now() - lastCheck < 1000 * 60 * 60 * 24) + return; + await doVersionCheck(false); } - /** Returns the thumbnail URL for a video with either a given quality identifier or index */ - function getThumbnailUrl(watchId, qualityOrIndex = "maxresdefault") { - return `https://img.youtube.com/vi/${watchId}/${qualityOrIndex}.jpg`; + catch (err) { + error("Version check failed:", err); } - /** Returns the best available thumbnail URL for a video with the given watch ID */ - async function getBestThumbnailUrl(watchId) { - try { - const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0]; - for (const quality of priorityList) { - let response; - const url = getThumbnailUrl(watchId, quality); - try { - response = await sendRequest({ url, method: "HEAD", timeout: 6000 }); - } - catch (err) { - error(`Error while sending HEAD request to thumbnail URL for video '${watchId}' with quality '${quality}':`, err); - void err; - } - if (response && response.status < 300 && response.status >= 200) - return url; - } - } - catch (err) { - throw new Error(`Couldn't get thumbnail URL for video '${watchId}': ${err}`); +} +/** + * Checks for a new version of the script and shows a dialog. + * If {@linkcode notifyNoNewVerFound} is set to true, a dialog is also shown if no updates were found. + */ +async function doVersionCheck(notifyNoNewVerFound = false) { + var _a; + await GM.setValue("bytm-version-check", Date.now()); + const res = await sendRequest({ + method: "GET", + url: releaseURL, + }); + // TODO: small dialog for "no update found" message? + const noNewVerFound = () => notifyNoNewVerFound ? showPrompt({ type: "alert", message: t("no_new_version_found") }) : undefined; + const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, ""); + if (!latestTag) + return await noNewVerFound(); + info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info); + if (compareVersions.compare(scriptInfo.version, latestTag, "<")) { + const dialog = await getVersionNotifDialog({ latestTag }); + await dialog.open(); + return; + } + return await noNewVerFound(); +}/** Max amount of seconds a toast can be shown for */ +const maxToastDuration = 30000; +let timeout; +/** Shows a toast message with an icon */ +async function showIconToast(_a) { + var { duration, position = "tr", iconPos = "left" } = _a, rest = __rest(_a, ["duration", "position", "iconPos"]); + if (typeof duration !== "number" || isNaN(duration)) + duration = getFeature("toastDuration") * 1000; + if (duration <= 0) + return info("Toast duration is <= 0, so it won't be shown"); + const toastWrapper = document.createElement("div"); + toastWrapper.classList.add("bytm-toast-flex-wrapper"); + let toastIcon; + if ("iconSrc" in rest) { + toastIcon = document.createElement("img"); + toastIcon.classList.add("bytm-toast-icon", "img"); + toastIcon.src = rest.iconSrc instanceof Promise + ? await rest.iconSrc + : rest.iconSrc; + } + else { + toastIcon = document.createElement("div"); + toastIcon.classList.add("bytm-toast-icon"); + const iconHtml = await resourceAsString(rest.icon); + if (iconHtml) + setInnerHtml(toastIcon, iconHtml); + if ("iconFill" in rest && rest.iconFill) + toastIcon.style.setProperty("--toast-icon-fill", rest.iconFill); + } + const toastMessage = document.createElement("div"); + toastMessage.classList.add("bytm-toast-message"); + if ("message" in rest) { + toastMessage.textContent = rest.message; + if ("subtitle" in rest && rest.subtitle) { + const subtitleEl = document.createElement("div"); + subtitleEl.classList.add("bytm-toast-subtitle"); + subtitleEl.textContent = rest.subtitle; + toastMessage.appendChild(subtitleEl); + } + } + else + toastMessage.appendChild(rest.element); + iconPos === "left" && toastWrapper.appendChild(toastIcon); + toastWrapper.appendChild(toastMessage); + iconPos === "right" && toastWrapper.appendChild(toastIcon); + return await showToast({ + duration, + position, + element: toastWrapper, + title: "message" in rest ? rest.message : rest.title, + onClick: rest.onClick, + }); +} +/** Shows a toast message or element in the specified position (top right corner by default) and uses the default timeout from the config option `toastDuration` */ +async function showToast(arg) { + const props = typeof arg === "string" + ? { + message: arg, + duration: getFeature("toastDuration") * 1000, + } + : arg; + const { duration: durationMs = getFeature("toastDuration") * 1000, onClick, position = "tr" } = props, rest = __rest(props, ["duration", "onClick", "position"]); + if (durationMs <= 0) + return info("Toast duration is <= 0, so it won't be shown"); + if (document.querySelector("#bytm-toast")) + await closeToast(); + const toastElem = document.createElement("div"); + toastElem.classList.add(`pos-${position.toLowerCase()}`); + onClick && toastElem.classList.add("clickable"); + toastElem.id = "bytm-toast"; + toastElem.role = "alert"; + toastElem.ariaLive = "polite"; + toastElem.ariaAtomic = "true"; + toastElem.addEventListener("click", async (e) => { + onClick === null || onClick === void 0 ? void 0 : onClick(e); + await closeToast(); + }, { once: true }); + if ("message" in rest) + toastElem.title = toastElem.ariaLabel = toastElem.textContent = rest.message; + else { + toastElem.appendChild(rest.element); + toastElem.title = toastElem.ariaLabel = rest.title; + } + document.body.appendChild(toastElem); + UserUtils.pauseFor(100).then(() => { + toastElem.classList.add("visible"); + if (durationMs < Number.POSITIVE_INFINITY && durationMs > 0) { + timeout && clearTimeout(timeout); + timeout = setTimeout(closeToast, UserUtils.clamp(durationMs, 250, maxToastDuration)); } + }); + return toastElem; +} +/** Closes the currently open toast */ +async function closeToast() { + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + const toastEls = document.querySelectorAll("#bytm-toast"); + if (toastEls.length === 0) + return; + await Promise.allSettled(Array.from(toastEls).map(async (toastEl) => { + toastEl.classList.remove("visible"); + await UserUtils.pauseFor(300); + toastEl.remove(); + await UserUtils.pauseFor(100); + })); +}//#region beforeunload popup +let discardBeforeUnload = false; +/** Disables the popup before leaving the site */ +function enableDiscardBeforeUnload() { + discardBeforeUnload = true; + info("Disabled popup before leaving the site"); +} +/** Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` event listeners before they can be called by the site */ +async function initBeforeUnloadHook() { + try { + UserUtils.interceptWindowEvent("beforeunload", () => discardBeforeUnload); } - /** Opens the given URL in a new tab, using GM.openInTab if available */ - function openInTab(href, background = false) { - try { - UserUtils.openInNewTab(href, background); - } - catch (_a) { - window.open(href, "_blank", "noopener noreferrer"); - } + catch (err) { + error("Error in beforeunload hook:", err); } - /** Tries to parse an uncompressed or compressed input string as a JSON object */ - async function tryToDecompressAndParse(input) { - let parsed = null; - try { - parsed = JSON.parse(input); - } - catch (_a) { +} +//#region auto close toasts +/** Closes toasts after a set amount of time */ +async function initAutoCloseToasts() { + const animTimeout = 300; + addSelectorListener("popupContainer", "ytmusic-notification-action-renderer", { + all: true, + continuous: true, + listener: async (toastContElems) => { try { - parsed = JSON.parse(await UserUtils.decompress(input, compressionFormat, "string")); + for (const toastContElem of toastContElems) { + const toastElem = toastContElem.querySelector("tp-yt-paper-toast#toast"); + if (!toastElem || !toastElem.hasAttribute("allow-click-through")) + continue; + if (toastElem.classList.contains("bytm-closing")) + continue; + toastElem.classList.add("bytm-closing"); + const closeTimeout = Math.max(getFeature("closeToastsTimeout") * 1000 + animTimeout, animTimeout); + await UserUtils.pauseFor(closeTimeout); + toastElem.classList.remove("paper-toast-open"); + toastElem.addEventListener("transitionend", () => { + toastElem.classList.remove("bytm-closing"); + toastElem.style.display = "none"; + clearNode(toastElem); + log(`Automatically closed toast after ${getFeature("closeToastsTimeout") * 1000}ms`); + }, { once: true }); + } } catch (err) { - error("Couldn't decompress and parse data due to an error:", err); - return null; + error("Error in automatic toast closing:", err); } - } - // artificial timeout to allow animations to finish and because dumb monkey brains *expect* a delay - await UserUtils.pauseFor(UserUtils.randRange(250, 500)); - return parsed; - } - /** Very crude OS detection */ - function getOS() { - if (navigator.userAgent.match(/mac(\s?os|intel)/i)) - return "mac"; - return "other"; - } - /** Turns the passed StringGen (either a string, stringifiable object or a sync or async function returning a string or stringifiable object) into a string */ - async function consumeStringGen(strGen) { - return typeof strGen === "string" - ? strGen - : String(typeof strGen === "function" - ? await strGen() - : strGen); - } - /** Formats a number based on the config or the passed {@linkcode notation} */ - function formatNumber(num, notation) { - return num.toLocaleString(getLocale().replace(/_/g, "-"), (notation !== null && notation !== void 0 ? notation : getFeature("numbersFormat")) === "short" - ? { - notation: "compact", - compactDisplay: "short", - maximumFractionDigits: 1, - } - : { - style: "decimal", - maximumFractionDigits: 0, - }); + }, + }); + log("Initialized automatic toast closing"); +} +let remVidsCache = []; +/** + * Remembers the time of the last played video and resumes playback from that time. + * **Needs to be called *before* DOM is ready!** + */ +async function initRememberSongTime() { + if (getFeature("rememberSongTimeSites") !== "all" && getFeature("rememberSongTimeSites") !== getDomain()) + return; + const storedDataRaw = await GM.getValue("bytm-rem-songs"); + if (!storedDataRaw) + await GM.setValue("bytm-rem-songs", "[]"); + try { + remVidsCache = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]")); + } + catch (err) { + error("Error parsing stored video time data, defaulting to empty cache:", err); + await GM.setValue("bytm-rem-songs", "[]"); + remVidsCache = []; + } + log(`Initialized video time restoring with ${remVidsCache.length} initial entr${remVidsCache.length === 1 ? "y" : "ies"}`); + await remTimeRestoreTime(); + try { + if (!domLoaded) + document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop); + else + remTimeStartUpdateLoop(); } - //#region resources - /** - * Returns the blob-URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl) - * Falls back to a `raw.githubusercontent.com` URL or base64-encoded data URI if the resource is not available in the GM resource cache - * @param uncached Set to true to fetch from the `raw.githubusercontent.com` URL instead of the GM resource cache - */ - async function getResourceUrl(name, uncached = false) { - var _a, _b, _c; - let url = !uncached && await GM.getResourceUrl(name); - if (!url || url.length === 0) { - const resources = (_a = GM.info.script) === null || _a === void 0 ? void 0 : _a.resources; - const resUrl = Array.isArray(resources) ? (_b = resources.find(r => r.name === name)) === null || _b === void 0 ? void 0 : _b.url : (_c = resources === null || resources === void 0 ? void 0 : resources[name]) === null || _c === void 0 ? void 0 : _c.url; - if (typeof resUrl === "string") { - const { pathname } = new URL(resUrl); - const resource = resourcesJson === null || resourcesJson === void 0 ? void 0 : resourcesJson[name]; - const ref = typeof resource === "object" && "ref" in resource ? resource.ref : branch; - if (pathname && pathname.startsWith("/") && pathname.length > 1) - return `https://raw.githubusercontent.com/${repo}/${ref}${pathname}`; + catch (err) { + error("Error in video time remembering update loop:", err); + } +} +/** Tries to restore the time of the currently playing video */ +async function remTimeRestoreTime() { + if (location.pathname.startsWith("/watch")) { + const watchID = new URL(location.href).searchParams.get("v"); + if (!watchID) + return; + if (initialParams.has("t")) + return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info); + const entry = remVidsCache.find(entry => entry.watchID === watchID); + if (entry) { + if (Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000) { + await remTimeDeleteEntry(entry.watchID); + return; + } + else if (isNaN(Number(entry.songTime))) + return; + else { + let vidElem; + const doRestoreTime = async () => { + var _a; + if (!vidElem) + vidElem = await waitVideoElementReady(); + const vidRestoreTime = entry.songTime - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0); + vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration); + await remTimeDeleteEntry(entry.watchID); + info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info); + }; + if (!domLoaded) + document.addEventListener("DOMContentLoaded", doRestoreTime); + else + doRestoreTime(); } - warn(`Couldn't get blob URL nor external URL for @resource '${name}', trying to use base64-encoded fallback`); - // @ts-ignore - url = await GM.getResourceUrl(name, false); } - return url; } - /** - * Returns the preferred locale of the user, provided it is supported by the userscript. - * Prioritizes `navigator.language`, then `navigator.languages`, then `"en-US"` as a fallback. - */ - function getPreferredLocale() { - var _a; - const nvLangs = navigator.languages - .filter(lang => lang.match(/^[a-z]{2}(-|_)[A-Z]$/) !== null); - if (Object.entries(langMapping).find(([key]) => key === navigator.language)) - return navigator.language; - for (const loc of nvLangs) { - if (Object.entries(langMapping).find(([key]) => key === loc)) - return loc; - } - // if navigator.languages has entries that aren't locale codes in the format xx-XX - if (navigator.languages.some(lang => lang.match(/^[a-z]{2}$/))) { - for (const lang of nvLangs) { - const foundLoc = (_a = Object.entries(langMapping).find(([key]) => key.startsWith(lang))) === null || _a === void 0 ? void 0 : _a[0]; - if (foundLoc) - return foundLoc; +} +let lastSongTime = -1; +let remVidCheckTimeout; +/** Only call once as this calls itself after a timeout! - Updates the currently playing video's entry in GM storage */ +async function remTimeStartUpdateLoop() { + var _a, _b, _c; + if (location.pathname.startsWith("/watch")) { + const watchID = getWatchId(); + const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0; + if (watchID && songTime !== lastSongTime) { + lastSongTime = songTime; + const paused = (_c = (_b = getVideoElement()) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false; + // don't immediately update to reduce race conditions and only update if the video is playing + // also it just sounds better if the song starts at the beginning if only a couple seconds have passed + if (songTime > getFeature("rememberSongTimeMinPlayTime") && !paused) { + const entry = { + watchID, + songTime, + updateTimestamp: Date.now(), + }; + await remTimeUpsertEntry(entry); + } + // if the song is rewound to the beginning, update the entry accordingly + else if (!paused) { + const entry = remVidsCache.find(entry => entry.watchID === watchID); + if (entry && songTime <= entry.songTime) + await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { songTime, updateTimestamp: Date.now() })); } } - return "en-US"; } - /** Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property */ - async function resourceAsString(resource) { - try { - const resourceUrl = await getResourceUrl(resource); - if (!resourceUrl) - throw new Error(`Couldn't find URL for resource '${resource}'`); - return await (await UserUtils.fetchAdvanced(resourceUrl)).text(); + const expiredEntries = remVidsCache.filter(entry => Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000); + for (const entry of expiredEntries) + await remTimeDeleteEntry(entry.watchID); + // for no overlapping calls and better error handling: + if (remVidCheckTimeout) + clearTimeout(remVidCheckTimeout); + remVidCheckTimeout = setTimeout(remTimeStartUpdateLoop, 1000); +} +/** Updates an existing or inserts a new entry to be remembered */ +async function remTimeUpsertEntry(data) { + const foundIdx = remVidsCache.findIndex(entry => entry.watchID === data.watchID); + if (foundIdx >= 0) + remVidsCache[foundIdx] = data; + else + remVidsCache.push(data); + await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache)); +} +/** Deletes an entry in the "remember cache" */ +async function remTimeDeleteEntry(watchID) { + remVidsCache = [...remVidsCache.filter(entry => entry.watchID !== watchID)]; + await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache)); +}const interactionKeys = ["Enter", " ", "Space"]; +/** + * Adds generic, accessible interaction listeners to the passed element. + * All listeners have the default behavior prevented and stop propagation (for keyboard events this only applies as long as the captured key is included in {@linkcode interactionKeys}). + * @param listenerOptions Provide a {@linkcode listenerOptions} object to configure the listeners + */ +function onInteraction(elem, listener, listenerOptions) { + const _a = listenerOptions !== null && listenerOptions !== void 0 ? listenerOptions : {}, { preventDefault = true, stopPropagation = true } = _a, listenerOpts = __rest(_a, ["preventDefault", "stopPropagation"]); + const proxListener = (e) => { + if (e instanceof KeyboardEvent) { + if (interactionKeys.includes(e.key)) { + preventDefault && e.preventDefault(); + stopPropagation && e.stopPropagation(); + } + else + return; } - catch (err) { - error("Couldn't get SVG element from resource:", err); - return null; + else if (e instanceof MouseEvent) { + preventDefault && e.preventDefault(); + stopPropagation && e.stopPropagation(); } + // clean up the other listener that isn't automatically removed if `once` is set + (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "keydown" && elem.removeEventListener("click", proxListener, listenerOpts); + (listenerOpts === null || listenerOpts === void 0 ? void 0 : listenerOpts.once) && e.type === "click" && elem.removeEventListener("keydown", proxListener, listenerOpts); + listener(e); + }; + elem.addEventListener("click", proxListener, listenerOpts); + elem.addEventListener("keydown", proxListener, listenerOpts); +}/** + * Creates an element with a ripple effect on click. + * @param rippleElement If passed, this element will be modified to have the ripple effect. Otherwise, a new element will be created. + * @returns The passed element or the newly created element with the ripple effect. + */ +function createRipple(rippleElement, properties) { + const props = Object.assign({ speed: "normal" }, properties); + const rippleEl = rippleElement !== null && rippleElement !== void 0 ? rippleElement : document.createElement("div"); + rippleEl.classList.add("bytm-ripple", props.speed); + const updateRippleWidth = () => rippleEl.style.setProperty("--bytm-ripple-cont-width", `${rippleEl.clientWidth}px`); + rippleEl.addEventListener("mousedown", (e) => { + updateRippleWidth(); + const x = e.clientX - rippleEl.getBoundingClientRect().left; + const y = e.clientY - rippleEl.getBoundingClientRect().top; + const rippleAreaEl = document.createElement("span"); + rippleAreaEl.classList.add("bytm-ripple-area"); + rippleAreaEl.style.left = `${Math.round(x)}px`; + rippleAreaEl.style.top = `${Math.round(y)}px`; + if (rippleEl.firstChild) + rippleEl.insertBefore(rippleAreaEl, rippleEl.firstChild); + else + rippleEl.appendChild(rippleAreaEl); + rippleAreaEl.addEventListener("animationend", () => rippleAreaEl.remove()); + }); + updateRippleWidth(); + return rippleEl; +}/** + * Creates a generic, circular, long button element with an icon and text. + * Has classes for the enabled and disabled states for easier styling. + * If `href` is provided, the button will be an anchor element. + * If `onClick` or `onToggle` is provided, the button will be a div element. + * Provide either `resourceName` or `src` to specify the icon inside the button. + */ +async function createLongBtn(_a) { + var { title, text, iconPosition, ripple } = _a, rest = __rest(_a, ["title", "text", "iconPosition", "ripple"]); + if (["href", "onClick", "onToggle"].every((key) => !(key in rest))) + throw new TypeError("Either 'href', 'onClick' or 'onToggle' must be provided"); + let btnElem; + if ("href" in rest && rest.href) { + btnElem = document.createElement("a"); + btnElem.href = rest.href; + btnElem.role = "button"; + btnElem.target = "_blank"; + btnElem.rel = "noopener noreferrer"; } - /** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */ - function parseMarkdown(mdString) { - return marked.marked.parse(mdString, { - async: true, - gfm: true, - }); + else + btnElem = document.createElement("div"); + if ("toggle" in rest && rest.toggle) { + btnElem.classList.add("bytm-toggle"); + if ("toggleInitialState" in rest && rest.toggleInitialState) + btnElem.classList.add("toggled"); } - /** Returns the content of the changelog markdown file */ - async function getChangelogMd() { - const clRes = await UserUtils.fetchAdvanced(await getResourceUrl("doc-changelog", true)); - log("Fetched changelog:", clRes); - return await clRes.text(); - } - /** Returns the changelog as HTML with a details element for each version */ - async function getChangelogHtmlWithDetails() { - try { - const changelogMd = await getChangelogMd(); - let changelogHtml = await parseMarkdown(changelogMd); - const getVerId = (verStr) => verStr.trim().replace(/[._#\s-]/g, ""); - changelogHtml = changelogHtml.replace(/\s*<\/div>\s*\n?\s*/gm, "
\n
\n
"); - const h2Matches = Array.from(changelogHtml.matchAll(/([\d\w\s.]+)<\/h2>/gm)); - for (const [fullMatch, , verStr] of h2Matches) - changelogHtml = changelogHtml.replace(fullMatch, `

${verStr}

`); - changelogHtml = `
${changelogHtml}
`; - return changelogHtml; - } - catch (err) { - return `Error while preparing changelog: ${err}`; - } - } - - class MarkdownDialog extends BytmDialog { - constructor(options) { - super(Object.assign(Object.assign({}, options), { id: `md-${options.id}`, renderBody: () => this.renderBody() })); - Object.defineProperty(this, "opts", { - enumerable: true, - configurable: true, - writable: true, - value: void 0 - }); - this.opts = options; - } - /** Parses the passed markdown string (supports GitHub flavor and HTML mixins) and returns it as an HTML string */ - static async parseMd(md) { - return await marked.marked.parse(md, { - async: true, - gfm: true, - breaks: true, - }); - } - /** Renders the dialog body elements from a markdown string using what's set in `this.opts.body` */ - async renderBody() { - const bodyEl = document.createElement("div"); - bodyEl.classList.add("bytm-md-dialog-body"); - const mdCont = await consumeStringGen(this.opts.body); - const markdownEl = document.createElement("div"); - markdownEl.classList.add("bytm-markdown-dialog-content", "bytm-markdown-container"); - markdownEl.tabIndex = 0; - setInnerHtml(markdownEl, await MarkdownDialog.parseMd(mdCont)); - bodyEl.appendChild(markdownEl); - return bodyEl; - } - } - - /** Max amount of seconds a toast can be shown for */ - const maxToastDuration = 30000; - let timeout; - /** Shows a toast message with an icon */ - async function showIconToast(_a) { - var { duration, position = "tr", iconPos = "left" } = _a, rest = __rest(_a, ["duration", "position", "iconPos"]); - if (typeof duration !== "number" || isNaN(duration)) - duration = getFeature("toastDuration") * 1000; - if (duration <= 0) - return info("Toast duration is <= 0, so it won't be shown"); - const toastWrapper = document.createElement("div"); - toastWrapper.classList.add("bytm-toast-flex-wrapper"); - let toastIcon; - if ("iconSrc" in rest) { - toastIcon = document.createElement("img"); - toastIcon.classList.add("bytm-toast-icon", "img"); - toastIcon.src = rest.iconSrc instanceof Promise - ? await rest.iconSrc - : rest.iconSrc; - } - else { - toastIcon = document.createElement("div"); - toastIcon.classList.add("bytm-toast-icon"); - const iconHtml = await resourceAsString(rest.icon); - if (iconHtml) - setInnerHtml(toastIcon, iconHtml); - if ("iconFill" in rest && rest.iconFill) - toastIcon.style.setProperty("--toast-icon-fill", rest.iconFill); - } - const toastMessage = document.createElement("div"); - toastMessage.classList.add("bytm-toast-message"); - if ("message" in rest) { - toastMessage.textContent = rest.message; - if ("subtitle" in rest && rest.subtitle) { - const subtitleEl = document.createElement("div"); - subtitleEl.classList.add("bytm-toast-subtitle"); - subtitleEl.textContent = rest.subtitle; - toastMessage.appendChild(subtitleEl); - } - } - else - toastMessage.appendChild(rest.element); - iconPos === "left" && toastWrapper.appendChild(toastIcon); - toastWrapper.appendChild(toastMessage); - iconPos === "right" && toastWrapper.appendChild(toastIcon); - return await showToast({ - duration, - position, - element: toastWrapper, - title: "message" in rest ? rest.message : rest.title, - onClick: rest.onClick, - }); - } - /** Shows a toast message or element in the specified position (top right corner by default) and uses the default timeout from the config option `toastDuration` */ - async function showToast(arg) { - const props = typeof arg === "string" - ? { - message: arg, - duration: getFeature("toastDuration") * 1000, - } - : arg; - const { duration: durationMs = getFeature("toastDuration") * 1000, onClick, position = "tr" } = props, rest = __rest(props, ["duration", "onClick", "position"]); - if (durationMs <= 0) - return info("Toast duration is <= 0, so it won't be shown"); - if (document.querySelector("#bytm-toast")) - await closeToast(); - const toastElem = document.createElement("div"); - toastElem.classList.add(`pos-${position.toLowerCase()}`); - onClick && toastElem.classList.add("clickable"); - toastElem.id = "bytm-toast"; - toastElem.role = "alert"; - toastElem.ariaLive = "polite"; - toastElem.ariaAtomic = "true"; - toastElem.addEventListener("click", async (e) => { - onClick === null || onClick === void 0 ? void 0 : onClick(e); - await closeToast(); - }, { once: true }); - if ("message" in rest) - toastElem.title = toastElem.ariaLabel = toastElem.textContent = rest.message; - else { - toastElem.appendChild(rest.element); - toastElem.title = toastElem.ariaLabel = rest.title; - } - document.body.appendChild(toastElem); - UserUtils.pauseFor(100).then(() => { - toastElem.classList.add("visible"); - if (durationMs < Number.POSITIVE_INFINITY && durationMs > 0) { - timeout && clearTimeout(timeout); - timeout = setTimeout(closeToast, UserUtils.clamp(durationMs, 250, maxToastDuration)); - } - }); - return toastElem; - } - /** Closes the currently open toast */ - async function closeToast() { - if (timeout) { - clearTimeout(timeout); - timeout = undefined; - } - const toastEls = document.querySelectorAll("#bytm-toast"); - if (toastEls.length === 0) - return; - await Promise.allSettled(Array.from(toastEls).map(async (toastEl) => { - toastEl.classList.remove("visible"); - await UserUtils.pauseFor(300); - toastEl.remove(); - await UserUtils.pauseFor(100); - })); - } - - /** Creates a simple toggle element */ - async function createToggleInput({ onChange, initialValue = false, id = UserUtils.randomId(6, 36), labelPos = "left", }) { - const wrapperEl = document.createElement("div"); - wrapperEl.classList.add("bytm-toggle-input-wrapper", "bytm-no-select"); - wrapperEl.role = "switch"; - wrapperEl.tabIndex = 0; - const labelEl = labelPos !== "off" && document.createElement("label"); - if (labelEl) { - labelEl.classList.add("bytm-toggle-input-label"); - labelEl.textContent = t(`toggled_${initialValue ? "on" : "off"}`); - if (id) - labelEl.htmlFor = `bytm-toggle-input-${id}`; - } - const toggleWrapperEl = document.createElement("div"); - toggleWrapperEl.classList.add("bytm-toggle-input"); - toggleWrapperEl.tabIndex = -1; - const toggleEl = document.createElement("input"); - toggleEl.type = "checkbox"; - toggleEl.checked = initialValue; - toggleEl.dataset.toggled = String(Boolean(initialValue)); - toggleEl.tabIndex = -1; - if (id) - toggleEl.id = `bytm-toggle-input-${id}`; - const toggleKnobEl = document.createElement("div"); - toggleKnobEl.classList.add("bytm-toggle-input-knob"); - setInnerHtml(toggleKnobEl, " "); - const toggleElClicked = (e) => { - e.preventDefault(); - e.stopPropagation(); - onChange(toggleEl.checked); - toggleEl.dataset.toggled = String(Boolean(toggleEl.checked)); - if (labelEl) - labelEl.textContent = t(`toggled_${toggleEl.checked ? "on" : "off"}`); - wrapperEl.ariaValueText = t(`toggled_${toggleEl.checked ? "on" : "off"}`); - }; - toggleEl.addEventListener("change", toggleElClicked); - wrapperEl.addEventListener("keydown", (e) => { - if (["Space", " ", "Enter"].includes(e.code)) { - toggleEl.checked = !toggleEl.checked; - toggleElClicked(e); - } - }); - toggleEl.appendChild(toggleKnobEl); - toggleWrapperEl.appendChild(toggleEl); - labelEl && labelPos === "left" && wrapperEl.appendChild(labelEl); - wrapperEl.appendChild(toggleWrapperEl); - labelEl && labelPos === "right" && wrapperEl.appendChild(labelEl); - return wrapperEl; - } - - //#region PromptDialog - let promptDialog = null; - class PromptDialog extends BytmDialog { - constructor(props) { - super({ - id: "prompt-dialog", - width: 500, - height: 400, - destroyOnClose: true, - closeBtnEnabled: true, - closeOnBgClick: props.type === "alert", - closeOnEscPress: true, - small: true, - renderHeader: () => this.renderHeader(props), - renderBody: () => this.renderBody(props), - renderFooter: () => this.renderFooter(props), - }); - this.on("render", this.focusOnRender); - } - emitResolve(val) { - this.events.emit("resolve", val); - } - async renderHeader({ type }) { - const headerEl = document.createElement("div"); - headerEl.id = "bytm-prompt-dialog-header"; - const iconSvg = await resourceAsString(type === "alert" ? "icon-alert" : "icon-prompt"); - if (iconSvg) - setInnerHtml(headerEl, iconSvg); - return headerEl; - } - async renderBody(_a) { - var _b; - var { type, message } = _a, rest = __rest(_a, ["type", "message"]); - const contElem = document.createElement("div"); - contElem.classList.add(`bytm-prompt-type-${type}`); - const upperContElem = document.createElement("div"); - upperContElem.id = "bytm-prompt-dialog-upper-cont"; - contElem.appendChild(upperContElem); - const messageElem = document.createElement("p"); - messageElem.id = "bytm-prompt-dialog-message"; - messageElem.role = "alert"; - messageElem.ariaLive = "polite"; - messageElem.tabIndex = 0; - messageElem.textContent = String(message); - upperContElem.appendChild(messageElem); - if (type === "prompt") { - const inputElem = document.createElement("input"); - inputElem.id = "bytm-prompt-dialog-input"; - inputElem.type = "text"; - inputElem.autocomplete = "off"; - inputElem.spellcheck = false; - inputElem.value = "defaultValue" in rest ? (_b = rest.defaultValue) !== null && _b !== void 0 ? _b : "" : ""; - const inputEnterListener = (e) => { - var _a, _b; - if (e.key === "Enter") { - inputElem.removeEventListener("keydown", inputEnterListener); - this.emitResolve((_b = (_a = inputElem === null || inputElem === void 0 ? void 0 : inputElem.value) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : null); - promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); - } - }; - inputElem.addEventListener("keydown", inputEnterListener); - promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.once("close", () => inputElem.removeEventListener("keydown", inputEnterListener)); - upperContElem.appendChild(inputElem); - } - return contElem; - } - async renderFooter(_a) { - var { type } = _a, rest = __rest(_a, ["type"]); - const buttonsWrapper = document.createElement("div"); - buttonsWrapper.id = "bytm-prompt-dialog-button-wrapper"; - const buttonsCont = document.createElement("div"); - buttonsCont.id = "bytm-prompt-dialog-buttons-cont"; - let confirmBtn; - if (type === "confirm" || type === "prompt") { - confirmBtn = document.createElement("button"); - confirmBtn.id = "bytm-prompt-dialog-confirm"; - confirmBtn.classList.add("bytm-prompt-dialog-button"); - confirmBtn.textContent = await this.consumePromptStringGen(type, rest.confirmBtnText, t("prompt_confirm")); - confirmBtn.ariaLabel = confirmBtn.title = await this.consumePromptStringGen(type, rest.confirmBtnTooltip, t("click_to_confirm_tooltip")); - confirmBtn.tabIndex = 0; - confirmBtn.addEventListener("click", () => { - var _a, _b, _c; - this.emitResolve(type === "confirm" ? true : (_c = (_b = (_a = (document.querySelector("#bytm-prompt-dialog-input"))) === null || _a === void 0 ? void 0 : _a.value) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : null); - promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); - }, { once: true }); - } - const closeBtn = document.createElement("button"); - closeBtn.id = "bytm-prompt-dialog-close"; - closeBtn.classList.add("bytm-prompt-dialog-button"); - closeBtn.textContent = await this.consumePromptStringGen(type, rest.denyBtnText, t(type === "alert" ? "prompt_close" : "prompt_cancel")); - closeBtn.ariaLabel = closeBtn.title = await this.consumePromptStringGen(type, rest.denyBtnTooltip, t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")); - closeBtn.tabIndex = 0; - closeBtn.addEventListener("click", () => { - const resVals = { - alert: true, - confirm: false, - prompt: null, - }; - this.emitResolve(resVals[type]); - promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); - }, { once: true }); - confirmBtn && getOS() !== "mac" && buttonsCont.appendChild(confirmBtn); - buttonsCont.appendChild(closeBtn); - confirmBtn && getOS() === "mac" && buttonsCont.appendChild(confirmBtn); - buttonsWrapper.appendChild(buttonsCont); - return buttonsWrapper; - } - /** Converts a {@linkcode stringGen} (stringifiable value or sync or async function that returns a stringifiable value) to a string - uses {@linkcode fallback} as a fallback */ - async consumePromptStringGen(curPromptType, stringGen, fallback) { - if (typeof stringGen === "function") - return await stringGen(curPromptType); - return String(stringGen !== null && stringGen !== void 0 ? stringGen : fallback); - } - /** Called on render to focus on the confirm or cancel button or text input, depending on prompt type */ - focusOnRender() { - const inputElem = document.querySelector("#bytm-prompt-dialog-input"); - if (inputElem) - return inputElem.focus(); - let captureEnterKey = true; - document.addEventListener("keydown", (e) => { - var _a; - if (e.key === "Enter" && captureEnterKey) { - const confBtn = document.querySelector("#bytm-prompt-dialog-confirm"); - const closeBtn = document.querySelector("#bytm-prompt-dialog-close"); - if (confBtn || closeBtn) { - (_a = confBtn === null || confBtn === void 0 ? void 0 : confBtn.click()) !== null && _a !== void 0 ? _a : closeBtn === null || closeBtn === void 0 ? void 0 : closeBtn.click(); - captureEnterKey = false; - } - } - }, { capture: true, once: true }); - } - } - /** Custom dialog to emulate and enhance the behavior of the native `confirm()`, `alert()`, and `prompt()` functions */ - function showPrompt(_a) { - var { type } = _a, rest = __rest(_a, ["type"]); - return new Promise((resolve) => { - if (BytmDialog.getOpenDialogs().includes("prompt-dialog")) - promptDialog === null || promptDialog === void 0 ? void 0 : promptDialog.close(); - promptDialog = new PromptDialog(Object.assign({ type }, rest)); - promptDialog.once("render", () => { - addSelectorListener("bytmDialogContainer", `#bytm-prompt-dialog-${type === "alert" ? "close" : "confirm"}`, { - listener: (btn) => btn.focus(), - }); - }); - // make config menu inert while prompt dialog is open - promptDialog.once("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); }); - promptDialog.once("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); }); - let resolveVal; - const tryResolve = () => resolve(typeof resolveVal !== "undefined" ? resolveVal : false); - let closeUnsub; // eslint-disable-line prefer-const - const resolveUnsub = promptDialog.on("resolve", (val) => { - resolveUnsub(); - if (resolveVal !== undefined) - return; - resolveVal = val; - tryResolve(); - closeUnsub === null || closeUnsub === void 0 ? void 0 : closeUnsub(); - }); - closeUnsub = promptDialog.on("close", () => { - closeUnsub(); - if (resolveVal !== undefined) - return; - resolveVal = type === "alert"; - if (type === "prompt") - resolveVal = null; - tryResolve(); - resolveUnsub(); - }); - promptDialog.open(); - }); - } - - let autoLikeDialog = null; - let autoLikeImExDialog = null; - /** Creates and/or returns the import dialog */ - async function getAutoLikeDialog() { - if (!autoLikeDialog) { - await initAutoLikeStore(); - autoLikeDialog = new BytmDialog({ - id: "auto-like-channels", - width: 700, - height: 1000, - closeBtnEnabled: true, - closeOnBgClick: true, - closeOnEscPress: true, - destroyOnClose: true, - removeListenersOnDestroy: false, - small: true, - verticalAlign: "top", - renderHeader: renderHeader$5, - renderBody: renderBody$5, - renderFooter: renderFooter$1, - }); - siteEvents.on("autoLikeChannelsUpdated", async () => { - try { - if (autoLikeImExDialog === null || autoLikeImExDialog === void 0 ? void 0 : autoLikeImExDialog.isOpen()) - autoLikeImExDialog.unmount(); - if (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.isOpen()) { - autoLikeDialog.unmount(); - await autoLikeDialog.open(); - log("Auto-like channels updated, refreshed dialog"); - } - } - catch (err) { - error("Couldn't refresh auto-like channels dialog:", err); - } - }); - autoLikeDialog.on("close", () => emitSiteEvent("autoLikeChannelsUpdated")); - } - if (!autoLikeImExDialog) { - autoLikeImExDialog = new ExImDialog({ - id: "auto-like-channels-export-import", - width: 800, - height: 600, - // try to compress the data if possible - exportData: async () => await compressionSupported() - ? await UserUtils.compress(JSON.stringify(autoLikeStore.getData()), compressionFormat, "string") - : JSON.stringify(autoLikeStore.getData()), - // copy plain when shift-clicking the copy button - exportDataSpecial: () => JSON.stringify(autoLikeStore.getData()), - async onImport(data) { - try { - const parsed = await tryToDecompressAndParse(data); - log("Trying to import auto-like data:", parsed); - if (!parsed || typeof parsed !== "object") - return await showPrompt({ type: "alert", message: t("import_error_invalid") }); - if (!parsed.channels || typeof parsed.channels !== "object" || Object.keys(parsed.channels).length === 0) - return await showPrompt({ type: "alert", message: t("import_error_no_data") }); - await autoLikeStore.setData(parsed); - emitSiteEvent("autoLikeChannelsUpdated"); - showToast({ message: t("import_success") }); - autoLikeImExDialog === null || autoLikeImExDialog === void 0 ? void 0 : autoLikeImExDialog.unmount(); - } - catch (err) { - error("Couldn't import auto-like channels data:", err); - } - }, - title: () => t("auto_like_export_import_title"), - descImport: () => t("auto_like_import_desc"), - descExport: () => t("auto_like_export_desc"), - }); - } - return autoLikeDialog; + onInteraction(btnElem, (evt) => { + var _a; + if ("onClick" in rest) + rest.onClick(evt); + if ("toggle" in rest && rest.toggle && ((_a = rest.togglePredicate) !== null && _a !== void 0 ? _a : (() => true))(evt)) + rest.onToggle(btnElem.classList.toggle("toggled"), evt); + }); + btnElem.classList.add("bytm-generic-btn", "long"); + btnElem.ariaLabel = btnElem.title = title; + btnElem.tabIndex = 0; + btnElem.role = "button"; + const imgElem = document.createElement("src" in rest ? "img" : "div"); + imgElem.classList.add("bytm-generic-btn-img", iconPosition !== null && iconPosition !== void 0 ? iconPosition : "left"); + if ("src" in rest) + imgElem.src = rest.src; + else + setInnerHtml(imgElem, await resourceAsString(rest.resourceName)); + const txtElem = document.createElement("span"); + txtElem.classList.add("bytm-generic-long-btn-txt", "bytm-no-select"); + txtElem.textContent = txtElem.ariaLabel = text; + iconPosition === "left" || !iconPosition && btnElem.appendChild(imgElem); + btnElem.appendChild(txtElem); + iconPosition === "right" && btnElem.appendChild(imgElem); + return ripple ? createRipple(btnElem, { speed: "normal" }) : btnElem; +}//#region class +/** Generic dialog for exporting and importing any string of data */ +class ExImDialog extends BytmDialog { + constructor(options) { + super(Object.assign({ renderHeader: () => ExImDialog.renderHeader(options), renderBody: () => ExImDialog.renderBody(options), renderFooter: undefined, closeOnBgClick: true, closeOnEscPress: true, closeBtnEnabled: true, unmountOnClose: true, small: true }, options)); } //#region header - async function renderHeader$5() { + static async renderHeader(opts) { const headerEl = document.createElement("h2"); - headerEl.classList.add("bytm-dialog-title"); + headerEl.classList.add("bytm-menu-title"); headerEl.role = "heading"; headerEl.ariaLevel = "1"; headerEl.tabIndex = 0; - headerEl.textContent = headerEl.ariaLabel = t("auto_like_channels_dialog_title"); + headerEl.textContent = headerEl.ariaLabel = await UserUtils.consumeStringGen(opts.title); return headerEl; } //#region body - async function renderBody$5() { - const contElem = document.createElement("div"); - const descriptionEl = document.createElement("p"); - descriptionEl.classList.add("bytm-auto-like-channels-desc"); - descriptionEl.textContent = t("auto_like_channels_dialog_desc"); - descriptionEl.tabIndex = 0; - contElem.appendChild(descriptionEl); - const searchCont = document.createElement("div"); - searchCont.classList.add("bytm-auto-like-channels-search-cont"); - contElem.appendChild(searchCont); - const searchbarEl = document.createElement("input"); - searchbarEl.classList.add("bytm-auto-like-channels-searchbar"); - searchbarEl.placeholder = t("search_placeholder"); - searchbarEl.type = searchbarEl.role = "search"; - searchbarEl.tabIndex = 0; - searchbarEl.autofocus = true; - searchbarEl.autocomplete = searchbarEl.autocapitalize = "off"; - searchbarEl.spellcheck = false; - searchbarEl.addEventListener("input", () => { - var _a, _b, _c, _d, _e, _f; - const searchVal = searchbarEl.value.trim().toLowerCase(); - const rows = document.querySelectorAll(".bytm-auto-like-channel-row"); - for (const row of rows) { - const name = (_c = (_b = (_a = row.querySelector(".bytm-auto-like-channel-name")) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase().replace(/\s/g, "")) !== null && _c !== void 0 ? _c : ""; - const id = (_f = (_e = (_d = row.querySelector(".bytm-auto-like-channel-id")) === null || _d === void 0 ? void 0 : _d.textContent) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : ""; - row.classList.toggle("hidden", !name.includes(searchVal) && !(id.startsWith("@") ? id : "").includes(searchVal)); - } - }); - searchCont.appendChild(searchbarEl); - const searchClearEl = document.createElement("button"); - searchClearEl.classList.add("bytm-auto-like-channels-search-clear"); - searchClearEl.title = searchClearEl.ariaLabel = t("search_clear"); - searchClearEl.tabIndex = 0; - searchClearEl.innerText = "×"; - onInteraction(searchClearEl, () => { - searchbarEl.value = ""; - searchbarEl.dispatchEvent(new Event("input")); - }); - searchCont.appendChild(searchClearEl); - const channelListCont = document.createElement("div"); - channelListCont.id = "bytm-auto-like-channels-list"; - const setChannelEnabled = UserUtils.debounce((id, enabled) => { - autoLikeStore.setData({ - channels: autoLikeStore.getData().channels - .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { enabled }) : ch), - }); - }, 250, "rising"); - const sortedChannels = autoLikeStore - .getData().channels - .sort((a, b) => a.name.localeCompare(b.name)); - for (const { name: chanName, id: chanId, enabled } of sortedChannels) { - const rowElem = document.createElement("div"); - rowElem.classList.add("bytm-auto-like-channel-row"); - const leftCont = document.createElement("div"); - leftCont.classList.add("bytm-auto-like-channel-row-left-cont"); - const nameLabelEl = document.createElement("label"); - nameLabelEl.ariaLabel = nameLabelEl.title = chanName; - nameLabelEl.htmlFor = `bytm-auto-like-channel-list-toggle-${chanId}`; - nameLabelEl.classList.add("bytm-auto-like-channel-name-label"); - const nameElem = document.createElement("a"); - nameElem.classList.add("bytm-auto-like-channel-name", "bytm-link"); - nameElem.ariaLabel = nameElem.textContent = chanName; - nameElem.href = (!chanId.startsWith("@") && getDomain() === "ytm") - ? `https://music.youtube.com/channel/${chanId}` - : `https://youtube.com/${chanId.startsWith("@") ? chanId : `channel/${chanId}`}`; - nameElem.target = "_blank"; - nameElem.rel = "noopener noreferrer"; - nameElem.tabIndex = 0; - const idElem = document.createElement("span"); - idElem.classList.add("bytm-auto-like-channel-id"); - idElem.textContent = idElem.title = chanId; - nameLabelEl.appendChild(nameElem); - nameLabelEl.appendChild(idElem); - const toggleElem = await createToggleInput({ - id: `auto-like-channel-list-${chanId}`, - labelPos: "off", - initialValue: enabled, - onChange: (en) => setChannelEnabled(chanId, en), - }); - toggleElem.classList.add("bytm-auto-like-channel-toggle"); - toggleElem.title = toggleElem.ariaLabel = t("auto_like_channel_toggle_tooltip", chanName); - const btnCont = document.createElement("div"); - btnCont.classList.add("bytm-auto-like-channel-row-btn-cont"); - const editBtn = await createCircularBtn({ - resourceName: "icon-edit", - title: t("edit_entry"), - async onClick() { - var _a, _b, _c; - const newNamePr = (_a = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_name_prompt"), defaultValue: chanName }))) === null || _a === void 0 ? void 0 : _a.trim(); - if (!newNamePr || newNamePr.length === 0) - return; - const newName = newNamePr.length > 0 ? newNamePr : chanName; - const newIdPr = (_b = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_id_prompt"), defaultValue: chanId }))) === null || _b === void 0 ? void 0 : _b.trim(); - if (!newIdPr || newIdPr.length === 0) - return; - const newId = newIdPr.length > 0 ? (_c = getChannelIdFromPrompt(newIdPr)) !== null && _c !== void 0 ? _c : chanId : chanId; - await autoLikeStore.setData({ - channels: autoLikeStore.getData().channels - .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { name: newName, id: newId }) : ch), - }); - emitSiteEvent("autoLikeChannelsUpdated"); - }, + static async renderBody(opts) { + const panesCont = document.createElement("div"); + panesCont.classList.add("bytm-exim-dialog-panes-cont"); + //#region export + const exportPane = document.createElement("div"); + exportPane.classList.add("bytm-exim-dialog-pane", "export"); + { + const descEl = document.createElement("p"); + descEl.classList.add("bytm-exim-dialog-desc"); + descEl.role = "note"; + descEl.tabIndex = 0; + descEl.textContent = descEl.ariaLabel = await UserUtils.consumeStringGen(opts.descExport); + const dataEl = document.createElement("textarea"); + dataEl.classList.add("bytm-exim-dialog-data"); + dataEl.readOnly = true; + dataEl.tabIndex = 0; + dataEl.value = t("click_to_reveal"); + onInteraction(dataEl, async () => { + dataEl.value = await UserUtils.consumeStringGen(opts.exportData); + dataEl.setSelectionRange(0, dataEl.value.length); }); - btnCont.appendChild(editBtn); - const removeBtn = await createCircularBtn({ - resourceName: "icon-delete", - title: t("remove_entry"), - async onClick() { - autoLikeStore.setData({ - channels: autoLikeStore.getData().channels.filter((ch) => ch.id !== chanId), - }); - rowElem.remove(); - emitSiteEvent("autoLikeChannelsUpdated"); + const exportCenterBtnCont = document.createElement("div"); + exportCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont"); + const copyBtn = createRipple(await createLongBtn({ + title: t("copy_to_clipboard"), + text: t("copy"), + resourceName: "icon-copy", + async onClick({ shiftKey }) { + const copyData = shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData; + copyToClipboard(await UserUtils.consumeStringGen(copyData)); + await showToast({ message: t("copied_to_clipboard") }); }, - }); - btnCont.appendChild(removeBtn); - leftCont.appendChild(toggleElem); - leftCont.appendChild(nameLabelEl); - rowElem.appendChild(leftCont); - rowElem.appendChild(btnCont); - channelListCont.appendChild(rowElem); - } - contElem.appendChild(channelListCont); - return contElem; - } - //#region footer - function renderFooter$1() { - const wrapperEl = document.createElement("div"); - wrapperEl.classList.add("bytm-auto-like-channels-footer-wrapper"); - const addNewBtnElem = document.createElement("button"); - addNewBtnElem.classList.add("bytm-btn"); - addNewBtnElem.textContent = t("new_entry"); - addNewBtnElem.ariaLabel = addNewBtnElem.title = t("new_entry_tooltip"); - wrapperEl.appendChild(addNewBtnElem); - const importExportBtnElem = document.createElement("button"); - importExportBtnElem.classList.add("bytm-btn"); - importExportBtnElem.textContent = t("export_import"); - importExportBtnElem.ariaLabel = importExportBtnElem.title = t("auto_like_export_or_import_tooltip"); - wrapperEl.appendChild(importExportBtnElem); - onInteraction(addNewBtnElem, addAutoLikeEntryPrompts); - onInteraction(importExportBtnElem, openImportExportAutoLikeChannelsDialog); - return wrapperEl; - } - async function openImportExportAutoLikeChannelsDialog() { - await (autoLikeImExDialog === null || autoLikeImExDialog === void 0 ? void 0 : autoLikeImExDialog.open()); - } - //#region add prompt - async function addAutoLikeEntryPrompts() { - var _a, _b, _c; - await autoLikeStore.loadData(); - const idPrompt = (_a = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_id_prompt") }))) === null || _a === void 0 ? void 0 : _a.trim(); - if (!idPrompt) - return; - const id = (_b = parseChannelIdFromUrl(idPrompt)) !== null && _b !== void 0 ? _b : (isValidChannelId(idPrompt) ? idPrompt : null); - if (!id || id.length <= 0) - return await showPrompt({ type: "alert", message: t("add_auto_like_channel_invalid_id") }); - let overwriteName = false; - const hasChannelEntry = autoLikeStore.getData().channels.find((ch) => ch.id === id); - if (hasChannelEntry) { - if (!await showPrompt({ type: "confirm", message: t("add_auto_like_channel_already_exists_prompt_new_name") })) - return; - overwriteName = true; - } - const name = (_c = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_name_prompt"), defaultValue: hasChannelEntry === null || hasChannelEntry === void 0 ? void 0 : hasChannelEntry.name }))) === null || _c === void 0 ? void 0 : _c.trim(); - if (!name || name.length === 0) - return; - await autoLikeStore.setData(overwriteName - ? { - channels: autoLikeStore.getData().channels - .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { name }) : ch), - } - : { - channels: [ - ...autoLikeStore.getData().channels, - { id, name, enabled: true }, - ], - }); - emitSiteEvent("autoLikeChannelsUpdated"); - const unsub = autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.on("clear", async () => { - unsub === null || unsub === void 0 ? void 0 : unsub(); - await (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.open()); - }); - autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.unmount(); - } - function getChannelIdFromPrompt(promptStr) { - const isId = promptStr.match(/^@?.+$/); - const isUrl = promptStr.match(/^(?:https?:\/\/)?(?:www\.)?(?:music\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/); - const id = ((isId === null || isId === void 0 ? void 0 : isId[0]) || (isUrl === null || isUrl === void 0 ? void 0 : isUrl[1]) || "").trim(); - return id.length > 0 ? id : null; - } - - let changelogDialog = null; - /** Creates and/or returns the changelog dialog */ - async function getChangelogDialog() { - if (!changelogDialog) { - changelogDialog = new BytmDialog({ - id: "changelog", - width: 1000, - height: 800, - closeBtnEnabled: true, - closeOnBgClick: true, - closeOnEscPress: true, - small: true, - verticalAlign: "top", - renderHeader: renderHeader$4, - renderBody: renderBody$4, - }); - changelogDialog.on("render", () => { - const mdContElem = document.querySelector("#bytm-changelog-dialog-text"); - if (!mdContElem) - return; - const anchors = mdContElem.querySelectorAll("a"); - for (const anchor of anchors) { - anchor.ariaLabel = anchor.title = anchor.href; - anchor.target = "_blank"; - } - const firstDetails = mdContElem.querySelector("details"); - if (firstDetails) - firstDetails.open = true; - const kbdElems = mdContElem.querySelectorAll("kbd"); - for (const kbdElem of kbdElems) - kbdElem.addEventListener("selectstart", (e) => e.preventDefault()); - }); - } - return changelogDialog; - } - async function renderHeader$4() { - const headerEl = document.createElement("h2"); - headerEl.classList.add("bytm-dialog-title"); - headerEl.role = "heading"; - headerEl.ariaLevel = "1"; - headerEl.tabIndex = 0; - headerEl.textContent = headerEl.ariaLabel = t("changelog_menu_title", scriptInfo.name); - return headerEl; - } - async function renderBody$4() { - const contElem = document.createElement("div"); - const mdContElem = document.createElement("div"); - mdContElem.id = "bytm-changelog-dialog-text"; - mdContElem.classList.add("bytm-markdown-container"); - setInnerHtml(mdContElem, await getChangelogHtmlWithDetails()); - contElem.appendChild(mdContElem); - return contElem; - } - - let featHelpDialog = null; - let curFeatKey = null; - /** Creates or modifies the help dialog for a specific feature and returns it */ - async function getFeatHelpDialog({ featKey, }) { - curFeatKey = featKey; - if (!featHelpDialog) { - featHelpDialog = new BytmDialog({ - id: "feat-help", - width: 600, - height: 400, - closeBtnEnabled: true, - closeOnBgClick: true, - closeOnEscPress: true, - small: true, - renderHeader: renderHeader$3, - renderBody: renderBody$3, - }); - // make config menu inert while help dialog is open - featHelpDialog.on("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); }); - featHelpDialog.on("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); }); + })); + exportCenterBtnCont.appendChild(copyBtn); + exportPane.append(descEl, dataEl, exportCenterBtnCont); } - return featHelpDialog; - } - async function renderHeader$3() { - const headerEl = document.createElement("div"); - const helpIconSvg = await resourceAsString("icon-help"); - if (helpIconSvg) - setInnerHtml(headerEl, helpIconSvg); - return headerEl; - } - async function renderBody$3() { - var _a, _b; - const contElem = document.createElement("div"); - const featDescElem = document.createElement("h3"); - featDescElem.role = "subheading"; - featDescElem.tabIndex = 0; - featDescElem.textContent = t(`feature_desc_${curFeatKey}`); - featDescElem.id = "bytm-feat-help-dialog-desc"; - const helpTextElem = document.createElement("div"); - helpTextElem.id = "bytm-feat-help-dialog-text"; - helpTextElem.tabIndex = 0; - // @ts-ignore - const helpText = (_b = (_a = featInfo[curFeatKey]) === null || _a === void 0 ? void 0 : _a.helpText) === null || _b === void 0 ? void 0 : _b.call(_a); - helpTextElem.textContent = helpText !== null && helpText !== void 0 ? helpText : t(`feature_helptext_${curFeatKey}`); - contElem.appendChild(featDescElem); - contElem.appendChild(helpTextElem); - return contElem; - } - - var name = "betterytm"; - var userscriptName = "BetterYTM"; - var version = "2.2.0"; - var description = "Lots of configurable layout and user experience improvements for YouTube Music™ and YouTube™"; - var homepage = "https://github.com/Sv443/BetterYTM"; - var main = "./src/index.ts"; - var type = "module"; - var scripts = { - dev: "concurrently \"nodemon --exec pnpm run build-private-dev\" \"pnpm run serve\"", - serve: "pnpm run node-ts ./src/tools/serve.ts", - lint: "eslint . && tsc --noEmit", - build: "rollup -c", - "build-private-dev": "rollup -c --config-mode development --config-host github --config-branch develop --config-assetSource=local", - "build-dev": "rollup -c --config-mode development --config-host github --config-branch develop", - preview: "pnpm run build-prod-gh --config-assetSource=local && pnpm run serve --auto-exit-time=6", - "build-prod": "pnpm run build-prod-gh && pnpm run build-prod-gf && pnpm run build-prod-oujs", - "build-prod-base": "rollup -c --config-mode production --config-branch main", - "build-prod-gh": "pnpm run build-prod-base --config-host github", - "build-prod-gf": "pnpm run build-prod-base --config-host greasyfork --config-suffix _gf", - "build-prod-oujs": "pnpm run build-prod-base --config-host openuserjs --config-suffix _oujs", - "post-build": "pnpm run node-ts ./src/tools/post-build.ts", - "tr-changed": "pnpm run node-ts ./src/tools/tr-changed.ts", - "tr-progress": "pnpm run node-ts ./src/tools/tr-progress.ts", - "tr-format": "pnpm run node-ts ./src/tools/tr-format.ts", - "tr-prep": "pnpm run tr-format -p", - "gen-readme": "pnpm run node-ts ./src/tools/gen-readme.ts", - "node-ts": "node --no-warnings=ExperimentalWarning --enable-source-maps --loader ts-node/esm", - invisible: "node --enable-source-maps src/tools/run-invisible.mjs", - test: "pnpm run node-ts ./test.ts", - knip: "knip", - storybook: "storybook dev -p 6006", - "build-storybook": "storybook build", - "dep-cruise": "npx depcruise src", - "dep-graph": "npx depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg && open-cli dependency-graph.svg -R" - }; - var engines = { - node: ">=19", - pnpm: ">=6" - }; - var repository = { - type: "git", - url: "git+https://github.com/Sv443/BetterYTM.git" - }; - var author = { - name: "Sv443", - url: "https://github.com/Sv443" - }; - var license = "AGPL-3.0-only"; - var bugs = { - url: "https://github.com/Sv443/BetterYTM/issues" - }; - var funding = { - type: "github", - url: "https://github.com/sponsors/Sv443" - }; - var hosts = { - github: "https://github.com/Sv443/BetterYTM", - greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm", - openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM" - }; - var updates = { - github: "https://github.com/Sv443/BetterYTM/releases", - greasyfork: "https://greasyfork.org/en/scripts/475682-betterytm", - openuserjs: "https://openuserjs.org/scripts/Sv443/BetterYTM" - }; - var dependencies = { - "@sv443-network/userutils": "^8.3.1", - "compare-versions": "^6.1.0", - dompurify: "^3.1.6", - marked: "^12.0.2", - tslib: "^2.6.3" - }; - var devDependencies = { - "@chromatic-com/storybook": "^1.5.0", - "@eslint/eslintrc": "^3.1.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-terser": "^0.4.4", - "@rollup/plugin-typescript": "^11.1.6", - "@storybook/addon-essentials": "^8.1.10", - "@storybook/addon-interactions": "^8.1.10", - "@storybook/addon-links": "^8.1.10", - "@storybook/blocks": "^8.1.10", - "@storybook/html": "^8.1.10", - "@storybook/html-vite": "^8.1.10", - "@storybook/test": "^8.1.10", - "@types/dompurify": "^3.0.5", - "@types/express": "^4.17.21", - "@types/greasemonkey": "^4.0.7", - "@types/node": "^20.14.8", - "@typescript-eslint/eslint-plugin": "^8.0.0", - "@typescript-eslint/parser": "^8.0.0", - "@typescript-eslint/utils": "^8.0.0", - concurrently: "^9.0.1", - "dependency-cruiser": "^16.3.10", - dotenv: "^16.4.5", - eslint: "^9.5.0", - "eslint-plugin-storybook": "^0.11.0", - express: "^4.19.2", - globals: "^15.6.0", - kleur: "^4.1.5", - knip: "^5.22.2", - nanoevents: "^9.0.0", - nodemon: "^3.1.4", - "open-cli": "^8.0.0", - pnpm: "^9.4.0", - rollup: "^4.18.0", - "rollup-plugin-execute": "^1.1.1", - "rollup-plugin-import-css": "^3.5.0", - storybook: "^8.1.10", - "storybook-dark-mode": "^4.0.2", - "ts-node": "^10.9.2", - tsx: "^4.19.2", - typescript: "^5.5.2" - }; - var browserslist = [ - "last 1 version", - "> 1%", - "not dead" - ]; - var nodemonConfig = { - watch: [ - "src/**", - "assets/**", - "rollup.config.mjs", - ".env", - "changelog.md", - "package.json" - ], - ext: "ts,mts,js,jsx,mjs,json,html,css,svg,png", - ignore: [ - "dist/*", - "dev/*", - "*/stories/*" - ] - }; - var packageJson = { - name: name, - userscriptName: userscriptName, - version: version, - description: description, - homepage: homepage, - main: main, - type: type, - scripts: scripts, - engines: engines, - repository: repository, - author: author, - license: license, - bugs: bugs, - funding: funding, - hosts: hosts, - updates: updates, - dependencies: dependencies, - devDependencies: devDependencies, - browserslist: browserslist, - nodemonConfig: nodemonConfig - }; - - let pluginListDialog = null; - /** Creates and/or returns the import dialog */ - async function getPluginListDialog() { - return pluginListDialog = pluginListDialog !== null && pluginListDialog !== void 0 ? pluginListDialog : new BytmDialog({ - id: "plugin-list", - width: 800, - height: 600, + //#region import + const importPane = document.createElement("div"); + importPane.classList.add("bytm-exim-dialog-pane", "import"); + { + const descEl = document.createElement("p"); + descEl.classList.add("bytm-exim-dialog-desc"); + descEl.role = "note"; + descEl.tabIndex = 0; + descEl.textContent = descEl.ariaLabel = await UserUtils.consumeStringGen(opts.descImport); + const dataEl = document.createElement("textarea"); + dataEl.classList.add("bytm-exim-dialog-data"); + dataEl.tabIndex = 0; + const importCenterBtnCont = document.createElement("div"); + importCenterBtnCont.classList.add("bytm-exim-dialog-center-btn-cont"); + const importBtn = createRipple(await createLongBtn({ + title: t("start_import_tooltip"), + text: t("import"), + resourceName: "icon-upload", + onClick: () => opts.onImport(dataEl.value), + })); + importCenterBtnCont.appendChild(importBtn); + importPane.append(descEl, dataEl, importCenterBtnCont); + } + panesCont.append(exportPane, importPane); + return panesCont; + } +}/** + * Creates a generic, circular button element. + * If `href` is provided, the button will be an anchor element. + * If `onClick` is provided, the button will be a div element. + * Provide either `resourceName` or `src` to specify the icon inside the button. + */ +async function createCircularBtn(_a) { + var { title, ripple = true } = _a, rest = __rest(_a, ["title", "ripple"]); + let btnElem; + if ("href" in rest && rest.href) { + btnElem = document.createElement("a"); + btnElem.href = rest.href; + btnElem.role = "button"; + btnElem.target = "_blank"; + btnElem.rel = "noopener noreferrer"; + } + else if ("onClick" in rest && rest.onClick) { + btnElem = document.createElement("div"); + rest.onClick && onInteraction(btnElem, rest.onClick); + } + else + throw new TypeError("Either 'href' or 'onClick' must be provided"); + btnElem.classList.add("bytm-generic-btn"); + btnElem.ariaLabel = btnElem.title = title; + btnElem.tabIndex = 0; + btnElem.role = "button"; + const imgElem = document.createElement("img"); + imgElem.classList.add("bytm-generic-btn-img"); + imgElem.src = "src" in rest + ? rest.src instanceof Promise + ? await rest.src + : rest.src + : await getResourceUrl(rest.resourceName); + btnElem.appendChild(imgElem); + return ripple ? createRipple(btnElem) : btnElem; +}let autoLikeDialog = null; +let autoLikeExImDialog = null; +/** Creates and/or returns the import dialog */ +async function getAutoLikeDialog() { + if (!autoLikeDialog) { + await initAutoLikeStore(); + autoLikeDialog = new BytmDialog({ + id: "auto-like-channels", + width: 700, + height: 1000, closeBtnEnabled: true, closeOnBgClick: true, closeOnEscPress: true, destroyOnClose: true, + removeListenersOnDestroy: false, small: true, - renderHeader: renderHeader$2, - renderBody: renderBody$2, + verticalAlign: "top", + renderHeader: renderHeader$4, + renderBody: renderBody$5, + renderFooter: renderFooter$1, }); - } - async function renderHeader$2() { - const titleElem = document.createElement("h2"); - titleElem.id = "bytm-plugin-list-title"; - titleElem.classList.add("bytm-dialog-title"); - titleElem.role = "heading"; - titleElem.ariaLevel = "1"; - titleElem.tabIndex = 0; - titleElem.textContent = t("plugin_list_title"); - return titleElem; - } - async function renderBody$2() { - var _a; - const listContainerEl = document.createElement("div"); - listContainerEl.id = "bytm-plugin-list-container"; - const registeredPlugins = getRegisteredPlugins(); - if (registeredPlugins.length === 0) { - const noPluginsEl = document.createElement("div"); - noPluginsEl.classList.add("bytm-plugin-list-no-plugins"); - noPluginsEl.tabIndex = 0; - setInnerHtml(noPluginsEl, t("plugin_list_no_plugins", ``, "")); - noPluginsEl.title = noPluginsEl.ariaLabel = t("plugin_list_no_plugins_tooltip"); - listContainerEl.appendChild(noPluginsEl); - return listContainerEl; - } - for (const [, { def: { plugin, intents } }] of registeredPlugins) { - const rowEl = document.createElement("div"); - rowEl.classList.add("bytm-plugin-list-row"); - const leftEl = document.createElement("div"); - leftEl.classList.add("bytm-plugin-list-row-left"); - rowEl.appendChild(leftEl); - const headerWrapperEl = document.createElement("div"); - headerWrapperEl.classList.add("bytm-plugin-list-row-header-wrapper"); - leftEl.appendChild(headerWrapperEl); - if (plugin.iconUrl) { - const iconEl = document.createElement("img"); - iconEl.classList.add("bytm-plugin-list-row-icon"); - iconEl.src = plugin.iconUrl; - iconEl.alt = ""; - headerWrapperEl.appendChild(iconEl); - } - const headerEl = document.createElement("div"); - headerEl.classList.add("bytm-plugin-list-row-header"); - headerWrapperEl.appendChild(headerEl); - const titleEl = document.createElement("div"); - titleEl.classList.add("bytm-plugin-list-row-title"); - titleEl.tabIndex = 0; - titleEl.textContent = titleEl.title = titleEl.ariaLabel = plugin.name; - headerEl.appendChild(titleEl); - const verEl = document.createElement("span"); - verEl.classList.add("bytm-plugin-list-row-version"); - verEl.textContent = verEl.title = verEl.ariaLabel = `v${plugin.version}`; - titleEl.appendChild(verEl); - const namespaceEl = document.createElement("div"); - namespaceEl.classList.add("bytm-plugin-list-row-namespace"); - namespaceEl.tabIndex = 0; - namespaceEl.textContent = namespaceEl.title = namespaceEl.ariaLabel = plugin.namespace; - headerEl.appendChild(namespaceEl); - const descEl = document.createElement("p"); - descEl.classList.add("bytm-plugin-list-row-desc"); - descEl.tabIndex = 0; - descEl.textContent = descEl.title = descEl.ariaLabel = (_a = plugin.description[getLocale()]) !== null && _a !== void 0 ? _a : plugin.description["en-US"]; - leftEl.appendChild(descEl); - const linksList = document.createElement("div"); - linksList.classList.add("bytm-plugin-list-row-links-list"); - leftEl.appendChild(linksList); - let linkElCreated = false; - for (const key in plugin.homepage) { - const url = plugin.homepage[key]; - if (!url) - continue; - if (linkElCreated) { - const bulletEl = document.createElement("span"); - bulletEl.classList.add("bytm-plugin-list-row-links-list-bullet"); - bulletEl.textContent = "•"; - linksList.appendChild(bulletEl); + siteEvents.on("autoLikeChannelsUpdated", async () => { + try { + if (autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.isOpen()) + autoLikeExImDialog.unmount(); + if (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.isOpen()) { + autoLikeDialog.unmount(); + await autoLikeDialog.open(); + log("Auto-like channels updated, refreshed dialog"); } - linkElCreated = true; - const linkEl = document.createElement("a"); - linkEl.classList.add("bytm-plugin-list-row-link", "bytm-link"); - linkEl.href = url; - linkEl.tabIndex = 0; - linkEl.target = "_blank"; - linkEl.rel = "noopener noreferrer"; - linkEl.textContent = linkEl.title = linkEl.ariaLabel = t(`plugin_link_type_${key}`); - linksList.appendChild(linkEl); } - const rightEl = document.createElement("div"); - rightEl.classList.add("bytm-plugin-list-row-right"); - rowEl.appendChild(rightEl); - const intentsAmount = Object.keys(PluginIntent).length / 2; - const intentsArr = typeof intents === "number" && intents > 0 ? (() => { - const arr = []; - for (let i = 0; i < intentsAmount; i++) - if (intents & (2 ** i)) - arr.push(2 ** i); - return arr; - })() : []; - const permissionsHeaderEl = document.createElement("div"); - permissionsHeaderEl.classList.add("bytm-plugin-list-row-permissions-header"); - permissionsHeaderEl.tabIndex = 0; - permissionsHeaderEl.textContent = permissionsHeaderEl.title = permissionsHeaderEl.ariaLabel = t("plugin_list_permissions_header"); - rightEl.appendChild(permissionsHeaderEl); - for (const intent of intentsArr) { - const intentEl = document.createElement("div"); - intentEl.classList.add("bytm-plugin-list-row-intent-item"); - intentEl.tabIndex = 0; - intentEl.textContent = PluginIntent[intent]; - intentEl.title = intentEl.ariaLabel = t(`plugin_intent_description_${PluginIntent[intent]}`); - rightEl.appendChild(intentEl); + catch (err) { + error("Couldn't refresh auto-like channels dialog:", err); } - listContainerEl.appendChild(rowEl); - } - return listContainerEl; - } - - let verNotifDialog = null; - /** Creates and/or returns the dialog to be shown when a new version is available */ - async function getVersionNotifDialog({ latestTag, }) { - if (!verNotifDialog) { - const changelogMdFull = await getChangelogMd(); - // I messed up because this should be 0 so the changelog will always need to have an extra div at the top for backwards compatibility - const changelogMd = changelogMdFull.split("
")[1]; - const changelogHtml = await parseMarkdown(changelogMd); - verNotifDialog = new BytmDialog({ - id: "version-notif", - width: 600, - height: 800, - closeBtnEnabled: false, - closeOnBgClick: false, - closeOnEscPress: true, - destroyOnClose: true, - small: true, - renderHeader: renderHeader$1, - renderBody: () => renderBody$1({ latestTag, changelogHtml }), - }); - } - return verNotifDialog; - } - async function renderHeader$1() { - const logoEl = document.createElement("img"); - logoEl.classList.add("bytm-dialog-header-img", "bytm-no-select"); - logoEl.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); - logoEl.alt = "BetterYTM logo"; - return logoEl; - } - let disableUpdateCheck = false; - async function renderBody$1({ latestTag, changelogHtml, }) { - disableUpdateCheck = false; - const wrapperEl = document.createElement("div"); - const pEl = document.createElement("p"); - pEl.textContent = t("new_version_available", scriptInfo.name, scriptInfo.version, latestTag, platformNames[host]); - wrapperEl.appendChild(pEl); - const changelogDetailsEl = document.createElement("details"); - changelogDetailsEl.id = "bytm-version-notif-changelog-details"; - changelogDetailsEl.open = false; - const changelogSummaryEl = document.createElement("summary"); - changelogSummaryEl.role = "button"; - changelogSummaryEl.tabIndex = 0; - changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = t("expand_release_notes"); - changelogDetailsEl.appendChild(changelogSummaryEl); - changelogDetailsEl.addEventListener("toggle", () => { - changelogSummaryEl.ariaLabel = changelogSummaryEl.title = changelogSummaryEl.textContent = changelogDetailsEl.open ? t("collapse_release_notes") : t("expand_release_notes"); - }); - const changelogEl = document.createElement("p"); - changelogEl.id = "bytm-version-notif-changelog-cont"; - changelogEl.classList.add("bytm-markdown-container"); - setInnerHtml(changelogEl, changelogHtml); - changelogEl.querySelectorAll("a").forEach((a) => { - a.target = "_blank"; - a.rel = "noopener noreferrer"; - }); - changelogDetailsEl.appendChild(changelogEl); - wrapperEl.appendChild(changelogDetailsEl); - const disableUpdCheckEl = document.createElement("div"); - disableUpdCheckEl.id = "bytm-disable-update-check-wrapper"; - if (!getFeature("versionCheck")) - disableUpdateCheck = true; - const disableToggleEl = await createToggleInput({ - id: "disable-update-check", - initialValue: disableUpdateCheck, - labelPos: "off", - onChange(checked) { - disableUpdateCheck = checked; - if (checked) - btnClose.textContent = t("close_and_ignore_until_reenabled"); - else - btnClose.textContent = t("close_and_ignore_for_24h"); - }, - }); - const labelWrapperEl = document.createElement("div"); - labelWrapperEl.classList.add("bytm-disable-update-check-toggle-label-wrapper"); - const labelEl = document.createElement("label"); - labelEl.htmlFor = "bytm-toggle-disable-update-check"; - labelEl.textContent = t("disable_update_check"); - const secondaryLabelEl = document.createElement("span"); - secondaryLabelEl.classList.add("bytm-secondary-label"); - secondaryLabelEl.textContent = t("reenable_in_config_menu"); - labelWrapperEl.appendChild(labelEl); - labelWrapperEl.appendChild(secondaryLabelEl); - disableUpdCheckEl.appendChild(disableToggleEl); - disableUpdCheckEl.appendChild(labelWrapperEl); - wrapperEl.appendChild(disableUpdCheckEl); - verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.on("close", async () => { - const config = getFeatures(); - const recreateCfgMenu = config.versionCheck === disableUpdateCheck; - if (config.versionCheck && disableUpdateCheck) - config.versionCheck = false; - else if (!config.versionCheck && !disableUpdateCheck) - config.versionCheck = true; - await setFeatures(config); - recreateCfgMenu && emitSiteEvent("recreateCfgMenu"); }); - const btnWrapper = document.createElement("div"); - btnWrapper.id = "bytm-version-notif-dialog-btns"; - const btnUpdate = document.createElement("button"); - btnUpdate.classList.add("bytm-btn"); - btnUpdate.tabIndex = 0; - btnUpdate.textContent = t("open_update_page_install_manually", platformNames[host]); - onInteraction(btnUpdate, () => { - window.open(packageJson.updates[host]); - verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close(); - }); - const btnClose = document.createElement("button"); - btnClose.classList.add("bytm-btn"); - btnClose.tabIndex = 0; - btnClose.textContent = t("close_and_ignore_for_24h"); - onInteraction(btnClose, () => verNotifDialog === null || verNotifDialog === void 0 ? void 0 : verNotifDialog.close()); - btnWrapper.appendChild(btnUpdate); - btnWrapper.appendChild(btnClose); - wrapperEl.appendChild(btnWrapper); - return wrapperEl; + autoLikeDialog.on("close", () => emitSiteEvent("autoLikeChannelsUpdated")); } - - //#region create menu - let isCfgMenuMounted = false; - let isCfgMenuOpen = false; - /** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */ - const scrollIndicatorOffsetThreshold = 50; - let scrollIndicatorEnabled = true; - /** Locale at the point of initializing the config menu */ - let initLocale; - /** Stringified config at the point of initializing the config menu */ - let initConfig$1; - /** Timeout id for the "copied" text in the hidden value copy button */ - let hiddenCopiedTxtTimeout; - /** - * Adds an element to open the BetterYTM menu - * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23 - */ - async function mountCfgMenu() { - var _a, _b, _c, _d, _e; - if (isCfgMenuMounted) - return; - isCfgMenuMounted = true; - BytmDialog.initDialogs(); - initLocale = getFeature("locale"); - initConfig$1 = getFeatures(); - const initLangReloadText = t("lang_changed_prompt_reload"); - //#region bg & container - const backgroundElem = document.createElement("div"); - backgroundElem.id = "bytm-cfg-menu-bg"; - backgroundElem.classList.add("bytm-menu-bg"); - backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip"); - backgroundElem.style.visibility = "hidden"; - backgroundElem.style.display = "none"; - backgroundElem.addEventListener("click", (e) => { - var _a; - if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg") - closeCfgMenu(e); - }); - document.body.addEventListener("keydown", (e) => { - if (isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu") - closeCfgMenu(e); - }); - const menuContainer = document.createElement("div"); - menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards - menuContainer.classList.add("bytm-menu"); - menuContainer.id = "bytm-cfg-menu"; - //#region title bar - const headerElem = document.createElement("div"); - headerElem.classList.add("bytm-menu-header"); - const titleLogoHeaderCont = document.createElement("div"); - titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont"); - const titleCont = document.createElement("div"); - titleCont.classList.add("bytm-menu-titlecont"); - titleCont.role = "heading"; - titleCont.ariaLevel = "1"; - const titleLogoElem = document.createElement("img"); - const logoSrc = await getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`); - titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select"); - if (logoSrc) - titleLogoElem.src = logoSrc; - titleLogoHeaderCont.appendChild(titleLogoElem); - const titleElem = document.createElement("h2"); - titleElem.classList.add("bytm-menu-title"); - const titleTextElem = document.createElement("div"); - titleTextElem.textContent = t("config_menu_title", scriptInfo.name); - titleElem.appendChild(titleTextElem); - const linksCont = document.createElement("div"); - linksCont.id = "bytm-menu-linkscont"; - linksCont.role = "navigation"; - const linkTitlesShort = { - github: "GitHub", - greasyfork: "GreasyFork", - openuserjs: "OpenUserJS", - discord: "Discord", - }; - const addLink = (imgSrc, href, title, titleKey) => { - const anchorElem = document.createElement("a"); - anchorElem.classList.add("bytm-menu-link", "bytm-no-select"); - anchorElem.rel = "noopener noreferrer"; - anchorElem.href = href; - anchorElem.target = "_blank"; - anchorElem.tabIndex = 0; - anchorElem.role = "button"; - anchorElem.ariaLabel = anchorElem.title = title; - const extendedAnchorEl = document.createElement("a"); - extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select"); - extendedAnchorEl.rel = "noopener noreferrer"; - extendedAnchorEl.href = href; - extendedAnchorEl.target = "_blank"; - extendedAnchorEl.tabIndex = -1; - extendedAnchorEl.textContent = linkTitlesShort[titleKey]; - extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title; - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-menu-img"); - imgElem.src = imgSrc; - anchorElem.appendChild(imgElem); - anchorElem.appendChild(extendedAnchorEl); - linksCont.appendChild(anchorElem); - }; - const links = [ - ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"], - ["greasyfork", await getResourceUrl("img-greasyfork"), packageJson.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"], - ["openuserjs", await getResourceUrl("img-openuserjs"), packageJson.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"], - ]; - const hostLink = links.find(([name]) => name === host); - const otherLinks = links.filter(([name]) => name !== host); - const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links; - for (const [, ...args] of reorderedLinks) - addLink(...args); - addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord"); - const closeElem = document.createElement("img"); - closeElem.classList.add("bytm-menu-close"); - closeElem.role = "button"; - closeElem.tabIndex = 0; - closeElem.src = await getResourceUrl("img-close"); - closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip"); - onInteraction(closeElem, closeCfgMenu); - titleCont.appendChild(titleElem); - titleCont.appendChild(linksCont); - titleLogoHeaderCont.appendChild(titleCont); - headerElem.appendChild(titleLogoHeaderCont); - headerElem.appendChild(closeElem); - //#region footer - const footerCont = document.createElement("div"); - footerCont.classList.add("bytm-menu-footer-cont"); - const reloadFooterCont = document.createElement("div"); - const reloadFooterEl = document.createElement("div"); - reloadFooterEl.id = "bytm-menu-footer-reload-hint"; - reloadFooterEl.classList.add("bytm-menu-footer", "hidden"); - reloadFooterEl.setAttribute("aria-hidden", "true"); - reloadFooterEl.textContent = t("reload_hint"); - reloadFooterEl.role = "alert"; - reloadFooterEl.ariaLive = "polite"; - const reloadTxtEl = document.createElement("button"); - reloadTxtEl.classList.add("bytm-btn"); - reloadTxtEl.style.marginLeft = "10px"; - reloadTxtEl.textContent = t("reload_now"); - reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip"); - reloadTxtEl.addEventListener("click", () => { - closeCfgMenu(); - disableBeforeUnload(); - location.reload(); - }); - reloadFooterEl.appendChild(reloadTxtEl); - reloadFooterCont.appendChild(reloadFooterEl); - /** For copying plain when shift-clicking the copy button or when compression is not supported */ - const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() }); - const exImDlg = new ExImDialog({ - id: "bytm-config-export-import", + if (!autoLikeExImDialog) { + autoLikeExImDialog = new ExImDialog({ + id: "auto-like-channels-export-import", width: 800, height: 600, // try to compress the data if possible exportData: async () => await compressionSupported() - ? await UserUtils.compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string") - : exportDataSpecial(), - exportDataSpecial, + ? await UserUtils.compress(JSON.stringify(autoLikeStore.getData()), compressionFormat, "string") + : JSON.stringify(autoLikeStore.getData()), + // copy plain when shift-clicking the copy button + exportDataSpecial: () => JSON.stringify(autoLikeStore.getData()), async onImport(data) { try { - const parsed = await tryToDecompressAndParse(data.trim()); - log("Trying to import configuration:", parsed); + const parsed = await tryToDecompressAndParse(data); + log("Trying to import auto-like data:", parsed); if (!parsed || typeof parsed !== "object") return await showPrompt({ type: "alert", message: t("import_error_invalid") }); - if (typeof parsed.formatVersion !== "number") - return await showPrompt({ type: "alert", message: t("import_error_no_format_version") }); - if (typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0) + if (!parsed.channels || typeof parsed.channels !== "object" || Object.keys(parsed.channels).length === 0) return await showPrompt({ type: "alert", message: t("import_error_no_data") }); - if (parsed.formatVersion < formatVersion) { - let newData = JSON.parse(JSON.stringify(parsed.data)); - const sortedMigrations = Object.entries(migrations) - .sort(([a], [b]) => Number(a) - Number(b)); - let curFmtVer = Number(parsed.formatVersion); - for (const [fmtVer, migrationFunc] of sortedMigrations) { - const ver = Number(fmtVer); - if (curFmtVer < formatVersion && curFmtVer < ver) { - try { - const migRes = JSON.parse(JSON.stringify(migrationFunc(newData))); - newData = migRes instanceof Promise ? await migRes : migRes; - curFmtVer = ver; - } - catch (err) { - error(`Error while running migration function for format version ${fmtVer}:`, err); - } - } - } - parsed.formatVersion = curFmtVer; - parsed.data = newData; - } - else if (parsed.formatVersion !== formatVersion) - return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) }); - await setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data)); - if (await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) { - disableBeforeUnload(); - return location.reload(); - } - exImDlg.unmount(); - emitSiteEvent("rebuildCfgMenu", parsed.data); + await autoLikeStore.setData(parsed); + emitSiteEvent("autoLikeChannelsUpdated"); + showToast({ message: t("import_success") }); + autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.unmount(); } catch (err) { - warn("Couldn't import configuration:", err); - await showPrompt({ type: "alert", message: t("import_error_invalid") }); + error("Couldn't import auto-like channels data:", err); } }, - title: () => t("bytm_config_export_import_title"), - descImport: () => t("bytm_config_import_desc"), - descExport: () => t("bytm_config_export_desc"), + title: () => t("auto_like_export_import_title"), + descImport: () => t("auto_like_import_desc"), + descExport: () => t("auto_like_export_desc"), }); - const exportImportBtn = document.createElement("button"); - exportImportBtn.classList.add("bytm-btn"); - exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import"); - onInteraction(exportImportBtn, async () => await exImDlg.open()); - const buttonsCont = document.createElement("div"); - buttonsCont.classList.add("bytm-menu-footer-buttons-cont"); - buttonsCont.appendChild(exportImportBtn); - footerCont.appendChild(reloadFooterCont); - footerCont.appendChild(buttonsCont); - //#region feature list - const featuresCont = document.createElement("div"); - featuresCont.id = "bytm-menu-opts"; - const onCfgChange = async (key, initialVal, newVal) => { - var _a, _b, _c, _d; - try { - const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val); - info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`); - const featConf = JSON.parse(JSON.stringify(getFeatures())); - featConf[key] = newVal; - const changedKeys = initConfig$1 ? Object.keys(featConf).filter((k) => typeof featConf[k] !== "object" - && featConf[k] !== initConfig$1[k]) : []; - const requiresReload = - // @ts-ignore - changedKeys.some((k) => { var _a; return ((_a = featInfo[k]) === null || _a === void 0 ? void 0 : _a.reloadRequired) !== false; }); - await setFeatures(featConf); - // @ts-ignore - (_b = (_a = featInfo[key]) === null || _a === void 0 ? void 0 : _a.change) === null || _b === void 0 ? void 0 : _b.call(_a, key, initialVal, newVal); - if (requiresReload) { - reloadFooterEl.classList.remove("hidden"); - reloadFooterEl.setAttribute("aria-hidden", "false"); - } - else { - reloadFooterEl.classList.add("hidden"); - reloadFooterEl.setAttribute("aria-hidden", "true"); - } - if (initLocale !== featConf.locale) { - await initTranslations(featConf.locale); - setLocale(featConf.locale); - const newText = t("lang_changed_prompt_reload"); - const newLangEmoji = ((_c = langMapping[featConf.locale]) === null || _c === void 0 ? void 0 : _c.emoji) ? `${langMapping[featConf.locale].emoji}\n` : ""; - const initLangEmoji = ((_d = langMapping[initLocale]) === null || _d === void 0 ? void 0 : _d.emoji) ? `${langMapping[initLocale].emoji}\n` : ""; - const confirmText = newText !== initLangReloadText ? `${newLangEmoji}${newText}\n\n\n${initLangEmoji}${initLangReloadText}` : newText; - if (await showPrompt({ - type: "confirm", - message: confirmText, - confirmBtnText: () => `${t("prompt_confirm")} / ${tl(initLocale, "prompt_confirm")}`, - confirmBtnTooltip: () => `${t("click_to_confirm_tooltip")} / ${tl(initLocale, "click_to_confirm_tooltip")}`, - denyBtnText: (type) => `${t(type === "alert" ? "prompt_close" : "prompt_cancel")} / ${tl(initLocale, type === "alert" ? "prompt_close" : "prompt_cancel")}`, - denyBtnTooltip: (type) => `${t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")} / ${tl(initLocale, type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")}`, - })) { - closeCfgMenu(); - disableBeforeUnload(); - location.reload(); - } - } - else if (getLocale() !== featConf.locale) - setLocale(featConf.locale); - } - catch (err) { - error("Error while reacting to config change:", err); - } - finally { - emitSiteEvent("configOptionChanged", key, initialVal, newVal); - } - }; - /** Call whenever the feature config is changed */ - const confChanged = UserUtils.debounce(onCfgChange, 333, "falling"); - const featureCfg = getFeatures(); - const featureCfgWithCategories = Object.entries(featInfo) - .reduce((acc, [key, { category }]) => { - if (!acc[category]) - acc[category] = {}; - acc[category][key] = featureCfg[key]; - return acc; - }, {}); - /** - * Formats the value `v` based on the provided `key` using the `featInfo` object. - * If a custom `renderValue` function is defined for the `key`, it will be used to format the value. - * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed. - * If the value is an object, it will be converted to a JSON string representation. - * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed). - */ - const fmtVal = (v, key) => { - var _a; - try { - // @ts-ignore - const renderValue = typeof ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.renderValue) === "function" ? featInfo[key].renderValue : undefined; - const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim(); - return renderValue ? renderValue(retVal) : retVal; - } - catch (_b) { - // absolute last resort fallback because stringify throws on circular refs - return String(v).trim(); - } - }; - for (const category in featureCfgWithCategories) { - const featObj = featureCfgWithCategories[category]; - const catHeaderElem = document.createElement("h3"); - catHeaderElem.classList.add("bytm-ftconf-category-header"); - catHeaderElem.role = "heading"; - catHeaderElem.ariaLevel = "2"; - catHeaderElem.tabIndex = 0; - catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`; - featuresCont.appendChild(catHeaderElem); - for (const featKey in featObj) { - const ftInfo = featInfo[featKey]; - if (!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true)) - continue; - if (ftInfo.advanced && !featureCfg.advancedMode) - continue; - const { type, default: ftDefault } = ftInfo; - const step = "step" in ftInfo ? ftInfo.step : undefined; - const val = featureCfg[featKey]; - const initialVal = (_a = val !== null && val !== void 0 ? val : ftDefault) !== null && _a !== void 0 ? _a : undefined; - const ftConfElem = document.createElement("div"); - ftConfElem.classList.add("bytm-ftitem"); - { - const featLeftSideElem = document.createElement("div"); - featLeftSideElem.classList.add("bytm-ftitem-leftside"); - if (getFeature("advancedMode")) { - const defVal = fmtVal(ftDefault, featKey); - const extraTxts = [ - `default: ${defVal.length === 0 ? "(undefined)" : defVal}`, - ]; - "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`); - "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`); - "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`); - const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : ""; - const adv = ftInfo.advanced ? " (advanced feature)" : ""; - ftConfElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`; - } - const textElem = document.createElement("span"); - textElem.classList.add("bytm-ftitem-text", "bytm-ellipsis-wrap"); - textElem.textContent = textElem.title = textElem.ariaLabel = t(`feature_desc_${featKey}`); - let adornmentElem; - const adornContentAsync = (_b = ftInfo.textAdornment) === null || _b === void 0 ? void 0 : _b.call(ftInfo); - const adornContent = adornContentAsync instanceof Promise ? await adornContentAsync : adornContentAsync; - if ((typeof adornContentAsync === "string" || adornContentAsync instanceof Promise) && typeof adornContent !== "undefined") { - adornmentElem = document.createElement("span"); - adornmentElem.id = `bytm-ftitem-${featKey}-adornment`; - adornmentElem.classList.add("bytm-ftitem-adornment"); - setInnerHtml(adornmentElem, adornContent); - } - let helpElem; - // @ts-ignore - const hasHelpTextFunc = typeof ((_c = featInfo[featKey]) === null || _c === void 0 ? void 0 : _c.helpText) === "function"; - // @ts-ignore - const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText(); - if (hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) { - const helpElemImgHtml = await resourceAsString("icon-help"); - if (helpElemImgHtml) { - helpElem = document.createElement("div"); - helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn"); - helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip", t(`feature_desc_${featKey}`)); - helpElem.role = "button"; - helpElem.tabIndex = 0; - setInnerHtml(helpElem, helpElemImgHtml); - onInteraction(helpElem, async (e) => { - e.preventDefault(); - e.stopPropagation(); - await (await getFeatHelpDialog({ featKey: featKey })).open(); - }); - } - else { - error(`Couldn't create help button SVG element for feature '${featKey}'`); - } - } - adornmentElem && featLeftSideElem.appendChild(adornmentElem); - featLeftSideElem.appendChild(textElem); - helpElem && featLeftSideElem.appendChild(helpElem); - ftConfElem.appendChild(featLeftSideElem); - } - { - let inputType = "text"; - let inputTag = "input"; - switch (type) { - case "toggle": - inputTag = undefined; - inputType = undefined; - break; - case "slider": - inputType = "range"; - break; - case "number": - inputType = "number"; - break; - case "text": - inputType = "text"; - break; - case "select": - inputTag = "select"; - inputType = undefined; - break; - case "hotkey": - inputTag = undefined; - inputType = undefined; - break; - case "button": - inputTag = undefined; - inputType = undefined; - break; - } - const inputElemId = `bytm-ftconf-${featKey}-input`; - const ctrlElem = document.createElement("span"); - ctrlElem.classList.add("bytm-ftconf-ctrl"); - // to prevent dev mode title from propagating: - ctrlElem.title = ""; - let advCopyHiddenCont; - if ((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) { - const advCopyHintElem = document.createElement("span"); - advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint"); - advCopyHintElem.textContent = t("copied"); - advCopyHintElem.role = "status"; - advCopyHintElem.style.display = "none"; - const advCopyHiddenBtn = document.createElement("button"); - advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn"); - advCopyHiddenBtn.tabIndex = 0; - advCopyHiddenBtn.textContent = t("copy_hidden"); - advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip"); - const copyHiddenInteraction = (e) => { - e.preventDefault(); - e.stopPropagation(); - copyToClipboard(getFeatures()[featKey]); - advCopyHintElem.style.display = "inline"; - if (typeof hiddenCopiedTxtTimeout === "undefined") { - hiddenCopiedTxtTimeout = setTimeout(() => { - advCopyHintElem.style.display = "none"; - hiddenCopiedTxtTimeout = undefined; - }, 3000); - } - }; - onInteraction(advCopyHiddenBtn, copyHiddenInteraction); - advCopyHiddenCont = document.createElement("span"); - advCopyHiddenCont.appendChild(advCopyHintElem); - advCopyHiddenCont.appendChild(advCopyHiddenBtn); - } - advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont); - if (inputTag) { - // standard input element: - const inputElem = document.createElement(inputTag); - inputElem.classList.add("bytm-ftconf-input"); - inputElem.id = inputElemId; - inputElem.ariaLabel = t(`feature_desc_${featKey}`); - if (inputType) - inputElem.type = inputType; - if ("min" in ftInfo && typeof ftInfo.min !== "undefined") - inputElem.min = String(ftInfo.min); - if ("max" in ftInfo && typeof ftInfo.max !== "undefined") - inputElem.max = String(ftInfo.max); - if (typeof initialVal !== "undefined") - inputElem.value = String(initialVal); - if (type === "text" && ftInfo.valueHidden) { - inputElem.type = "password"; - inputElem.autocomplete = "off"; - } - if (type === "number" || type === "slider" && step) - inputElem.step = String(step); - if (type === "toggle" && typeof initialVal !== "undefined") - inputElem.checked = Boolean(initialVal); - const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string" - ? ftInfo.unit - : ("unit" in ftInfo && typeof ftInfo.unit === "function" - ? ftInfo.unit(Number(inputElem.value)) - : "")); - let labelElem; - let lastDisplayedVal; - if (type === "slider") { - labelElem = document.createElement("label"); - labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label"); - labelElem.textContent = `${fmtVal(initialVal, featKey)}${unitTxt}`; - inputElem.addEventListener("input", () => { - if (labelElem && lastDisplayedVal !== inputElem.value) { - labelElem.textContent = `${fmtVal(inputElem.value, featKey)}${unitTxt}`; - lastDisplayedVal = inputElem.value; - } - }); - } - else if (type === "select") { - const ftOpts = typeof ftInfo.options === "function" - ? ftInfo.options() - : ftInfo.options; - for (const { value, label } of ftOpts) { - const optionElem = document.createElement("option"); - optionElem.value = String(value); - optionElem.textContent = label; - if (value === initialVal) - optionElem.selected = true; - inputElem.appendChild(optionElem); - } - } - if (type === "text") { - let lastValue = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default; - const textInputUpdate = () => { - let v = String(inputElem.value).trim(); - if (type === "text" && ftInfo.normalize) - v = inputElem.value = ftInfo.normalize(String(v)); - if (v === lastValue) - return; - lastValue = v; - if (v === "") - v = ftInfo.default; - if (typeof initialVal !== "undefined") - confChanged(featKey, initialVal, v); - }; - const unsub = siteEvents.on("cfgMenuClosed", () => { - unsub(); - textInputUpdate(); - }); - inputElem.addEventListener("blur", () => textInputUpdate()); - inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate()); - } - else { - inputElem.addEventListener("input", () => { - let v = String(inputElem.value).trim(); - if (["number", "slider"].includes(type) || v.match(/^-?\d+$/)) - v = Number(v); - if (typeof initialVal !== "undefined") - confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked)); - }); - } - if (labelElem) { - labelElem.id = `bytm-ftconf-${featKey}-label`; - labelElem.htmlFor = inputElemId; - ctrlElem.appendChild(labelElem); - } - ctrlElem.appendChild(inputElem); - } - else { - // custom input element: - let customInputEl; - switch (type) { - case "hotkey": - customInputEl = createHotkeyInput({ - initialValue: typeof initialVal === "object" ? initialVal : undefined, - onChange: (hotkey) => confChanged(featKey, initialVal, hotkey), - createTitle: (value) => t("hotkey_input_click_to_change_tooltip", t(`feature_desc_${featKey}`), value), - }); - break; - case "toggle": - customInputEl = await createToggleInput({ - initialValue: Boolean(initialVal), - onChange: (checked) => confChanged(featKey, initialVal, checked), - id: `ftconf-${featKey}`, - labelPos: "left", - }); - break; - case "button": - customInputEl = document.createElement("button"); - customInputEl.classList.add("bytm-btn"); - customInputEl.tabIndex = 0; - customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action"); - customInputEl.ariaLabel = customInputEl.title = t(`feature_desc_${featKey}`); - onInteraction(customInputEl, async () => { - if (customInputEl.disabled) - return; - const startTs = Date.now(); - const res = ftInfo.click(); - customInputEl.disabled = true; - customInputEl.classList.add("bytm-busy"); - customInputEl.textContent = hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running"); - if (res instanceof Promise) - await res; - const finalize = () => { - customInputEl.disabled = false; - customInputEl.classList.remove("bytm-busy"); - customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action"); - }; - // artificial timeout ftw - if (Date.now() - startTs < 350) - setTimeout(finalize, 350 - (Date.now() - startTs)); - else - finalize(); - }); - break; - } - if (customInputEl && !customInputEl.hasAttribute("aria-label")) - customInputEl.ariaLabel = t(`feature_desc_${featKey}`); - ctrlElem.appendChild(customInputEl); - } - ftConfElem.appendChild(ctrlElem); - } - featuresCont.appendChild(ftConfElem); - } + } + return autoLikeDialog; +} +//#region header +async function renderHeader$4() { + const headerEl = document.createElement("h2"); + headerEl.classList.add("bytm-dialog-title"); + headerEl.role = "heading"; + headerEl.ariaLevel = "1"; + headerEl.tabIndex = 0; + headerEl.textContent = headerEl.ariaLabel = t("auto_like_channels_dialog_title"); + return headerEl; +} +//#region body +async function renderBody$5() { + const contElem = document.createElement("div"); + const descriptionEl = document.createElement("p"); + descriptionEl.classList.add("bytm-auto-like-channels-desc"); + descriptionEl.textContent = t("auto_like_channels_dialog_desc"); + descriptionEl.tabIndex = 0; + contElem.appendChild(descriptionEl); + const searchCont = document.createElement("div"); + searchCont.classList.add("bytm-auto-like-channels-search-cont"); + contElem.appendChild(searchCont); + const searchbarEl = document.createElement("input"); + searchbarEl.classList.add("bytm-auto-like-channels-searchbar"); + searchbarEl.placeholder = t("search_placeholder"); + searchbarEl.type = searchbarEl.role = "search"; + searchbarEl.tabIndex = 0; + searchbarEl.autofocus = true; + searchbarEl.autocomplete = searchbarEl.autocapitalize = "off"; + searchbarEl.spellcheck = false; + searchbarEl.addEventListener("input", () => { + var _a, _b, _c, _d, _e, _f; + const searchVal = searchbarEl.value.trim().toLowerCase(); + const rows = document.querySelectorAll(".bytm-auto-like-channel-row"); + for (const row of rows) { + const name = (_c = (_b = (_a = row.querySelector(".bytm-auto-like-channel-name")) === null || _a === void 0 ? void 0 : _a.textContent) === null || _b === void 0 ? void 0 : _b.trim().toLowerCase().replace(/\s/g, "")) !== null && _c !== void 0 ? _c : ""; + const id = (_f = (_e = (_d = row.querySelector(".bytm-auto-like-channel-id")) === null || _d === void 0 ? void 0 : _d.textContent) === null || _e === void 0 ? void 0 : _e.trim()) !== null && _f !== void 0 ? _f : ""; + row.classList.toggle("hidden", !name.includes(searchVal) && !(id.startsWith("@") ? id : "").includes(searchVal)); } - //#region reset inputs on external change - siteEvents.on("rebuildCfgMenu", (newConfig) => { - for (const ftKey in featInfo) { - const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`); - const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`); - if (!ftElem) - continue; - const ftInfo = featInfo[ftKey]; - const value = newConfig[ftKey]; - if (ftInfo.type === "toggle") - ftElem.checked = Boolean(value); - else - ftElem.value = String(value); - if (!labelElem) - continue; - const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string" - ? ftInfo.unit - : ("unit" in ftInfo && typeof ftInfo.unit === "function" - ? ftInfo.unit(Number(ftElem.value)) - : "")); - if (ftInfo.type === "slider") - labelElem.textContent = `${fmtVal(Number(value), ftKey)}${unitTxt}`; - } - info("Rebuilt config menu"); + }); + searchCont.appendChild(searchbarEl); + const searchClearEl = document.createElement("button"); + searchClearEl.classList.add("bytm-auto-like-channels-search-clear"); + searchClearEl.title = searchClearEl.ariaLabel = t("search_clear"); + searchClearEl.tabIndex = 0; + searchClearEl.innerText = "×"; + onInteraction(searchClearEl, () => { + searchbarEl.value = ""; + searchbarEl.dispatchEvent(new Event("input")); + }); + searchCont.appendChild(searchClearEl); + const channelListCont = document.createElement("div"); + channelListCont.id = "bytm-auto-like-channels-list"; + const setChannelEnabled = UserUtils.debounce((id, enabled) => { + autoLikeStore.setData({ + channels: autoLikeStore.getData().channels + .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { enabled }) : ch), }); - //#region scroll indicator - const scrollIndicator = document.createElement("img"); - scrollIndicator.id = "bytm-menu-scroll-indicator"; - scrollIndicator.src = await getResourceUrl("icon-arrow_down"); - scrollIndicator.role = "button"; - scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom"); - featuresCont.appendChild(scrollIndicator); - scrollIndicator.addEventListener("click", () => { - const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor"); - bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({ - behavior: "smooth", - }); + }, 250); + const sortedChannels = autoLikeStore + .getData().channels + .sort((a, b) => a.name.localeCompare(b.name)); + for (const { name: chanName, id: chanId, enabled } of sortedChannels) { + const rowElem = document.createElement("div"); + rowElem.classList.add("bytm-auto-like-channel-row"); + const leftCont = document.createElement("div"); + leftCont.classList.add("bytm-auto-like-channel-row-left-cont"); + const nameLabelEl = document.createElement("label"); + nameLabelEl.ariaLabel = nameLabelEl.title = chanName; + nameLabelEl.htmlFor = `bytm-auto-like-channel-list-toggle-${chanId}`; + nameLabelEl.classList.add("bytm-auto-like-channel-name-label"); + const nameElem = document.createElement("a"); + nameElem.classList.add("bytm-auto-like-channel-name", "bytm-link"); + nameElem.ariaLabel = nameElem.textContent = chanName; + nameElem.href = (!chanId.startsWith("@") && getDomain() === "ytm") + ? `https://music.youtube.com/channel/${chanId}` + : `https://youtube.com/${chanId.startsWith("@") ? chanId : `channel/${chanId}`}`; + nameElem.target = "_blank"; + nameElem.rel = "noopener noreferrer"; + nameElem.tabIndex = 0; + const idElem = document.createElement("span"); + idElem.classList.add("bytm-auto-like-channel-id"); + idElem.textContent = idElem.title = chanId; + nameLabelEl.appendChild(nameElem); + nameLabelEl.appendChild(idElem); + const toggleElem = await createToggleInput({ + id: `auto-like-channel-list-${chanId}`, + labelPos: "off", + initialValue: enabled, + onChange: (en) => setChannelEnabled(chanId, en), }); - featuresCont.addEventListener("scroll", (evt) => { - var _a, _b; - const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0; - const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator"); - if (!scrollIndicator) - return; - if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) { - scrollIndicator.classList.add("bytm-hidden"); - } - else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) { - scrollIndicator.classList.remove("bytm-hidden"); - } + toggleElem.classList.add("bytm-auto-like-channel-toggle"); + toggleElem.title = toggleElem.ariaLabel = t("auto_like_channel_toggle_tooltip", chanName); + const btnCont = document.createElement("div"); + btnCont.classList.add("bytm-auto-like-channel-row-btn-cont"); + const editBtn = await createCircularBtn({ + resourceName: "icon-edit", + title: t("edit_entry"), + async onClick() { + var _a, _b, _c; + const newNamePr = (_a = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_name_prompt"), defaultValue: chanName }))) === null || _a === void 0 ? void 0 : _a.trim(); + if (!newNamePr || newNamePr.length === 0) + return; + const newName = newNamePr.length > 0 ? newNamePr : chanName; + const newIdPr = (_b = (await showPrompt({ type: "prompt", message: t("auto_like_channel_edit_id_prompt"), defaultValue: chanId }))) === null || _b === void 0 ? void 0 : _b.trim(); + if (!newIdPr || newIdPr.length === 0) + return; + const newId = newIdPr.length > 0 ? (_c = getChannelIdFromPrompt(newIdPr)) !== null && _c !== void 0 ? _c : chanId : chanId; + await autoLikeStore.setData({ + channels: autoLikeStore.getData().channels + .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { name: newName, id: newId }) : ch), + }); + emitSiteEvent("autoLikeChannelsUpdated"); + }, }); - const bottomAnchor = document.createElement("div"); - bottomAnchor.id = "bytm-menu-bottom-anchor"; - featuresCont.appendChild(bottomAnchor); - //#region finalize - menuContainer.appendChild(headerElem); - menuContainer.appendChild(featuresCont); - const subtitleElemCont = document.createElement("div"); - subtitleElemCont.id = "bytm-menu-subtitle-cont"; - subtitleElemCont.classList.add("bytm-ellipsis"); - const versionEl = document.createElement("a"); - versionEl.id = "bytm-menu-version-anchor"; - versionEl.classList.add("bytm-link", "bytm-ellipsis"); - versionEl.role = "button"; - versionEl.tabIndex = 0; - versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber); - versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`; - onInteraction(versionEl, async (e) => { - e.preventDefault(); - e.stopPropagation(); - const dlg = await getChangelogDialog(); - dlg.on("close", openCfgMenu); - await dlg.mount(); - closeCfgMenu(undefined, false); - await dlg.open(); + btnCont.appendChild(editBtn); + const removeBtn = await createCircularBtn({ + resourceName: "icon-delete", + title: t("remove_entry"), + async onClick() { + autoLikeStore.setData({ + channels: autoLikeStore.getData().channels.filter((ch) => ch.id !== chanId), + }); + rowElem.remove(); + emitSiteEvent("autoLikeChannelsUpdated"); + }, }); - subtitleElemCont.appendChild(versionEl); - titleElem.appendChild(subtitleElemCont); - const modeItems = []; - mode === "development" && modeItems.push("dev_mode"); - getFeature("advancedMode") && modeItems.push("advanced_mode"); - if (modeItems.length > 0) { - const modeDisplayEl = document.createElement("span"); - modeDisplayEl.id = "bytm-menu-mode-display"; - modeDisplayEl.classList.add("bytm-ellipsis"); - modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`; - modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & ")); - subtitleElemCont.appendChild(modeDisplayEl); - } - menuContainer.appendChild(footerCont); - backgroundElem.appendChild(menuContainer); - ((_d = document.querySelector("#bytm-dialog-container")) !== null && _d !== void 0 ? _d : document.body).appendChild(backgroundElem); - window.addEventListener("resize", UserUtils.debounce(checkToggleScrollIndicator, 250, "rising")); - log("Added menu element"); - // ensure stuff is reset if menu was opened before being added - isCfgMenuOpen = false; - document.body.classList.remove("bytm-disable-scroll"); - (_e = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _e === void 0 ? void 0 : _e.removeAttribute("inert"); - backgroundElem.style.visibility = "hidden"; - backgroundElem.style.display = "none"; - siteEvents.on("recreateCfgMenu", async () => { - const bgElem = document.querySelector("#bytm-cfg-menu-bg"); - if (!bgElem) - return; - closeCfgMenu(); - bgElem.remove(); - isCfgMenuMounted = false; - await mountCfgMenu(); - await openCfgMenu(); + btnCont.appendChild(removeBtn); + leftCont.appendChild(toggleElem); + leftCont.appendChild(nameLabelEl); + rowElem.appendChild(leftCont); + rowElem.appendChild(btnCont); + channelListCont.appendChild(rowElem); + } + contElem.appendChild(channelListCont); + return contElem; +} +//#region footer +function renderFooter$1() { + const wrapperEl = document.createElement("div"); + wrapperEl.classList.add("bytm-auto-like-channels-footer-wrapper"); + const addNewBtnElem = document.createElement("button"); + addNewBtnElem.classList.add("bytm-btn"); + addNewBtnElem.textContent = t("new_entry"); + addNewBtnElem.ariaLabel = addNewBtnElem.title = t("new_entry_tooltip"); + wrapperEl.appendChild(addNewBtnElem); + const importExportBtnElem = document.createElement("button"); + importExportBtnElem.classList.add("bytm-btn"); + importExportBtnElem.textContent = t("export_import"); + importExportBtnElem.ariaLabel = importExportBtnElem.title = t("auto_like_export_or_import_tooltip"); + wrapperEl.appendChild(importExportBtnElem); + onInteraction(addNewBtnElem, addAutoLikeEntryPrompts); + onInteraction(importExportBtnElem, openImportExportAutoLikeChannelsDialog); + return wrapperEl; +} +async function openImportExportAutoLikeChannelsDialog() { + await (autoLikeExImDialog === null || autoLikeExImDialog === void 0 ? void 0 : autoLikeExImDialog.open()); +} +//#region add prompt +async function addAutoLikeEntryPrompts() { + var _a, _b, _c; + await autoLikeStore.loadData(); + const idPrompt = (_a = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_id_prompt") }))) === null || _a === void 0 ? void 0 : _a.trim(); + if (!idPrompt) + return; + const id = (_b = parseChannelIdFromUrl(idPrompt)) !== null && _b !== void 0 ? _b : (isValidChannelId(idPrompt) ? idPrompt : null); + if (!id || id.length <= 0) + return await showPrompt({ type: "alert", message: t("add_auto_like_channel_invalid_id") }); + let overwriteName = false; + const hasChannelEntry = autoLikeStore.getData().channels.find((ch) => ch.id === id); + if (hasChannelEntry) { + if (!await showPrompt({ type: "confirm", message: t("add_auto_like_channel_already_exists_prompt_new_name") })) + return; + overwriteName = true; + } + const name = (_c = (await showPrompt({ type: "prompt", message: t("add_auto_like_channel_name_prompt"), defaultValue: hasChannelEntry === null || hasChannelEntry === void 0 ? void 0 : hasChannelEntry.name }))) === null || _c === void 0 ? void 0 : _c.trim(); + if (!name || name.length === 0) + return; + await autoLikeStore.setData(overwriteName + ? { + channels: autoLikeStore.getData().channels + .map((ch) => ch.id === id ? Object.assign(Object.assign({}, ch), { name }) : ch), + } + : { + channels: [ + ...autoLikeStore.getData().channels, + { id, name, enabled: true }, + ], }); - } - //#region open & close - /** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */ - function closeCfgMenu(evt, enableScroll = true) { - var _a, _b, _c; - if (!isCfgMenuOpen) + emitSiteEvent("autoLikeChannelsUpdated"); + const unsub = autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.on("clear", async () => { + unsub === null || unsub === void 0 ? void 0 : unsub(); + await (autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.open()); + }); + autoLikeDialog === null || autoLikeDialog === void 0 ? void 0 : autoLikeDialog.unmount(); +} +function getChannelIdFromPrompt(promptStr) { + const isId = promptStr.match(/^@?.+$/); + const isUrl = promptStr.match(/^(?:https?:\/\/)?(?:www\.)?(?:music\.)?youtube\.com\/(?:channel\/|@)([a-zA-Z0-9_-]+)/); + const id = ((isId === null || isId === void 0 ? void 0 : isId[0]) || (isUrl === null || isUrl === void 0 ? void 0 : isUrl[1]) || "").trim(); + return id.length > 0 ? id : null; +}const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"]; +//#region arrow key skip +async function initArrowKeySkip() { + document.addEventListener("keydown", (evt) => { + var _a, _b, _c, _d, _e, _f; + if (!getFeature("arrowKeySupport")) return; - isCfgMenuOpen = false; - (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation(); - if (enableScroll) { - document.body.classList.remove("bytm-disable-scroll"); - (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); - } - const menuBg = document.querySelector("#bytm-cfg-menu-bg"); - clearTimeout(hiddenCopiedTxtTimeout); - openDialogs.splice(openDialogs.indexOf("cfg-menu"), 1); - setCurrentDialogId((_b = openDialogs === null || openDialogs === void 0 ? void 0 : openDialogs[0]) !== null && _b !== void 0 ? _b : null); - // since this menu doesn't have a BytmDialog instance, it's undefined here - emitInterface("bytm:dialogClosed", undefined); - emitInterface("bytm:dialogClosed:cfg-menu", undefined); - if (!menuBg) - return warn("Couldn't close config menu because background element couldn't be found. The config menu is considered closed but might still be open. In this case please reload the page. If the issue persists, please create an issue on GitHub."); - (_c = menuBg.querySelectorAll(".bytm-ftconf-adv-copy-hint")) === null || _c === void 0 ? void 0 : _c.forEach((el) => el.style.display = "none"); - menuBg.style.visibility = "hidden"; - menuBg.style.display = "none"; - } - /** Opens the config menu if it is closed */ - async function openCfgMenu() { - var _a; - if (!isCfgMenuMounted) - await mountCfgMenu(); - if (isCfgMenuOpen) + if (!["ArrowLeft", "ArrowRight"].includes(evt.code)) return; - isCfgMenuOpen = true; - document.body.classList.add("bytm-disable-scroll"); - (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); - const menuBg = document.querySelector("#bytm-cfg-menu-bg"); - setCurrentDialogId("cfg-menu"); - openDialogs.unshift("cfg-menu"); - // since this menu doesn't have a BytmDialog instance, it's undefined here - emitInterface("bytm:dialogOpened", undefined); - emitInterface("bytm:dialogOpened:cfg-menu", undefined); - checkToggleScrollIndicator(); - if (!menuBg) - return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub."); - menuBg.style.visibility = "visible"; - menuBg.style.display = "block"; - } - //#region chk scroll indicator - /** Checks if the features container is scrollable and toggles the scroll indicator accordingly */ - function checkToggleScrollIndicator() { - const featuresCont = document.querySelector("#bytm-menu-opts"); - const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator"); - // disable scroll indicator if container doesn't scroll - if (featuresCont && scrollIndicator) { - const verticalScroll = UserUtils.isScrollable(featuresCont).vertical; - /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */ - const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold; - if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) { - scrollIndicatorEnabled = true; - scrollIndicator.classList.remove("bytm-hidden"); - } - if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) { - scrollIndicatorEnabled = false; - scrollIndicator.classList.add("bytm-hidden"); - } - } - } - - let welcomeDialog = null; - /** Creates and/or returns the import dialog */ - async function getWelcomeDialog() { - if (!welcomeDialog) { - welcomeDialog = new BytmDialog({ - id: "welcome", - width: 700, - height: 500, - closeBtnEnabled: true, - closeOnBgClick: true, - closeOnEscPress: true, - destroyOnClose: true, - renderHeader, - renderBody, - renderFooter, - }); - welcomeDialog.on("render", retranslateWelcomeMenu); - } - return welcomeDialog; - } - async function renderHeader() { - const titleWrapperElem = document.createElement("div"); - titleWrapperElem.id = "bytm-welcome-menu-title-wrapper"; - const titleLogoElem = document.createElement("img"); - titleLogoElem.id = "bytm-welcome-menu-title-logo"; - titleLogoElem.classList.add("bytm-no-select"); - titleLogoElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); - const titleElem = document.createElement("h2"); - titleElem.id = "bytm-welcome-menu-title"; - titleElem.classList.add("bytm-dialog-title"); - titleElem.role = "heading"; - titleElem.ariaLevel = "1"; - titleElem.tabIndex = 0; - titleWrapperElem.appendChild(titleLogoElem); - titleWrapperElem.appendChild(titleElem); - return titleWrapperElem; - } - async function renderBody() { - const contentWrapper = document.createElement("div"); - contentWrapper.id = "bytm-welcome-menu-content-wrapper"; - // locale switcher - const localeCont = document.createElement("div"); - localeCont.id = "bytm-welcome-menu-locale-cont"; - const localeImg = document.createElement("img"); - localeImg.id = "bytm-welcome-menu-locale-img"; - localeImg.classList.add("bytm-no-select"); - localeImg.src = await getResourceUrl("icon-globe"); - const localeSelectElem = document.createElement("select"); - localeSelectElem.id = "bytm-welcome-menu-locale-select"; - for (const [locale, { name }] of Object.entries(langMapping)) { - const localeOptionElem = document.createElement("option"); - localeOptionElem.value = locale; - localeOptionElem.textContent = name; - localeSelectElem.appendChild(localeOptionElem); - } - localeSelectElem.value = getFeature("locale"); - localeSelectElem.addEventListener("change", async () => { - const selectedLocale = localeSelectElem.value; - const feats = Object.assign({}, getFeatures()); - feats.locale = selectedLocale; - setFeatures(feats); - await initTranslations(selectedLocale); - setLocale(selectedLocale); - retranslateWelcomeMenu(); - }); - localeCont.appendChild(localeImg); - localeCont.appendChild(localeSelectElem); - contentWrapper.appendChild(localeCont); - // text - const textCont = document.createElement("div"); - textCont.id = "bytm-welcome-menu-text-cont"; - const textElem = document.createElement("p"); - textElem.id = "bytm-welcome-menu-text"; - const textElems = []; - const line1Elem = document.createElement("span"); - line1Elem.id = "bytm-welcome-text-line1"; - line1Elem.tabIndex = 0; - textElems.push(line1Elem); - const br1Elem = document.createElement("br"); - textElems.push(br1Elem); - const line2Elem = document.createElement("span"); - line2Elem.id = "bytm-welcome-text-line2"; - line2Elem.tabIndex = 0; - textElems.push(line2Elem); - const br2Elem = document.createElement("br"); - textElems.push(br2Elem); - const br3Elem = document.createElement("br"); - textElems.push(br3Elem); - const line3Elem = document.createElement("span"); - line3Elem.id = "bytm-welcome-text-line3"; - line3Elem.tabIndex = 0; - textElems.push(line3Elem); - const br4Elem = document.createElement("br"); - textElems.push(br4Elem); - const line4Elem = document.createElement("span"); - line4Elem.id = "bytm-welcome-text-line4"; - line4Elem.tabIndex = 0; - textElems.push(line4Elem); - const br5Elem = document.createElement("br"); - textElems.push(br5Elem); - const br6Elem = document.createElement("br"); - textElems.push(br6Elem); - const line5Elem = document.createElement("span"); - line5Elem.id = "bytm-welcome-text-line5"; - line5Elem.tabIndex = 0; - textElems.push(line5Elem); - textElems.forEach((elem) => textElem.appendChild(elem)); - textCont.appendChild(textElem); - contentWrapper.appendChild(textCont); - return contentWrapper; - } - /** Retranslates all elements inside the welcome menu */ - function retranslateWelcomeMenu() { - const getLink = (href) => { - return [``, ""]; - }; - const changes = { - "#bytm-welcome-menu-title": (e) => e.textContent = e.ariaLabel = t("welcome_menu_title", scriptInfo.name), - "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"), - "#bytm-welcome-menu-open-cfg": (e) => { - e.textContent = e.ariaLabel = t("config_menu"); - e.ariaLabel = e.title = t("open_config_menu_tooltip"); - }, - "#bytm-welcome-menu-open-changelog": (e) => { - e.textContent = e.ariaLabel = t("open_changelog"); - e.ariaLabel = e.title = t("open_changelog_tooltip"); - }, - "#bytm-welcome-menu-footer-close": (e) => { - e.textContent = e.ariaLabel = t("close"); - e.ariaLabel = e.title = t("close_menu_tooltip"); - }, - "#bytm-welcome-text-line1": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_1")), - "#bytm-welcome-text-line2": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_2", scriptInfo.name)), - "#bytm-welcome-text-line3": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${packageJson.hosts.greasyfork}/feedback`), ...getLink(packageJson.hosts.openuserjs))), - "#bytm-welcome-text-line4": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_4", ...getLink(packageJson.funding.url))), - "#bytm-welcome-text-line5": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_5", ...getLink(packageJson.bugs.url))), - }; - for (const [selector, fn] of Object.entries(changes)) { - const el = document.querySelector(selector); - if (!el) { - warn(`Couldn't find element in welcome menu with selector '${selector}'`); - continue; - } - fn(el); - } - } - async function renderFooter() { - const footerCont = document.createElement("div"); - footerCont.id = "bytm-welcome-menu-footer-cont"; - const openCfgElem = document.createElement("button"); - openCfgElem.id = "bytm-welcome-menu-open-cfg"; - openCfgElem.classList.add("bytm-btn"); - openCfgElem.addEventListener("click", () => { - welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close(); - openCfgMenu(); - }); - const openChangelogElem = document.createElement("button"); - openChangelogElem.id = "bytm-welcome-menu-open-changelog"; - openChangelogElem.classList.add("bytm-btn"); - openChangelogElem.addEventListener("click", async () => { - const dlg = await getChangelogDialog(); - await dlg.mount(); - welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close(); - await dlg.open(); - }); - const closeBtnElem = document.createElement("button"); - closeBtnElem.id = "bytm-welcome-menu-footer-close"; - closeBtnElem.classList.add("bytm-btn"); - closeBtnElem.addEventListener("click", async () => { - welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close(); - }); - const leftButtonsCont = document.createElement("div"); - leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont"; - leftButtonsCont.appendChild(openCfgElem); - leftButtonsCont.appendChild(openChangelogElem); - footerCont.appendChild(leftButtonsCont); - footerCont.appendChild(closeBtnElem); - return footerCont; + const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"]; + // discard the event when a (text) input is currently active, like when editing a playlist + if ((inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "") || ["volume-slider"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : "")) + && !allowedClasses.some((cls) => { var _a; return (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.classList.contains(cls); })) + return info(`Captured valid key to skip forward or backward but the current active element is <${(_e = document.activeElement) === null || _e === void 0 ? void 0 : _e.tagName.toLowerCase()}>, so the keypress is ignored`); + evt.preventDefault(); + evt.stopImmediatePropagation(); + let skipBy = (_f = getFeature("arrowKeySkipBy")) !== null && _f !== void 0 ? _f : featInfo.arrowKeySkipBy.default; + if (evt.code === "ArrowLeft") + skipBy *= -1; + log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`); + const vidElem = getVideoElement(); + if (vidElem && vidElem.readyState > 0) + vidElem.currentTime = UserUtils.clamp(vidElem.currentTime + skipBy, 0, vidElem.duration); + }); + log("Added arrow key press listener"); +} +//#region site switch +/** switch sites only if current video time is greater than this value */ +const videoTimeThreshold = 3; +let siteSwitchEnabled = true; +/** Initializes the site switch feature */ +async function initSiteSwitch(domain) { + document.addEventListener("keydown", (e) => { + var _a, _b; + if (!getFeature("switchBetweenSites")) + return; + if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "")) + return; + const hk = getFeature("switchSitesHotkey"); + if (siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt) + switchSite(domain === "yt" ? "ytm" : "yt"); + }); + siteEvents.on("hotkeyInputActive", (state) => { + if (!getFeature("switchBetweenSites")) + return; + siteSwitchEnabled = !state; + }); + log("Initialized site switch listener"); +} +/** Switches to the other site (between YT and YTM) */ +async function switchSite(newDomain) { + try { + if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v)))) + return warn("Not on a supported page, so the site switch is ignored"); + let subdomain; + if (newDomain === "ytm") + subdomain = "music"; + else if (newDomain === "yt") + subdomain = "www"; + if (!subdomain) + throw new Error(`Unrecognized domain '${newDomain}'`); + enableDiscardBeforeUnload(); + const { pathname, search, hash } = new URL(location.href); + const vt = await getVideoTime(0); + log(`Found video time of ${vt} seconds`); + const cleanSearch = search.split("&") + .filter((param) => !param.match(/^\??(t|time_continue)=/)) + .join("&"); + const newSearch = typeof vt === "number" && vt > videoTimeThreshold ? + cleanSearch.includes("?") + ? `${cleanSearch.startsWith("?") + ? cleanSearch + : "?" + cleanSearch}&time_continue=${vt}` + : `?time_continue=${vt}` + : cleanSearch; + const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`; + info(`Switching to domain '${newDomain}' at ${newUrl}`); + location.assign(newUrl); } - - const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest"; - /** Initializes the version check feature */ - async function initVersionCheck() { - try { - if (getFeature("versionCheck") === false) - return info("Version check is disabled"); - const lastCheck = await GM.getValue("bytm-version-check", 0); - if (Date.now() - lastCheck < 1000 * 60 * 60 * 24) - return; - await doVersionCheck(false); - } - catch (err) { - error("Version check failed:", err); - } + catch (err) { + error("Error while switching site:", err); } - /** - * Checks for a new version of the script and shows a dialog. - * If {@linkcode notifyNoUpdatesFound} is set to true, a dialog is also shown if no updates were found. - */ - async function doVersionCheck(notifyNoUpdatesFound = false) { - var _a; - await GM.setValue("bytm-version-check", Date.now()); - const res = await sendRequest({ - method: "GET", - url: releaseURL, - }); - // TODO: small dialog for "no update found" message? - const noUpdateFound = () => notifyNoUpdatesFound ? showPrompt({ type: "alert", message: t("no_updates_found") }) : undefined; - const latestTag = (_a = res.finalUrl.split("/").pop()) === null || _a === void 0 ? void 0 : _a.replace(/[a-zA-Z]/g, ""); - if (!latestTag) - return await noUpdateFound(); - info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info); - if (compareVersions.compare(scriptInfo.version, latestTag, "<")) { - const dialog = await getVersionNotifDialog({ latestTag }); - await dialog.open(); +} +//#region num keys skip +const numKeysIgnoreTagNames = [...inputIgnoreTagNames]; +/** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */ +async function initNumKeysSkip() { + document.addEventListener("keydown", (e) => { + var _a, _b; + if (!getFeature("numKeysSkipToTime")) + return; + if (!e.key.trim().match(/^[0-9]$/)) return; + // discard the event when an unexpected element is currently active or in focus, like when editing a playlist or when the search bar is focused + const ignoreElement = numKeysIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""); + if ((document.activeElement !== document.body && ignoreElement) || ignoreElement) + return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement); + const vidElem = getVideoElement(); + if (!vidElem || vidElem.readyState === 0) + return warn("Could not find video element, so the keypress is ignored"); + const newVidTime = vidElem.duration / (10 / Number(e.key)); + if (!isNaN(newVidTime)) { + log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`); + vidElem.currentTime = newVidTime; } - return await noUpdateFound(); - } - - //#region beforeunload popup - let beforeUnloadEnabled = true; - /** Disables the popup before leaving the site */ - function disableBeforeUnload() { - beforeUnloadEnabled = false; - info("Disabled popup before leaving the site"); - } - /** Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` event listeners before they can be called by the site */ - async function initBeforeUnloadHook() { - var _a; - if (((_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) && GM.info.scriptHandler !== "FireMonkey") - UserUtils.interceptWindowEvent("beforeunload", () => !beforeUnloadEnabled); - else - warn(`Event intercepting is not available in ${GM.info.scriptHandler}, please use a different userscript extension`); - } - //#region auto close toasts - /** Closes toasts after a set amount of time */ - async function initAutoCloseToasts() { - const animTimeout = 300; - addSelectorListener("popupContainer", "ytmusic-notification-action-renderer", { - all: true, - continuous: true, - listener: async (toastContElems) => { - try { - for (const toastContElem of toastContElems) { - const toastElem = toastContElem.querySelector("tp-yt-paper-toast#toast"); - if (!toastElem || !toastElem.hasAttribute("allow-click-through")) - continue; - if (toastElem.classList.contains("bytm-closing")) - continue; - toastElem.classList.add("bytm-closing"); - const closeTimeout = Math.max(getFeature("closeToastsTimeout") * 1000 + animTimeout, animTimeout); - await UserUtils.pauseFor(closeTimeout); - toastElem.classList.remove("paper-toast-open"); - toastElem.addEventListener("transitionend", () => { - toastElem.classList.remove("bytm-closing"); - toastElem.style.display = "none"; - clearNode(toastElem); - log(`Automatically closed toast after ${getFeature("closeToastsTimeout") * 1000}ms`); - }, { once: true }); + }); + log("Added number key press listener"); +} +//#region auto-like vids +let canCompress$1 = false; +/** DataStore instance for all auto-liked channels */ +const autoLikeStore = new UserUtils.DataStore({ + id: "bytm-auto-like-channels", + formatVersion: 2, + defaultData: { + channels: [], + }, + encodeData: (data) => canCompress$1 ? UserUtils.compress(data, compressionFormat, "string") : data, + decodeData: (data) => canCompress$1 ? UserUtils.decompress(data, compressionFormat, "string") : data, + migrations: { + // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing + 2: (oldData) => ({ + channels: oldData.channels.map((ch) => (Object.assign(Object.assign({}, ch), { id: isValidChannelId(ch.id.trim()) + ? ch.id.trim() + : `@${ch.id.trim()}` }))), + }), + }, +}); +let autoLikeStoreLoaded = false; +/** Inits the auto-like DataStore instance */ +async function initAutoLikeStore() { + if (autoLikeStoreLoaded) + return; + autoLikeStoreLoaded = true; + return autoLikeStore.loadData(); +} +/** Initializes the auto-like feature */ +async function initAutoLike() { + try { + canCompress$1 = await compressionSupported(); + await initAutoLikeStore(); + //#SECTION ytm + if (getDomain() === "ytm") { + let timeout; + siteEvents.on("songTitleChanged", () => { + var _a; + const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000; + timeout && clearTimeout(timeout); + const ytmTryAutoLike = () => { + const artistEls = document.querySelectorAll("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]"); + const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string"); + const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id)); + if (!likeChan || !likeChan.enabled) + return; + if (artistEls.length === 0) + return error("Couldn't auto-like channel because the artist element couldn't be found"); + const likeRendererEl = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer"); + const likeBtnEl = likeRendererEl === null || likeRendererEl === void 0 ? void 0 : likeRendererEl.querySelector("#button-shape-like button"); + if (!likeRendererEl || !likeBtnEl) + return error("Couldn't auto-like channel because the like button couldn't be found"); + if (likeRendererEl.getAttribute("like-status") !== "LIKE") { + likeBtnEl.click(); + getFeature("autoLikeShowToast") && showIconToast({ + message: t(`auto_liked_a_channels_${getCurrentMediaType()}`, likeChan.name), + subtitle: t("auto_like_click_to_configure"), + icon: "icon-auto_like", + onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), + }).catch(e => error("Error while showing auto-like toast:", e)); + log(`Auto-liked ${getCurrentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`); } + }; + timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs); + siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs)); + }); + const recreateBtn = (headerCont, chanId) => { + var _a, _b, _c, _d, _e, _f; + const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title, ytmusic-immersive-header-renderer .ytmusic-immersive-header-renderer yt-formatted-string.title"); + if (!titleCont) + return; + const checkBtn = () => setTimeout(() => { + if (!document.querySelector(".bytm-auto-like-toggle-btn")) + recreateBtn(headerCont, chanId); + }, 250); + const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null; + log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId); + const buttonsCont = headerCont.querySelector(".buttons"); + if (buttonsCont) { + const lastBtn = buttonsCont.querySelector("ytmusic-subscribe-button-renderer"); + const chanName = (_d = (_c = document.querySelector("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")) === null || _c === void 0 ? void 0 : _c.textContent) !== null && _d !== void 0 ? _d : null; + lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName).then(checkBtn); } - catch (err) { - error("Error in automatic toast closing:", err); + else { + // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason + const shareBtnEl = headerCont.querySelector("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type"); + const chanName = (_f = (_e = headerCont.querySelector("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")) === null || _e === void 0 ? void 0 : _e.textContent) !== null && _f !== void 0 ? _f : null; + shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName).then(checkBtn); } - }, - }); - log("Initialized automatic toast closing"); - } - let remVidsCache = []; - /** - * Remembers the time of the last played video and resumes playback from that time. - * **Needs to be called *before* DOM is ready!** - */ - async function initRememberSongTime() { - if (getFeature("rememberSongTimeSites") !== "all" && getFeature("rememberSongTimeSites") !== getDomain()) - return; - const storedDataRaw = await GM.getValue("bytm-rem-songs"); - if (!storedDataRaw) - await GM.setValue("bytm-rem-songs", "[]"); - try { - remVidsCache = JSON.parse(String(storedDataRaw !== null && storedDataRaw !== void 0 ? storedDataRaw : "[]")); - } - catch (err) { - error("Error parsing stored video time data, defaulting to empty cache:", err); - await GM.setValue("bytm-rem-songs", "[]"); - remVidsCache = []; - } - log(`Initialized video time restoring with ${remVidsCache.length} initial entr${remVidsCache.length === 1 ? "y" : "ies"}`); - await remTimeRestoreTime(); - try { - if (!domLoaded) - document.addEventListener("DOMContentLoaded", remTimeStartUpdateLoop); - else - remTimeStartUpdateLoop(); - } - catch (err) { - error("Error in video time remembering update loop:", err); - } - } - /** Tries to restore the time of the currently playing video */ - async function remTimeRestoreTime() { - if (location.pathname.startsWith("/watch")) { - const watchID = new URL(location.href).searchParams.get("v"); - if (!watchID) - return; - if (initialParams.has("t")) - return info("Not restoring song time because the URL has the '&t' parameter", LogLevel.Info); - const entry = remVidsCache.find(entry => entry.watchID === watchID); - if (entry) { - if (Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000) { - await remTimeDeleteEntry(entry.watchID); - return; + }; + siteEvents.on("pathChanged", (path) => { + if (getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) { + const chanId = getCurrentChannelId(); + if (!chanId) + return error("Couldn't extract channel ID from URL"); + document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn)); + addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", { + listener: (el) => recreateBtn(el, chanId), + }); } - else if (isNaN(Number(entry.songTime))) + }); + } + //#SECTION yt + else if (getDomain() === "yt") { + addStyleFromResource("css-auto_like"); + let timeout; + siteEvents.on("watchIdChanged", () => { + var _a; + const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000; + timeout && clearTimeout(timeout); + if (!location.pathname.startsWith("/watch")) return; - else { - let vidElem; - const doRestoreTime = async () => { - var _a; - if (!vidElem) - vidElem = await waitVideoElementReady(); - const vidRestoreTime = entry.songTime - ((_a = getFeature("rememberSongTimeReduction")) !== null && _a !== void 0 ? _a : 0); - vidElem.currentTime = UserUtils.clamp(Math.max(vidRestoreTime, 0), 0, vidElem.duration); - await remTimeDeleteEntry(entry.watchID); - info(`Restored ${getDomain() === "ytm" ? getCurrentMediaType() : "video"} time to ${Math.floor(vidRestoreTime / 60)}m, ${(vidRestoreTime % 60).toFixed(1)}s`, LogLevel.Info); + const ytTryAutoLike = () => { + addSelectorListener("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", { + listener(chanElem) { + var _a, _b; + const chanElemId = (_b = (_a = chanElem.href.split("/").pop()) === null || _a === void 0 ? void 0 : _a.split("/")[0]) !== null && _b !== void 0 ? _b : null; + const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId); + if (!likeChan || !likeChan.enabled) + return; + addSelectorListener("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", { + listener(likeBtn) { + if (likeBtn.getAttribute("aria-pressed") !== "true") { + likeBtn.click(); + getFeature("autoLikeShowToast") && showIconToast({ + message: t("auto_liked_a_channels_video", likeChan.name), + subtitle: t("auto_like_click_to_configure"), + icon: "icon-auto_like", + onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), + }).catch(e => error("Error while showing auto-like toast:", e)); + log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`); + } + } + }); + } + }); + }; + siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs)); + timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs); + }); + siteEvents.on("pathChanged", (path) => { + if (path.match(/(\/?@|\/?channel\/)\S+/)) { + const chanId = getCurrentChannelId(); + if (!chanId) + return error("Couldn't extract channel ID from URL"); + document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn)); + const recreateBtn = (headerCont) => { + var _a, _b; + const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title"); + if (!titleCont) + return; + const checkBtn = () => setTimeout(() => { + if (!document.querySelector(".bytm-auto-like-toggle-btn")) + recreateBtn(headerCont); + }, 350); + const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null; + log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId); + const buttonsCont = headerCont.querySelector("#inner-header-container #buttons, yt-flexible-actions-view-model"); + if (buttonsCont) { + addSelectorListener("ytAppHeader", "#channel-header-container #other-buttons, yt-flexible-actions-view-model .yt-flexible-actions-view-model-wiz__action", { + listener: (otherBtns) => addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin", "right-margin"]).then(checkBtn), + }); + } + else if (titleCont) + addAutoLikeToggleBtn(titleCont, chanId, chanName).then(checkBtn); }; - if (!domLoaded) - document.addEventListener("DOMContentLoaded", doRestoreTime); - else - doRestoreTime(); + addSelectorListener("ytAppHeader", "#channel-header-container, #page-header", { + listener: recreateBtn, + }); } - } + }); } + log("Initialized auto-like channels feature"); } - let lastSongTime = -1; - let remVidCheckTimeout; - /** Only call once as this calls itself after a timeout! - Updates the currently playing video's entry in GM storage */ - async function remTimeStartUpdateLoop() { - var _a, _b, _c; - if (location.pathname.startsWith("/watch")) { - const watchID = getWatchId(); - const songTime = (_a = await getVideoTime()) !== null && _a !== void 0 ? _a : 0; - if (watchID && songTime !== lastSongTime) { - lastSongTime = songTime; - const paused = (_c = (_b = getVideoElement()) === null || _b === void 0 ? void 0 : _b.paused) !== null && _c !== void 0 ? _c : false; - // don't immediately update to reduce race conditions and only update if the video is playing - // also it just sounds better if the song starts at the beginning if only a couple seconds have passed - if (songTime > getFeature("rememberSongTimeMinPlayTime") && !paused) { - const entry = { - watchID, - songTime, - updateTimestamp: Date.now(), - }; - await remTimeUpsertEntry(entry); + catch (err) { + error("Error while auto-liking channel:", err); + } +} +//#SECTION toggle btn +/** Adds a toggle button to enable or disable auto-liking videos from a channel */ +async function addAutoLikeToggleBtn(siblingEl, channelId, channelName, extraClasses) { + var _a; + const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId); + log(`Adding auto-like toggle button for channel with ID '${channelId}' - current state:`, chan); + siteEvents.on("autoLikeChannelsUpdated", () => { + var _a, _b; + const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`); + if (!buttonEl) + return warn("Couldn't find auto-like toggle button for channel ID:", channelId); + const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false; + if (enabled) + buttonEl.classList.add("toggled"); + else + buttonEl.classList.remove("toggled"); + }); + const buttonEl = await createLongBtn({ + resourceName: `icon-auto_like${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : ""}`, + text: t("auto_like"), + title: t(`auto_like_button_tooltip${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : "_disabled"}`), + toggle: true, + toggleInitialState: (_a = chan === null || chan === void 0 ? void 0 : chan.enabled) !== null && _a !== void 0 ? _a : false, + togglePredicate(e) { + e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open()); + return !e.shiftKey; + }, + async onToggle(toggled) { + var _a; + try { + await autoLikeStore.loadData(); + buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`); + const chanId = sanitizeChannelId((_a = buttonEl.dataset.channelId) !== null && _a !== void 0 ? _a : channelId); + const imgEl = buttonEl.querySelector(".bytm-generic-btn-img"); + imgEl && setInnerHtml(imgEl, await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`)); + if (autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) { + await autoLikeStore.setData({ + channels: [ + ...autoLikeStore.getData().channels, + { id: chanId, name: channelName !== null && channelName !== void 0 ? channelName : "", enabled: toggled }, + ], + }); } - // if the song is rewound to the beginning, update the entry accordingly - else if (!paused) { - const entry = remVidsCache.find(entry => entry.watchID === watchID); - if (entry && songTime <= entry.songTime) - await remTimeUpsertEntry(Object.assign(Object.assign({}, entry), { songTime, updateTimestamp: Date.now() })); + else { + await autoLikeStore.setData({ + channels: autoLikeStore.getData().channels + .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { enabled: toggled }) : ch), + }); } + emitSiteEvent("autoLikeChannelsUpdated"); + showIconToast({ + message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"), + subtitle: t("auto_like_click_to_configure"), + icon: `icon-auto_like${toggled ? "_enabled" : ""}`, + onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), + }).catch(e => error("Error while showing auto-like toast:", e)); + log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`); + } + catch (err) { + error("Error while toggling auto-like channel:", err); } } - const expiredEntries = remVidsCache.filter(entry => Date.now() - entry.updateTimestamp > getFeature("rememberSongTimeDuration") * 1000); - for (const entry of expiredEntries) - await remTimeDeleteEntry(entry.watchID); - // for no overlapping calls and better error handling: - if (remVidCheckTimeout) - clearTimeout(remVidCheckTimeout); - remVidCheckTimeout = setTimeout(remTimeStartUpdateLoop, 1000); - } - /** Updates an existing or inserts a new entry to be remembered */ - async function remTimeUpsertEntry(data) { - const foundIdx = remVidsCache.findIndex(entry => entry.watchID === data.watchID); - if (foundIdx >= 0) - remVidsCache[foundIdx] = data; + }); + buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses !== null && extraClasses !== void 0 ? extraClasses : [])]); + buttonEl.dataset.channelId = channelId; + siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl)); + siteEvents.on("autoLikeChannelsUpdated", async () => { + var _a, _b; + const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`); + if (!buttonEl) + return; + const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false; + if (enabled) + buttonEl.classList.add("toggled"); else - remVidsCache.push(data); - await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache)); - } - /** Deletes an entry in the "remember cache" */ - async function remTimeDeleteEntry(watchID) { - remVidsCache = [...remVidsCache.filter(entry => entry.watchID !== watchID)]; - await GM.setValue("bytm-rem-songs", JSON.stringify(remVidsCache)); - } - - const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"]; - //#region arrow key skip - async function initArrowKeySkip() { - document.addEventListener("keydown", (evt) => { - var _a, _b, _c, _d, _e, _f; - if (!getFeature("arrowKeySupport")) - return; - if (!["ArrowLeft", "ArrowRight"].includes(evt.code)) - return; - const allowedClasses = ["bytm-generic-btn", "yt-spec-button-shape-next"]; - // discard the event when a (text) input is currently active, like when editing a playlist - if ((inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "") || ["volume-slider"].includes((_d = (_c = document.activeElement) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : "")) - && !allowedClasses.some((cls) => { var _a; return (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.classList.contains(cls); })) - return info(`Captured valid key to skip forward or backward but the current active element is <${(_e = document.activeElement) === null || _e === void 0 ? void 0 : _e.tagName.toLowerCase()}>, so the keypress is ignored`); - evt.preventDefault(); - evt.stopImmediatePropagation(); - let skipBy = (_f = getFeature("arrowKeySkipBy")) !== null && _f !== void 0 ? _f : featInfo.arrowKeySkipBy.default; - if (evt.code === "ArrowLeft") - skipBy *= -1; - log(`Captured arrow key '${evt.code}' - skipping by ${skipBy} seconds`); - const vidElem = getVideoElement(); - if (vidElem) - vidElem.currentTime = UserUtils.clamp(vidElem.currentTime + skipBy, 0, vidElem.duration); - }); - log("Added arrow key press listener"); - } - //#region site switch - /** switch sites only if current video time is greater than this value */ - const videoTimeThreshold = 3; - let siteSwitchEnabled = true; - /** Initializes the site switch feature */ - async function initSiteSwitch(domain) { - document.addEventListener("keydown", (e) => { - var _a, _b; - if (!getFeature("switchBetweenSites")) - return; - if (inputIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : "")) - return; - const hk = getFeature("switchSitesHotkey"); - if (siteSwitchEnabled && e.code === hk.code && e.shiftKey === hk.shift && e.ctrlKey === hk.ctrl && e.altKey === hk.alt) - switchSite(domain === "yt" ? "ytm" : "yt"); + buttonEl.classList.remove("toggled"); + const imgEl = buttonEl.querySelector(".bytm-generic-btn-img"); + imgEl && setInnerHtml(imgEl, await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`)); + }); +}class MarkdownDialog extends BytmDialog { + constructor(options) { + super(Object.assign(Object.assign({}, options), { id: `md-${options.id}`, renderBody: () => this.renderBody() })); + Object.defineProperty(this, "opts", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 }); - siteEvents.on("hotkeyInputActive", (state) => { - if (!getFeature("switchBetweenSites")) - return; - siteSwitchEnabled = !state; + this.opts = options; + } + /** Parses the passed markdown string (supports GitHub flavor and HTML mixins) and returns it as an HTML string */ + static async parseMd(md) { + return await marked.marked.parse(md, { + async: true, + gfm: true, + breaks: true, }); - log("Initialized site switch listener"); } - /** Switches to the other site (between YT and YTM) */ - async function switchSite(newDomain) { - try { - if (!(["/watch", "/playlist"].some(v => location.pathname.startsWith(v)))) - return warn("Not on a supported page, so the site switch is ignored"); - let subdomain; - if (newDomain === "ytm") - subdomain = "music"; - else if (newDomain === "yt") - subdomain = "www"; - if (!subdomain) - throw new Error(`Unrecognized domain '${newDomain}'`); - disableBeforeUnload(); - const { pathname, search, hash } = new URL(location.href); - const vt = await getVideoTime(0); - log(`Found video time of ${vt} seconds`); - const cleanSearch = search.split("&") - .filter((param) => !param.match(/^\??(t|time_continue)=/)) - .join("&"); - const newSearch = typeof vt === "number" && vt > videoTimeThreshold ? - cleanSearch.includes("?") - ? `${cleanSearch.startsWith("?") - ? cleanSearch - : "?" + cleanSearch}&time_continue=${vt}` - : `?time_continue=${vt}` - : cleanSearch; - const newUrl = `https://${subdomain}.youtube.com${pathname}${newSearch}${hash}`; - info(`Switching to domain '${newDomain}' at ${newUrl}`); - location.assign(newUrl); - } - catch (err) { - error("Error while switching site:", err); - } + /** Renders the dialog body elements from a markdown string using what's set in `this.opts.body` */ + async renderBody() { + const bodyEl = document.createElement("div"); + bodyEl.classList.add("bytm-md-dialog-body"); + const mdCont = await UserUtils.consumeStringGen(this.opts.body); + const markdownEl = document.createElement("div"); + markdownEl.classList.add("bytm-markdown-dialog-content", "bytm-markdown-container"); + markdownEl.tabIndex = 0; + setInnerHtml(markdownEl, await MarkdownDialog.parseMd(mdCont)); + bodyEl.appendChild(markdownEl); + return bodyEl; + } +}//#region logging fns +let curLogLevel = LogLevel.Info; +/** Common prefix to be able to tell logged messages apart and filter them in devtools */ +const consPrefix = `[${scriptInfo.name}]`; +const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`; +/** Sets the current log level. 0 = Debug, 1 = Info */ +function setLogLevel(level) { + curLogLevel = level; + setGlobalProp("logLevel", level); + if (curLogLevel !== level) + log("Set the log level to", LogLevel[level]); +} +/** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */ +function getLogLevel(args) { + const minLogLvl = 0, maxLogLvl = 1; + if (typeof args.at(-1) === "number") + return UserUtils.clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl); + return LogLevel.Debug; +} +/** + * Logs all passed values to the console, as long as the log level is sufficient. + * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be. + */ +function log(...args) { + if (curLogLevel <= getLogLevel(args)) + console.log(consPrefix, ...args); +} +/** + * Logs all passed values to the console as info, as long as the log level is sufficient. + * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be. + */ +function info(...args) { + if (curLogLevel <= getLogLevel(args)) + console.info(consPrefix, ...args); +} +/** Logs all passed values to the console as a warning, no matter the log level. */ +function warn(...args) { + console.warn(consPrefix, ...args); +} +/** Logs all passed values to the console as an error, no matter the log level. */ +function error(...args) { + var _a, _b; + console.error(consPrefix, ...args); + if (getFeature("showToastOnGenericError")) { + const errName = (_b = (_a = args.find(a => a instanceof Error)) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : t("error"); + UserUtils.debounce(() => showIconToast({ + message: t("generic_error_toast_encountered_error_type", errName), + subtitle: t("generic_error_toast_click_for_details"), + icon: "icon-error", + iconFill: "var(--bytm-error-col)", + onClick: () => getErrorDialog(errName, Array.isArray(args) ? args : []).open(), + }))(); } - //#region num keys skip - const numKeysIgnoreTagNames = [...inputIgnoreTagNames]; - /** Adds the ability to skip to a certain time in the video by pressing a number key (0-9) */ - async function initNumKeysSkip() { - document.addEventListener("keydown", (e) => { - var _a, _b; - if (!getFeature("numKeysSkipToTime")) - return; - if (!e.key.trim().match(/^[0-9]$/)) - return; - // discard the event when an unexpected element is currently active or in focus, like when editing a playlist or when the search bar is focused - const ignoreElement = numKeysIgnoreTagNames.includes((_b = (_a = document.activeElement) === null || _a === void 0 ? void 0 : _a.tagName) !== null && _b !== void 0 ? _b : ""); - if ((document.activeElement !== document.body && ignoreElement) || ignoreElement) - return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement); - const vidElem = getVideoElement(); - if (!vidElem) - return warn("Could not find video element, so the keypress is ignored"); - const newVidTime = vidElem.duration / (10 / Number(e.key)); - if (!isNaN(newVidTime)) { - log(`Captured number key [${e.key}], skipping to ${Math.floor(newVidTime / 60)}m ${(newVidTime % 60).toFixed(1)}s`); - vidElem.currentTime = newVidTime; - } - }); - log("Added number key press listener"); - } - //#region auto-like vids - let canCompress$1 = false; - /** DataStore instance for all auto-liked channels */ - const autoLikeStore = new UserUtils.DataStore({ - id: "bytm-auto-like-channels", - formatVersion: 2, - defaultData: { - channels: [], - }, - encodeData: (data) => canCompress$1 ? UserUtils.compress(data, compressionFormat, "string") : data, - decodeData: (data) => canCompress$1 ? UserUtils.decompress(data, compressionFormat, "string") : data, - migrations: { - // 1 -> 2 (v2.1-pre) - add @ prefix to channel IDs if missing - 2: (oldData) => ({ - channels: oldData.channels.map((ch) => (Object.assign(Object.assign({}, ch), { id: isValidChannelId(ch.id.trim()) - ? ch.id.trim() - : `@${ch.id.trim()}` }))), - }), +} +/** Logs all passed values to the console with a debug-specific prefix */ +function dbg(...args) { + console.log(consPrefixDbg, ...args); +} +//#region error dialog +function getErrorDialog(errName, args) { + return new MarkdownDialog({ + id: "generic-error", + height: 400, + width: 500, + small: true, + destroyOnClose: true, + renderHeader() { + const header = document.createElement("h2"); + header.classList.add("bytm-dialog-title"); + header.role = "heading"; + header.ariaLevel = "1"; + header.tabIndex = 0; + header.textContent = header.ariaLabel = errName; + return header; }, + body: `\ +${args.length > 0 ? args.join(" ") : t("generic_error_dialog_message")} +${t("generic_error_dialog_open_console_note", consPrefix, packageJson.bugs.url)}`, }); - let autoLikeStoreLoaded = false; - /** Inits the auto-like DataStore instance */ - async function initAutoLikeStore() { - if (autoLikeStoreLoaded) - return; - autoLikeStoreLoaded = true; - return autoLikeStore.loadData(); +} +//#region error classes +class CustomError extends Error { + constructor(name, message, opts) { + super(message, opts); + Object.defineProperty(this, "time", { + enumerable: true, + configurable: true, + writable: true, + value: void 0 + }); + this.name = name; + this.time = Date.now(); } - /** Initializes the auto-like feature */ - async function initAutoLike() { - try { - canCompress$1 = await compressionSupported(); - await initAutoLikeStore(); - //#SECTION ytm - if (getDomain() === "ytm") { - let timeout; - siteEvents.on("songTitleChanged", () => { - var _a; - const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000; - timeout && clearTimeout(timeout); - const ytmTryAutoLike = () => { - const artistEls = document.querySelectorAll("ytmusic-player-bar .content-info-wrapper .subtitle a.yt-formatted-string[href]"); - const channelIds = [...artistEls].map(a => a.href.split("/").pop()).filter(a => typeof a === "string"); - const likeChan = autoLikeStore.getData().channels.find((ch) => channelIds.includes(ch.id)); - if (!likeChan || !likeChan.enabled) - return; - if (artistEls.length === 0) - return error("Couldn't auto-like channel because the artist element couldn't be found"); - const likeRendererEl = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer"); - const likeBtnEl = likeRendererEl === null || likeRendererEl === void 0 ? void 0 : likeRendererEl.querySelector("#button-shape-like button"); - if (!likeRendererEl || !likeBtnEl) - return error("Couldn't auto-like channel because the like button couldn't be found"); - if (likeRendererEl.getAttribute("like-status") !== "LIKE") { - likeBtnEl.click(); - getFeature("autoLikeShowToast") && showIconToast({ - message: t(`auto_liked_a_channels_${getCurrentMediaType()}`, likeChan.name), - subtitle: t("auto_like_click_to_configure"), - icon: "icon-auto_like", - onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), - }).catch(e => error("Error while showing auto-like toast:", e)); - log(`Auto-liked ${getCurrentMediaType()} from channel '${likeChan.name}' (${likeChan.id})`); - } - }; - timeout = setTimeout(ytmTryAutoLike, autoLikeTimeoutMs); - siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytmTryAutoLike, autoLikeTimeoutMs)); - }); - const recreateBtn = (headerCont, chanId) => { - var _a, _b, _c, _d, _e, _f; - const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title, ytmusic-immersive-header-renderer .ytmusic-immersive-header-renderer yt-formatted-string.title"); - if (!titleCont) - return; - const checkBtn = () => setTimeout(() => { - if (!document.querySelector(".bytm-auto-like-toggle-btn")) - recreateBtn(headerCont, chanId); - }, 250); - const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null; - log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId); - const buttonsCont = headerCont.querySelector(".buttons"); - if (buttonsCont) { - const lastBtn = buttonsCont.querySelector("ytmusic-subscribe-button-renderer"); - const chanName = (_d = (_c = document.querySelector("ytmusic-immersive-header-renderer .content-container yt-formatted-string[role=\"heading\"]")) === null || _c === void 0 ? void 0 : _c.textContent) !== null && _d !== void 0 ? _d : null; - lastBtn && addAutoLikeToggleBtn(lastBtn, chanId, chanName).then(checkBtn); - } - else { - // some channels don't have a subscribe button and instead only have a "share" button for some bullshit reason - const shareBtnEl = headerCont.querySelector("ytmusic-menu-renderer #top-level-buttons yt-button-renderer:last-of-type"); - const chanName = (_f = (_e = headerCont.querySelector("ytmusic-visual-header-renderer .content-container h2 yt-formatted-string")) === null || _e === void 0 ? void 0 : _e.textContent) !== null && _f !== void 0 ? _f : null; - shareBtnEl && chanName && addAutoLikeToggleBtn(shareBtnEl, chanId, chanName).then(checkBtn); - } - }; - siteEvents.on("pathChanged", (path) => { - if (getFeature("autoLikeChannelToggleBtn") && path.match(/\/channel\/.+/)) { - const chanId = getCurrentChannelId(); - if (!chanId) - return error("Couldn't extract channel ID from URL"); - document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn)); - addSelectorListener("browseResponse", "ytmusic-browse-response #header.ytmusic-browse-response", { - listener: (el) => recreateBtn(el, chanId), - }); - } - }); - } - //#SECTION yt - else if (getDomain() === "yt") { - addStyleFromResource("css-auto_like"); - let timeout; - siteEvents.on("watchIdChanged", () => { - var _a; - const autoLikeTimeoutMs = ((_a = getFeature("autoLikeTimeout")) !== null && _a !== void 0 ? _a : 5) * 1000; - timeout && clearTimeout(timeout); - if (!location.pathname.startsWith("/watch")) - return; - const ytTryAutoLike = () => { - addSelectorListener("ytWatchMetadata", "#owner ytd-channel-name yt-formatted-string a", { - listener(chanElem) { - var _a, _b; - const chanElemId = (_b = (_a = chanElem.href.split("/").pop()) === null || _a === void 0 ? void 0 : _a.split("/")[0]) !== null && _b !== void 0 ? _b : null; - const likeChan = autoLikeStore.getData().channels.find((ch) => ch.id === chanElemId); - if (!likeChan || !likeChan.enabled) - return; - addSelectorListener("ytWatchMetadata", "#actions ytd-menu-renderer like-button-view-model button", { - listener(likeBtn) { - if (likeBtn.getAttribute("aria-pressed") !== "true") { - likeBtn.click(); - getFeature("autoLikeShowToast") && showIconToast({ - message: t("auto_liked_a_channels_video", likeChan.name), - subtitle: t("auto_like_click_to_configure"), - icon: "icon-auto_like", - onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), - }); - log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`); - } - } - }); - } - }); - }; - siteEvents.on("autoLikeChannelsUpdated", () => setTimeout(ytTryAutoLike, autoLikeTimeoutMs)); - timeout = setTimeout(ytTryAutoLike, autoLikeTimeoutMs); - }); - siteEvents.on("pathChanged", (path) => { - if (path.match(/(\/?@|\/?channel\/)\S+/)) { - const chanId = getCurrentChannelId(); - if (!chanId) - return error("Couldn't extract channel ID from URL"); - document.querySelectorAll(".bytm-auto-like-toggle-btn").forEach((btn) => clearNode(btn)); - const recreateBtn = (headerCont) => { - var _a, _b; - const titleCont = headerCont.querySelector("ytd-channel-name #container, yt-dynamic-text-view-model.page-header-view-model-wiz__page-header-title"); - if (!titleCont) - return; - const checkBtn = () => setTimeout(() => { - if (!document.querySelector(".bytm-auto-like-toggle-btn")) - recreateBtn(headerCont); - }, 350); - const chanName = (_b = (_a = titleCont.querySelector("yt-formatted-string, span.yt-core-attributed-string")) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : null; - log("Re-rendering auto-like toggle button for channel", chanName, "with ID", chanId); - const buttonsCont = headerCont.querySelector("#inner-header-container #buttons, yt-flexible-actions-view-model"); - if (buttonsCont) { - addSelectorListener("ytAppHeader", "#channel-header-container #other-buttons, yt-flexible-actions-view-model .yt-flexible-actions-view-model-wiz__action", { - listener: (otherBtns) => addAutoLikeToggleBtn(otherBtns, chanId, chanName, ["left-margin", "right-margin"]).then(checkBtn), - }); - } - else if (titleCont) - addAutoLikeToggleBtn(titleCont, chanId, chanName).then(checkBtn); - }; - addSelectorListener("ytAppHeader", "#channel-header-container, #page-header", { - listener: recreateBtn, - }); - } - }); - } - log("Initialized auto-like channels feature"); - } - catch (err) { - error("Error while auto-liking channel:", err); - } - } - //#SECTION toggle btn - /** Adds a toggle button to enable or disable auto-liking videos from a channel */ - async function addAutoLikeToggleBtn(siblingEl, channelId, channelName, extraClasses) { - var _a; - const chan = autoLikeStore.getData().channels.find((ch) => ch.id === channelId); - log(`Adding auto-like toggle button for channel with ID '${channelId}' - current state:`, chan); - siteEvents.on("autoLikeChannelsUpdated", () => { - var _a, _b; - const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`); - if (!buttonEl) - return warn("Couldn't find auto-like toggle button for channel ID:", channelId); - const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false; - if (enabled) - buttonEl.classList.add("toggled"); - else - buttonEl.classList.remove("toggled"); - }); - const buttonEl = await createLongBtn({ - resourceName: `icon-auto_like${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : ""}`, - text: t("auto_like"), - title: t(`auto_like_button_tooltip${(chan === null || chan === void 0 ? void 0 : chan.enabled) ? "_enabled" : "_disabled"}`), - toggle: true, - toggleInitialState: (_a = chan === null || chan === void 0 ? void 0 : chan.enabled) !== null && _a !== void 0 ? _a : false, - togglePredicate(e) { - e.shiftKey && getAutoLikeDialog().then((dlg) => dlg.open()); - return !e.shiftKey; - }, - async onToggle(toggled) { - var _a; - try { - await autoLikeStore.loadData(); - buttonEl.title = buttonEl.ariaLabel = t(`auto_like_button_tooltip${toggled ? "_enabled" : "_disabled"}`); - const chanId = sanitizeChannelId((_a = buttonEl.dataset.channelId) !== null && _a !== void 0 ? _a : channelId); - const imgEl = buttonEl.querySelector(".bytm-generic-btn-img"); - const imgHtml = await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`); - if (imgEl && imgHtml) - setInnerHtml(imgEl, imgHtml); - if (autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) { - await autoLikeStore.setData({ - channels: [ - ...autoLikeStore.getData().channels, - { id: chanId, name: channelName !== null && channelName !== void 0 ? channelName : "", enabled: toggled }, - ], - }); - } - else { - await autoLikeStore.setData({ - channels: autoLikeStore.getData().channels - .map((ch) => ch.id === chanId ? Object.assign(Object.assign({}, ch), { enabled: toggled }) : ch), - }); - } - emitSiteEvent("autoLikeChannelsUpdated"); - showIconToast({ - message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"), - icon: `icon-auto_like${toggled ? "_enabled" : ""}`, - }); - log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`); - } - catch (err) { - error("Error while toggling auto-like channel:", err); - } - } - }); - buttonEl.classList.add(...["bytm-auto-like-toggle-btn", ...(extraClasses !== null && extraClasses !== void 0 ? extraClasses : [])]); - buttonEl.dataset.channelId = channelId; - siblingEl.insertAdjacentElement("afterend", createRipple(buttonEl)); - siteEvents.on("autoLikeChannelsUpdated", async () => { - var _a, _b; - const buttonEl = document.querySelector(`.bytm-auto-like-toggle-btn[data-channel-id="${channelId}"]`); - if (!buttonEl) - return; - const enabled = (_b = (_a = autoLikeStore.getData().channels.find((ch) => ch.id === channelId)) === null || _a === void 0 ? void 0 : _a.enabled) !== null && _b !== void 0 ? _b : false; - if (enabled) - buttonEl.classList.add("toggled"); - else - buttonEl.classList.remove("toggled"); - const imgEl = buttonEl.querySelector(".bytm-generic-btn-img"); - const imgHtml = await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`); - if (imgEl && imgHtml) - setInnerHtml(imgEl, imgHtml); - }); +} +class LyricsError extends CustomError { + constructor(message, opts) { + super("LyricsError", message, opts); } - - //#region logging fns - let curLogLevel = LogLevel.Info; - /** Common prefix to be able to tell logged messages apart and filter them in devtools */ - const consPrefix = `[${scriptInfo.name}]`; - const consPrefixDbg = `[${scriptInfo.name}/#DEBUG]`; - /** Sets the current log level. 0 = Debug, 1 = Info */ - function setLogLevel(level) { - curLogLevel = level; - setGlobalProp("logLevel", level); - if (curLogLevel !== level) - log("Set the log level to", LogLevel[level]); - } - /** Extracts the log level from the last item from spread arguments - returns 0 if the last item is not a number or too low or high */ - function getLogLevel(args) { - const minLogLvl = 0, maxLogLvl = 1; - if (typeof args.at(-1) === "number") - return UserUtils.clamp(args.splice(args.length - 1)[0], minLogLvl, maxLogLvl); - return LogLevel.Debug; +} +class PluginError extends CustomError { + constructor(message, opts) { + super("PluginError", message, opts); + } +}//#region misc +let domain; +/** + * Returns the current domain as a constant string representation + * @throws Throws if script runs on an unexpected website + */ +function getDomain() { + if (domain) + return domain; + if (location.hostname.match(/^music\.youtube/)) + return domain = "ytm"; + else if (location.hostname.match(/youtube\./)) + return domain = "yt"; + else + throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header."); +} +/** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */ +function getSessionId() { + try { + if (!sessionStorageAvailable) + throw new Error("Session storage unavailable"); + let sesId = window.sessionStorage.getItem("_bytm-session-id"); + if (!sesId) + window.sessionStorage.setItem("_bytm-session-id", sesId = UserUtils.randomId(10, 36)); + return sesId; + } + catch (err) { + warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err); + return null; } - /** - * Logs all passed values to the console, as long as the log level is sufficient. - * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be. - */ - function log(...args) { - if (curLogLevel <= getLogLevel(args)) - console.log(consPrefix, ...args); +} +let isCompressionSupported; +/** Tests whether compression via the predefined {@linkcode compressionFormat} is supported (only on the first call, then returns the cached result) */ +async function compressionSupported() { + if (typeof isCompressionSupported === "boolean") + return isCompressionSupported; + try { + await UserUtils.compress(".", compressionFormat, "string"); + return isCompressionSupported = true; + } + catch (_a) { + return isCompressionSupported = false; } - /** - * Logs all passed values to the console as info, as long as the log level is sufficient. - * @param args Last parameter is log level (0 = Debug, 1/undefined = Info) - any number as the last parameter will be stripped out! Convert to string if it shouldn't be. - */ - function info(...args) { - if (curLogLevel <= getLogLevel(args)) - console.info(consPrefix, ...args); +} +/** Returns a string with the given array's items separated by a default separator (`", "` by default), with an optional different separator for the last item */ +function arrayWithSeparators(array, separator = ", ", lastSeparator) { + const arr = [...array]; + if (arr.length === 0) + return ""; + else if (arr.length <= 2) + return arr.join(lastSeparator); + else + return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)}`; +} +/** Returns the watch ID of the current video or null if not on a video page */ +function getWatchId() { + const { searchParams, pathname } = new URL(location.href); + return pathname.includes("/watch") ? searchParams.get("v") : null; +} +/** + * Returns the ID of the current channel in the format `@User` or `UC...` from URLs with the path `/@User`, `/@User/videos`, `/channel/UC...` or `/channel/UC.../videos` + * Returns null if the current page is not a channel page or there was an error parsing the URL + */ +function getCurrentChannelId() { + return parseChannelIdFromUrl(location.href); +} +/** Returns the channel ID from a URL or null if the URL is invalid */ +function parseChannelIdFromUrl(url) { + try { + const { pathname } = url instanceof URL ? url : new URL(url); + if (pathname.includes("/channel/")) + return sanitizeChannelId(pathname.split("/channel/")[1].split("/")[0]); + else if (pathname.includes("/@")) + return sanitizeChannelId(pathname.split("/@")[1].split("/")[0]); + else + return null; } - /** Logs all passed values to the console as a warning, no matter the log level. */ - function warn(...args) { - console.warn(consPrefix, ...args); + catch (_a) { + return null; } - /** Logs all passed values to the console as an error, no matter the log level. */ - function error(...args) { - var _a, _b; - console.error(consPrefix, ...args); - if (getFeature("showToastOnGenericError")) { - const errName = (_b = (_a = args.find(a => a instanceof Error)) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : t("error"); - UserUtils.debounce(() => showIconToast({ - message: t("generic_error_toast_encountered_error_type", errName), - subtitle: t("generic_error_toast_click_for_details"), - icon: "icon-error", - iconFill: "var(--bytm-error-col)", - onClick: () => getErrorDialog(errName, Array.isArray(args) ? args : []).open(), - }))(); +} +/** Sanitizes a channel ID by adding a leading `@` if the ID doesn't start with `UC...` */ +function sanitizeChannelId(channelId) { + channelId = String(channelId).trim(); + return isValidChannelId(channelId) || channelId.startsWith("@") + ? channelId + : `@${channelId}`; +} +/** Tests whether a string is a valid channel ID in the format `@User` or `UC...` */ +function isValidChannelId(channelId) { + return channelId.match(/^(UC|@)[a-zA-Z0-9_-]+$/) !== null; +} +/** Returns the thumbnail URL for a video with either a given quality identifier or index */ +function getThumbnailUrl(watchId, qualityOrIndex = "maxresdefault") { + return `https://img.youtube.com/vi/${watchId}/${qualityOrIndex}.jpg`; +} +/** Returns the best available thumbnail URL for a video with the given watch ID */ +async function getBestThumbnailUrl(watchId) { + try { + const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0]; + for (const quality of priorityList) { + let response; + const url = getThumbnailUrl(watchId, quality); + try { + response = await sendRequest({ url, method: "HEAD", timeout: 6000 }); + } + catch (err) { + error(`Error while sending HEAD request to thumbnail URL for video '${watchId}' with quality '${quality}':`, err); + void err; + } + if (response && response.status < 300 && response.status >= 200) + return url; } } - /** Logs all passed values to the console with a debug-specific prefix */ - function dbg(...args) { - console.log(consPrefixDbg, ...args); + catch (err) { + throw new Error(`Couldn't get thumbnail URL for video '${watchId}': ${err}`); } - //#region error dialog - function getErrorDialog(errName, args) { - return new MarkdownDialog({ - id: "generic-error", - height: 400, - width: 500, - small: true, - destroyOnClose: true, - renderHeader() { - const header = document.createElement("h2"); - header.classList.add("bytm-dialog-title"); - header.role = "heading"; - header.ariaLevel = "1"; - header.tabIndex = 0; - header.textContent = header.ariaLabel = errName; - return header; - }, - body: `\ -${args.length > 0 ? args.join(" ") : t("generic_error_dialog_message")} -${t("generic_error_dialog_open_console_note", consPrefix, packageJson.bugs.url)}`, - }); +} +/** Opens the given URL in a new tab, using GM.openInTab if available */ +function openInTab(href, background = false) { + try { + UserUtils.openInNewTab(href, background); } - //#region rrror classes - class LyricsError extends Error { - constructor(message) { - super(message); - this.name = "LyricsError"; - } + catch (_a) { + window.open(href, "_blank", "noopener noreferrer"); } - class PluginError extends Error { - constructor(message) { - super(message); - this.name = "PluginError"; +} +/** Tries to parse an uncompressed or compressed input string as a JSON object */ +async function tryToDecompressAndParse(input) { + let parsed = null; + const val = await UserUtils.consumeStringGen(input); + try { + parsed = JSON.parse(val); + } + catch (_a) { + try { + parsed = JSON.parse(await UserUtils.decompress(val, compressionFormat, "string")); + } + catch (err) { + error("Couldn't decompress and parse data due to an error:", err); + return null; } } - - /** Central serializer for all data stores */ - let serializer; - /** Returns the serializer for all data stores */ - function getStoreSerializer() { - if (!serializer) { - serializer = new UserUtils.DataStoreSerializer([ - configStore, - autoLikeStore, - ], { - addChecksum: true, - ensureIntegrity: true, - }); + // artificial timeout to allow animations to finish and because dumb monkey brains *expect* a delay + await UserUtils.pauseFor(UserUtils.randRange(250, 500)); + return parsed; +} +/** Very crude OS detection */ +function getOS() { + if (navigator.userAgent.match(/mac(\s?os|intel)/i)) + return "mac"; + return "other"; +} +/** Formats a number based on the config or the passed {@linkcode notation} */ +function formatNumber(num, notation) { + return num.toLocaleString(getLocale().replace(/_/g, "-"), (notation !== null && notation !== void 0 ? notation : getFeature("numbersFormat")) === "short" + ? { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, + } + : { + style: "decimal", + maximumFractionDigits: 0, + }); +} +/** add `time_continue` param only if current video time is greater than this value */ +const reloadTabVideoTimeThreshold = 3; +/** Reloads the tab. If a video is currently playing, its time and volume will be preserved through the URL parameter `time_continue` and `bytm-reload-tab-volume` in GM storage */ +async function reloadTab() { + var _a, _b, _c; + const win = UserUtils.getUnsafeWindow(); + try { + enableDiscardBeforeUnload(); + if (((_b = (_a = getVideoElement()) === null || _a === void 0 ? void 0 : _a.readyState) !== null && _b !== void 0 ? _b : 0) > 0) { + const time = (_c = await getVideoTime(0)) !== null && _c !== void 0 ? _c : 0; + const volume = Math.round(getVideoElement().volume * 100); + const url = new URL(win.location.href); + if (!isNaN(time) && time > reloadTabVideoTimeThreshold) + url.searchParams.set("time_continue", String(time)); + if (!isNaN(volume) && volume > 0) + await GM.setValue("bytm-reload-tab-volume", String(volume)); + return win.location.replace(url); + } + win.location.reload(); + } + catch (err) { + error("Couldn't save video time and volume before reloading tab:", err); + win.location.reload(); + } +} +//#region resources +/** + * Returns the blob-URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl) + * Falls back to a CDN URL or base64-encoded data URI if the resource is not available in the GM resource cache + * @param name The name / key of the resource as defined in `assets/resources.json` - you can use `as "_"` to make TypeScript shut up if the name can not be typed as `ResourceKey` + * @param uncached Set to true to always fetch from the CDN URL instead of the GM resource cache + */ +async function getResourceUrl(name, uncached = false) { + var _a; + let url = !uncached && await GM.getResourceUrl(name); + if (!url || url.length === 0) { + const resObjOrStr = (_a = resourcesJson.resources) === null || _a === void 0 ? void 0 : _a[name]; + if (typeof resObjOrStr === "object" || typeof resObjOrStr === "string") { + const pathName = typeof resObjOrStr === "object" && "path" in resObjOrStr ? resObjOrStr === null || resObjOrStr === void 0 ? void 0 : resObjOrStr.path : resObjOrStr; + const ghRef = typeof resObjOrStr === "object" && "ref" in resObjOrStr ? resObjOrStr === null || resObjOrStr === void 0 ? void 0 : resObjOrStr.ref : buildNumber; + if (pathName) { + return pathName.startsWith("http") + ? pathName + : (() => { + let path = pathName; + if (path.startsWith("/")) + path = path.slice(1); + switch (assetSource) { + case "jsdelivr": + return `https://cdn.jsdelivr.net/gh/${repo}@${ghRef}/assets/${path}`; + case "github": + return `https://raw.githubusercontent.com/${repo}/${ghRef}/assets/${path}`; + case "local": + return `http://localhost:${devServerPort}/assets/${path}`; + } + })(); + } } - return serializer; + warn(`Couldn't get blob URL nor external URL for @resource '${name}', attempting to use base64-encoded fallback`); + // @ts-ignore + url = await GM.getResourceUrl(name, false); } - /** Downloads the current data stores as a single file */ - async function downloadData() { - const serializer = getStoreSerializer(); - const pad = (num, len = 2) => String(num).padStart(len, "0"); - const d = new Date(); - const dateStr = `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; - const fileName = `BetterYTM ${packageJson.version} data export ${dateStr}.json`; - const data = JSON.stringify(JSON.parse(await serializer.serialize()), undefined, 2); - downloadFile(fileName, data, "application/json"); + return url; +} +/** + * Resolves the preferred locale of the user given their browser's language settings, as long as it is supported by the userscript directly or via the `altLocales` prop in `locales.json` + * Prioritizes any supported value of `navigator.language`, then `navigator.languages`, then goes over them again, trimming off the part after the hyphen, then falls back to `"en-US"` + */ +function getPreferredLocale() { + var _a, _b; + const sanEq = (str1, str2) => str1.trim().toLowerCase() === str2.trim().toLowerCase(); + const allNvLocs = [...new Set([navigator.language, ...navigator.languages])] + .map((v) => v.replace(/_/g, "-")); + for (const nvLoc of allNvLocs) { + const resolvedLoc = (_a = Object.entries(locales) + .find(([key, { altLocales }]) => sanEq(key, nvLoc) || altLocales.find(al => sanEq(al, nvLoc)))) === null || _a === void 0 ? void 0 : _a[0]; + if (resolvedLoc) + return resolvedLoc.trim(); + const trimmedNvLoc = nvLoc.split("-")[0]; + const resolvedFallbackLoc = (_b = Object.entries(locales) + .find(([key, { altLocales }]) => sanEq(key.split("-")[0], trimmedNvLoc) || altLocales.find(al => sanEq(al.split("-")[0], trimmedNvLoc)))) === null || _b === void 0 ? void 0 : _b[0]; + if (resolvedFallbackLoc) + return resolvedFallbackLoc.trim(); + } + return "en-US"; +} +/** Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property */ +async function resourceAsString(resource) { + const resourceUrl = await getResourceUrl(resource); + try { + if (!resourceUrl) + throw new Error(`Couldn't find URL for resource '${resource}'`); + return await (await UserUtils.fetchAdvanced(resourceUrl)).text(); + } + catch (err) { + error(`Couldn't get SVG element '${resource}' from resource at URL '${resourceUrl}':`, err); + return null; } - - //#region cfg menu btns - let logoExchanged = false, improveLogoCalled = false; - /** Adds a watermark beneath the logo */ - async function addWatermark() { - const watermark = document.createElement("a"); - watermark.role = "button"; - watermark.id = "bytm-watermark"; - watermark.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select"); - watermark.textContent = scriptInfo.name; - watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name); - watermark.tabIndex = 0; - improveLogo(); - const watermarkOpenMenu = (e) => { - e.stopPropagation(); - if ((!e.shiftKey && !e.ctrlKey) || logoExchanged) - openCfgMenu(); - if (!logoExchanged && (e.shiftKey || e.ctrlKey)) - exchangeLogo(); - }; - onInteraction(watermark, watermarkOpenMenu); - addSelectorListener("navBar", "ytmusic-nav-bar #left-content", { - listener: (logoElem) => logoElem.insertAdjacentElement("afterend", watermark), +} +/** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */ +function parseMarkdown(mdString) { + return marked.marked.parse(mdString, { + async: true, + gfm: true, + }); +} +/** Returns the content of the changelog markdown file */ +async function getChangelogMd() { + const clRes = await UserUtils.fetchAdvanced(changelogUrl); + log("Fetched changelog:", clRes); + return await clRes.text(); +} +/** Returns the changelog as HTML with a details element for each version */ +async function getChangelogHtmlWithDetails() { + try { + const changelogMd = await getChangelogMd(); + let changelogHtml = await parseMarkdown(changelogMd); + const getVerId = (verStr) => verStr.trim().replace(/[._#\s-]/g, ""); + changelogHtml = changelogHtml.replace(/\s*<\/div>\s*\n?\s*/gm, "
\n
\n
"); + const h2Matches = Array.from(changelogHtml.matchAll(/([\d\w\s.]+)<\/h2>/gm)); + for (const [fullMatch, , verStr] of h2Matches) + changelogHtml = changelogHtml.replace(fullMatch, `

${verStr}

`); + changelogHtml = `
${changelogHtml}
`; + return changelogHtml; + } + catch (err) { + return `Error while preparing changelog: ${err}`; + } +}/** Central serializer for all data stores */ +let serializer; +/** Array of all data stores that are included in the DataStoreSerializer instance */ +const getSerializerStores = () => [ + configStore, + autoLikeStore, +]; +/** Array of IDs of all stores included in the DataStoreSerializer instance */ +const getSerializerStoresIds = () => getSerializerStores().map(store => store.id); +/** Returns the serializer for all data stores */ +function getStoreSerializer() { + if (!serializer) { + serializer = new UserUtils.DataStoreSerializer(getSerializerStores(), { + addChecksum: true, + ensureIntegrity: true, }); - log("Added watermark element"); } - /** Turns the regular ``-based logo into inline SVG to be able to animate and modify parts of it */ - async function improveLogo() { - try { - if (improveLogoCalled) - return; - improveLogoCalled = true; - const res = await UserUtils.fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg"); - const svg = await res.text(); - addSelectorListener("navBar", "ytmusic-logo a", { - listener: (logoElem) => { - var _a; - logoElem.classList.add("bytm-mod-logo", "bytm-no-select"); - setInnerHtml(logoElem, svg); - logoElem.querySelectorAll("ellipse").forEach((e) => { - e.classList.add("bytm-mod-logo-ellipse"); - }); - (_a = logoElem.querySelector("path")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-mod-logo-path"); - log("Swapped logo to inline SVG"); - }, - }); - } - catch (err) { - error("Couldn't improve logo due to an error:", err); - } + return serializer; +} +/** Downloads the current data stores as a single file */ +async function downloadData() { + const serializer = getStoreSerializer(); + const pad = (num, len = 2) => String(num).padStart(len, "0"); + const d = new Date(); + const dateStr = `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; + const fileName = `BetterYTM ${packageJson.version} data export ${dateStr}.json`; + const data = JSON.stringify(JSON.parse(await serializer.serialize()), undefined, 2); + downloadFile(fileName, data, "application/json"); +}let pluginListDialog = null; +/** Creates and/or returns the import dialog */ +async function getPluginListDialog() { + return pluginListDialog = pluginListDialog !== null && pluginListDialog !== void 0 ? pluginListDialog : new BytmDialog({ + id: "plugin-list", + width: 800, + height: 600, + closeBtnEnabled: true, + closeOnBgClick: true, + closeOnEscPress: true, + destroyOnClose: true, + small: true, + renderHeader: renderHeader$3, + renderBody: renderBody$4, + }); +} +async function renderHeader$3() { + const titleElem = document.createElement("h2"); + titleElem.id = "bytm-plugin-list-title"; + titleElem.classList.add("bytm-dialog-title"); + titleElem.role = "heading"; + titleElem.ariaLevel = "1"; + titleElem.tabIndex = 0; + titleElem.textContent = t("plugin_list_title"); + return titleElem; +} +async function renderBody$4() { + var _a; + const listContainerEl = document.createElement("div"); + listContainerEl.id = "bytm-plugin-list-container"; + const registeredPlugins = getRegisteredPlugins(); + if (registeredPlugins.length === 0) { + const noPluginsEl = document.createElement("div"); + noPluginsEl.classList.add("bytm-plugin-list-no-plugins"); + noPluginsEl.tabIndex = 0; + setInnerHtml(noPluginsEl, t("plugin_list_no_plugins", ``, "")); + noPluginsEl.title = noPluginsEl.ariaLabel = t("plugin_list_no_plugins_tooltip"); + listContainerEl.appendChild(noPluginsEl); + return listContainerEl; } - /** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */ - function exchangeLogo() { - addSelectorListener("navBar", ".bytm-mod-logo", { - listener: async (logoElem) => { - if (logoElem.classList.contains("bytm-logo-exchanged")) - return; - logoExchanged = true; - logoElem.classList.add("bytm-logo-exchanged"); - const iconUrl = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); - const newLogo = document.createElement("img"); - newLogo.classList.add("bytm-mod-logo-img"); - newLogo.src = iconUrl; - logoElem.insertBefore(newLogo, logoElem.querySelector("svg")); - document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => { - e.href = iconUrl; - }); - setTimeout(() => { - logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove()); - }, 1000); - }, + for (const [, { def: { plugin, intents } }] of registeredPlugins) { + const rowEl = document.createElement("div"); + rowEl.classList.add("bytm-plugin-list-row"); + const leftEl = document.createElement("div"); + leftEl.classList.add("bytm-plugin-list-row-left"); + rowEl.appendChild(leftEl); + const headerWrapperEl = document.createElement("div"); + headerWrapperEl.classList.add("bytm-plugin-list-row-header-wrapper"); + leftEl.appendChild(headerWrapperEl); + if (plugin.iconUrl) { + const iconEl = document.createElement("img"); + iconEl.classList.add("bytm-plugin-list-row-icon"); + iconEl.src = plugin.iconUrl; + iconEl.alt = ""; + headerWrapperEl.appendChild(iconEl); + } + const headerEl = document.createElement("div"); + headerEl.classList.add("bytm-plugin-list-row-header"); + headerWrapperEl.appendChild(headerEl); + const titleEl = document.createElement("div"); + titleEl.classList.add("bytm-plugin-list-row-title"); + titleEl.tabIndex = 0; + titleEl.textContent = titleEl.title = titleEl.ariaLabel = plugin.name; + headerEl.appendChild(titleEl); + const verEl = document.createElement("span"); + verEl.classList.add("bytm-plugin-list-row-version"); + verEl.textContent = verEl.title = verEl.ariaLabel = `v${plugin.version}`; + titleEl.appendChild(verEl); + const namespaceEl = document.createElement("div"); + namespaceEl.classList.add("bytm-plugin-list-row-namespace"); + namespaceEl.tabIndex = 0; + namespaceEl.textContent = namespaceEl.title = namespaceEl.ariaLabel = plugin.namespace; + headerEl.appendChild(namespaceEl); + const descEl = document.createElement("p"); + descEl.classList.add("bytm-plugin-list-row-desc"); + descEl.tabIndex = 0; + descEl.textContent = descEl.title = descEl.ariaLabel = (_a = plugin.description[getLocale()]) !== null && _a !== void 0 ? _a : plugin.description["en-US"]; + leftEl.appendChild(descEl); + const linksList = document.createElement("div"); + linksList.classList.add("bytm-plugin-list-row-links-list"); + leftEl.appendChild(linksList); + let linkElCreated = false; + for (const key in plugin.homepage) { + const url = plugin.homepage[key]; + if (!url) + continue; + if (linkElCreated) { + const bulletEl = document.createElement("span"); + bulletEl.classList.add("bytm-plugin-list-row-links-list-bullet"); + bulletEl.textContent = "•"; + linksList.appendChild(bulletEl); + } + linkElCreated = true; + const linkEl = document.createElement("a"); + linkEl.classList.add("bytm-plugin-list-row-link", "bytm-link"); + linkEl.href = url; + linkEl.tabIndex = 0; + linkEl.target = "_blank"; + linkEl.rel = "noopener noreferrer"; + linkEl.textContent = linkEl.title = linkEl.ariaLabel = t(`plugin_link_type_${key}`); + linksList.appendChild(linkEl); + } + const rightEl = document.createElement("div"); + rightEl.classList.add("bytm-plugin-list-row-right"); + rowEl.appendChild(rightEl); + const intentsAmount = Object.keys(PluginIntent).length / 2; + const intentsArr = typeof intents === "number" && intents > 0 ? (() => { + const arr = []; + for (let i = 0; i < intentsAmount; i++) + if (intents & (2 ** i)) + arr.push(2 ** i); + return arr; + })() : []; + const permissionsHeaderEl = document.createElement("div"); + permissionsHeaderEl.classList.add("bytm-plugin-list-row-permissions-header"); + permissionsHeaderEl.tabIndex = 0; + permissionsHeaderEl.textContent = permissionsHeaderEl.title = permissionsHeaderEl.ariaLabel = t("plugin_list_permissions_header"); + rightEl.appendChild(permissionsHeaderEl); + for (const intent of intentsArr) { + const intentEl = document.createElement("div"); + intentEl.classList.add("bytm-plugin-list-row-intent-item"); + intentEl.tabIndex = 0; + intentEl.textContent = t(`plugin_intent_name_${PluginIntent[intent]}`); + intentEl.title = intentEl.ariaLabel = t(`plugin_intent_description_${PluginIntent[intent]}`); + rightEl.appendChild(intentEl); + } + listContainerEl.appendChild(rowEl); + } + return listContainerEl; +}let featHelpDialog = null; +let curFeatKey = null; +/** Creates or modifies the help dialog for a specific feature and returns it */ +async function getFeatHelpDialog({ featKey, }) { + curFeatKey = featKey; + if (!featHelpDialog) { + featHelpDialog = new BytmDialog({ + id: "feat-help", + width: 600, + height: 400, + closeBtnEnabled: true, + closeOnBgClick: true, + closeOnEscPress: true, + small: true, + renderHeader: renderHeader$2, + renderBody: renderBody$3, }); + // make config menu inert while help dialog is open + featHelpDialog.on("open", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); }); + featHelpDialog.on("close", () => { var _a; return (_a = document.querySelector("#bytm-cfg-menu")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); }); } - /** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */ - async function addConfigMenuOptionYTM(container) { - const cfgOptElem = document.createElement("div"); - cfgOptElem.classList.add("bytm-cfg-menu-option"); - const cfgOptItemElem = document.createElement("div"); - cfgOptItemElem.classList.add("bytm-cfg-menu-option-item"); - cfgOptItemElem.role = "button"; - cfgOptItemElem.tabIndex = 0; - cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name); - onInteraction(cfgOptItemElem, async (e) => { - const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button"); - settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click(); - await UserUtils.pauseFor(20); - if ((!e.shiftKey && !e.ctrlKey) || logoExchanged) - openCfgMenu(); - if (!logoExchanged && (e.shiftKey || e.ctrlKey)) - exchangeLogo(); + return featHelpDialog; +} +async function renderHeader$2() { + const headerEl = document.createElement("div"); + setInnerHtml(headerEl, await resourceAsString("icon-help")); + return headerEl; +} +async function renderBody$3() { + var _a, _b; + const contElem = document.createElement("div"); + const featDescElem = document.createElement("h3"); + featDescElem.role = "subheading"; + featDescElem.tabIndex = 0; + featDescElem.textContent = t(`feature_desc_${curFeatKey}`); + featDescElem.id = "bytm-feat-help-dialog-desc"; + const helpTextElem = document.createElement("div"); + helpTextElem.id = "bytm-feat-help-dialog-text"; + helpTextElem.tabIndex = 0; + // @ts-ignore + const helpText = (_b = (_a = featInfo[curFeatKey]) === null || _a === void 0 ? void 0 : _a.helpText) === null || _b === void 0 ? void 0 : _b.call(_a); + helpTextElem.textContent = helpText !== null && helpText !== void 0 ? helpText : t(`feature_helptext_${curFeatKey}`); + contElem.appendChild(featDescElem); + contElem.appendChild(helpTextElem); + return contElem; +}let changelogDialog = null; +/** Creates and/or returns the changelog dialog */ +async function getChangelogDialog() { + if (!changelogDialog) { + changelogDialog = new BytmDialog({ + id: "changelog", + width: 1000, + height: 800, + closeBtnEnabled: true, + closeOnBgClick: true, + closeOnEscPress: true, + small: true, + verticalAlign: "top", + renderHeader: renderHeader$1, + renderBody: renderBody$2, + }); + changelogDialog.on("render", () => { + const mdContElem = document.querySelector("#bytm-changelog-dialog-text"); + if (!mdContElem) + return; + const anchors = mdContElem.querySelectorAll("a"); + for (const anchor of anchors) { + anchor.ariaLabel = anchor.title = anchor.href; + anchor.target = "_blank"; + } + const firstDetails = mdContElem.querySelector("details"); + if (firstDetails) + firstDetails.open = true; + const kbdElems = mdContElem.querySelectorAll("kbd"); + for (const kbdElem of kbdElems) + kbdElem.addEventListener("selectstart", (e) => e.preventDefault()); }); - const cfgOptIconElem = document.createElement("img"); - cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon"); - cfgOptIconElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); - const cfgOptTextElem = document.createElement("div"); - cfgOptTextElem.classList.add("bytm-cfg-menu-option-text"); - cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name); - cfgOptItemElem.appendChild(cfgOptIconElem); - cfgOptItemElem.appendChild(cfgOptTextElem); - cfgOptElem.appendChild(cfgOptItemElem); - container.appendChild(cfgOptElem); - improveLogo(); - log("Added BYTM-Configuration button to menu popover"); - } - /** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */ - async function addConfigMenuOptionYT(container) { - const cfgOptWrapperElem = document.createElement("div"); - cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore"); - cfgOptWrapperElem.role = "button"; - cfgOptWrapperElem.tabIndex = 0; - cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name); - const cfgOptElem = document.createElement("div"); - cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner"); - const cfgOptImgElem = document.createElement("img"); - cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon"); - cfgOptImgElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); - const cfgOptItemElem = document.createElement("div"); - cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item"); - cfgOptItemElem.textContent = scriptInfo.name; - cfgOptElem.appendChild(cfgOptImgElem); - cfgOptElem.appendChild(cfgOptItemElem); - cfgOptWrapperElem.appendChild(cfgOptElem); - onInteraction(cfgOptWrapperElem, openCfgMenu); - const firstChild = container === null || container === void 0 ? void 0 : container.firstElementChild; - if (firstChild) - container.insertBefore(cfgOptWrapperElem, firstChild); - else - return error("Couldn't add config menu option to YT titlebar - couldn't find container element"); } - //#region anchor impr. - /** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */ - async function addAnchorImprovements() { - try { - await addStyleFromResource("css-anchor_improvements"); + return changelogDialog; +} +async function renderHeader$1() { + const headerEl = document.createElement("h2"); + headerEl.classList.add("bytm-dialog-title"); + headerEl.role = "heading"; + headerEl.ariaLevel = "1"; + headerEl.tabIndex = 0; + headerEl.textContent = headerEl.ariaLabel = t("changelog_menu_title", scriptInfo.name); + return headerEl; +} +async function renderBody$2() { + const contElem = document.createElement("div"); + const mdContElem = document.createElement("div"); + mdContElem.id = "bytm-changelog-dialog-text"; + mdContElem.classList.add("bytm-markdown-container"); + setInnerHtml(mdContElem, await getChangelogHtmlWithDetails()); + contElem.appendChild(mdContElem); + return contElem; +}let otherHotkeyInputActive = false; +const reservedKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "Meta", "Tab", "Space", " "]; +/** Creates a hotkey input element */ +function createHotkeyInput({ initialValue, onChange, createTitle }) { + var _a; + const initialHotkey = initialValue; + let currentHotkey; + if (!createTitle) + createTitle = (value) => value; + const wrapperElem = document.createElement("div"); + wrapperElem.classList.add("bytm-hotkey-wrapper"); + const infoElem = document.createElement("span"); + infoElem.classList.add("bytm-hotkey-info"); + const inputElem = document.createElement("button"); + inputElem.role = "button"; + inputElem.classList.add("bytm-ftconf-input", "bytm-hotkey-input", "bytm-btn"); + inputElem.dataset.state = "inactive"; + inputElem.innerText = (_a = initialValue === null || initialValue === void 0 ? void 0 : initialValue.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change"); + inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue)); + const resetElem = document.createElement("span"); + resetElem.classList.add("bytm-hotkey-reset", "bytm-link", "bytm-hidden"); + resetElem.role = "button"; + resetElem.tabIndex = 0; + resetElem.textContent = `(${t("reset")})`; + resetElem.ariaLabel = resetElem.title = t("hotkey_input_click_to_reset_tooltip"); + const deactivate = () => { + var _a; + if (!otherHotkeyInputActive) + return; + emitSiteEvent("hotkeyInputActive", false); + otherHotkeyInputActive = false; + const curHk = currentHotkey !== null && currentHotkey !== void 0 ? currentHotkey : initialValue; + inputElem.innerText = (_a = curHk === null || curHk === void 0 ? void 0 : curHk.code) !== null && _a !== void 0 ? _a : t("hotkey_input_click_to_change"); + inputElem.dataset.state = "inactive"; + inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(curHk)); + setInnerHtml(infoElem, curHk ? getHotkeyInfoHtml(curHk) : ""); + }; + const activate = () => { + if (otherHotkeyInputActive) + return; + emitSiteEvent("hotkeyInputActive", true); + otherHotkeyInputActive = true; + inputElem.innerText = "< ... >"; + inputElem.dataset.state = "active"; + inputElem.ariaLabel = inputElem.title = t("click_to_cancel_tooltip"); + }; + const resetClicked = (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + onChange(initialValue); + currentHotkey = initialValue; + deactivate(); + inputElem.innerText = initialValue.code; + setInnerHtml(infoElem, getHotkeyInfoHtml(initialValue)); + resetElem.classList.add("bytm-hidden"); + inputElem.ariaLabel = inputElem.title = createTitle(hotkeyToString(initialValue)); + }; + onInteraction(resetElem, resetClicked); + if (initialValue) + setInnerHtml(infoElem, getHotkeyInfoHtml(initialValue)); + let lastKeyDown; + document.addEventListener("keypress", (e) => { + if (inputElem.dataset.state === "inactive") + return; + if ((lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.code) === e.code && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.shift) === e.shiftKey && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.ctrl) === e.ctrlKey && (lastKeyDown === null || lastKeyDown === void 0 ? void 0 : lastKeyDown.alt) === e.altKey) + return; + e.preventDefault(); + e.stopImmediatePropagation(); + const hotkey = { + code: e.code, + shift: e.shiftKey, + ctrl: e.ctrlKey, + alt: e.altKey, + }; + inputElem.innerText = hotkey.code; + inputElem.dataset.state = "inactive"; + setInnerHtml(infoElem, getHotkeyInfoHtml(hotkey)); + inputElem.ariaLabel = inputElem.title = t("click_to_cancel_tooltip"); + onChange(hotkey); + currentHotkey = hotkey; + }); + document.addEventListener("keydown", (e) => { + if (reservedKeys.filter(k => k !== "Tab").includes(e.code)) + return; + if (inputElem.dataset.state !== "active") + return; + if (e.code === "Tab" || e.code === " " || e.code === "Space" || e.code === "Escape" || e.code === "Enter") { + deactivate(); + return; } - catch (err) { - error("Couldn't add anchor improvements CSS due to an error:", err); + if (["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight"].includes(e.code)) + return; + e.preventDefault(); + e.stopImmediatePropagation(); + const hotkey = { + code: e.code, + shift: e.shiftKey, + ctrl: e.ctrlKey, + alt: e.altKey, + }; + const keyChanged = (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.code) !== hotkey.code || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.shift) !== hotkey.shift || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.ctrl) !== hotkey.ctrl || (initialHotkey === null || initialHotkey === void 0 ? void 0 : initialHotkey.alt) !== hotkey.alt; + lastKeyDown = hotkey; + onChange(hotkey); + currentHotkey = hotkey; + if (keyChanged) { + deactivate(); + resetElem.classList.remove("bytm-hidden"); } - //#region carousel shelves + else + resetElem.classList.add("bytm-hidden"); + inputElem.innerText = hotkey.code; + inputElem.dataset.state = "inactive"; + setInnerHtml(infoElem, getHotkeyInfoHtml(hotkey)); + }); + siteEvents.on("cfgMenuClosed", deactivate); + inputElem.addEventListener("click", () => { + if (inputElem.dataset.state === "inactive") + activate(); + else + deactivate(); + }); + inputElem.addEventListener("keydown", (e) => { + if (reservedKeys.includes(e.code)) + return; + if (inputElem.dataset.state === "inactive") + activate(); + }); + wrapperElem.appendChild(resetElem); + wrapperElem.appendChild(infoElem); + wrapperElem.appendChild(inputElem); + return wrapperElem; +} +/** Returns HTML for the hotkey modifier keys info element */ +function getHotkeyInfoHtml(hotkey) { + const modifiers = []; + hotkey.ctrl && modifiers.push(`${t("hotkey_key_ctrl")}`); + hotkey.shift && modifiers.push(`${t("hotkey_key_shift")}`); + hotkey.alt && modifiers.push(`${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}`); + return `\ +
+ + ${modifiers.reduce((a, c) => `${a ? a + " " : ""}${c}`, "")} + + + ${modifiers.length > 0 ? "+" : ""} + +
`; +} +/** Converts a hotkey object to a string */ +function hotkeyToString(hotkey) { + if (!hotkey) + return t("hotkey_key_none"); + let str = ""; + if (hotkey.ctrl) + str += `${t("hotkey_key_ctrl")}+`; + if (hotkey.shift) + str += `${t("hotkey_key_shift")}+`; + if (hotkey.alt) + str += `${getOS() === "mac" ? t("hotkey_key_mac_option") : t("hotkey_key_alt")}+`; + str += hotkey.code; + return str; +}//#region create menu +let isCfgMenuMounted = false; +let isCfgMenuOpen = false; +/** Threshold in pixels from the top of the options container that dictates for how long the scroll indicator is shown */ +const scrollIndicatorOffsetThreshold = 50; +let scrollIndicatorEnabled = true; +/** Locale at the point of initializing the config menu */ +let initLocale; +/** Stringified config at the point of initializing the config menu */ +let initConfig$1; +/** Timeout id for the "copied" text in the hidden value copy button */ +let hiddenCopiedTxtTimeout; +/** + * Adds an element to open the BetterYTM menu + * @deprecated to be replaced with new menu - see https://github.com/Sv443/BetterYTM/issues/23 + */ +async function mountCfgMenu() { + var _a, _b, _c, _d; + if (isCfgMenuMounted) + return; + isCfgMenuMounted = true; + BytmDialog.initDialogs(); + initLocale = getFeature("locale"); + initConfig$1 = getFeatures(); + const initLangReloadText = t("lang_changed_prompt_reload"); + //#region bg & container + const backgroundElem = document.createElement("div"); + backgroundElem.id = "bytm-cfg-menu-bg"; + backgroundElem.classList.add("bytm-menu-bg"); + backgroundElem.ariaLabel = backgroundElem.title = t("close_menu_tooltip"); + backgroundElem.style.visibility = "hidden"; + backgroundElem.style.display = "none"; + backgroundElem.addEventListener("click", (e) => { + var _a; + if (isCfgMenuOpen && ((_a = e.target) === null || _a === void 0 ? void 0 : _a.id) === "bytm-cfg-menu-bg") + closeCfgMenu(e); + }); + document.body.addEventListener("keydown", (e) => { + if (isCfgMenuOpen && e.key === "Escape" && BytmDialog.getCurrentDialogId() === "cfg-menu") + closeCfgMenu(e); + }); + const menuContainer = document.createElement("div"); + menuContainer.ariaLabel = menuContainer.title = ""; // prevent bg title from propagating downwards + menuContainer.classList.add("bytm-menu"); + menuContainer.id = "bytm-cfg-menu"; + //#region title bar + const headerElem = document.createElement("div"); + headerElem.classList.add("bytm-menu-header"); + const titleLogoHeaderCont = document.createElement("div"); + titleLogoHeaderCont.classList.add("bytm-menu-title-logo-header-cont"); + const titleCont = document.createElement("div"); + titleCont.classList.add("bytm-menu-titlecont"); + titleCont.role = "heading"; + titleCont.ariaLevel = "1"; + const titleLogoElem = document.createElement("img"); + const logoSrc = await getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`); + titleLogoElem.classList.add("bytm-cfg-menu-logo", "bytm-no-select"); + if (logoSrc) + titleLogoElem.src = logoSrc; + titleLogoHeaderCont.appendChild(titleLogoElem); + const titleElem = document.createElement("h2"); + titleElem.classList.add("bytm-menu-title"); + const titleTextElem = document.createElement("div"); + titleTextElem.textContent = t("config_menu_title", scriptInfo.name); + titleElem.appendChild(titleTextElem); + const linksCont = document.createElement("div"); + linksCont.id = "bytm-menu-linkscont"; + linksCont.role = "navigation"; + const linkTitlesShort = { + github: "GitHub", + greasyfork: "GreasyFork", + openuserjs: "OpenUserJS", + discord: "Discord", + }; + const addLink = (imgSrc, href, title, titleKey) => { + const anchorElem = document.createElement("a"); + anchorElem.classList.add("bytm-menu-link", "bytm-no-select"); + anchorElem.rel = "noopener noreferrer"; + anchorElem.href = href; + anchorElem.target = "_blank"; + anchorElem.tabIndex = 0; + anchorElem.role = "button"; + anchorElem.ariaLabel = anchorElem.title = title; + const extendedAnchorEl = document.createElement("a"); + extendedAnchorEl.classList.add("bytm-menu-link", "extended-link", "bytm-no-select"); + extendedAnchorEl.rel = "noopener noreferrer"; + extendedAnchorEl.href = href; + extendedAnchorEl.target = "_blank"; + extendedAnchorEl.tabIndex = -1; + extendedAnchorEl.textContent = linkTitlesShort[titleKey]; + extendedAnchorEl.ariaLabel = extendedAnchorEl.title = title; + const imgElem = document.createElement("img"); + imgElem.classList.add("bytm-menu-img"); + imgElem.src = imgSrc; + anchorElem.appendChild(imgElem); + anchorElem.appendChild(extendedAnchorEl); + linksCont.appendChild(anchorElem); + }; + const links = [ + ["github", await getResourceUrl("img-github"), scriptInfo.namespace, t("open_github", scriptInfo.name), "github"], + ["greasyfork", await getResourceUrl("img-greasyfork"), packageJson.hosts.greasyfork, t("open_greasyfork", scriptInfo.name), "greasyfork"], + ["openuserjs", await getResourceUrl("img-openuserjs"), packageJson.hosts.openuserjs, t("open_openuserjs", scriptInfo.name), "openuserjs"], + ]; + const hostLink = links.find(([name]) => name === host); + const otherLinks = links.filter(([name]) => name !== host); + const reorderedLinks = hostLink ? [hostLink, ...otherLinks] : links; + for (const [, ...args] of reorderedLinks) + addLink(...args); + addLink(await getResourceUrl("img-discord"), "https://dc.sv443.net/", t("open_discord"), "discord"); + const closeElem = document.createElement("img"); + closeElem.classList.add("bytm-menu-close"); + closeElem.role = "button"; + closeElem.tabIndex = 0; + closeElem.src = await getResourceUrl("img-close"); + closeElem.ariaLabel = closeElem.title = t("close_menu_tooltip"); + onInteraction(closeElem, closeCfgMenu); + titleCont.appendChild(titleElem); + titleCont.appendChild(linksCont); + titleLogoHeaderCont.appendChild(titleCont); + headerElem.appendChild(titleLogoHeaderCont); + headerElem.appendChild(closeElem); + //#region footer + const footerCont = document.createElement("div"); + footerCont.classList.add("bytm-menu-footer-cont"); + const reloadFooterCont = document.createElement("div"); + const reloadFooterEl = document.createElement("div"); + reloadFooterEl.id = "bytm-menu-footer-reload-hint"; + reloadFooterEl.classList.add("bytm-menu-footer", "hidden"); + reloadFooterEl.setAttribute("aria-hidden", "true"); + reloadFooterEl.textContent = t("reload_hint"); + reloadFooterEl.role = "alert"; + reloadFooterEl.ariaLive = "polite"; + const reloadTxtEl = document.createElement("button"); + reloadTxtEl.classList.add("bytm-btn"); + reloadTxtEl.style.marginLeft = "10px"; + reloadTxtEl.textContent = t("reload_now"); + reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip"); + reloadTxtEl.addEventListener("click", () => { + closeCfgMenu(); + reloadTab(); + }); + reloadFooterEl.appendChild(reloadTxtEl); + reloadFooterCont.appendChild(reloadFooterEl); + /** For copying plain when shift-clicking the copy button or when compression is not supported */ + const exportDataSpecial = () => JSON.stringify({ formatVersion, data: getFeatures() }); + const exImDlg = new ExImDialog({ + id: "bytm-config-export-import", + width: 800, + height: 600, + // try to compress the data if possible + exportData: async () => await compressionSupported() + ? await UserUtils.compress(JSON.stringify({ formatVersion, data: getFeatures() }), compressionFormat, "string") + : exportDataSpecial(), + exportDataSpecial, + async onImport(data) { + try { + const parsed = await tryToDecompressAndParse(data.trim()); + log("Trying to import configuration:", parsed); + if (!parsed || typeof parsed !== "object") + return await showPrompt({ type: "alert", message: t("import_error_invalid") }); + if (typeof parsed.formatVersion !== "number") + return await showPrompt({ type: "alert", message: t("import_error_no_format_version") }); + if (typeof parsed.data !== "object" || parsed.data === null || Object.keys(parsed.data).length === 0) + return await showPrompt({ type: "alert", message: t("import_error_no_data") }); + if (parsed.formatVersion < formatVersion) { + let newData = JSON.parse(JSON.stringify(parsed.data)); + const sortedMigrations = Object.entries(migrations) + .sort(([a], [b]) => Number(a) - Number(b)); + let curFmtVer = Number(parsed.formatVersion); + for (const [fmtVer, migrationFunc] of sortedMigrations) { + const ver = Number(fmtVer); + if (curFmtVer < formatVersion && curFmtVer < ver) { + try { + const migRes = JSON.parse(JSON.stringify(migrationFunc(newData))); + newData = migRes instanceof Promise ? await migRes : migRes; + curFmtVer = ver; + } + catch (err) { + error(`Error while running migration function for format version ${fmtVer}:`, err); + } + } + } + parsed.formatVersion = curFmtVer; + parsed.data = newData; + } + else if (parsed.formatVersion !== formatVersion) + return await showPrompt({ type: "alert", message: t("import_error_wrong_format_version", formatVersion, parsed.formatVersion) }); + await setFeatures(Object.assign(Object.assign({}, getFeatures()), parsed.data)); + if (await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) { + log("Reloading tab after importing configuration"); + return reloadTab(); + } + exImDlg.unmount(); + emitSiteEvent("rebuildCfgMenu", parsed.data); + } + catch (err) { + warn("Couldn't import configuration:", err); + await showPrompt({ type: "alert", message: t("import_error_invalid") }); + } + }, + title: () => t("bytm_config_export_import_title"), + descImport: () => t("bytm_config_import_desc"), + descExport: () => t("bytm_config_export_desc"), + }); + const exportImportBtn = document.createElement("button"); + exportImportBtn.classList.add("bytm-btn"); + exportImportBtn.textContent = exportImportBtn.ariaLabel = exportImportBtn.title = t("export_import"); + onInteraction(exportImportBtn, async () => await exImDlg.open()); + const buttonsCont = document.createElement("div"); + buttonsCont.classList.add("bytm-menu-footer-buttons-cont"); + buttonsCont.appendChild(exportImportBtn); + footerCont.appendChild(reloadFooterCont); + footerCont.appendChild(buttonsCont); + //#region feature list + const featuresCont = document.createElement("div"); + featuresCont.id = "bytm-menu-opts"; + const onCfgChange = async (key, initialVal, newVal) => { + var _a, _b, _c, _d; try { - const preventDefault = (e) => e.preventDefault(); - /** Adds anchor improvements to <ytmusic-responsive-list-item-renderer> */ - const addListItemAnchors = (items) => { - var _a; - for (const item of items) { - if (item.classList.contains("bytm-anchor-improved")) - continue; - item.classList.add("bytm-anchor-improved"); - const thumbnailElem = item.querySelector(".left-items"); - const titleElem = item.querySelector(".title-column .title a"); - if (!thumbnailElem || !titleElem) - continue; - const anchorElem = document.createElement("a"); - anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor"); - anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#"; - anchorElem.target = "_self"; - anchorElem.role = "button"; - anchorElem.addEventListener("click", preventDefault); - UserUtils.addParent(thumbnailElem, anchorElem); + const fmt = (val) => typeof val === "object" ? JSON.stringify(val) : String(val); + info(`Feature config changed at key '${key}', from value '${fmt(initialVal)}' to '${fmt(newVal)}'`); + const featConf = JSON.parse(JSON.stringify(getFeatures())); + featConf[key] = newVal; + const changedKeys = initConfig$1 ? Object.keys(featConf).filter((k) => typeof featConf[k] !== "object" + && featConf[k] !== initConfig$1[k]) : []; + const requiresReload = + // @ts-ignore + changedKeys.some((k) => { var _a; return ((_a = featInfo[k]) === null || _a === void 0 ? void 0 : _a.reloadRequired) !== false; }); + await setFeatures(featConf); + // @ts-ignore + (_b = (_a = featInfo[key]) === null || _a === void 0 ? void 0 : _a.change) === null || _b === void 0 ? void 0 : _b.call(_a, key, initialVal, newVal); + if (requiresReload) { + reloadFooterEl.classList.remove("hidden"); + reloadFooterEl.setAttribute("aria-hidden", "false"); + } + else { + reloadFooterEl.classList.add("hidden"); + reloadFooterEl.setAttribute("aria-hidden", "true"); + } + if (initLocale !== featConf.locale) { + await initTranslations(featConf.locale); + setLocale(featConf.locale); + const newText = t("lang_changed_prompt_reload"); + const newLangEmoji = ((_c = locales[featConf.locale]) === null || _c === void 0 ? void 0 : _c.emoji) ? `${locales[featConf.locale].emoji}\n` : ""; + const initLangEmoji = ((_d = locales[initLocale]) === null || _d === void 0 ? void 0 : _d.emoji) ? `${locales[initLocale].emoji}\n` : ""; + const confirmText = newText !== initLangReloadText ? `${newLangEmoji}${newText}\n\n\n${initLangEmoji}${initLangReloadText}` : newText; + if (await showPrompt({ + type: "confirm", + message: confirmText, + confirmBtnText: () => `${t("prompt_confirm")} / ${tl(initLocale, "prompt_confirm")}`, + confirmBtnTooltip: () => `${t("click_to_confirm_tooltip")} / ${tl(initLocale, "click_to_confirm_tooltip")}`, + denyBtnText: (type) => `${t(type === "alert" ? "prompt_close" : "prompt_cancel")} / ${tl(initLocale, type === "alert" ? "prompt_close" : "prompt_cancel")}`, + denyBtnTooltip: (type) => `${t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")} / ${tl(initLocale, type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")}`, + })) { + closeCfgMenu(); + log("Reloading tab after changing language"); + await reloadTab(); } - }; - // home page - addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); - // related tab in /watch - addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); - // playlists - addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); - // generic shelves - addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", { - continuous: true, - all: true, - listener: addListItemAnchors, - }); + } + else if (getLocale() !== featConf.locale) + setLocale(featConf.locale); } catch (err) { - error("Couldn't improve carousel shelf anchors due to an error:", err); - } - //#region sidebar - try { - const addSidebarAnchors = (sidebarCont) => { - const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item"); - improveSidebarAnchors(items); - return items.length; - }; - addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", { - listener: (sidebarCont) => { - const itemsAmt = addSidebarAnchors(sidebarCont); - log(`Added anchors around ${itemsAmt} sidebar ${UserUtils.autoPlural("item", itemsAmt)}`); - }, - }); - addSelectorListener("sideBarMini", "ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", { - listener: (miniSidebarCont) => { - const itemsAmt = addSidebarAnchors(miniSidebarCont); - log(`Added anchors around ${itemsAmt} mini sidebar ${UserUtils.autoPlural("item", itemsAmt)}`); - }, - }); + error("Error while reacting to config change:", err); } - catch (err) { - error("Couldn't add anchors to sidebar items due to an error:", err); + finally { + emitSiteEvent("configOptionChanged", key, initialVal, newVal); } - } - const sidebarPaths = [ - "/", - "/explore", - "/library", - ]; + }; + /** Call whenever the feature config is changed */ + const confChanged = UserUtils.debounce(onCfgChange, 333); + const featureCfg = getFeatures(); + const featureCfgWithCategories = Object.entries(featInfo) + .reduce((acc, [key, { category }]) => { + if (!acc[category]) + acc[category] = {}; + acc[category][key] = featureCfg[key]; + return acc; + }, {}); /** - * Adds anchors to the sidebar items so they can be opened in a new tab - * @param sidebarItem + * Formats the value `v` based on the provided `key` using the `featInfo` object. + * If a custom `renderValue` function is defined for the `key`, it will be used to format the value. + * If no custom `renderValue` function is defined, the value will be converted to a string and trimmed. + * If the value is an object, it will be converted to a JSON string representation. + * If an error occurs during formatting (like when passing objects with circular references), the original value will be returned as a string (trimmed). */ - function improveSidebarAnchors(sidebarItems) { - sidebarItems.forEach((item, i) => { - var _a; - const anchorElem = document.createElement("a"); - anchorElem.classList.add("bytm-anchor", "bytm-no-select"); - anchorElem.role = "button"; - anchorElem.target = "_self"; - anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#"; - anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab"); - anchorElem.addEventListener("click", (e) => { - e.preventDefault(); - }); - UserUtils.addParent(item, anchorElem); - }); - } - //#region share track par. - /** Removes the ?si tracking parameter from share URLs */ - async function initRemShareTrackParam() { - const removeSiParam = (inputElem) => { - try { - if (!inputElem.value.match(/(&|\?)si=/i)) - return; - const url = new URL(inputElem.value); - url.searchParams.delete("si"); - inputElem.value = String(url); - log(`Removed tracking parameter from share link -> ${url}`); - } - catch (err) { - warn("Couldn't remove tracking parameter from share link due to error:", err); - } - }; - const [sharePanelSel, inputSel] = (() => { - switch (getDomain()) { - case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"]; - case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"]; - } - })(); - addSelectorListener("body", sharePanelSel, { - listener: (sharePanelEl) => { - const obs = new MutationObserver(() => { - const inputElem = sharePanelEl.querySelector(inputSel); - inputElem && removeSiParam(inputElem); - }); - obs.observe(sharePanelEl, { - childList: true, - subtree: true, - characterData: true, - attributeFilter: ["aria-hidden", "aria-checked", "checked"], - }); - }, - }); - } - //#region fix spacing - /** Applies global CSS to fix various spacings */ - async function fixSpacing() { - if (!await addStyleFromResource("css-fix_spacing")) - error("Couldn't fix spacing"); - } - //#region ab.queue btns - async function initAboveQueueBtns() { - const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures(); - if (!await addStyleFromResource("css-above_queue_btns")) - error("Couldn't add CSS for above queue buttons"); - const contBtns = [ - { - condition: scrollToActiveSongBtn, - id: "scroll-to-active", - resourceName: "icon-skip_to", - titleKey: "scroll_to_playing", - async interaction(evt) { - const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]"); - if (!activeItem) - return; - activeItem.scrollIntoView({ - behavior: evt.shiftKey ? "instant" : "smooth", - block: evt.ctrlKey || evt.altKey ? "start" : "center", - inline: "center", - }); - }, - }, + const fmtVal = (v, key) => { + var _a; + try { + // @ts-ignore + const renderValue = typeof ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.renderValue) === "function" ? featInfo[key].renderValue : undefined; + const retVal = (typeof v === "object" ? JSON.stringify(v) : String(v)).trim(); + return renderValue ? renderValue(retVal) : retVal; + } + catch (_b) { + // absolute last resort fallback because stringify throws on circular refs + return String(v).trim(); + } + }; + for (const category in featureCfgWithCategories) { + const featObj = featureCfgWithCategories[category]; + const catHeaderElem = document.createElement("h3"); + catHeaderElem.classList.add("bytm-ftconf-category-header"); + catHeaderElem.role = "heading"; + catHeaderElem.ariaLevel = "2"; + catHeaderElem.tabIndex = 0; + catHeaderElem.textContent = `${t(`feature_category_${category}`)}:`; + featuresCont.appendChild(catHeaderElem); + for (const featKey in featObj) { + const ftInfo = featInfo[featKey]; + if (!ftInfo || ("hidden" in ftInfo && ftInfo.hidden === true)) + continue; + if (ftInfo.advanced && !featureCfg.advancedMode) + continue; + const { type, default: ftDefault } = ftInfo; + const step = "step" in ftInfo ? ftInfo.step : undefined; + const val = featureCfg[featKey]; + const initialVal = val !== null && val !== void 0 ? val : ftDefault; + const ftConfElem = document.createElement("div"); + ftConfElem.classList.add("bytm-ftitem"); { - condition: clearQueueBtn, - id: "clear-queue", - resourceName: "icon-clear_list", - titleKey: "clear_list", - async interaction(evt) { - try { - if (evt.shiftKey || await showPrompt({ type: "confirm", message: t("clear_list_confirm") })) { - const url = new URL(location.href); - url.searchParams.delete("list"); - url.searchParams.set("time_continue", String(await getVideoTime(0))); - location.assign(url); - } - } - catch (err) { - error("Couldn't clear queue due to an error:", err); - } - }, - }, - ]; - if (!contBtns.some(b => Boolean(b.condition))) - return; - addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", { - async listener(rightBtnsEl) { - try { - const aboveQueueBtnCont = document.createElement("div"); - aboveQueueBtnCont.id = "bytm-above-queue-btn-cont"; - UserUtils.addParent(rightBtnsEl, aboveQueueBtnCont); - const headerEl = rightBtnsEl.closest("ytmusic-queue-header-renderer"); - if (!headerEl) - return error("Couldn't find queue header element while adding above queue buttons"); - siteEvents.on("fullscreenToggled", (isFullscreen) => { - headerEl.classList[isFullscreen ? "add" : "remove"]("hidden"); - }); - const wrapperElem = document.createElement("div"); - wrapperElem.id = "bytm-above-queue-btn-wrapper"; - for (const item of contBtns) { - if (Boolean(item.condition) === false) - continue; - const btnElem = await createCircularBtn({ - resourceName: item.resourceName, - onClick: item.interaction, - title: t(item.titleKey), + const featLeftSideElem = document.createElement("div"); + featLeftSideElem.classList.add("bytm-ftitem-leftside"); + if (getFeature("advancedMode")) { + const defVal = fmtVal(ftDefault, featKey); + const extraTxts = [ + `default: ${defVal.length === 0 ? "(undefined)" : defVal}`, + ]; + "min" in ftInfo && extraTxts.push(`min: ${ftInfo.min}`); + "max" in ftInfo && extraTxts.push(`max: ${ftInfo.max}`); + "step" in ftInfo && extraTxts.push(`step: ${ftInfo.step}`); + const rel = "reloadRequired" in ftInfo && ftInfo.reloadRequired !== false ? " (reload required)" : ""; + const adv = ftInfo.advanced ? " (advanced feature)" : ""; + ftConfElem.title = `${featKey}${rel}${adv}${extraTxts.length > 0 ? `\n${extraTxts.join(" - ")}` : ""}`; + } + const textElem = document.createElement("span"); + textElem.classList.add("bytm-ftitem-text", "bytm-ellipsis-wrap"); + textElem.textContent = textElem.title = textElem.ariaLabel = t(`feature_desc_${featKey}`); + let adornmentElem; + const adornContentAsync = (_a = ftInfo.textAdornment) === null || _a === void 0 ? void 0 : _a.call(ftInfo); + const adornContent = adornContentAsync instanceof Promise ? await adornContentAsync : adornContentAsync; + if ((typeof adornContentAsync === "string" || adornContentAsync instanceof Promise) && typeof adornContent !== "undefined") { + adornmentElem = document.createElement("span"); + adornmentElem.id = `bytm-ftitem-${featKey}-adornment`; + adornmentElem.classList.add("bytm-ftitem-adornment"); + setInnerHtml(adornmentElem, adornContent); + } + let helpElem; + // @ts-ignore + const hasHelpTextFunc = typeof ((_b = featInfo[featKey]) === null || _b === void 0 ? void 0 : _b.helpText) === "function"; + // @ts-ignore + const helpTextVal = hasHelpTextFunc && featInfo[featKey].helpText(); + if (hasKey(`feature_helptext_${featKey}`) || (helpTextVal && hasKey(helpTextVal))) { + const helpElemImgHtml = await resourceAsString("icon-help"); + if (helpElemImgHtml) { + helpElem = document.createElement("div"); + helpElem.classList.add("bytm-ftitem-help-btn", "bytm-generic-btn"); + helpElem.ariaLabel = helpElem.title = t("feature_help_button_tooltip", t(`feature_desc_${featKey}`)); + helpElem.role = "button"; + helpElem.tabIndex = 0; + setInnerHtml(helpElem, helpElemImgHtml); + onInteraction(helpElem, async (e) => { + e.preventDefault(); + e.stopPropagation(); + await (await getFeatHelpDialog({ featKey: featKey })).open(); }); - btnElem.id = `bytm-${item.id}-btn`; - btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn"); - wrapperElem.appendChild(btnElem); } - rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem); - } - catch (err) { - error("Couldn't add above queue buttons due to an error:", err); + else { + error(`Couldn't create help button SVG element for feature '${featKey}'`); + } } - }, - }); - } - //#region thumb.overlay - /** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */ - let invertOverlay = false; - async function initThumbnailOverlay() { - const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown"); - if (getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown) - return; - // so the script init doesn't keep waiting until a /watch page is loaded - waitVideoElementReady().then(() => { - const playerSelector = "ytmusic-player#player"; - const playerEl = document.querySelector(playerSelector); - if (!playerEl) - return error("Couldn't find video player element while adding thumbnail overlay"); - /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */ - const updateOverlayVisibility = async () => { - if (!domLoaded) - return; - const behavior = getFeature("thumbnailOverlayBehavior"); - let showOverlay = behavior === "always"; - const isVideo = getCurrentMediaType() === "video"; - if (behavior === "videosOnly" && isVideo) - showOverlay = true; - else if (behavior === "songsOnly" && !isVideo) - showOverlay = true; - showOverlay = invertOverlay ? !showOverlay : showOverlay; - const overlayElem = document.querySelector("#bytm-thumbnail-overlay"); - const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img"); - const indicatorElem = document.querySelector("#bytm-thumbnail-overlay-indicator"); - if (overlayElem) - overlayElem.style.display = showOverlay ? "block" : "none"; - if (thumbElem) - thumbElem.ariaHidden = String(!showOverlay); - if (indicatorElem) { - indicatorElem.style.display = showOverlay ? "block" : "none"; - indicatorElem.ariaHidden = String(!showOverlay); + adornmentElem && featLeftSideElem.appendChild(adornmentElem); + featLeftSideElem.appendChild(textElem); + helpElem && featLeftSideElem.appendChild(helpElem); + ftConfElem.appendChild(featLeftSideElem); + } + { + let inputType = "text"; + let inputTag = "input"; + switch (type) { + case "toggle": + inputTag = undefined; + inputType = undefined; + break; + case "slider": + inputType = "range"; + break; + case "number": + inputType = "number"; + break; + case "text": + inputType = "text"; + break; + case "select": + inputTag = "select"; + inputType = undefined; + break; + case "hotkey": + inputTag = undefined; + inputType = undefined; + break; + case "button": + inputTag = undefined; + inputType = undefined; + break; } - if (getFeature("thumbnailOverlayToggleBtnShown")) { - addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", { - async listener(toggleBtnElem) { - const toggleBtnImgElem = toggleBtnElem.querySelector("img"); - if (toggleBtnImgElem) - toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}`); - if (toggleBtnElem) - toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`); - }, - }); + const inputElemId = `bytm-ftconf-${featKey}-input`; + const ctrlElem = document.createElement("span"); + ctrlElem.classList.add("bytm-ftconf-ctrl"); + // to prevent dev mode title from propagating: + ctrlElem.title = ""; + let advCopyHiddenCont; + if ((getFeature("advancedMode") || mode === "development") && ftInfo.valueHidden) { + const advCopyHintElem = document.createElement("span"); + advCopyHintElem.classList.add("bytm-ftconf-adv-copy-hint"); + advCopyHintElem.textContent = t("copied"); + advCopyHintElem.role = "status"; + advCopyHintElem.style.display = "none"; + const advCopyHiddenBtn = document.createElement("button"); + advCopyHiddenBtn.classList.add("bytm-ftconf-adv-copy-btn", "bytm-btn"); + advCopyHiddenBtn.tabIndex = 0; + advCopyHiddenBtn.textContent = t("copy_hidden"); + advCopyHiddenBtn.ariaLabel = advCopyHiddenBtn.title = t("copy_hidden_tooltip"); + const copyHiddenInteraction = (e) => { + e.preventDefault(); + e.stopPropagation(); + copyToClipboard(getFeatures()[featKey]); + advCopyHintElem.style.display = "inline"; + if (typeof hiddenCopiedTxtTimeout === "undefined") { + hiddenCopiedTxtTimeout = setTimeout(() => { + advCopyHintElem.style.display = "none"; + hiddenCopiedTxtTimeout = undefined; + }, 3000); + } + }; + onInteraction(advCopyHiddenBtn, copyHiddenInteraction); + advCopyHiddenCont = document.createElement("span"); + advCopyHiddenCont.appendChild(advCopyHintElem); + advCopyHiddenCont.appendChild(advCopyHiddenBtn); } - }; - const applyThumbUrl = async (watchId) => { - try { - const thumbUrl = await getBestThumbnailUrl(watchId); - if (thumbUrl) { - const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle"); - const thumbImgElem = document.querySelector("#bytm-thumbnail-overlay-img"); - if ((toggleBtnElem === null || toggleBtnElem === void 0 ? void 0 : toggleBtnElem.href) === thumbUrl && (thumbImgElem === null || thumbImgElem === void 0 ? void 0 : thumbImgElem.src) === thumbUrl) - return; - if (toggleBtnElem) - toggleBtnElem.href = thumbUrl; - if (thumbImgElem) - thumbImgElem.src = thumbUrl; - log("Applied thumbnail URL to overlay:", thumbUrl); + advCopyHiddenCont && ctrlElem.appendChild(advCopyHiddenCont); + if (inputTag) { + // standard input element: + const inputElem = document.createElement(inputTag); + inputElem.classList.add("bytm-ftconf-input"); + inputElem.id = inputElemId; + inputElem.ariaLabel = t(`feature_desc_${featKey}`); + if (inputType) + inputElem.type = inputType; + if ("min" in ftInfo && typeof ftInfo.min !== "undefined") + inputElem.min = String(ftInfo.min); + if ("max" in ftInfo && typeof ftInfo.max !== "undefined") + inputElem.max = String(ftInfo.max); + if (typeof initialVal !== "undefined") + inputElem.value = String(initialVal); + if (type === "text" && ftInfo.valueHidden) { + inputElem.type = "password"; + inputElem.autocomplete = "off"; } - else - error("Couldn't get thumbnail URL for watch ID", watchId); - } - catch (err) { - error("Couldn't apply thumbnail URL to overlay due to an error:", err); - } - }; - const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => { - unsubWatchIdChanged(); - addSelectorListener("body", "#bytm-thumbnail-overlay", { - listener: () => { - applyThumbUrl(watchId); - updateOverlayVisibility(); - }, - }); - }); - const createElements = async () => { - try { - // overlay - const overlayElem = document.createElement("div"); - overlayElem.id = "bytm-thumbnail-overlay"; - overlayElem.title = ""; // prevent child titles from propagating - overlayElem.classList.add("bytm-no-select"); - overlayElem.style.display = "none"; - let indicatorElem; - if (getFeature("thumbnailOverlayShowIndicator")) { - indicatorElem = document.createElement("img"); - indicatorElem.id = "bytm-thumbnail-overlay-indicator"; - indicatorElem.src = await getResourceUrl("icon-image"); - indicatorElem.role = "presentation"; - indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip"); - indicatorElem.ariaHidden = "true"; - indicatorElem.style.display = "none"; - indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100); + if (type === "number" || type === "slider" && step) + inputElem.step = String(step); + if (type === "toggle" && typeof initialVal !== "undefined") + inputElem.checked = Boolean(initialVal); + const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string" + ? ftInfo.unit + : ("unit" in ftInfo && typeof ftInfo.unit === "function" + ? ftInfo.unit(Number(inputElem.value)) + : "")); + let labelElem; + let lastDisplayedVal; + if (type === "slider") { + labelElem = document.createElement("label"); + labelElem.classList.add("bytm-ftconf-label", "bytm-slider-label"); + labelElem.textContent = `${fmtVal(initialVal, featKey)}${unitTxt}`; + inputElem.addEventListener("input", () => { + if (labelElem && lastDisplayedVal !== inputElem.value) { + labelElem.textContent = `${fmtVal(inputElem.value, featKey)}${unitTxt}`; + lastDisplayedVal = inputElem.value; + } + }); } - const thumbImgElem = document.createElement("img"); - thumbImgElem.id = "bytm-thumbnail-overlay-img"; - thumbImgElem.role = "presentation"; - thumbImgElem.ariaHidden = "true"; - thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit"); - overlayElem.appendChild(thumbImgElem); - playerEl.appendChild(overlayElem); - indicatorElem && playerEl.appendChild(indicatorElem); - siteEvents.on("watchIdChanged", async (watchId) => { - invertOverlay = false; - applyThumbUrl(watchId); - updateOverlayVisibility(); - }); - const params = new URL(location.href).searchParams; - if (params.has("v")) { - applyThumbUrl(params.get("v")); - updateOverlayVisibility(); + else if (type === "select") { + const ftOpts = typeof ftInfo.options === "function" + ? ftInfo.options() + : ftInfo.options; + for (const { value, label } of ftOpts) { + const optionElem = document.createElement("option"); + optionElem.value = String(value); + optionElem.textContent = label; + if (value === initialVal) + optionElem.selected = true; + inputElem.appendChild(optionElem); + } } - // toggle button - if (toggleBtnShown) { - const toggleBtnElem = createRipple(document.createElement("a")); - toggleBtnElem.id = "bytm-thumbnail-overlay-toggle"; - toggleBtnElem.role = "button"; - toggleBtnElem.tabIndex = 0; - toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select"); - onInteraction(toggleBtnElem, (e) => { - if (e.shiftKey) - return openInTab(toggleBtnElem.href, false); - invertOverlay = !invertOverlay; - updateOverlayVisibility(); + if (type === "text") { + let lastValue = inputElem.value && inputElem.value.length > 0 ? inputElem.value : ftInfo.default; + const textInputUpdate = () => { + let v = String(inputElem.value).trim(); + if (type === "text" && ftInfo.normalize) + v = inputElem.value = ftInfo.normalize(String(v)); + if (v === lastValue) + return; + lastValue = v; + if (v === "") + v = ftInfo.default; + if (typeof initialVal !== "undefined") + confChanged(featKey, initialVal, v); + }; + const unsub = siteEvents.on("cfgMenuClosed", () => { + unsub(); + textInputUpdate(); }); - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-generic-btn-img"); - toggleBtnElem.appendChild(imgElem); - addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { - listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem), + inputElem.addEventListener("blur", () => textInputUpdate()); + inputElem.addEventListener("keydown", (e) => e.key === "Tab" && textInputUpdate()); + } + else { + inputElem.addEventListener("input", () => { + let v = String(inputElem.value).trim(); + if (["number", "slider"].includes(type) || v.match(/^-?\d+$/)) + v = Number(v); + if (typeof initialVal !== "undefined") + confChanged(featKey, initialVal, (type !== "toggle" ? v : inputElem.checked)); }); } - log("Added thumbnail overlay"); - } - catch (err) { - error("Couldn't create thumbnail overlay elements due to an error:", err); + if (labelElem) { + labelElem.id = `bytm-ftconf-${featKey}-label`; + labelElem.htmlFor = inputElemId; + ctrlElem.appendChild(labelElem); + } + ctrlElem.appendChild(inputElem); } - }; - addSelectorListener("mainPanel", playerSelector, { - listener(playerEl) { - if (playerEl.getAttribute("player-ui-state") === "INACTIVE") { - const obs = new MutationObserver(() => { - if (playerEl.getAttribute("player-ui-state") === "INACTIVE") - return; - createElements(); - obs.disconnect(); - }); - obs.observe(playerEl, { - attributes: true, - attributeFilter: ["player-ui-state"], - }); + else { + // custom input element: + let customInputEl; + switch (type) { + case "hotkey": + customInputEl = createHotkeyInput({ + initialValue: typeof initialVal === "object" ? initialVal : undefined, + onChange: (hotkey) => confChanged(featKey, initialVal, hotkey), + createTitle: (value) => t("hotkey_input_click_to_change_tooltip", t(`feature_desc_${featKey}`), value), + }); + break; + case "toggle": + customInputEl = await createToggleInput({ + initialValue: Boolean(initialVal), + onChange: (checked) => confChanged(featKey, initialVal, checked), + id: `ftconf-${featKey}`, + labelPos: "left", + }); + break; + case "button": + customInputEl = document.createElement("button"); + customInputEl.classList.add("bytm-btn"); + customInputEl.tabIndex = 0; + customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action"); + customInputEl.ariaLabel = customInputEl.title = t(`feature_desc_${featKey}`); + onInteraction(customInputEl, async () => { + if (customInputEl.disabled) + return; + const startTs = Date.now(); + const res = ftInfo.click(); + customInputEl.disabled = true; + customInputEl.classList.add("bytm-busy"); + customInputEl.textContent = hasKey(`feature_btn_${featKey}_running`) ? t(`feature_btn_${featKey}_running`) : t("trigger_btn_action_running"); + if (res instanceof Promise) + await res; + const finalize = () => { + customInputEl.disabled = false; + customInputEl.classList.remove("bytm-busy"); + customInputEl.textContent = hasKey(`feature_btn_${featKey}`) ? t(`feature_btn_${featKey}`) : t("trigger_btn_action"); + }; + // artificial timeout ftw + if (Date.now() - startTs < 350) + setTimeout(finalize, 350 - (Date.now() - startTs)); + else + finalize(); + }); + break; } - else - createElements(); - }, - }); - }); + if (customInputEl && !customInputEl.hasAttribute("aria-label")) + customInputEl.ariaLabel = t(`feature_desc_${featKey}`); + ctrlElem.appendChild(customInputEl); + } + ftConfElem.appendChild(ctrlElem); + } + featuresCont.appendChild(ftConfElem); + } } - //#region idle hide cursor - async function initHideCursorOnIdle() { - addSelectorListener("mainPanel", "ytmusic-player#player", { - listener(vidContainer) { - const overlaySelector = "ytmusic-player #song-media-window"; - const overlayElem = document.querySelector(overlaySelector); - if (!overlayElem) - return warn("Couldn't find overlay element while initializing cursor hiding"); - /** Timer after which the cursor is hidden */ - let cursorHideTimer; - /** Timer for the opacity transition while switching to the hidden state */ - let hideTransTimer; - const hide = () => { - if (!getFeature("hideCursorOnIdle")) - return; - if (vidContainer.classList.contains("bytm-cursor-hidden")) - return; - overlayElem.style.opacity = ".000001 !important"; - hideTransTimer = setTimeout(() => { - overlayElem.style.display = "none"; - vidContainer.style.cursor = "none"; - vidContainer.classList.add("bytm-cursor-hidden"); - hideTransTimer = undefined; - }, 200); - }; - const show = () => { - hideTransTimer && clearTimeout(hideTransTimer); - if (!vidContainer.classList.contains("bytm-cursor-hidden")) - return; - vidContainer.classList.remove("bytm-cursor-hidden"); - vidContainer.style.cursor = "initial"; - overlayElem.style.display = "initial"; - overlayElem.style.opacity = "1 !important"; - }; - const cursorHideTimerCb = () => cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000); - const onMove = () => { - cursorHideTimer && clearTimeout(cursorHideTimer); - show(); - cursorHideTimerCb(); - }; - vidContainer.addEventListener("mouseenter", onMove); - vidContainer.addEventListener("mousemove", UserUtils.debounce(onMove, 200, "rising")); - vidContainer.addEventListener("mouseleave", () => { - cursorHideTimer && clearTimeout(cursorHideTimer); - hideTransTimer && clearTimeout(hideTransTimer); - hide(); - }); - vidContainer.addEventListener("click", () => { - show(); - cursorHideTimerCb(); - setTimeout(hide, 3000); + //#region reset inputs on external change + siteEvents.on("rebuildCfgMenu", (newConfig) => { + for (const ftKey in featInfo) { + const ftElem = document.querySelector(`#bytm-ftconf-${ftKey}-input`); + const labelElem = document.querySelector(`#bytm-ftconf-${ftKey}-label`); + if (!ftElem) + continue; + const ftInfo = featInfo[ftKey]; + const value = newConfig[ftKey]; + if (ftInfo.type === "toggle") + ftElem.checked = Boolean(value); + else + ftElem.value = String(value); + if (!labelElem) + continue; + const unitTxt = ("unit" in ftInfo && typeof ftInfo.unit === "string" + ? ftInfo.unit + : ("unit" in ftInfo && typeof ftInfo.unit === "function" + ? ftInfo.unit(Number(ftElem.value)) + : "")); + if (ftInfo.type === "slider") + labelElem.textContent = `${fmtVal(Number(value), ftKey)}${unitTxt}`; + } + info("Rebuilt config menu"); + }); + //#region scroll indicator + const scrollIndicator = document.createElement("img"); + scrollIndicator.id = "bytm-menu-scroll-indicator"; + scrollIndicator.src = await getResourceUrl("icon-arrow_down"); + scrollIndicator.role = "button"; + scrollIndicator.ariaLabel = scrollIndicator.title = t("scroll_to_bottom"); + featuresCont.appendChild(scrollIndicator); + scrollIndicator.addEventListener("click", () => { + const bottomAnchor = document.querySelector("#bytm-menu-bottom-anchor"); + bottomAnchor === null || bottomAnchor === void 0 ? void 0 : bottomAnchor.scrollIntoView({ + behavior: "smooth", + }); + }); + featuresCont.addEventListener("scroll", (evt) => { + var _a, _b; + const scrollPos = (_b = (_a = evt.target) === null || _a === void 0 ? void 0 : _a.scrollTop) !== null && _b !== void 0 ? _b : 0; + const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator"); + if (!scrollIndicator) + return; + if (scrollIndicatorEnabled && scrollPos > scrollIndicatorOffsetThreshold && !scrollIndicator.classList.contains("bytm-hidden")) { + scrollIndicator.classList.add("bytm-hidden"); + } + else if (scrollIndicatorEnabled && scrollPos <= scrollIndicatorOffsetThreshold && scrollIndicator.classList.contains("bytm-hidden")) { + scrollIndicator.classList.remove("bytm-hidden"); + } + }); + const bottomAnchor = document.createElement("div"); + bottomAnchor.id = "bytm-menu-bottom-anchor"; + featuresCont.appendChild(bottomAnchor); + //#region finalize + menuContainer.appendChild(headerElem); + menuContainer.appendChild(featuresCont); + const subtitleElemCont = document.createElement("div"); + subtitleElemCont.id = "bytm-menu-subtitle-cont"; + subtitleElemCont.classList.add("bytm-ellipsis"); + const versionEl = document.createElement("a"); + versionEl.id = "bytm-menu-version-anchor"; + versionEl.classList.add("bytm-link", "bytm-ellipsis"); + versionEl.role = "button"; + versionEl.tabIndex = 0; + versionEl.ariaLabel = versionEl.title = t("version_tooltip", scriptInfo.version, buildNumber); + versionEl.textContent = `v${scriptInfo.version} (#${buildNumber})`; + onInteraction(versionEl, async (e) => { + e.preventDefault(); + e.stopPropagation(); + const dlg = await getChangelogDialog(); + dlg.on("close", openCfgMenu); + await dlg.mount(); + closeCfgMenu(undefined, false); + await dlg.open(); + }); + subtitleElemCont.appendChild(versionEl); + titleElem.appendChild(subtitleElemCont); + const modeItems = []; + mode === "development" && modeItems.push("dev_mode"); + getFeature("advancedMode") && modeItems.push("advanced_mode"); + if (modeItems.length > 0) { + const modeDisplayEl = document.createElement("span"); + modeDisplayEl.id = "bytm-menu-mode-display"; + modeDisplayEl.classList.add("bytm-ellipsis"); + modeDisplayEl.textContent = `[${t("active_mode_display", arrayWithSeparators(modeItems.map(v => t(`${v}_short`)), ", ", " & "))}]`; + modeDisplayEl.ariaLabel = modeDisplayEl.title = tp("active_mode_tooltip", modeItems, arrayWithSeparators(modeItems.map(t), ", ", " & ")); + subtitleElemCont.appendChild(modeDisplayEl); + } + menuContainer.appendChild(footerCont); + backgroundElem.appendChild(menuContainer); + ((_c = document.querySelector("#bytm-dialog-container")) !== null && _c !== void 0 ? _c : document.body).appendChild(backgroundElem); + window.addEventListener("resize", UserUtils.debounce(checkToggleScrollIndicator, 250)); + log("Added menu element"); + // ensure stuff is reset if menu was opened before being added + isCfgMenuOpen = false; + document.body.classList.remove("bytm-disable-scroll"); + (_d = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _d === void 0 ? void 0 : _d.removeAttribute("inert"); + backgroundElem.style.visibility = "hidden"; + backgroundElem.style.display = "none"; + siteEvents.on("recreateCfgMenu", async () => { + const bgElem = document.querySelector("#bytm-cfg-menu-bg"); + if (!bgElem) + return; + closeCfgMenu(); + bgElem.remove(); + isCfgMenuMounted = false; + await mountCfgMenu(); + await openCfgMenu(); + }); +} +//#region open & close +/** Closes the config menu if it is open. If a bubbling event is passed, its propagation will be prevented. */ +function closeCfgMenu(evt, enableScroll = true) { + var _a, _b, _c; + if (!isCfgMenuOpen) + return; + isCfgMenuOpen = false; + (evt === null || evt === void 0 ? void 0 : evt.bubbles) && evt.stopPropagation(); + if (enableScroll) { + document.body.classList.remove("bytm-disable-scroll"); + (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.removeAttribute("inert"); + } + const menuBg = document.querySelector("#bytm-cfg-menu-bg"); + clearTimeout(hiddenCopiedTxtTimeout); + UserUtils.openDialogs.splice(UserUtils.openDialogs.indexOf("cfg-menu"), 1); + setCurrentDialogId((_b = UserUtils.openDialogs === null || UserUtils.openDialogs === void 0 ? void 0 : UserUtils.openDialogs[0]) !== null && _b !== void 0 ? _b : null); + // since this menu doesn't have a BytmDialog instance, it's undefined here + emitInterface("bytm:dialogClosed", undefined); + emitInterface("bytm:dialogClosed:cfg-menu", undefined); + if (!menuBg) + return warn("Couldn't close config menu because background element couldn't be found. The config menu is considered closed but might still be open. In this case please reload the page. If the issue persists, please create an issue on GitHub."); + (_c = menuBg.querySelectorAll(".bytm-ftconf-adv-copy-hint")) === null || _c === void 0 ? void 0 : _c.forEach((el) => el.style.display = "none"); + menuBg.style.visibility = "hidden"; + menuBg.style.display = "none"; +} +/** Opens the config menu if it is closed */ +async function openCfgMenu() { + var _a; + if (!isCfgMenuMounted) + await mountCfgMenu(); + if (isCfgMenuOpen) + return; + isCfgMenuOpen = true; + document.body.classList.add("bytm-disable-scroll"); + (_a = document.querySelector(getDomain() === "ytm" ? "ytmusic-app" : "ytd-app")) === null || _a === void 0 ? void 0 : _a.setAttribute("inert", "true"); + const menuBg = document.querySelector("#bytm-cfg-menu-bg"); + setCurrentDialogId("cfg-menu"); + UserUtils.openDialogs.unshift("cfg-menu"); + // since this menu doesn't have a BytmDialog instance, it's undefined here + emitInterface("bytm:dialogOpened", undefined); + emitInterface("bytm:dialogOpened:cfg-menu", undefined); + if (!menuBg) + return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub."); + menuBg.style.visibility = "visible"; + menuBg.style.display = "block"; + checkToggleScrollIndicator(); +} +//#region chk scroll indicator +/** Checks if the features container is scrollable and toggles the scroll indicator accordingly */ +function checkToggleScrollIndicator() { + const featuresCont = document.querySelector("#bytm-menu-opts"); + const scrollIndicator = document.querySelector("#bytm-menu-scroll-indicator"); + // disable scroll indicator if container doesn't scroll + if (featuresCont && scrollIndicator) { + const verticalScroll = UserUtils.isScrollable(featuresCont).vertical; + /** If true, the indicator's threshold is under the available scrollable space and so it should be disabled */ + const underThreshold = featuresCont.scrollHeight - featuresCont.clientHeight <= scrollIndicatorOffsetThreshold; + if (!underThreshold && verticalScroll && !scrollIndicatorEnabled) { + scrollIndicatorEnabled = true; + scrollIndicator.classList.remove("bytm-hidden"); + } + if ((!verticalScroll && scrollIndicatorEnabled) || underThreshold) { + scrollIndicatorEnabled = false; + scrollIndicator.classList.add("bytm-hidden"); + } + } +}//#region cfg menu btns +let logoExchanged = false, improveLogoCalled = false; +/** Adds a watermark beneath the logo */ +async function addWatermark() { + const watermark = document.createElement("a"); + watermark.role = "button"; + watermark.id = "bytm-watermark"; + watermark.classList.add("style-scope", "ytmusic-nav-bar", "bytm-no-select"); + watermark.textContent = scriptInfo.name; + watermark.ariaLabel = watermark.title = t("open_menu_tooltip", scriptInfo.name); + watermark.tabIndex = 0; + improveLogo(); + const watermarkOpenMenu = (e) => { + e.stopPropagation(); + if ((!e.shiftKey && !e.ctrlKey) || logoExchanged) + openCfgMenu(); + if (!logoExchanged && (e.shiftKey || e.ctrlKey)) + exchangeLogo(); + }; + onInteraction(watermark, watermarkOpenMenu); + addSelectorListener("navBar", "ytmusic-nav-bar #left-content", { + listener: (logoElem) => logoElem.insertAdjacentElement("afterend", watermark), + }); + log("Added watermark element"); +} +/** Turns the regular ``-based logo into inline SVG to be able to animate and modify parts of it */ +async function improveLogo() { + try { + if (improveLogoCalled) + return; + improveLogoCalled = true; + const res = await UserUtils.fetchAdvanced("https://music.youtube.com/img/on_platform_logo_dark.svg"); + const svg = await res.text(); + addSelectorListener("navBar", "ytmusic-logo a", { + listener: (logoElem) => { + var _a; + logoElem.classList.add("bytm-mod-logo", "bytm-no-select"); + setInnerHtml(logoElem, svg); + logoElem.querySelectorAll("ellipse").forEach((e) => { + e.classList.add("bytm-mod-logo-ellipse"); }); - log("Initialized cursor hiding on idle"); + (_a = logoElem.querySelector("path")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-mod-logo-path"); + log("Swapped logo to inline SVG"); }, }); } - //#region fix HDR - /** Prevents visual issues when using HDR */ - async function fixHdrIssues() { - if (!await addStyleFromResource("css-fix_hdr")) - error("Couldn't load stylesheet to fix HDR issues"); - else - log("Fixed HDR issues"); + catch (err) { + error("Couldn't improve logo due to an error:", err); } - //#region show vote nums - /** Shows the amount of likes and dislikes on the current song */ - async function initShowVotes() { - addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", { - async listener(voteCont) { - try { - const watchId = getWatchId(); - if (!watchId) { - await siteEvents.once("watchIdChanged"); - return initShowVotes(); - } - const voteObj = await fetchVideoVotes(watchId); - if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj)) - return error("Couldn't fetch votes from the Return YouTube Dislike API"); - if (getFeature("showVotes")) { - addVoteNumbers(voteCont, voteObj); - siteEvents.on("watchIdChanged", async (watchId) => { - var _a, _b; - const labelLikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.likes"); - const labelDislikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.dislikes"); - if (!labelLikes || !labelDislikes) - return error("Couldn't find vote label elements while updating like and dislike counts"); - if (labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId) - return log("Vote labels already updated for this video"); - const voteObj = await fetchVideoVotes(watchId); - if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj)) - return error("Couldn't fetch votes from the Return YouTube Dislike API"); - const likesLabelText = tp("vote_label_likes", voteObj.likes, formatNumber(voteObj.likes, "long")); - const dislikesLabelText = tp("vote_label_dislikes", voteObj.dislikes, formatNumber(voteObj.dislikes, "long")); - labelLikes.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : ""; - labelLikes.textContent = formatNumber(voteObj.likes); - labelLikes.title = labelLikes.ariaLabel = likesLabelText; - labelDislikes.textContent = formatNumber(voteObj.dislikes); - labelDislikes.title = labelDislikes.ariaLabel = dislikesLabelText; - labelDislikes.dataset.watchId = (_b = getWatchId()) !== null && _b !== void 0 ? _b : ""; - addSelectorListener("playerBar", "ytmusic-like-button-renderer#like-button-renderer", { - listener: (bar) => upsertVoteBtnLabels(bar, likesLabelText, dislikesLabelText), - }); - }); - } - } - catch (err) { - error("Couldn't initialize show votes feature due to an error:", err); - } +} +/** Exchanges the default YTM logo into BetterYTM's logo with a sick ass animation */ +function exchangeLogo() { + addSelectorListener("navBar", ".bytm-mod-logo", { + listener: async (logoElem) => { + if (logoElem.classList.contains("bytm-logo-exchanged")) + return; + logoExchanged = true; + logoElem.classList.add("bytm-logo-exchanged"); + const iconUrl = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); + const newLogo = document.createElement("img"); + newLogo.classList.add("bytm-mod-logo-img"); + newLogo.src = iconUrl; + logoElem.insertBefore(newLogo, logoElem.querySelector("svg")); + document.head.querySelectorAll("link[rel=\"icon\"]").forEach((e) => { + e.href = iconUrl; + }); + setTimeout(() => { + logoElem.querySelectorAll(".bytm-mod-logo-ellipse").forEach(e => e.remove()); + }, 1000); + }, + }); +} +/** Called whenever the avatar popover menu exists on YTM to add a BYTM config menu button to the user menu popover */ +async function addConfigMenuOptionYTM(container) { + const cfgOptElem = document.createElement("div"); + cfgOptElem.classList.add("bytm-cfg-menu-option"); + const cfgOptItemElem = document.createElement("div"); + cfgOptItemElem.classList.add("bytm-cfg-menu-option-item"); + cfgOptItemElem.role = "button"; + cfgOptItemElem.tabIndex = 0; + cfgOptItemElem.ariaLabel = cfgOptItemElem.title = t("open_menu_tooltip", scriptInfo.name); + onInteraction(cfgOptItemElem, async (e) => { + const settingsBtnElem = document.querySelector("ytmusic-nav-bar ytmusic-settings-button tp-yt-paper-icon-button"); + settingsBtnElem === null || settingsBtnElem === void 0 ? void 0 : settingsBtnElem.click(); + await UserUtils.pauseFor(20); + if ((!e.shiftKey && !e.ctrlKey) || logoExchanged) + openCfgMenu(); + if (!logoExchanged && (e.shiftKey || e.ctrlKey)) + exchangeLogo(); + }); + const cfgOptIconElem = document.createElement("img"); + cfgOptIconElem.classList.add("bytm-cfg-menu-option-icon"); + cfgOptIconElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); + const cfgOptTextElem = document.createElement("div"); + cfgOptTextElem.classList.add("bytm-cfg-menu-option-text"); + cfgOptTextElem.textContent = t("config_menu_option", scriptInfo.name); + cfgOptItemElem.appendChild(cfgOptIconElem); + cfgOptItemElem.appendChild(cfgOptTextElem); + cfgOptElem.appendChild(cfgOptItemElem); + container.appendChild(cfgOptElem); + improveLogo(); + log("Added BYTM-Configuration button to menu popover"); +} +/** Called whenever the titlebar (masthead) exists on YT to add a BYTM config menu button */ +async function addConfigMenuOptionYT(container) { + const cfgOptWrapperElem = document.createElement("div"); + cfgOptWrapperElem.classList.add("bytm-yt-cfg-menu-option", "darkreader-ignore"); + cfgOptWrapperElem.role = "button"; + cfgOptWrapperElem.tabIndex = 0; + cfgOptWrapperElem.ariaLabel = cfgOptWrapperElem.title = t("open_menu_tooltip", scriptInfo.name); + const cfgOptElem = document.createElement("div"); + cfgOptElem.classList.add("bytm-yt-cfg-menu-option-inner"); + const cfgOptImgElem = document.createElement("img"); + cfgOptImgElem.classList.add("bytm-yt-cfg-menu-option-icon"); + cfgOptImgElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); + const cfgOptItemElem = document.createElement("div"); + cfgOptItemElem.classList.add("bytm-yt-cfg-menu-option-item"); + cfgOptItemElem.textContent = scriptInfo.name; + cfgOptElem.appendChild(cfgOptImgElem); + cfgOptElem.appendChild(cfgOptItemElem); + cfgOptWrapperElem.appendChild(cfgOptElem); + onInteraction(cfgOptWrapperElem, openCfgMenu); + const firstChild = container === null || container === void 0 ? void 0 : container.firstElementChild; + if (firstChild) + container.insertBefore(cfgOptWrapperElem, firstChild); + else + return error("Couldn't add config menu option to YT titlebar - couldn't find container element"); +} +//#region anchor impr. +/** Adds anchors around elements and tweaks existing ones so songs are easier to open in a new tab */ +async function addAnchorImprovements() { + try { + await addStyleFromResource("css-anchor_improvements"); + } + catch (err) { + error("Couldn't add anchor improvements CSS due to an error:", err); + } + //#region carousel shelves + try { + const preventDefault = (e) => e.preventDefault(); + /** Adds anchor improvements to <ytmusic-responsive-list-item-renderer> */ + const addListItemAnchors = (items) => { + var _a; + for (const item of items) { + if (item.classList.contains("bytm-anchor-improved")) + continue; + item.classList.add("bytm-anchor-improved"); + const thumbnailElem = item.querySelector(".left-items"); + const titleElem = item.querySelector(".title-column .title a"); + if (!thumbnailElem || !titleElem) + continue; + const anchorElem = document.createElement("a"); + anchorElem.classList.add("bytm-anchor", "bytm-carousel-shelf-anchor"); + anchorElem.href = (_a = titleElem === null || titleElem === void 0 ? void 0 : titleElem.href) !== null && _a !== void 0 ? _a : "#"; + anchorElem.target = "_self"; + anchorElem.role = "button"; + anchorElem.addEventListener("click", preventDefault); + UserUtils.addParent(thumbnailElem, anchorElem); } + }; + // home page + addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-carousel-shelf-renderer ytmusic-responsive-list-item-renderer", { + continuous: true, + all: true, + listener: addListItemAnchors, + }); + // related tab in /watch + addSelectorListener("body", "ytmusic-tab-renderer[page-type=\"MUSIC_PAGE_TYPE_TRACK_RELATED\"] ytmusic-responsive-list-item-renderer", { + continuous: true, + all: true, + listener: addListItemAnchors, + }); + // playlists + addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-playlist-shelf-renderer ytmusic-responsive-list-item-renderer", { + continuous: true, + all: true, + listener: addListItemAnchors, + }); + // generic shelves + addSelectorListener("body", "#contents.ytmusic-section-list-renderer ytmusic-shelf-renderer ytmusic-responsive-list-item-renderer", { + continuous: true, + all: true, + listener: addListItemAnchors, }); } - function addVoteNumbers(voteCont, voteObj) { - const likeBtn = voteCont.querySelector("#button-shape-like"); - const dislikeBtn = voteCont.querySelector("#button-shape-dislike"); - if (!likeBtn || !dislikeBtn) - return error("Couldn't find like or dislike button while adding vote numbers"); - const createLabel = (amount, type) => { - var _a; - const label = document.createElement("span"); - label.classList.add("bytm-vote-label", "bytm-no-select", type); - label.textContent = String(formatNumber(amount)); - label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatNumber(amount, "long")); - label.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : ""; - label.addEventListener("click", (e) => { - var _a; - e.preventDefault(); - e.stopPropagation(); - (_a = (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")) === null || _a === void 0 ? void 0 : _a.click(); - }); - return label; + catch (err) { + error("Couldn't improve carousel shelf anchors due to an error:", err); + } + //#region sidebar + try { + const addSidebarAnchors = (sidebarCont) => { + const items = sidebarCont.parentNode.querySelectorAll("ytmusic-guide-entry-renderer tp-yt-paper-item"); + improveSidebarAnchors(items); + return items.length; }; - addStyleFromResource("css-show_votes") - .catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e)); - const likeLblEl = createLabel(voteObj.likes, "likes"); - likeBtn.insertAdjacentElement("afterend", likeLblEl); - const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes"); - dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl); - upsertVoteBtnLabels(voteCont, likeLblEl.title, dislikeLblEl.title); - log("Added vote number labels to like and dislike buttons"); - } - /** Updates or inserts the labels on the native like and dislike buttons */ - function upsertVoteBtnLabels(parentEl, likesLabelText, dislikesLabelText) { - const likeBtn = parentEl.querySelector("#button-shape-like button"); - const dislikeBtn = parentEl.querySelector("#button-shape-dislike button"); - if (likeBtn) - likeBtn.title = likeBtn.ariaLabel = likesLabelText; - if (dislikeBtn) - dislikeBtn.title = dislikeBtn.ariaLabel = dislikesLabelText; + addSelectorListener("sideBar", "#contentContainer #guide-content #items ytmusic-guide-entry-renderer", { + listener: (sidebarCont) => { + const itemsAmt = addSidebarAnchors(sidebarCont); + log(`Added anchors around ${itemsAmt} sidebar ${UserUtils.autoPlural("item", itemsAmt)}`); + }, + }); + addSelectorListener("sideBarMini", "ytmusic-guide-renderer ytmusic-guide-section-renderer #items ytmusic-guide-entry-renderer", { + listener: (miniSidebarCont) => { + const itemsAmt = addSidebarAnchors(miniSidebarCont); + log(`Added anchors around ${itemsAmt} mini sidebar ${UserUtils.autoPlural("item", itemsAmt)}`); + }, + }); } - - //#region Dark Reader - /** Disables Dark Reader if it is present */ - async function disableDarkReader() { - if (getFeature("disableDarkReaderSites") !== getDomain() && getFeature("disableDarkReaderSites") !== "all") - return; - const metaElem = document.createElement("meta"); - metaElem.name = "darkreader-lock"; - metaElem.id = "bytm-disable-dark-reader"; - document.head.appendChild(metaElem); - info("Disabled Dark Reader"); - } - //#region SponsorBlock - /** Fixes the z-index of the SponsorBlock panel */ - async function fixSponsorBlock() { - try { - return addStyleFromResource("css-fix_sponsorblock"); - } - catch (err) { - error("Failed to fix SponsorBlock styling:", err); - } + catch (err) { + error("Couldn't add anchors to sidebar items due to an error:", err); } - //#region ThemeSong - /** Adjust the BetterYTM styles if ThemeSong is ***not*** used */ - async function fixPlayerPageTheming() { +} +const sidebarPaths = [ + "/", + "/explore", + "/library", +]; +/** + * Adds anchors to the sidebar items so they can be opened in a new tab + * @param sidebarItem + */ +function improveSidebarAnchors(sidebarItems) { + sidebarItems.forEach((item, i) => { + var _a; + const anchorElem = document.createElement("a"); + anchorElem.classList.add("bytm-anchor", "bytm-no-select"); + anchorElem.role = "button"; + anchorElem.target = "_self"; + anchorElem.href = (_a = sidebarPaths[i]) !== null && _a !== void 0 ? _a : "#"; + anchorElem.ariaLabel = anchorElem.title = t("middle_click_open_tab"); + anchorElem.addEventListener("click", (e) => { + e.preventDefault(); + }); + UserUtils.addParent(item, anchorElem); + }); +} +//#region share track par. +/** Removes the ?si tracking parameter from share URLs */ +async function initRemShareTrackParam() { + const removeSiParam = (inputElem) => { try { - return addStyleFromResource("css-fix_playerpage_theming"); + if (!inputElem.value.match(/(&|\?)si=/i)) + return; + const url = new URL(inputElem.value); + url.searchParams.delete("si"); + inputElem.value = String(url); + log(`Removed tracking parameter from share link -> ${url}`); } catch (err) { - error("Failed to fix BetterYTM player page theming:", err); + warn("Couldn't remove tracking parameter from share link due to error:", err); } - } - /** Sets the lightness of the theme color used by BYTM according to the configured lightness value */ - async function fixThemeSong() { - try { - const cssVarName = (() => { - switch (getFeature("themeSongLightness")) { - default: - case "darker": - return "--ts-palette-darkmuted-hex"; - case "normal": - return "--ts-palette-muted-hex"; - case "lighter": - return "--ts-palette-lightmuted-hex"; + }; + const [sharePanelSel, inputSel] = (() => { + switch (getDomain()) { + case "ytm": return ["tp-yt-paper-dialog ytmusic-unified-share-panel-renderer", "input#share-url"]; + case "yt": return ["ytd-unified-share-panel-renderer", "input#share-url"]; + } + })(); + addSelectorListener("body", sharePanelSel, { + listener: (sharePanelEl) => { + const obs = new MutationObserver(() => { + const inputElem = sharePanelEl.querySelector(inputSel); + inputElem && removeSiParam(inputElem); + }); + obs.observe(sharePanelEl, { + childList: true, + subtree: true, + characterData: true, + attributeFilter: ["aria-hidden", "aria-checked", "checked"], + }); + }, + }); +} +//#region fix spacing +/** Applies global CSS to fix various spacings */ +async function fixSpacing() { + if (!await addStyleFromResource("css-fix_spacing")) + error("Couldn't fix spacing"); +} +//#region ab.queue btns +async function initAboveQueueBtns() { + const { scrollToActiveSongBtn, clearQueueBtn } = getFeatures(); + if (!await addStyleFromResource("css-above_queue_btns")) + error("Couldn't add CSS for above queue buttons"); + else if (getFeature("aboveQueueBtnsSticky")) + addStyleFromResource("css-above_queue_btns_sticky"); + const contBtns = [ + { + condition: scrollToActiveSongBtn, + id: "scroll-to-active", + resourceName: "icon-skip_to", + titleKey: "scroll_to_playing", + async interaction(evt) { + const activeItem = document.querySelector("#side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"loading\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"playing\"], #side-panel .ytmusic-player-queue ytmusic-player-queue-item[play-button-state=\"paused\"]"); + if (!activeItem) + return; + activeItem.scrollIntoView({ + behavior: evt.shiftKey ? "instant" : "smooth", + block: evt.ctrlKey || evt.altKey ? "start" : "center", + inline: "center", + }); + }, + }, + { + condition: clearQueueBtn, + id: "clear-queue", + resourceName: "icon-clear_list", + titleKey: "clear_list", + async interaction(evt) { + try { + if (evt.shiftKey || await showPrompt({ type: "confirm", message: t("clear_list_confirm") })) { + const url = new URL(location.href); + url.searchParams.delete("list"); + url.searchParams.set("time_continue", String(await getVideoTime(0))); + location.assign(url); + } } - ; - })(); - document.documentElement.style.setProperty("--bytm-themesong-bg-accent-col", `var(${cssVarName})`); - } - catch (err) { - error("Failed to set ThemeSong integration color lightness:", err); - } - } - - /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */ - const geniUrlRatelimitTimeframe = 30; - //#region media control bar - let currentSongTitle = ""; - /** Adds a lyrics button to the player bar */ - async function addPlayerBarLyricsBtn() { - addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn }); - } - /** Actually adds the lyrics button after the like button renderer has been verified to exist */ - async function addActualLyricsBtn(likeContainer) { - const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); - if (!songTitleElem) - return warn("Couldn't find song title element"); - currentSongTitle = songTitleElem.title; - const spinnerIconUrl = await getResourceUrl("icon-spinner"); - const lyricsIconUrl = await getResourceUrl("icon-lyrics"); - const errorIconUrl = await getResourceUrl("icon-error"); - const onMutation = async (mutations) => { - var _a, e_1, _b, _c; + catch (err) { + error("Couldn't clear queue due to an error:", err); + } + }, + }, + ]; + if (!contBtns.some(b => Boolean(b.condition))) + return; + addSelectorListener("sidePanel", "ytmusic-tab-renderer ytmusic-queue-header-renderer #buttons", { + async listener(rightBtnsEl) { try { - for (var _d = true, mutations_1 = __asyncValues(mutations), mutations_1_1; mutations_1_1 = await mutations_1.next(), _a = mutations_1_1.done, !_a; _d = true) { - _c = mutations_1_1.value; - _d = false; - const mut = _c; - const newTitle = mut.target.title; - if (newTitle !== currentSongTitle && newTitle.length > 0) { - const lyricsBtn = document.querySelector("#bytm-player-bar-lyrics-btn"); - if (!lyricsBtn) - continue; - lyricsBtn.style.cursor = "wait"; - lyricsBtn.style.pointerEvents = "none"; - const imgElem = lyricsBtn.querySelector("img"); - imgElem.src = spinnerIconUrl; - imgElem.classList.add("bytm-spinner"); - currentSongTitle = newTitle; - const url = await getCurrentLyricsUrl(); // can take a second or two - imgElem.src = lyricsIconUrl; - imgElem.classList.remove("bytm-spinner"); - if (!url) { - let artist, song; - if ("mediaSession" in navigator && navigator.mediaSession.metadata) { - artist = navigator.mediaSession.metadata.artist; - song = navigator.mediaSession.metadata.title; - } - const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : ""; - imgElem.src = errorIconUrl; - lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search"); - lyricsBtn.style.cursor = "pointer"; - lyricsBtn.style.pointerEvents = "all"; - lyricsBtn.style.display = "inline-flex"; - lyricsBtn.style.visibility = "visible"; - lyricsBtn.href = `https://genius.com/search${query}`; - continue; - } - lyricsBtn.href = url; - lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics"); - lyricsBtn.style.cursor = "pointer"; - lyricsBtn.style.visibility = "visible"; - lyricsBtn.style.display = "inline-flex"; - lyricsBtn.style.pointerEvents = "initial"; - } + const aboveQueueBtnCont = document.createElement("div"); + aboveQueueBtnCont.id = "bytm-above-queue-btn-cont"; + UserUtils.addParent(rightBtnsEl, aboveQueueBtnCont); + const headerEl = rightBtnsEl.closest("ytmusic-queue-header-renderer"); + if (!headerEl) + return error("Couldn't find queue header element while adding above queue buttons"); + siteEvents.on("fullscreenToggled", (isFullscreen) => { + headerEl.classList[isFullscreen ? "add" : "remove"]("hidden"); + }); + const wrapperElem = document.createElement("div"); + wrapperElem.id = "bytm-above-queue-btn-wrapper"; + for (const item of contBtns) { + if (Boolean(item.condition) === false) + continue; + const btnElem = await createCircularBtn({ + resourceName: item.resourceName, + onClick: item.interaction, + title: t(item.titleKey), + }); + btnElem.id = `bytm-${item.id}-btn`; + btnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-above-queue-btn"); + wrapperElem.appendChild(btnElem); } + rightBtnsEl.insertAdjacentElement("beforebegin", wrapperElem); } - catch (e_1_1) { e_1 = { error: e_1_1 }; } - finally { - try { - if (!_d && !_a && (_b = mutations_1.return)) await _b.call(mutations_1); - } - finally { if (e_1) throw e_1.error; } + catch (err) { + error("Couldn't add above queue buttons due to an error:", err); } - }; - // since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title - const obs = new MutationObserver(onMutation); - obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] }); - const lyricsBtnElem = await createLyricsBtn(undefined); - lyricsBtnElem.id = "bytm-player-bar-lyrics-btn"; - // run parallel so the element is inserted as soon as possible - getCurrentLyricsUrl().then(url => { - url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url); - }); - log("Inserted lyrics button into media controls bar"); - const thumbToggleElem = document.querySelector("#bytm-thumbnail-overlay-toggle"); - if (thumbToggleElem) - thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem); - else - likeContainer.insertAdjacentElement("afterend", lyricsBtnElem); - } - //#region lyrics utils - /** Removes everything in parentheses from the passed song name */ - function sanitizeSong(songName) { - if (typeof songName !== "string") - return songName; - const parensRegex = /\(.+\)/gmi; - const squareParensRegex = /\[.+\]/gmi; - // trim right after the song name: - const sanitized = songName - .replace(parensRegex, "") - .replace(squareParensRegex, ""); - return sanitized.trim(); - } - /** Removes the secondary artist (if it exists) from the passed artists string */ - function sanitizeArtists(artists) { - artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at • [•] character - if (artists.match(/&/)) - artists = artists.split(/\s*&\s*/gm)[0]; - if (artists.match(/,/)) - artists = artists.split(/,\s*/gm)[0]; - if (artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) { - const parensRegex = /\(.+\)/gmi; - const squareParensRegex = /\[.+\]/gmi; - artists = artists - .replace(parensRegex, "") - .replace(squareParensRegex, ""); - } - return artists.trim(); - } - /** Returns the lyrics URL from genius for the currently selected song */ - async function getCurrentLyricsUrl() { - try { - // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title + }, + }); +} +//#region thumb.overlay +/** To be changed when the toggle button is pressed - used to invert the state of "showOverlay" */ +let invertOverlay = false; +async function initThumbnailOverlay() { + const toggleBtnShown = getFeature("thumbnailOverlayToggleBtnShown"); + if (getFeature("thumbnailOverlayBehavior") === "never" && !toggleBtnShown) + return; + // so the script init doesn't keep waiting until a /watch page is loaded + waitVideoElementReady().then(() => { + const playerSelector = "ytmusic-player#player"; + const playerEl = document.querySelector(playerSelector); + if (!playerEl) + return error("Couldn't find video player element while adding thumbnail overlay"); + /** Checks and updates the overlay and toggle button states based on the current song type (yt video or ytm song) */ + const updateOverlayVisibility = async () => { + if (!domLoaded) + return; + const behavior = getFeature("thumbnailOverlayBehavior"); + let showOverlay = behavior === "always"; const isVideo = getCurrentMediaType() === "video"; - const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); - const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child"); - if (!songTitleElem || !songMetaElem) - return undefined; - const songNameRaw = songTitleElem.title; - let songName = songNameRaw; - let artistName = songMetaElem.textContent; - if (isVideo) { - // for some fucking reason some music videos have YTM-like song title and artist separation, some don't - if (songName.includes("-")) { - const split = splitVideoTitle(songName); - songName = split.song; - artistName = split.artist; - } - } - if (!artistName) - return undefined; - const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName)); - if (url) { - emitInterface("bytm:lyricsLoaded", { - type: "current", - artists: artistName, - title: songName, - url, - }); + if (behavior === "videosOnly" && isVideo) + showOverlay = true; + else if (behavior === "songsOnly" && !isVideo) + showOverlay = true; + showOverlay = invertOverlay ? !showOverlay : showOverlay; + const overlayElem = document.querySelector("#bytm-thumbnail-overlay"); + const thumbElem = document.querySelector("#bytm-thumbnail-overlay-img"); + const indicatorElem = document.querySelector("#bytm-thumbnail-overlay-indicator"); + if (overlayElem) + overlayElem.style.display = showOverlay ? "block" : "none"; + if (thumbElem) + thumbElem.ariaHidden = String(!showOverlay); + if (indicatorElem) { + indicatorElem.style.display = showOverlay ? "block" : "none"; + indicatorElem.ariaHidden = String(!showOverlay); + } + if (getFeature("thumbnailOverlayToggleBtnShown")) { + addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", { + async listener(toggleBtnElem) { + var _a; + const toggleBtnIconElem = toggleBtnElem.querySelector("svg"); + if (toggleBtnIconElem) { + setInnerHtml(toggleBtnElem, await resourceAsString(`icon-image${showOverlay ? "_filled" : ""}`)); + (_a = toggleBtnElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img"); + } + if (toggleBtnElem) + toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`); + }, + }); + } + }; + const applyThumbUrl = async (watchId) => { + try { + const thumbUrl = await getBestThumbnailUrl(watchId); + if (thumbUrl) { + const toggleBtnElem = document.querySelector("#bytm-thumbnail-overlay-toggle"); + const thumbImgElem = document.querySelector("#bytm-thumbnail-overlay-img"); + if ((toggleBtnElem === null || toggleBtnElem === void 0 ? void 0 : toggleBtnElem.href) === thumbUrl && (thumbImgElem === null || thumbImgElem === void 0 ? void 0 : thumbImgElem.src) === thumbUrl) + return; + if (toggleBtnElem) + toggleBtnElem.href = thumbUrl; + if (thumbImgElem) + thumbImgElem.src = thumbUrl; + log("Applied thumbnail URL to overlay:", thumbUrl); + } + else + error("Couldn't get thumbnail URL for watch ID", watchId); + } + catch (err) { + error("Couldn't apply thumbnail URL to overlay due to an error:", err); + } + }; + const unsubWatchIdChanged = siteEvents.on("watchIdChanged", (watchId) => { + unsubWatchIdChanged(); + addSelectorListener("body", "#bytm-thumbnail-overlay", { + listener: () => { + applyThumbUrl(watchId); + updateOverlayVisibility(); + }, + }); + }); + const createElements = async () => { + var _a; + try { + // overlay + const overlayElem = document.createElement("div"); + overlayElem.id = "bytm-thumbnail-overlay"; + overlayElem.title = ""; // prevent child titles from propagating + overlayElem.classList.add("bytm-no-select"); + overlayElem.style.display = "none"; + let indicatorElem; + if (getFeature("thumbnailOverlayShowIndicator")) { + indicatorElem = document.createElement("img"); + indicatorElem.id = "bytm-thumbnail-overlay-indicator"; + indicatorElem.src = await getResourceUrl("icon-image"); + indicatorElem.role = "presentation"; + indicatorElem.title = indicatorElem.ariaLabel = t("thumbnail_overlay_indicator_tooltip"); + indicatorElem.ariaHidden = "true"; + indicatorElem.style.display = "none"; + indicatorElem.style.opacity = String(getFeature("thumbnailOverlayIndicatorOpacity") / 100); + } + const thumbImgElem = document.createElement("img"); + thumbImgElem.id = "bytm-thumbnail-overlay-img"; + thumbImgElem.role = "presentation"; + thumbImgElem.ariaHidden = "true"; + thumbImgElem.style.objectFit = getFeature("thumbnailOverlayImageFit"); + overlayElem.appendChild(thumbImgElem); + playerEl.appendChild(overlayElem); + indicatorElem && playerEl.appendChild(indicatorElem); + siteEvents.on("watchIdChanged", async (watchId) => { + invertOverlay = false; + applyThumbUrl(watchId); + updateOverlayVisibility(); + }); + const params = new URL(location.href).searchParams; + if (params.has("v")) { + applyThumbUrl(params.get("v")); + updateOverlayVisibility(); + } + // toggle button + if (toggleBtnShown) { + const toggleBtnElem = createRipple(document.createElement("a")); + toggleBtnElem.id = "bytm-thumbnail-overlay-toggle"; + toggleBtnElem.role = "button"; + toggleBtnElem.tabIndex = 0; + toggleBtnElem.classList.add("ytmusic-player-bar", "bytm-generic-btn", "bytm-no-select"); + onInteraction(toggleBtnElem, (e) => { + if (e.shiftKey) + return openInTab(toggleBtnElem.href, false); + invertOverlay = !invertOverlay; + updateOverlayVisibility(); + }); + setInnerHtml(toggleBtnElem, await resourceAsString("icon-image")); + (_a = toggleBtnElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img"); + addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { + listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem), + }); + } + log("Added thumbnail overlay"); + } + catch (err) { + error("Couldn't create thumbnail overlay elements due to an error:", err); + } + }; + addSelectorListener("mainPanel", playerSelector, { + listener(playerEl) { + if (playerEl.getAttribute("player-ui-state") === "INACTIVE") { + const obs = new MutationObserver(() => { + if (playerEl.getAttribute("player-ui-state") === "INACTIVE") + return; + createElements(); + obs.disconnect(); + }); + obs.observe(playerEl, { + attributes: true, + attributeFilter: ["player-ui-state"], + }); + } + else + createElements(); + }, + }); + }); +} +//#region idle hide cursor +async function initHideCursorOnIdle() { + addSelectorListener("mainPanel", "ytmusic-player#player", { + listener(vidContainer) { + const overlaySelector = "ytmusic-player #song-media-window"; + const overlayElem = document.querySelector(overlaySelector); + if (!overlayElem) + return warn("Couldn't find overlay element while initializing cursor hiding"); + /** Timer after which the cursor is hidden */ + let cursorHideTimer; + /** Timer for the opacity transition while switching to the hidden state */ + let hideTransTimer; + const hide = () => { + if (!getFeature("hideCursorOnIdle")) + return; + if (vidContainer.classList.contains("bytm-cursor-hidden")) + return; + overlayElem.style.opacity = ".000001 !important"; + hideTransTimer = setTimeout(() => { + overlayElem.style.display = "none"; + vidContainer.style.cursor = "none"; + vidContainer.classList.add("bytm-cursor-hidden"); + hideTransTimer = undefined; + }, 200); + }; + const show = () => { + hideTransTimer && clearTimeout(hideTransTimer); + if (!vidContainer.classList.contains("bytm-cursor-hidden")) + return; + vidContainer.classList.remove("bytm-cursor-hidden"); + vidContainer.style.cursor = "initial"; + overlayElem.style.display = "initial"; + overlayElem.style.opacity = "1 !important"; + }; + const cursorHideTimerCb = () => cursorHideTimer = setTimeout(hide, getFeature("hideCursorOnIdleDelay") * 1000); + const onMove = () => { + cursorHideTimer && clearTimeout(cursorHideTimer); + show(); + cursorHideTimerCb(); + }; + vidContainer.addEventListener("mouseenter", onMove); + vidContainer.addEventListener("mousemove", UserUtils.debounce(onMove, 200)); + vidContainer.addEventListener("mouseleave", () => { + cursorHideTimer && clearTimeout(cursorHideTimer); + hideTransTimer && clearTimeout(hideTransTimer); + hide(); + }); + vidContainer.addEventListener("click", () => { + show(); + cursorHideTimerCb(); + setTimeout(hide, 3000); + }); + log("Initialized cursor hiding on idle"); + }, + }); +} +//#region fix HDR +/** Prevents visual issues when using HDR */ +async function fixHdrIssues() { + if (!await addStyleFromResource("css-fix_hdr")) + error("Couldn't load stylesheet to fix HDR issues"); + else + log("Fixed HDR issues"); +} +//#region show vote nums +/** Shows the amount of likes and dislikes on the current song */ +async function initShowVotes() { + addSelectorListener("playerBar", ".middle-controls-buttons ytmusic-like-button-renderer", { + async listener(voteCont) { + try { + const watchId = getWatchId(); + if (!watchId) { + await siteEvents.once("watchIdChanged"); + return initShowVotes(); + } + const voteObj = await fetchVideoVotes(watchId); + if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj)) + return error("Couldn't fetch votes from the Return YouTube Dislike API"); + if (getFeature("showVotes")) { + addVoteNumbers(voteCont, voteObj); + siteEvents.on("watchIdChanged", async (watchId) => { + var _a, _b; + const labelLikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.likes"); + const labelDislikes = document.querySelector("ytmusic-like-button-renderer .bytm-vote-label.dislikes"); + if (!labelLikes || !labelDislikes) + return error("Couldn't find vote label elements while updating like and dislike counts"); + if (labelLikes.dataset.watchId === watchId && labelDislikes.dataset.watchId === watchId) + return log("Vote labels already updated for this video"); + const voteObj = await fetchVideoVotes(watchId); + if (!voteObj || !("likes" in voteObj) || !("dislikes" in voteObj) || !("rating" in voteObj)) + return error("Couldn't fetch votes from the Return YouTube Dislike API"); + const likesLabelText = tp("vote_label_likes", voteObj.likes, formatNumber(voteObj.likes, "long")); + const dislikesLabelText = tp("vote_label_dislikes", voteObj.dislikes, formatNumber(voteObj.dislikes, "long")); + labelLikes.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : ""; + labelLikes.textContent = formatNumber(voteObj.likes); + labelLikes.title = labelLikes.ariaLabel = likesLabelText; + labelDislikes.textContent = formatNumber(voteObj.dislikes); + labelDislikes.title = labelDislikes.ariaLabel = dislikesLabelText; + labelDislikes.dataset.watchId = (_b = getWatchId()) !== null && _b !== void 0 ? _b : ""; + addSelectorListener("playerBar", "ytmusic-like-button-renderer#like-button-renderer", { + listener: (bar) => upsertVoteBtnLabels(bar, likesLabelText, dislikesLabelText), + }); + }); + } + } + catch (err) { + error("Couldn't initialize show votes feature due to an error:", err); } - return url; - } - catch (err) { - getFeature("errorOnLyricsNotFound") && error("Couldn't resolve lyrics URL:", err); - return undefined; } + }); +} +function addVoteNumbers(voteCont, voteObj) { + const likeBtn = voteCont.querySelector("#button-shape-like"); + const dislikeBtn = voteCont.querySelector("#button-shape-dislike"); + if (!likeBtn || !dislikeBtn) + return error("Couldn't find like or dislike button while adding vote numbers"); + const createLabel = (amount, type) => { + var _a; + const label = document.createElement("span"); + label.classList.add("bytm-vote-label", "bytm-no-select", type); + label.textContent = String(formatNumber(amount)); + label.title = label.ariaLabel = tp(`vote_label_${type}`, amount, formatNumber(amount, "long")); + label.dataset.watchId = (_a = getWatchId()) !== null && _a !== void 0 ? _a : ""; + label.addEventListener("click", (e) => { + var _a; + e.preventDefault(); + e.stopPropagation(); + (_a = (type === "likes" ? likeBtn : dislikeBtn).querySelector("button")) === null || _a === void 0 ? void 0 : _a.click(); + }); + return label; + }; + addStyleFromResource("css-show_votes") + .catch((e) => error("Couldn't add CSS for show votes feature due to an error:", e)); + const likeLblEl = createLabel(voteObj.likes, "likes"); + likeBtn.insertAdjacentElement("afterend", likeLblEl); + const dislikeLblEl = createLabel(voteObj.dislikes, "dislikes"); + dislikeBtn.insertAdjacentElement("afterend", dislikeLblEl); + upsertVoteBtnLabels(voteCont, likeLblEl.title, dislikeLblEl.title); + log("Added vote number labels to like and dislike buttons"); +} +/** Updates or inserts the labels on the native like and dislike buttons */ +function upsertVoteBtnLabels(parentEl, likesLabelText, dislikesLabelText) { + const likeBtn = parentEl.querySelector("#button-shape-like button"); + const dislikeBtn = parentEl.querySelector("#button-shape-dislike button"); + if (likeBtn) + likeBtn.title = likeBtn.ariaLabel = likesLabelText; + if (dislikeBtn) + dislikeBtn.title = dislikeBtn.ariaLabel = dislikesLabelText; +}//#region Dark Reader +/** Disables Dark Reader if it is present */ +async function disableDarkReader() { + if (getFeature("disableDarkReaderSites") !== getDomain() && getFeature("disableDarkReaderSites") !== "all") + return; + const metaElem = document.createElement("meta"); + metaElem.name = "darkreader-lock"; + metaElem.id = "bytm-disable-dark-reader"; + document.head.appendChild(metaElem); + info("Disabled Dark Reader"); +} +//#region SponsorBlock +/** Fixes the z-index of the SponsorBlock panel */ +async function fixSponsorBlock() { + try { + return addStyleFromResource("css-fix_sponsorblock"); } - /** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */ - async function fetchLyricsUrlTop(artist, song) { - var _a, _b; - try { - return (_b = (_a = (await fetchLyricsUrls(artist, song))) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.url; - } - catch (err) { - getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err); - return undefined; - } + catch (err) { + error("Failed to fix SponsorBlock styling:", err); } - /** - * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search - * **the passed parameters need to be sanitized first!** - */ - async function fetchLyricsUrls(artist, song) { - var _a, _b, _c; - try { - const cacheEntry = getLyricsCacheEntry(artist, song); - if (cacheEntry) { - info(`Found lyrics URL in cache: ${cacheEntry.url}`); - return [cacheEntry]; - } - const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, { - disableFuzzy: null, - utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`, - q: `${artist} ${song}`, - }); - log("Requesting lyrics from geniURL:", fetchUrl); - const token = getFeature("geniUrlToken"); - const fetchRes = await UserUtils.fetchAdvanced(fetchUrl, Object.assign({}, (token ? { - headers: { - Authorization: `Bearer ${token}`, - }, - } : {}))); - if (fetchRes.status === 429) { - const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe); - await showPrompt({ type: "alert", message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) }); - return undefined; +} +//#region ThemeSong +/** Adjust the BetterYTM styles if ThemeSong is ***not*** used */ +async function fixPlayerPageTheming() { + try { + return addStyleFromResource("css-fix_playerpage_theming"); + } + catch (err) { + error("Failed to fix BetterYTM player page theming:", err); + } +} +/** Sets the lightness of the theme color used by BYTM according to the configured lightness value */ +async function fixThemeSong() { + try { + const cssVarName = (() => { + switch (getFeature("themeSongLightness")) { + default: + case "darker": + return "--ts-palette-darkmuted-hex"; + case "normal": + return "--ts-palette-muted-hex"; + case "lighter": + return "--ts-palette-lightmuted-hex"; } - else if (fetchRes.status < 200 || fetchRes.status >= 300) { - getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (await fetchRes.json()).message) !== null && _b !== void 0 ? _b : await fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`)); - return undefined; + ; + })(); + document.documentElement.style.setProperty("--bytm-themesong-bg-accent-col", `var(${cssVarName})`); + } + catch (err) { + error("Failed to set ThemeSong integration color lightness:", err); + } +}/** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */ +const geniUrlRatelimitTimeframe = 30; +//#region media control bar +let currentSongTitle = ""; +/** Adds a lyrics button to the player bar */ +async function addPlayerBarLyricsBtn() { + addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn }); +} +/** Actually adds the lyrics button after the like button renderer has been verified to exist */ +async function addActualLyricsBtn(likeContainer) { + const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); + if (!songTitleElem) + return warn("Couldn't find song title element"); + currentSongTitle = songTitleElem.title; + const onMutation = async (mutations) => { + var _a, e_1, _b, _c; + var _d, _e, _f; + try { + for (var _g = true, mutations_1 = __asyncValues(mutations), mutations_1_1; mutations_1_1 = await mutations_1.next(), _a = mutations_1_1.done, !_a; _g = true) { + _c = mutations_1_1.value; + _g = false; + const mut = _c; + const newTitle = mut.target.title; + if (newTitle !== currentSongTitle && newTitle.length > 0) { + const lyricsBtn = document.querySelector("#bytm-player-bar-lyrics-btn"); + if (!lyricsBtn) + continue; + lyricsBtn.style.cursor = "wait"; + lyricsBtn.style.pointerEvents = "none"; + setInnerHtml(lyricsBtn, await resourceAsString("icon-spinner")); + (_d = lyricsBtn.querySelector("svg")) === null || _d === void 0 ? void 0 : _d.classList.add("bytm-generic-btn-img", "bytm-spinner"); + currentSongTitle = newTitle; + const url = await getCurrentLyricsUrl(); // can take a second or two + setInnerHtml(lyricsBtn, await resourceAsString("icon-lyrics")); + (_e = lyricsBtn.querySelector("svg")) === null || _e === void 0 ? void 0 : _e.classList.add("bytm-generic-btn-img"); + if (!url) { + let artist, song; + if ("mediaSession" in navigator && navigator.mediaSession.metadata) { + artist = navigator.mediaSession.metadata.artist; + song = navigator.mediaSession.metadata.title; + } + const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : ""; + setInnerHtml(lyricsBtn, await resourceAsString("icon-error")); + (_f = lyricsBtn.querySelector("svg")) === null || _f === void 0 ? void 0 : _f.classList.add("bytm-generic-btn-img"); + lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search"); + lyricsBtn.style.cursor = "pointer"; + lyricsBtn.style.pointerEvents = "all"; + lyricsBtn.style.display = "inline-flex"; + lyricsBtn.style.visibility = "visible"; + lyricsBtn.href = `https://genius.com/search${query}`; + continue; + } + lyricsBtn.href = url; + lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics"); + lyricsBtn.style.cursor = "pointer"; + lyricsBtn.style.visibility = "visible"; + lyricsBtn.style.display = "inline-flex"; + lyricsBtn.style.pointerEvents = "initial"; + } } - const result = await fetchRes.json(); - if (typeof result === "object" && result.error || !result || !result.all) { - getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL: ${result.message}`)); - return undefined; + } + catch (e_1_1) { e_1 = { error: e_1_1 }; } + finally { + try { + if (!_g && !_a && (_b = mutations_1.return)) await _b.call(mutations_1); } - const allResults = result.all; - if (allResults.length === 0) { - warn("No lyrics URL found for the provided song"); - return undefined; + finally { if (e_1) throw e_1.error; } + } + }; + // since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title + const obs = new MutationObserver(onMutation); + obs.observe(songTitleElem, { attributes: true, attributeFilter: ["title"] }); + const lyricsBtnElem = await createLyricsBtn(undefined); + lyricsBtnElem.id = "bytm-player-bar-lyrics-btn"; + // run parallel so the element is inserted as soon as possible + getCurrentLyricsUrl().then(url => { + url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url); + }); + log("Inserted lyrics button into media controls bar"); + const thumbToggleElem = document.querySelector("#bytm-thumbnail-overlay-toggle"); + if (thumbToggleElem) + thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem); + else + likeContainer.insertAdjacentElement("afterend", lyricsBtnElem); +} +//#region lyrics utils +/** Removes everything in parentheses from the passed song name */ +function sanitizeSong(songName) { + if (typeof songName !== "string") + return songName; + const parensRegex = /\(.+\)/gmi; + const squareParensRegex = /\[.+\]/gmi; + // trim right after the song name: + const sanitized = songName + .replace(parensRegex, "") + .replace(squareParensRegex, ""); + return sanitized.trim(); +} +/** Removes the secondary artist (if it exists) from the passed artists string */ +function sanitizeArtists(artists) { + artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at • [•] character + if (artists.match(/&/)) + artists = artists.split(/\s*&\s*/gm)[0]; + if (artists.match(/,/)) + artists = artists.split(/,\s*/gm)[0]; + if (artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) { + const parensRegex = /\(.+\)/gmi; + const squareParensRegex = /\[.+\]/gmi; + artists = artists + .replace(parensRegex, "") + .replace(squareParensRegex, ""); + } + return artists.trim(); +} +/** Returns the lyrics URL from genius for the currently selected song */ +async function getCurrentLyricsUrl() { + try { + // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title + const isVideo = getCurrentMediaType() === "video"; + const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string"); + const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string :first-child"); + if (!songTitleElem || !songMetaElem) + return undefined; + const songNameRaw = songTitleElem.title; + let songName = songNameRaw; + let artistName = songMetaElem.textContent; + if (isVideo) { + // for some fucking reason some music videos have YTM-like song title and artist separation, some don't + if (songName.includes("-")) { + const split = splitVideoTitle(songName); + songName = split.song; + artistName = split.artist; } - const allResultsSan = allResults - .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url) - .map(({ meta, url }) => { - var _a; - return ({ - meta: Object.assign(Object.assign({}, meta), { title: sanitizeSong(String((_a = meta.title) !== null && _a !== void 0 ? _a : meta.fullTitle)), artists: sanitizeArtists(String(meta.artists)) }), - url, - }); - }); - const topRes = allResultsSan[0]; - topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url); - return allResultsSan.map(r => ({ - artist: r.meta.primaryArtist.name, - song: r.meta.title, - url: r.url, - })); } - catch (err) { - getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err); + if (!artistName) return undefined; + const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName)); + if (url) { + emitInterface("bytm:lyricsLoaded", { + type: "current", + artists: artistName, + title: songName, + url, + }); } + return url; } - /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */ - async function addGeniusUrlToLyricsBtn(btnElem, geniusUrl) { - btnElem.href = geniusUrl; - btnElem.ariaLabel = btnElem.title = t("open_lyrics"); - btnElem.style.visibility = "visible"; - btnElem.style.display = "inline-flex"; - } - /** Creates the base lyrics button element */ - async function createLyricsBtn(geniusUrl, hideIfLoading = true) { - const linkElem = document.createElement("a"); - linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn"); - linkElem.ariaLabel = linkElem.title = t("lyrics_loading"); - linkElem.role = "button"; - linkElem.target = "_blank"; - linkElem.rel = "noopener noreferrer"; - linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden"; - linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none"; - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-generic-btn-img"); - imgElem.src = await getResourceUrl("icon-lyrics"); - onInteraction(linkElem, (e) => { - var _a; - const url = (_a = linkElem.href) !== null && _a !== void 0 ? _a : geniusUrl; - if (!url || e instanceof MouseEvent) - return; - if (!e.ctrlKey && !e.altKey) - openInTab(url); - }, { - preventDefault: false, - stopPropagation: false, + catch (err) { + getFeature("errorOnLyricsNotFound") && error("Couldn't resolve lyrics URL:", err); + return undefined; + } +} +/** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */ +async function fetchLyricsUrlTop(artist, song) { + var _a, _b; + try { + return (_b = (_a = (await fetchLyricsUrls(artist, song))) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.url; + } + catch (err) { + getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err); + return undefined; + } +} +/** + * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search + * **the passed parameters need to be sanitized first!** + */ +async function fetchLyricsUrls(artist, song) { + var _a, _b, _c; + try { + const cacheEntry = getLyricsCacheEntry(artist, song); + if (cacheEntry) { + info(`Found lyrics URL in cache: ${cacheEntry.url}`); + return [cacheEntry]; + } + const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, { + disableFuzzy: null, + utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`, + q: `${artist} ${song}`, }); - linkElem.appendChild(imgElem); - onInteraction(linkElem, async (e) => { - if (e.ctrlKey || e.altKey) { - e.preventDefault(); - e.stopImmediatePropagation(); - const search = await showPrompt({ type: "prompt", message: t("open_lyrics_search_prompt") }); - if (search && search.length > 0) - openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`); - } - }, { - preventDefault: false, - stopPropagation: false, + log("Requesting lyrics from geniURL:", fetchUrl); + const token = getFeature("geniUrlToken"); + const fetchRes = await UserUtils.fetchAdvanced(fetchUrl, Object.assign({}, (token ? { + headers: { + Authorization: `Bearer ${token}`, + }, + } : {}))); + if (fetchRes.status === 429) { + const waitSeconds = Number((_a = fetchRes.headers.get("retry-after")) !== null && _a !== void 0 ? _a : geniUrlRatelimitTimeframe); + await showPrompt({ type: "alert", message: tp("lyrics_rate_limited", waitSeconds, waitSeconds) }); + return undefined; + } + else if (fetchRes.status < 200 || fetchRes.status >= 300) { + getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(_c = (_b = (await fetchRes.json()).message) !== null && _b !== void 0 ? _b : await fetchRes.text()) !== null && _c !== void 0 ? _c : "(none)"}`)); + return undefined; + } + const result = await fetchRes.json(); + if (typeof result === "object" && result.error || !result || !result.all) { + getFeature("errorOnLyricsNotFound") && error(new LyricsError(`Couldn't fetch lyrics URLs from geniURL: ${result.message}`)); + return undefined; + } + const allResults = result.all; + if (allResults.length === 0) { + warn("No lyrics URL found for the provided song"); + return undefined; + } + const allResultsSan = allResults + .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url) + .map(({ meta, url }) => { + var _a; + return ({ + meta: Object.assign(Object.assign({}, meta), { title: sanitizeSong(String((_a = meta.title) !== null && _a !== void 0 ? _a : meta.fullTitle)), artists: sanitizeArtists(String(meta.artists)) }), + url, + }); }); - return linkElem; + const topRes = allResultsSan[0]; + topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url); + return allResultsSan.map(r => ({ + artist: r.meta.primaryArtist.name, + song: r.meta.title, + url: r.url, + })); } - /** Splits a video title that contains a hyphen into an artist and song */ - function splitVideoTitle(title) { - const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v); - return { artist, song: rest.join("-") }; + catch (err) { + getFeature("errorOnLyricsNotFound") && error("Couldn't get lyrics URL due to error:", err); + return undefined; } - - //#region init queue btns - /** Initializes the queue buttons */ - async function initQueueButtons() { - const addCurrentQueueBtns = (evt) => { - let amt = 0; - for (const queueItm of evt.childNodes) { - if (!queueItm.classList.contains("bytm-has-queue-btns")) { - addQueueButtons(queueItm, undefined, "currentQueue"); - amt++; - } +} +/** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */ +async function addGeniusUrlToLyricsBtn(btnElem, geniusUrl) { + btnElem.href = geniusUrl; + btnElem.ariaLabel = btnElem.title = t("open_lyrics"); + btnElem.style.visibility = "visible"; + btnElem.style.display = "inline-flex"; +} +/** Creates the base lyrics button element */ +async function createLyricsBtn(geniusUrl, hideIfLoading = true) { + var _a; + const linkElem = document.createElement("a"); + linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn"); + linkElem.ariaLabel = linkElem.title = t("lyrics_loading"); + linkElem.role = "button"; + linkElem.target = "_blank"; + linkElem.rel = "noopener noreferrer"; + linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden"; + linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none"; + onInteraction(linkElem, (e) => { + var _a; + const url = (_a = linkElem.href) !== null && _a !== void 0 ? _a : geniusUrl; + if (!url || e instanceof MouseEvent) + return; + if (!e.ctrlKey && !e.altKey) + openInTab(url); + }, { + preventDefault: false, + stopPropagation: false, + }); + setInnerHtml(linkElem, await resourceAsString("icon-lyrics")); + (_a = linkElem.querySelector("svg")) === null || _a === void 0 ? void 0 : _a.classList.add("bytm-generic-btn-img"); + onInteraction(linkElem, async (e) => { + if (e.ctrlKey || e.altKey) { + e.preventDefault(); + e.stopImmediatePropagation(); + const search = await showPrompt({ type: "prompt", message: t("open_lyrics_search_prompt") }); + if (search && search.length > 0) + openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`); + } + }, { + preventDefault: false, + stopPropagation: false, + }); + return linkElem; +} +/** Splits a video title that contains a hyphen into an artist and song */ +function splitVideoTitle(title) { + const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v); + return { artist, song: rest.join("-") }; +}//#region init queue btns +/** Initializes the queue buttons */ +async function initQueueButtons() { + const addCurrentQueueBtns = (evt) => { + let amt = 0; + for (const queueItm of evt.childNodes) { + if (!queueItm.classList.contains("bytm-has-queue-btns")) { + addQueueButtons(queueItm, undefined, "currentQueue"); + amt++; } - if (amt > 0) - log(`Added buttons to ${amt} new queue ${UserUtils.autoPlural("item", amt)}`); - }; - // current queue - siteEvents.on("queueChanged", addCurrentQueueBtns); - siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns); - const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item"); - if (queueItems.length > 0) { - queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue")); - log(`Added buttons to ${queueItems.length} existing "current song queue" ${UserUtils.autoPlural("item", queueItems)}`); } - // generic lists - const addGenericListQueueBtns = (listElem) => { - const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer"); - if (queueItems.length === 0) + if (amt > 0) + log(`Added buttons to ${amt} new queue ${UserUtils.autoPlural("item", amt)}`); + }; + // current queue + siteEvents.on("queueChanged", addCurrentQueueBtns); + siteEvents.on("autoplayQueueChanged", addCurrentQueueBtns); + const queueItems = document.querySelectorAll("#contents.ytmusic-player-queue > ytmusic-player-queue-item"); + if (queueItems.length > 0) { + queueItems.forEach(itm => addQueueButtons(itm, undefined, "currentQueue")); + log(`Added buttons to ${queueItems.length} existing "current song queue" ${UserUtils.autoPlural("item", queueItems)}`); + } + // generic lists + const addGenericListQueueBtns = (listElem) => { + const queueItems = listElem.querySelectorAll("ytmusic-responsive-list-item-renderer"); + if (queueItems.length === 0) + return; + let addedBtnsCount = 0; + queueItems.forEach(itm => { + if (itm.classList.contains("bytm-has-btns")) return; - let addedBtnsCount = 0; - queueItems.forEach(itm => { - if (itm.classList.contains("bytm-has-btns")) - return; - itm.classList.add("bytm-has-btns"); - addQueueButtons(itm, ".flex-columns", "genericList", ["bytm-generic-list-queue-btn-container"], "afterParent"); - addedBtnsCount++; - }); - addedBtnsCount > 0 && - log(`Added buttons to ${addedBtnsCount} new "generic song list" ${UserUtils.autoPlural("item", addedBtnsCount)} in list`, listElem); - }; - const listSelector = `\ + itm.classList.add("bytm-has-btns"); + addQueueButtons(itm, ".flex-columns", "genericList", ["bytm-generic-list-queue-btn-container"], "afterParent"); + addedBtnsCount++; + }); + addedBtnsCount > 0 && + log(`Added buttons to ${addedBtnsCount} new "generic song list" ${UserUtils.autoPlural("item", addedBtnsCount)} in list`, listElem); + }; + const listSelector = `\ ytmusic-playlist-shelf-renderer #contents, ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ALBUM"] ytmusic-shelf-renderer #contents, ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_ARTIST"] ytmusic-shelf-renderer #contents, ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic-shelf-renderer #contents\ `; - if (getFeature("listButtonsPlacement") === "everywhere") { - const checkAddGenericBtns = (songLists) => { - for (const list of songLists) - addGenericListQueueBtns(list); - }; - addSelectorListener("body", listSelector, { - all: true, - continuous: true, - debounce: 150, - // TODO: switch to longer debounce time and edge type "risingIdle" after UserUtils update - debounceEdge: "falling", - listener: checkAddGenericBtns, - }); - siteEvents.on("pathChanged", () => { - const songLists = document.querySelectorAll(listSelector); - if (songLists.length > 0) - checkAddGenericBtns(songLists); - }); - } + if (getFeature("listButtonsPlacement") === "everywhere") { + const checkAddGenericBtns = (songLists) => { + for (const list of songLists) + addGenericListQueueBtns(list); + }; + addSelectorListener("body", listSelector, { + all: true, + continuous: true, + debounce: 150, + listener: checkAddGenericBtns, + }); + siteEvents.on("pathChanged", () => { + const songLists = document.querySelectorAll(listSelector); + if (songLists.length > 0) + checkAddGenericBtns(songLists); + }); } - //#region add queue btns - /** - * Adds the buttons to each item in the current song queue. - * Also observes for changes to add new buttons to new items in the queue. - * @param queueItem The element with tagname `ytmusic-player-queue-item` or `ytmusic-responsive-list-item-renderer` to add queue buttons to - * @param listType The type of list the queue item is in - * @param classes Extra CSS classes to apply to the container - * @param insertPosition Where to insert the button container in relation to the parent element - */ - async function addQueueButtons(queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = [], insertPosition = "child") { - const queueBtnsCont = document.createElement("div"); - queueBtnsCont.classList.add(...["bytm-queue-btn-container", ...classes]); - const lyricsIconUrl = await getResourceUrl("icon-lyrics"); - const deleteIconUrl = await getResourceUrl("icon-delete"); - //#region lyrics btn - let lyricsBtnElem; - if (getFeature("lyricsQueueButton")) { - lyricsBtnElem = await createLyricsBtn(undefined, false); - lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics"); - lyricsBtnElem.style.display = "inline-flex"; - lyricsBtnElem.style.visibility = "initial"; - lyricsBtnElem.style.pointerEvents = "initial"; - lyricsBtnElem.role = "link"; - lyricsBtnElem.tabIndex = 0; - onInteraction(lyricsBtnElem, async (e) => { - var _a; - e.preventDefault(); - e.stopImmediatePropagation(); - let song, artist; - if (listType === "currentQueue") { - const songInfo = queueItem.querySelector(".song-info"); - if (!songInfo) - return; - const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string"); - song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent; - artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent; - } - else if (listType === "genericList") { - const songEl = queueItem.querySelector(".title-column yt-formatted-string a"); - let artistEl = null; - if (location.pathname.startsWith("/playlist")) - artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a"); - if (!artistEl || !artistEl.textContent) - artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a"); - song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent; +} +//#region add queue btns +/** + * Adds the buttons to each item in the current song queue. + * Also observes for changes to add new buttons to new items in the queue. + * @param queueItem The element with tagname `ytmusic-player-queue-item` or `ytmusic-responsive-list-item-renderer` to add queue buttons to + * @param listType The type of list the queue item is in + * @param classes Extra CSS classes to apply to the container + * @param insertPosition Where to insert the button container in relation to the parent element + */ +async function addQueueButtons(queueItem, containerParentSelector = ".song-info", listType = "currentQueue", classes = [], insertPosition = "child") { + const queueBtnsCont = document.createElement("div"); + queueBtnsCont.classList.add(...["bytm-queue-btn-container", ...classes]); + const lyricsIconUrl = await getResourceUrl("icon-lyrics"); + const deleteIconUrl = await getResourceUrl("icon-delete"); + //#region lyrics btn + let lyricsBtnElem; + if (getFeature("lyricsQueueButton")) { + lyricsBtnElem = await createLyricsBtn(undefined, false); + lyricsBtnElem.ariaLabel = lyricsBtnElem.title = t("open_lyrics"); + lyricsBtnElem.style.display = "inline-flex"; + lyricsBtnElem.style.visibility = "initial"; + lyricsBtnElem.style.pointerEvents = "initial"; + lyricsBtnElem.role = "link"; + lyricsBtnElem.tabIndex = 0; + onInteraction(lyricsBtnElem, async (e) => { + var _a; + e.preventDefault(); + e.stopImmediatePropagation(); + let song, artist; + if (listType === "currentQueue") { + const songInfo = queueItem.querySelector(".song-info"); + if (!songInfo) + return; + const [songEl, artistEl] = songInfo.querySelectorAll("yt-formatted-string"); + song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent; + artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent; + } + else if (listType === "genericList") { + const songEl = queueItem.querySelector(".title-column yt-formatted-string a"); + let artistEl = null; + if (location.pathname.startsWith("/playlist")) + artistEl = document.querySelector("ytmusic-detail-header-renderer .metadata .subtitle-container yt-formatted-string a"); + if (!artistEl || !artistEl.textContent) + artistEl = queueItem.querySelector(".secondary-flex-columns yt-formatted-string:first-child a"); + song = songEl === null || songEl === void 0 ? void 0 : songEl.textContent; + artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent; + if (!artist) { + // new playlist design + artistEl = document.querySelector("ytmusic-responsive-header-renderer .strapline a.yt-formatted-string[href]"); artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent; - if (!artist) { - // new playlist design - artistEl = document.querySelector("ytmusic-responsive-header-renderer .strapline a.yt-formatted-string[href]"); - artist = artistEl === null || artistEl === void 0 ? void 0 : artistEl.textContent; - } } - else + } + else + return; + if (!song || !artist) + return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist); + let lyricsUrl; + const artistsSan = sanitizeArtists(artist); + const songSan = sanitizeSong(song); + const splitTitle = splitVideoTitle(songSan); + const cachedLyricsEntry = songSan.includes("-") + ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song) + : getLyricsCacheEntry(artistsSan, songSan); + if (cachedLyricsEntry) + lyricsUrl = cachedLyricsEntry.url; + else if (!queueItem.hasAttribute("data-bytm-loading")) { + const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img"); + if (!imgEl) return; - if (!song || !artist) - return error("Couldn't get song or artist name from queue item - song:", song, "- artist:", artist); - let lyricsUrl; - const artistsSan = sanitizeArtists(artist); - const songSan = sanitizeSong(song); - const splitTitle = splitVideoTitle(songSan); - const cachedLyricsEntry = songSan.includes("-") - ? getLyricsCacheEntry(splitTitle.artist, splitTitle.song) - : getLyricsCacheEntry(artistsSan, songSan); - if (cachedLyricsEntry) - lyricsUrl = cachedLyricsEntry.url; - else if (!queueItem.hasAttribute("data-bytm-loading")) { - const imgEl = lyricsBtnElem === null || lyricsBtnElem === void 0 ? void 0 : lyricsBtnElem.querySelector("img"); - if (!imgEl) - return; - if (!cachedLyricsEntry) { - queueItem.setAttribute("data-bytm-loading", ""); - imgEl.src = await getResourceUrl("icon-spinner"); - imgEl.classList.add("bytm-spinner"); - } - lyricsUrl = (_a = cachedLyricsEntry === null || cachedLyricsEntry === void 0 ? void 0 : cachedLyricsEntry.url) !== null && _a !== void 0 ? _a : await fetchLyricsUrlTop(artistsSan, songSan); - if (lyricsUrl) { - emitInterface("bytm:lyricsLoaded", { - type: "queue", - artists: artist, - title: song, - url: lyricsUrl, - }); - } - const resetImgElem = () => { - imgEl.src = lyricsIconUrl; - imgEl.classList.remove("bytm-spinner"); - }; - if (!cachedLyricsEntry) { - queueItem.removeAttribute("data-bytm-loading"); - // so the new image doesn't "blink" - setTimeout(resetImgElem, 100); - } - if (!lyricsUrl) { - resetImgElem(); - if (await showPrompt({ type: "confirm", message: t("lyrics_not_found_confirm_open_search") })) - openInTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`); - return; - } + if (!cachedLyricsEntry) { + queueItem.setAttribute("data-bytm-loading", ""); + imgEl.src = await getResourceUrl("icon-spinner"); + imgEl.classList.add("bytm-spinner"); } - lyricsUrl && openInTab(lyricsUrl); - }); - } - //#region delete btn - let deleteBtnElem; - if (getFeature("deleteFromQueueButton")) { - deleteBtnElem = document.createElement("a"); - deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list")); - deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn"); - deleteBtnElem.role = "button"; - deleteBtnElem.tabIndex = 0; - deleteBtnElem.style.visibility = "initial"; - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-generic-btn-img"); - imgElem.src = deleteIconUrl; - onInteraction(deleteBtnElem, async (e) => { - e.preventDefault(); - e.stopImmediatePropagation(); - // container of the queue item popup menu - element gets reused for every queue item - let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown"); - try { - // three dots button to open the popup menu of a queue item - const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button"); - if (dotsBtnElem) { - if (queuePopupCont) - queuePopupCont.setAttribute("data-bytm-hidden", "true"); - dotsBtnElem.click(); - } - else { - info("Couldn't find three dots button in queue item, trying to open the context menu manually"); - queueItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: false })); - } - queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown"); - queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true"); - await UserUtils.pauseFor(15); - const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)"); - removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click(); - // queue items aren't removed automatically outside of the current queue - if (removeFromQueueBtn && listType === "genericList") { - await UserUtils.pauseFor(200); - clearInner(queueItem); - queueItem.remove(); - } - if (!removeFromQueueBtn) { - error("Couldn't find 'remove from queue' button in queue item three dots menu.\nPlease make sure all autoplay restrictions on your browser's side are disabled for this page."); - dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click(); - imgElem.src = await getResourceUrl("icon-error"); - if (deleteBtnElem) - deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list")); - } + lyricsUrl = (_a = cachedLyricsEntry === null || cachedLyricsEntry === void 0 ? void 0 : cachedLyricsEntry.url) !== null && _a !== void 0 ? _a : await fetchLyricsUrlTop(artistsSan, songSan); + if (lyricsUrl) { + emitInterface("bytm:lyricsLoaded", { + type: "queue", + artists: artist, + title: song, + url: lyricsUrl, + }); } - catch (err) { - error("Couldn't remove song from queue due to error:", err); + const resetImgElem = () => { + imgEl.src = lyricsIconUrl; + imgEl.classList.remove("bytm-spinner"); + }; + if (!cachedLyricsEntry) { + queueItem.removeAttribute("data-bytm-loading"); + // so the new image doesn't "blink" + setTimeout(resetImgElem, 100); } - finally { - queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden"); + if (!lyricsUrl) { + resetImgElem(); + if (await showPrompt({ type: "confirm", message: t("lyrics_not_found_confirm_open_search") })) + openInTab(`https://genius.com/search?q=${encodeURIComponent(`${artistsSan} - ${songSan}`)}`); + return; } - }); - deleteBtnElem.appendChild(imgElem); - } - lyricsBtnElem && queueBtnsCont.appendChild(createRipple(lyricsBtnElem)); - deleteBtnElem && queueBtnsCont.appendChild(createRipple(deleteBtnElem)); - const parentEl = queueItem.querySelector(containerParentSelector); - if (insertPosition === "child") - parentEl === null || parentEl === void 0 ? void 0 : parentEl.appendChild(queueBtnsCont); - else if (insertPosition === "beforeParent") - parentEl === null || parentEl === void 0 ? void 0 : parentEl.before(queueBtnsCont); - else if (insertPosition === "afterParent") - parentEl === null || parentEl === void 0 ? void 0 : parentEl.after(queueBtnsCont); - queueItem.classList.add("bytm-has-queue-btns"); - } - - //#region init vol features - /** Initializes all volume-related features */ - async function initVolumeFeatures() { - let listenerOnce = false; - // sliderElem is not technically an input element but behaves pretty much the same - const listener = async (type, sliderElem) => { - const volSliderCont = document.createElement("div"); - volSliderCont.classList.add("bytm-vol-slider-cont"); - if (getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default) - initScrollStep(volSliderCont, sliderElem); - UserUtils.addParent(sliderElem, volSliderCont); - if (getFeature("volumeSliderLabel")) - await addVolumeSliderLabel(type, sliderElem, volSliderCont); - setVolSliderStep(sliderElem); - if (getFeature("volumeSharedBetweenTabs")) - sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value))); - if (listenerOnce) - return; - listenerOnce = true; - // the following are only run once: - if (getFeature("setInitialTabVolume")) - setInitialTabVolume(sliderElem); - if (typeof getFeature("volumeSliderSize") === "number") - setVolSliderSize(); - if (getFeature("volumeSharedBetweenTabs")) - checkSharedVolume(); - }; - addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", { - listener: (el) => listener("normal", el), + } + lyricsUrl && openInTab(lyricsUrl); }); - let sizeSmOnce = false; - const onResize = () => { - if (sizeSmOnce || window.innerWidth >= 1150) - return; - sizeSmOnce = true; - addSelectorListener("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", { - listener: (el) => listener("expand", el), - debounceEdge: "falling", - }); - }; - window.addEventListener("resize", UserUtils.debounce(onResize, 150, "falling")); - waitVideoElementReady().then(onResize); - onResize(); - } - //#region scroll step - /** Initializes the volume slider scroll step feature */ - function initScrollStep(volSliderCont, sliderElem) { - for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) { - volSliderCont.addEventListener(evtName, (e) => { - var _a, _b; - e.preventDefault(); - // cancels all the other events that would be fired - e.stopImmediatePropagation(); - const delta = Number((_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e.detail) !== null && _b !== void 0 ? _b : 1); - if (isNaN(delta)) - return warn("Invalid scroll delta:", delta); - const volumeDir = -Math.sign(delta); - const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir)); - sliderElem.value = newVolume; - sliderElem.setAttribute("aria-valuenow", newVolume); - // make the site actually change the volume - sliderElem.dispatchEvent(new Event("change", { bubbles: true })); - }, { - // takes precedence over the slider's own event listener - capture: true, - }); - } } - //#region volume slider label - /** Adds a percentage label to the volume slider and tooltip */ - async function addVolumeSliderLabel(type, sliderElem, sliderContainer) { - const labelContElem = document.createElement("div"); - labelContElem.classList.add("bytm-vol-slider-label"); - const volShared = getFeature("volumeSharedBetweenTabs"); - if (volShared) { - const linkIconHtml = await resourceAsString("icon-link"); - if (linkIconHtml) { - const linkIconElem = document.createElement("div"); - linkIconElem.classList.add("bytm-vol-slider-shared"); - setInnerHtml(linkIconElem, linkIconHtml); - linkIconElem.role = "alert"; - linkIconElem.ariaLive = "polite"; - linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip"); - labelContElem.classList.add("has-icon"); - labelContElem.appendChild(linkIconElem); + //#region delete btn + let deleteBtnElem; + if (getFeature("deleteFromQueueButton")) { + deleteBtnElem = document.createElement("a"); + deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("remove_from_queue") : t("delete_from_list")); + deleteBtnElem.classList.add("ytmusic-player-bar", "bytm-delete-from-queue", "bytm-generic-btn"); + deleteBtnElem.role = "button"; + deleteBtnElem.tabIndex = 0; + deleteBtnElem.style.visibility = "initial"; + const imgElem = document.createElement("img"); + imgElem.classList.add("bytm-generic-btn-img"); + imgElem.src = deleteIconUrl; + onInteraction(deleteBtnElem, async (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + // container of the queue item popup menu - element gets reused for every queue item + let queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown"); + try { + // three dots button to open the popup menu of a queue item + const dotsBtnElem = queueItem.querySelector("ytmusic-menu-renderer yt-button-shape[id=\"button-shape\"] button"); + if (dotsBtnElem) { + if (queuePopupCont) + queuePopupCont.setAttribute("data-bytm-hidden", "true"); + dotsBtnElem.click(); + } + else { + info("Couldn't find three dots button in queue item, trying to open the context menu manually"); + queueItem.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: false })); + } + queuePopupCont = document.querySelector("ytmusic-app ytmusic-popup-container tp-yt-iron-dropdown"); + queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.setAttribute("data-bytm-hidden", "true"); + await UserUtils.pauseFor(15); + const removeFromQueueBtn = queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.querySelector("tp-yt-paper-listbox ytmusic-menu-service-item-renderer:nth-of-type(3)"); + removeFromQueueBtn === null || removeFromQueueBtn === void 0 ? void 0 : removeFromQueueBtn.click(); + // queue items aren't removed automatically outside of the current queue + if (removeFromQueueBtn && listType === "genericList") { + await UserUtils.pauseFor(200); + clearInner(queueItem); + queueItem.remove(); + } + if (!removeFromQueueBtn) { + error("Couldn't find 'remove from queue' button in queue item three dots menu.\nPlease make sure all autoplay restrictions on your browser's side are disabled for this page."); + dotsBtnElem === null || dotsBtnElem === void 0 ? void 0 : dotsBtnElem.click(); + imgElem.src = await getResourceUrl("icon-error"); + if (deleteBtnElem) + deleteBtnElem.ariaLabel = deleteBtnElem.title = (listType === "currentQueue" ? t("couldnt_remove_from_queue") : t("couldnt_delete_from_list")); + } } - } - const getLabel = (value) => `${value}%`; - const labelElem = document.createElement("div"); - labelElem.classList.add("label"); - labelElem.textContent = getLabel(sliderElem.value); - labelContElem.appendChild(labelElem); - // prevent video from minimizing - labelContElem.addEventListener("click", (e) => e.stopPropagation()); - labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation()); - const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = getFeature("volumeSliderStep")) !== null && _a !== void 0 ? _a : slider.step); }; - const labelFull = getLabelText(sliderElem); - sliderContainer.setAttribute("title", labelFull); - sliderElem.setAttribute("title", labelFull); - sliderElem.setAttribute("aria-valuetext", labelFull); - const updateLabel = () => { - const labelFull = getLabelText(sliderElem); - sliderContainer.setAttribute("title", labelFull); - sliderElem.setAttribute("title", labelFull); - sliderElem.setAttribute("aria-valuetext", labelFull); - const labelElem2 = document.querySelectorAll(".bytm-vol-slider-label div.label"); - for (const el of labelElem2) - el.textContent = getLabel(sliderElem.value); - }; - sliderElem.addEventListener("change", updateLabel); - siteEvents.on("configChanged", updateLabel); - addSelectorListener("playerBarRightControls", type === "normal" ? ".bytm-vol-slider-cont" : "ytmusic-player-expanding-menu .bytm-vol-slider-cont", { - listener: (volumeCont) => volumeCont.appendChild(labelContElem), - }); - let lastSliderVal = Number(sliderElem.value); - // show label if hovering over slider or slider is focused - const sliderHoverObserver = new MutationObserver(() => { - if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem) - labelContElem.classList.add("bytm-visible"); - else if (labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem) - labelContElem.classList.remove("bytm-visible"); - if (Number(sliderElem.value) !== lastSliderVal) { - lastSliderVal = Number(sliderElem.value); - updateLabel(); + catch (err) { + error("Couldn't remove song from queue due to error:", err); + } + finally { + queuePopupCont === null || queuePopupCont === void 0 ? void 0 : queuePopupCont.removeAttribute("data-bytm-hidden"); } }); - sliderHoverObserver.observe(sliderElem, { - attributes: true, + deleteBtnElem.appendChild(imgElem); + } + lyricsBtnElem && queueBtnsCont.appendChild(createRipple(lyricsBtnElem)); + deleteBtnElem && queueBtnsCont.appendChild(createRipple(deleteBtnElem)); + const parentEl = queueItem.querySelector(containerParentSelector); + if (insertPosition === "child") + parentEl === null || parentEl === void 0 ? void 0 : parentEl.appendChild(queueBtnsCont); + else if (insertPosition === "beforeParent") + parentEl === null || parentEl === void 0 ? void 0 : parentEl.before(queueBtnsCont); + else if (insertPosition === "afterParent") + parentEl === null || parentEl === void 0 ? void 0 : parentEl.after(queueBtnsCont); + queueItem.classList.add("bytm-has-queue-btns"); +}//#region init vol features +/** Initializes all volume-related features */ +async function initVolumeFeatures() { + let listenerOnce = false; + // sliderElem is not technically an input element but behaves pretty much the same + const listener = async (type, sliderElem) => { + const volSliderCont = document.createElement("div"); + volSliderCont.classList.add("bytm-vol-slider-cont"); + if (getFeature("volumeSliderScrollStep") !== featInfo.volumeSliderScrollStep.default) + initScrollStep(volSliderCont, sliderElem); + UserUtils.addParent(sliderElem, volSliderCont); + if (getFeature("volumeSliderLabel")) + await addVolumeSliderLabel(type, sliderElem, volSliderCont); + setVolSliderStep(sliderElem); + if (getFeature("volumeSharedBetweenTabs")) + sliderElem.addEventListener("change", () => sharedVolumeChanged(Number(sliderElem.value))); + if (listenerOnce) + return; + listenerOnce = true; + // the following are only run once: + setInitialTabVolume(sliderElem); + if (typeof getFeature("volumeSliderSize") === "number") + setVolSliderSize(); + if (getFeature("volumeSharedBetweenTabs")) + checkSharedVolume(); + }; + addSelectorListener("playerBarRightControls", "tp-yt-paper-slider#volume-slider", { + listener: (el) => listener("normal", el), + }); + let sizeSmOnce = false; + const onResize = () => { + if (sizeSmOnce || window.innerWidth >= 1150) + return; + sizeSmOnce = true; + addSelectorListener("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", { + listener: (el) => listener("expand", el), + }); + }; + window.addEventListener("resize", UserUtils.debounce(onResize, 150)); + waitVideoElementReady().then(onResize); + onResize(); +} +//#region scroll step +/** Initializes the volume slider scroll step feature */ +function initScrollStep(volSliderCont, sliderElem) { + for (const evtName of ["wheel", "scroll", "mousewheel", "DOMMouseScroll"]) { + volSliderCont.addEventListener(evtName, (e) => { + var _a, _b; + e.preventDefault(); + // cancels all the other events that would be fired + e.stopImmediatePropagation(); + const delta = Number((_b = (_a = e.deltaY) !== null && _a !== void 0 ? _a : e === null || e === void 0 ? void 0 : e.detail) !== null && _b !== void 0 ? _b : 1); + if (isNaN(delta)) + return warn("Invalid scroll delta:", delta); + const volumeDir = -Math.sign(delta); + const newVolume = String(Number(sliderElem.value) + (getFeature("volumeSliderScrollStep") * volumeDir)); + sliderElem.value = newVolume; + sliderElem.setAttribute("aria-valuenow", newVolume); + // make the site actually change the volume + sliderElem.dispatchEvent(new Event("change", { bubbles: true })); + }, { + // takes precedence over the slider's own event listener + capture: true, }); } - //#region volume slider size - /** Sets the volume slider to a set size */ - function setVolSliderSize() { - const size = getFeature("volumeSliderSize"); - if (typeof size !== "number" || isNaN(Number(size))) - return error("Invalid volume slider size:", size); - setGlobalCssVar("vol-slider-size", `${size}px`); - addStyleFromResource("css-vol_slider_size"); - } - //#region volume slider step - /** Sets the `step` attribute of the volume slider */ - function setVolSliderStep(sliderElem) { - sliderElem.setAttribute("step", String(getFeature("volumeSliderStep"))); - } - //#region shared volume - /** Saves the shared volume level to persistent storage */ - async function sharedVolumeChanged(vol) { - try { - await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol)); - } - catch (err) { - error("Couldn't save shared volume level due to an error:", err); +} +//#region volume slider label +/** Adds a percentage label to the volume slider and tooltip */ +async function addVolumeSliderLabel(type, sliderElem, sliderContainer) { + const labelContElem = document.createElement("div"); + labelContElem.classList.add("bytm-vol-slider-label"); + const volShared = getFeature("volumeSharedBetweenTabs"); + if (volShared) { + const linkIconHtml = await resourceAsString("icon-link"); + if (linkIconHtml) { + const linkIconElem = document.createElement("div"); + linkIconElem.classList.add("bytm-vol-slider-shared"); + setInnerHtml(linkIconElem, linkIconHtml); + linkIconElem.role = "alert"; + linkIconElem.ariaLive = "polite"; + linkIconElem.title = linkIconElem.ariaLabel = t("volume_shared_tooltip"); + labelContElem.classList.add("has-icon"); + labelContElem.appendChild(linkIconElem); + } + } + const getLabel = (value) => `${value}%`; + const labelElem = document.createElement("div"); + labelElem.classList.add("label"); + labelElem.textContent = getLabel(sliderElem.value); + labelContElem.appendChild(labelElem); + // prevent video from minimizing + labelContElem.addEventListener("click", (e) => e.stopPropagation()); + labelContElem.addEventListener("keydown", (e) => ["Enter", "Space", " "].includes(e.key) && e.stopPropagation()); + const getLabelText = (slider) => { var _a; return t("volume_tooltip", slider.value, (_a = getFeature("volumeSliderStep")) !== null && _a !== void 0 ? _a : slider.step); }; + const labelFull = getLabelText(sliderElem); + sliderContainer.setAttribute("title", labelFull); + sliderElem.setAttribute("title", labelFull); + sliderElem.setAttribute("aria-valuetext", labelFull); + const updateLabel = () => { + const labelFull = getLabelText(sliderElem); + sliderContainer.setAttribute("title", labelFull); + sliderElem.setAttribute("title", labelFull); + sliderElem.setAttribute("aria-valuetext", labelFull); + const labelElem2 = document.querySelectorAll(".bytm-vol-slider-label div.label"); + for (const el of labelElem2) + el.textContent = getLabel(sliderElem.value); + }; + sliderElem.addEventListener("change", updateLabel); + siteEvents.on("configChanged", updateLabel); + addSelectorListener("playerBarRightControls", type === "normal" ? ".bytm-vol-slider-cont" : "ytmusic-player-expanding-menu .bytm-vol-slider-cont", { + listener: (volumeCont) => volumeCont.appendChild(labelContElem), + }); + let lastSliderVal = Number(sliderElem.value); + // show label if hovering over slider or slider is focused + const sliderHoverObserver = new MutationObserver(() => { + if (sliderElem.classList.contains("on-hover") || document.activeElement === sliderElem) + labelContElem.classList.add("bytm-visible"); + else if (labelContElem.classList.contains("bytm-visible") || document.activeElement !== sliderElem) + labelContElem.classList.remove("bytm-visible"); + if (Number(sliderElem.value) !== lastSliderVal) { + lastSliderVal = Number(sliderElem.value); + updateLabel(); } + }); + sliderHoverObserver.observe(sliderElem, { + attributes: true, + }); +} +//#region volume slider size +/** Sets the volume slider to a set size */ +function setVolSliderSize() { + const size = getFeature("volumeSliderSize"); + if (typeof size !== "number" || isNaN(Number(size))) + return error("Invalid volume slider size:", size); + setGlobalCssVar("vol-slider-size", `${size}px`); + addStyleFromResource("css-vol_slider_size"); +} +//#region volume slider step +/** Sets the `step` attribute of the volume slider */ +function setVolSliderStep(sliderElem) { + sliderElem.setAttribute("step", String(getFeature("volumeSliderStep"))); +} +//#region shared volume +/** Saves the shared volume level to persistent storage */ +async function sharedVolumeChanged(vol) { + try { + await GM.setValue("bytm-shared-volume", String(lastCheckedSharedVolume = ignoreVal = vol)); } - let ignoreVal = -1; - let lastCheckedSharedVolume = -1; - /** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */ - async function checkSharedVolume() { - try { - const vol = await GM.getValue("bytm-shared-volume"); - if (vol && lastCheckedSharedVolume !== Number(vol)) { - if (ignoreVal === Number(vol)) - return; - lastCheckedSharedVolume = Number(vol); - const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider"); - if (sliderElem) { - sliderElem.value = String(vol); - sliderElem.dispatchEvent(new Event("change", { bubbles: true })); - } + catch (err) { + error("Couldn't save shared volume level due to an error:", err); + } +} +let ignoreVal = -1; +let lastCheckedSharedVolume = -1; +/** Only call once as this calls itself after a timeout! - Checks if the shared volume has changed and updates the volume slider accordingly */ +async function checkSharedVolume() { + try { + const vol = await GM.getValue("bytm-shared-volume"); + if (vol && lastCheckedSharedVolume !== Number(vol)) { + if (ignoreVal === Number(vol)) + return; + lastCheckedSharedVolume = Number(vol); + const sliderElem = document.querySelector("tp-yt-paper-slider#volume-slider"); + if (sliderElem) { + sliderElem.value = String(vol); + sliderElem.dispatchEvent(new Event("change", { bubbles: true })); } - setTimeout(checkSharedVolume, 333); - } - catch (err) { - error("Couldn't check for shared volume level due to an error:", err); } + setTimeout(checkSharedVolume, 333); } - //#region initial volume - /** Sets the volume slider to a set volume level when the session starts */ - async function setInitialTabVolume(sliderElem) { - await waitVideoElementReady(); - const initialVol = getFeature("initialTabVolumeLevel"); - if (getFeature("volumeSharedBetweenTabs")) { - lastCheckedSharedVolume = ignoreVal = initialVol; - if (getFeature("volumeSharedBetweenTabs")) - GM.setValue("bytm-shared-volume", String(initialVol)); - } - sliderElem.value = String(initialVol); - sliderElem.dispatchEvent(new Event("change", { bubbles: true })); - log(`Set initial tab volume to ${initialVol}%`); + catch (err) { + error("Couldn't check for shared volume level due to an error:", err); } - - //#region misc - function noop() { - } - /** Creates an HTML string for the given adornment properties */ - const getAdornHtml = async (className, title, resource, extraAttributes) => { var _a; return `${(_a = await resourceAsString(resource)) !== null && _a !== void 0 ? _a : ""}`; }; - /** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */ - const combineAdornments = (adornments) => new Promise(async (resolve) => { - const sortedAdornments = adornments.sort((a, b) => { - const aIndex = adornmentOrder.get(a) ? adornmentOrder.get(a) : -1; - const bIndex = adornmentOrder.has(b) ? adornmentOrder.get(b) : -1; - return aIndex - bIndex; - }); - const html = []; - for (const adornment of sortedAdornments) { - const val = typeof adornment === "function" - ? await adornment() - : await adornment; - val && html.push(val); - } - resolve(html.join("")); +} +//#region initial volume +/** Sets the volume slider to a set volume level when the session starts */ +async function setInitialTabVolume(sliderElem) { + const reloadTabVol = Number(await GM.getValue("bytm-reload-tab-volume", 0)); + GM.deleteValue("bytm-reload-tab-volume").catch(() => void 0); + if ((isNaN(reloadTabVol) || reloadTabVol === 0) && !getFeature("setInitialTabVolume")) + return; + await waitVideoElementReady(); + const initialVol = Math.round(!isNaN(reloadTabVol) && reloadTabVol > 0 ? reloadTabVol : getFeature("initialTabVolumeLevel")); + if (isNaN(initialVol) || initialVol < 0 || initialVol > 100) + return; + if (getFeature("volumeSharedBetweenTabs")) { + lastCheckedSharedVolume = ignoreVal = initialVol; + if (getFeature("volumeSharedBetweenTabs")) + GM.setValue("bytm-shared-volume", String(initialVol)).catch((err) => error("Couldn't save shared volume level due to an error:", err)); + } + sliderElem.value = String(initialVol); + sliderElem.dispatchEvent(new Event("change", { bubbles: true })); + log(`Set initial tab volume to ${initialVol}%${reloadTabVol > 0 ? " (from GM storage)" : " (from configuration)"}`); +}//#region misc +function noop() { +} +/** Creates an HTML string for the given adornment properties */ +const getAdornHtml = async (className, title, resource, extraAttributes) => { + var _a; + title = title ? await UserUtils.consumeStringGen(title) : undefined; + extraAttributes = extraAttributes ? await UserUtils.consumeStringGen(extraAttributes) : undefined; + return `${(_a = await resourceAsString(resource)) !== null && _a !== void 0 ? _a : ""}`; +}; +/** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */ +const combineAdornments = (adornments) => new Promise(async (resolve) => { + const sortedAdornments = adornments.sort((a, b) => { + const aIndex = adornmentOrder.get(a) ? adornmentOrder.get(a) : -1; + const bIndex = adornmentOrder.has(b) ? adornmentOrder.get(b) : -1; + return aIndex - bIndex; }); - /** Decoration elements that can be added next to the label */ - const adornments = { - advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"), - experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"), - globe: async () => getAdornHtml("bytm-locale-icon", undefined, "icon-globe_small"), - alert: async (title) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""), - reload: async () => getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined, - }; - /** Order of adornment elements in the {@linkcode combineAdornments()} function */ - const adornmentOrder = new Map(); - adornmentOrder.set(adornments.alert, 0); - adornmentOrder.set(adornments.experimental, 1); - adornmentOrder.set(adornments.globe, 2); - adornmentOrder.set(adornments.reload, 3); - adornmentOrder.set(adornments.advanced, 4); - /** Common options for config items of type "select" */ - const options = { - siteSelection: () => [ - { value: "all", label: t("site_selection_both_sites") }, - { value: "yt", label: t("site_selection_only_yt") }, - { value: "ytm", label: t("site_selection_only_ytm") }, + const html = []; + for (const adornment of sortedAdornments) { + const val = typeof adornment === "function" + ? await adornment() + : await adornment; + val && html.push(val); + } + resolve(html.join("")); +}); +/** Decoration elements that can be added next to the label */ +const adornments = { + advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"), + experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"), + globe: async () => getAdornHtml("bytm-locale-icon", undefined, "icon-globe_small"), + alert: async (title) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""), + reload: async () => getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined, +}; +/** Order of adornment elements in the {@linkcode combineAdornments()} function */ +const adornmentOrder = new Map(); +adornmentOrder.set(adornments.alert, 0); +adornmentOrder.set(adornments.experimental, 1); +adornmentOrder.set(adornments.globe, 2); +adornmentOrder.set(adornments.reload, 3); +adornmentOrder.set(adornments.advanced, 4); +/** Common options for config items of type "select" */ +const options = { + siteSelection: () => [ + { value: "all", label: t("site_selection_both_sites") }, + { value: "yt", label: t("site_selection_only_yt") }, + { value: "ytm", label: t("site_selection_only_ytm") }, + ], + siteSelectionOrNone: () => [ + { value: "all", label: t("site_selection_both_sites") }, + { value: "yt", label: t("site_selection_only_yt") }, + { value: "ytm", label: t("site_selection_only_ytm") }, + { value: "none", label: t("site_selection_none") }, + ], + locale: () => Object.entries(locales) + .reduce((a, [locale, { name }]) => { + return [...a, { + value: locale, + label: name, + }]; + }, []) + .sort((a, b) => a.label.localeCompare(b.label)), + colorLightness: () => [ + { value: "darker", label: t("color_lightness_darker") }, + { value: "normal", label: t("color_lightness_normal") }, + { value: "lighter", label: t("color_lightness_lighter") }, + ], +}; +//#region rendering +/** Renders a long number with a thousands separator */ +function renderLongNumberValue(val, maximumFractionDigits = 0) { + return Number(val).toLocaleString(getLocale().replace(/_/g, "-"), { + style: "decimal", + maximumFractionDigits, + }); +} +//#region features +/** + * Contains all possible features with their default values and other configuration. + * + * **Required props:** + * + * | Property | Description | + * | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | + * | `type: string` | Type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` | + * | `category: string` | Category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` | + * | `default: unknown` | Default value of the feature - type of the value depends on the given `type` | + * | `enable(value: unknown): void` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time | + * + * + * + * **Optional props:** + * + * | Property | Description | + * | :----------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------| + * | `disable(newValue: unknown): void` | For type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function | + * | `change(key: string, prevValue: unknown, newValue: unknown): void` | For types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed | + * | `click(): void` | For type `button` only - function that will be called when the button is clicked | + * | `helpText: string \| () => string` | Function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available | + * | `textAdornment(): string \| Promise` | Function that returns an HTML string that will be appended to the text in the config menu as an adornment element | + * | `unit: string \| (val: number) => string` | For types `number` or `slider` only - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added too! | + * | `min: number` | For types `number` or `slider` only - Overwrites the default of the `min` property of the HTML input element | + * | `max: number` | For types `number` or `slider` only - Overwrites the default of the `max` property of the HTML input element | + * | `step: number` | For types `number` or `slider` only - Overwrites the default of the `step` property of the HTML input element | + * | `options: SelectOption[] \| () => SelectOption[]` | For type `select` only - function that returns an array of objects with `value` and `label` properties | + * | `reloadRequired: boolean` | If true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided | + * | `advanced: boolean` | If true, the feature will only be shown if the advanced mode feature has been turned on | + * | `hidden: boolean` | If true, the feature will not be shown in the settings - default is undefined (false) | + * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) | + * | `normalize(val: unknown): unknown` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations | + * | `renderValue(val: string): string` | If provided, is used to render the value's label in the config menu | + * + * + * TODO: go through all features and set as many as possible to reloadRequired = false + */ +const featInfo = { + //#region cat:layout + watermarkEnabled: { + type: "toggle", + category: "layout", + default: true, + textAdornment: adornments.reload, + }, + removeShareTrackingParam: { + type: "toggle", + category: "layout", + default: true, + textAdornment: adornments.reload, + }, + removeShareTrackingParamSites: { + type: "select", + category: "layout", + options: options.siteSelection, + default: "all", + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + fixSpacing: { + type: "toggle", + category: "layout", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + thumbnailOverlayBehavior: { + type: "select", + category: "layout", + options: () => [ + { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") }, + { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") }, + { value: "always", label: t("thumbnail_overlay_behavior_always") }, + { value: "never", label: t("thumbnail_overlay_behavior_never") }, ], - siteSelectionOrNone: () => [ - { value: "all", label: t("site_selection_both_sites") }, - { value: "yt", label: t("site_selection_only_yt") }, - { value: "ytm", label: t("site_selection_only_ytm") }, - { value: "none", label: t("site_selection_none") }, + default: "songsOnly", + reloadRequired: false, + enable: noop, + }, + thumbnailOverlayToggleBtnShown: { + type: "toggle", + category: "layout", + default: true, + textAdornment: adornments.reload, + }, + thumbnailOverlayShowIndicator: { + type: "toggle", + category: "layout", + default: true, + textAdornment: adornments.reload, + }, + thumbnailOverlayIndicatorOpacity: { + type: "slider", + category: "layout", + min: 5, + max: 100, + step: 5, + default: 40, + unit: "%", + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + thumbnailOverlayImageFit: { + type: "select", + category: "layout", + options: () => [ + { value: "cover", label: t("thumbnail_overlay_image_fit_crop") }, + { value: "contain", label: t("thumbnail_overlay_image_fit_full") }, + { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") }, ], - locale: () => Object.entries(langMapping) - .reduce((a, [locale, { name }]) => { - return [...a, { - value: locale, - label: name, - }]; - }, []) - .sort((a, b) => a.label.localeCompare(b.label)), - colorLightness: () => [ - { value: "darker", label: t("color_lightness_darker") }, - { value: "normal", label: t("color_lightness_normal") }, - { value: "lighter", label: t("color_lightness_lighter") }, + default: "cover", + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + hideCursorOnIdle: { + type: "toggle", + category: "layout", + default: true, + reloadRequired: false, + enable: noop, + }, + hideCursorOnIdleDelay: { + type: "slider", + category: "layout", + min: 0.5, + max: 10, + step: 0.25, + default: 2, + unit: "s", + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + fixHdrIssues: { + type: "toggle", + category: "layout", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + showVotes: { + type: "toggle", + category: "layout", + default: true, + textAdornment: adornments.reload, + }, + // archived idea for future version + // (shows a bar under the like/dislike buttons that shows the ratio of likes to dislikes) + // showVoteRatio: { + // type: "select", + // category: "layout", + // options: () => [ + // { value: "disabled", label: t("vote_ratio_disabled") }, + // { value: "greenRed", label: t("vote_ratio_green_red") }, + // { value: "blueGray", label: t("vote_ratio_blue_gray") }, + // ], + // default: "disabled", + // textAdornment: adornments.reload, + // }, + //#region cat:volume + volumeSliderLabel: { + type: "toggle", + category: "volume", + default: true, + textAdornment: adornments.reload, + }, + volumeSliderSize: { + type: "number", + category: "volume", + min: 50, + max: 500, + step: 5, + default: 150, + unit: "px", + textAdornment: adornments.reload, + }, + volumeSliderStep: { + type: "slider", + category: "volume", + min: 1, + max: 25, + default: 2, + unit: "%", + textAdornment: adornments.reload, + }, + volumeSliderScrollStep: { + type: "slider", + category: "volume", + min: 1, + max: 25, + default: 4, + unit: "%", + textAdornment: adornments.reload, + }, + volumeSharedBetweenTabs: { + type: "toggle", + category: "volume", + default: false, + textAdornment: adornments.reload, + }, + setInitialTabVolume: { + type: "toggle", + category: "volume", + default: false, + textAdornment: () => getFeature("volumeSharedBetweenTabs") + ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload]) + : adornments.reload(), + }, + initialTabVolumeLevel: { + type: "slider", + category: "volume", + min: 0, + max: 100, + step: 1, + default: 100, + unit: "%", + textAdornment: () => getFeature("volumeSharedBetweenTabs") + ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload]) + : adornments.reload(), + reloadRequired: false, + enable: noop, + }, + //#region cat:song lists + lyricsQueueButton: { + type: "toggle", + category: "songLists", + default: true, + textAdornment: adornments.reload, + }, + deleteFromQueueButton: { + type: "toggle", + category: "songLists", + default: true, + textAdornment: adornments.reload, + }, + listButtonsPlacement: { + type: "select", + category: "songLists", + options: () => [ + { value: "queueOnly", label: t("list_button_placement_queue_only") }, + { value: "everywhere", label: t("list_button_placement_everywhere") }, ], - }; - //#region rendering - /** Renders a long number with a thousands separator */ - function renderLongNumberValue(val, maximumFractionDigits = 0) { - return Number(val).toLocaleString(getLocale().replace(/_/g, "-"), { - style: "decimal", - maximumFractionDigits, - }); - } - //#region features - /** - * Contains all possible features with their default values and other configuration. - * - * **Required props:** - * - * | Property | Description | - * | :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------- | - * | `type: string` | Type of the feature configuration element - use autocomplete or check `FeatureTypeProps` in `src/types.ts` | - * | `category: string` | Category of the feature - use autocomplete or check `FeatureCategory` in `src/types.ts` | - * | `default: unknown` | Default value of the feature - type of the value depends on the given `type` | - * | `enable(value: unknown): void` | (required if reloadRequired = false) - function that will be called when the feature is enabled / initialized for the first time | - * - * - * - * **Optional props:** - * - * | Property | Description | - * | :----------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------| - * | `disable(newValue: unknown): void` | For type `toggle` only - function that will be called when the feature is disabled - can be a synchronous or asynchronous function | - * | `change(key: string, prevValue: unknown, newValue: unknown): void` | For types `number`, `select`, `slider` and `hotkey` only - function that will be called when the value is changed | - * | `click(): void` | For type `button` only - function that will be called when the button is clicked | - * | `helpText: string \| () => string` | Function that returns an HTML string or the literal string itself that will be the help text for this feature - writing as function is useful for pluralizing or inserting values into the translation at runtime - if not set, translation with key `feature_helptext_featureKey` will be used instead, if available | - * | `textAdornment(): string \| Promise` | Function that returns an HTML string that will be appended to the text in the config menu as an adornment element | - * | `unit: string \| (val: number) => string` | For types `number` or `slider` only - The unit text that is displayed next to the input element, i.e. " px" - a leading space need to be added too! | - * | `min: number` | For types `number` or `slider` only - Overwrites the default of the `min` property of the HTML input element | - * | `max: number` | For types `number` or `slider` only - Overwrites the default of the `max` property of the HTML input element | - * | `step: number` | For types `number` or `slider` only - Overwrites the default of the `step` property of the HTML input element | - * | `options: SelectOption[] \| () => SelectOption[]` | For type `select` only - function that returns an array of objects with `value` and `label` properties | - * | `reloadRequired: boolean` | If true (default), the page needs to be reloaded for the changes to take effect - if false, `enable()` needs to be provided | - * | `advanced: boolean` | If true, the feature will only be shown if the advanced mode feature has been turned on | - * | `hidden: boolean` | If true, the feature will not be shown in the settings - default is undefined (false) | - * | `valueHidden: boolean` | If true, the value of the feature will be hidden in the settings and via the plugin interface - default is undefined (false) | - * | `normalize(val: unknown): unknown` | Function that will be called to normalize the value before it is saved - useful for trimming strings or other simple operations | - * | `renderValue(val: string): string` | If provided, is used to render the value's label in the config menu | - * - * - * TODO: go through all features and set as many as possible to reloadRequired = false - */ - const featInfo = { - //#region cat:layout - watermarkEnabled: { - type: "toggle", - category: "layout", - default: true, - textAdornment: adornments.reload, - }, - removeShareTrackingParam: { - type: "toggle", - category: "layout", - default: true, - textAdornment: adornments.reload, - }, - removeShareTrackingParamSites: { - type: "select", - category: "layout", - options: options.siteSelection, - default: "all", - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - fixSpacing: { - type: "toggle", - category: "layout", - default: true, - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - thumbnailOverlayBehavior: { - type: "select", - category: "layout", - options: () => [ - { value: "songsOnly", label: t("thumbnail_overlay_behavior_songs_only") }, - { value: "videosOnly", label: t("thumbnail_overlay_behavior_videos_only") }, - { value: "always", label: t("thumbnail_overlay_behavior_always") }, - { value: "never", label: t("thumbnail_overlay_behavior_never") }, - ], - default: "songsOnly", - reloadRequired: false, - enable: noop, - }, - thumbnailOverlayToggleBtnShown: { - type: "toggle", - category: "layout", - default: true, - textAdornment: adornments.reload, - }, - thumbnailOverlayShowIndicator: { - type: "toggle", - category: "layout", - default: true, - textAdornment: adornments.reload, - }, - thumbnailOverlayIndicatorOpacity: { - type: "slider", - category: "layout", - min: 5, - max: 100, - step: 5, - default: 40, - unit: "%", - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - thumbnailOverlayImageFit: { - type: "select", - category: "layout", - options: () => [ - { value: "cover", label: t("thumbnail_overlay_image_fit_crop") }, - { value: "contain", label: t("thumbnail_overlay_image_fit_full") }, - { value: "fill", label: t("thumbnail_overlay_image_fit_stretch") }, - ], - default: "cover", - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - hideCursorOnIdle: { - type: "toggle", - category: "layout", - default: true, - reloadRequired: false, - enable: noop, - }, - hideCursorOnIdleDelay: { - type: "slider", - category: "layout", - min: 0.5, - max: 10, - step: 0.25, - default: 2, - unit: "s", - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - fixHdrIssues: { - type: "toggle", - category: "layout", - default: true, - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - showVotes: { - type: "toggle", - category: "layout", - default: true, - textAdornment: adornments.reload, - }, - // archived idea for future version - // (shows a bar under the like/dislike buttons that shows the ratio of likes to dislikes) - // showVoteRatio: { - // type: "select", - // category: "layout", - // options: () => [ - // { value: "disabled", label: t("vote_ratio_disabled") }, - // { value: "greenRed", label: t("vote_ratio_green_red") }, - // { value: "blueGray", label: t("vote_ratio_blue_gray") }, - // ], - // default: "disabled", - // textAdornment: adornments.reload, - // }, - //#region cat:volume - volumeSliderLabel: { - type: "toggle", - category: "volume", - default: true, - textAdornment: adornments.reload, - }, - volumeSliderSize: { - type: "number", - category: "volume", - min: 50, - max: 500, - step: 5, - default: 150, - unit: "px", - textAdornment: adornments.reload, - }, - volumeSliderStep: { - type: "slider", - category: "volume", - min: 1, - max: 25, - default: 2, - unit: "%", - textAdornment: adornments.reload, - }, - volumeSliderScrollStep: { - type: "slider", - category: "volume", - min: 1, - max: 25, - default: 4, - unit: "%", - textAdornment: adornments.reload, - }, - volumeSharedBetweenTabs: { - type: "toggle", - category: "volume", - default: false, - textAdornment: adornments.reload, - }, - setInitialTabVolume: { - type: "toggle", - category: "volume", - default: false, - textAdornment: () => getFeature("volumeSharedBetweenTabs") - ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload]) - : adornments.reload(), - }, - initialTabVolumeLevel: { - type: "slider", - category: "volume", - min: 0, - max: 100, - step: 1, - default: 100, - unit: "%", - textAdornment: () => getFeature("volumeSharedBetweenTabs") - ? combineAdornments([adornments.alert(t("feature_warning_setInitialTabVolume_volumeSharedBetweenTabs_incompatible").replace(/"/g, "'")), adornments.reload]) - : adornments.reload(), - reloadRequired: false, - enable: noop, - }, - //#region cat:song lists - lyricsQueueButton: { - type: "toggle", - category: "songLists", - default: true, - textAdornment: adornments.reload, - }, - deleteFromQueueButton: { - type: "toggle", - category: "songLists", - default: true, - textAdornment: adornments.reload, - }, - listButtonsPlacement: { - type: "select", - category: "songLists", - options: () => [ - { value: "queueOnly", label: t("list_button_placement_queue_only") }, - { value: "everywhere", label: t("list_button_placement_everywhere") }, - ], - default: "everywhere", - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - scrollToActiveSongBtn: { - type: "toggle", - category: "songLists", - default: true, - textAdornment: adornments.reload, - }, - clearQueueBtn: { - type: "toggle", - category: "songLists", - default: true, - textAdornment: adornments.reload, - }, - //#region cat:behavior - disableBeforeUnloadPopup: { - type: "toggle", - category: "behavior", - default: false, - textAdornment: adornments.reload, - }, - closeToastsTimeout: { - type: "number", - category: "behavior", - min: 0, - max: 30, - step: 0.5, - default: 3, - unit: "s", - reloadRequired: false, - enable: noop, - }, - rememberSongTime: { - type: "toggle", - category: "behavior", - default: true, - helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")), - textAdornment: adornments.reload, - }, - rememberSongTimeSites: { - type: "select", - category: "behavior", - options: options.siteSelection, - default: "all", - textAdornment: adornments.reload, - }, - rememberSongTimeDuration: { - type: "number", - category: "behavior", - min: 1, - max: 60 * 60 * 24 * 7, - step: 1, - default: 60, - unit: "s", - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - rememberSongTimeReduction: { - type: "number", - category: "behavior", - min: 0, - max: 30, - step: 0.05, - default: 0.2, - unit: "s", - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - rememberSongTimeMinPlayTime: { - type: "slider", - category: "behavior", - min: 3, - max: 30, - step: 0.5, - default: 10, - unit: "s", - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - //#region cat:input - arrowKeySupport: { - type: "toggle", - category: "input", - default: true, - reloadRequired: false, - enable: noop, - }, - arrowKeySkipBy: { - type: "slider", - category: "input", - min: 0.5, - max: 30, - step: 0.5, - default: 5, - unit: "s", - reloadRequired: false, - enable: noop, - }, - switchBetweenSites: { - type: "toggle", - category: "input", - default: true, - reloadRequired: false, - enable: noop, - }, - switchSitesHotkey: { - type: "hotkey", - category: "input", - default: { - code: "F9", - shift: false, - ctrl: false, - alt: false, - }, - reloadRequired: false, - enable: noop, - }, - anchorImprovements: { - type: "toggle", - category: "input", - default: true, - textAdornment: adornments.reload, - }, - numKeysSkipToTime: { - type: "toggle", - category: "input", - default: true, - reloadRequired: false, - enable: noop, - }, - autoLikeChannels: { - type: "toggle", - category: "input", - default: true, - textAdornment: adornments.reload, - }, - autoLikeChannelToggleBtn: { - type: "toggle", - category: "input", - default: true, - reloadRequired: false, - enable: noop, - advanced: true, - textAdornment: adornments.advanced, - }, - // TODO(v2.2): - // autoLikePlayerBarToggleBtn: { - // type: "toggle", - // category: "input", - // default: false, - // textAdornment: adornments.reload, - // }, - autoLikeTimeout: { - type: "slider", - category: "input", - min: 3, - max: 30, - step: 0.5, - default: 5, - unit: "s", - advanced: true, - reloadRequired: false, - enable: noop, - textAdornment: adornments.advanced, - }, - autoLikeShowToast: { - type: "toggle", - category: "input", - default: true, - reloadRequired: false, - advanced: true, - enable: noop, - textAdornment: adornments.advanced, - }, - autoLikeOpenMgmtDialog: { - type: "button", - category: "input", - click: () => getAutoLikeDialog().then(d => d.open()), - }, - //#region cat:lyrics - geniusLyrics: { - type: "toggle", - category: "lyrics", - default: true, - textAdornment: adornments.reload, - }, - errorOnLyricsNotFound: { - type: "toggle", - category: "lyrics", - default: false, - reloadRequired: false, - enable: noop, - }, - geniUrlBase: { - type: "text", - category: "lyrics", - default: "https://api.sv443.net/geniurl", - normalize: (val) => val.trim().replace(/\/+$/, ""), - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - geniUrlToken: { - type: "text", - valueHidden: true, - category: "lyrics", - default: "", - normalize: (val) => val.trim(), - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - lyricsCacheMaxSize: { - type: "slider", - category: "lyrics", - default: 2000, - min: 100, - max: 10000, - step: 100, - unit: (val) => ` ${tp("unit_entries", val)}`, - renderValue: renderLongNumberValue, - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - lyricsCacheTTL: { - type: "slider", - category: "lyrics", - default: 21, - min: 1, - max: 100, - step: 1, - unit: (val) => " " + tp("unit_days", val), - advanced: true, - textAdornment: adornments.advanced, - reloadRequired: false, - enable: noop, - }, - clearLyricsCache: { - type: "button", - category: "lyrics", - async click() { - const entries = getLyricsCache().length; - const formattedEntries = entries.toLocaleString(getLocale(), { style: "decimal", maximumFractionDigits: 0 }); - if (await showPrompt({ type: "confirm", message: tp("lyrics_clear_cache_confirm_prompt", entries, formattedEntries) })) { - await clearLyricsCache(); - await showPrompt({ type: "alert", message: t("lyrics_clear_cache_success") }); - } - }, - advanced: true, - textAdornment: adornments.advanced, - }, - // advancedLyricsFilter: { - // type: "toggle", - // category: "lyrics", - // default: false, - // change: () => setTimeout(async () => await showPrompt({ type: "confirm", message: t("lyrics_cache_changed_clear_confirm") }) && clearLyricsCache(), 200), - // advanced: true, - // textAdornment: adornments.experimental, - // reloadRequired: false, - // enable: noop, - // }, - //#region cat:integrations - disableDarkReaderSites: { - type: "select", - category: "integrations", - options: options.siteSelectionOrNone, - default: "all", - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - sponsorBlockIntegration: { - type: "toggle", - category: "integrations", - default: true, - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - themeSongIntegration: { - type: "toggle", - category: "integrations", - default: false, - textAdornment: adornments.reload, - }, - themeSongLightness: { - type: "select", - category: "integrations", - options: options.colorLightness, - default: "darker", - textAdornment: adornments.reload, - }, - //#region cat:plugins - openPluginList: { - type: "button", - category: "plugins", - default: undefined, - click: () => getPluginListDialog().then(d => d.open()), - }, - initTimeout: { - type: "number", - category: "plugins", - min: 3, - max: 30, - default: 8, - step: 0.1, - unit: "s", - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - //#region cat:general - locale: { - type: "select", - category: "general", - options: options.locale, - default: getPreferredLocale(), - textAdornment: () => combineAdornments([adornments.globe, adornments.reload]), - }, - localeFallback: { - type: "toggle", - category: "general", - default: true, - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - versionCheck: { - type: "toggle", - category: "general", - default: true, - textAdornment: adornments.reload, - }, - checkVersionNow: { - type: "button", - category: "general", - click: () => doVersionCheck(true), - }, - numbersFormat: { - type: "select", - category: "general", - options: () => [ - { value: "long", label: `${formatNumber(12345678, "long")} (${t("votes_format_long")})` }, - { value: "short", label: `${formatNumber(12345678, "short")} (${t("votes_format_short")})` }, - ], - default: "short", - reloadRequired: false, - enable: noop, - }, - toastDuration: { - type: "slider", - category: "general", - min: 0, - max: 15, - default: 4, - step: 0.5, - unit: "s", - reloadRequired: false, - advanced: true, - textAdornment: adornments.advanced, - enable: noop, - change: () => showIconToast({ - message: t("example_toast"), - iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`), - }), - }, - showToastOnGenericError: { - type: "toggle", - category: "general", - default: true, - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - resetConfig: { - type: "button", - category: "general", - click: promptResetConfig, - textAdornment: adornments.reload, - }, - resetEverything: { - type: "button", - category: "general", - click: async () => { - if (await showPrompt({ - type: "confirm", - message: t("reset_everything_confirm"), - })) { - await getStoreSerializer().resetStoresData(); - location.reload(); - } - }, - advanced: true, - textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), - }, - logLevel: { - type: "select", - category: "general", - options: () => [ - { value: 0, label: t("log_level_debug") }, - { value: 1, label: t("log_level_info") }, - ], - default: 1, - textAdornment: adornments.reload, - }, - advancedMode: { - type: "toggle", - category: "general", - default: false, - textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined, - change: (_key, prevValue, newValue) => prevValue !== newValue && - emitSiteEvent("recreateCfgMenu"), - }, - }; - - /** If this number is incremented, the features object data will be migrated to the new format */ - const formatVersion = 9; - const defaultData = Object.keys(featInfo) - // @ts-ignore - .filter((ftKey) => { var _a; return ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[ftKey]) === null || _a === void 0 ? void 0 : _a.default) !== undefined; }) - .reduce((acc, key) => { - var _a; - // @ts-ignore - acc[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; - return acc; - }, {}); - /** Config data format migration dictionary */ - const migrations = { - // 1 -> 2 (<=v1.0) - 2: (oldData) => { - const queueBtnsEnabled = Boolean(oldData.queueButtons); - delete oldData.queueButtons; - return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled }); - }, - // 2 -> 3 (v1.0) - 3: (oldData) => useDefaultConfig(oldData, [ - "removeShareTrackingParam", "numKeysSkipToTime", - "fixSpacing", "scrollToActiveSongBtn", "logLevel", - ]), - // 3 -> 4 (v1.1) - 4: (oldData) => { - var _a, _b, _c, _d; - const oldSwitchSitesHotkey = oldData.switchSitesHotkey; - return Object.assign(Object.assign({}, useDefaultConfig(oldData, [ - "rememberSongTime", "rememberSongTimeSites", - "volumeSliderScrollStep", "locale", "versionCheck", - ])), { arrowKeySkipBy: 10, switchSitesHotkey: { - code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9", - shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false), - ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false), - alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false), - }, listButtonsPlacement: "queueOnly" }); - }, - // 4 -> 5 (v2.0) - 5: (oldData) => useDefaultConfig(oldData, [ - "localeFallback", "geniUrlBase", "geniUrlToken", - "lyricsCacheMaxSize", "lyricsCacheTTL", - "clearLyricsCache", "advancedMode", - "checkVersionNow", "advancedLyricsFilter", - "rememberSongTimeDuration", "rememberSongTimeReduction", - "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs", - "setInitialTabVolume", "initialTabVolumeLevel", - "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown", - "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity", - "thumbnailOverlayImageFit", "removeShareTrackingParamSites", - "fixHdrIssues", "clearQueueBtn", - "closeToastsTimeout", "disableDarkReaderSites", - ]), - // 5 -> 6 (v2.1) - 6: (oldData) => { - const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [ - "autoLikeChannels", "autoLikeChannelToggleBtn", - "autoLikeTimeout", "autoLikeShowToast", - "autoLikeOpenMgmtDialog", "showVotes", - "numbersFormat", "toastDuration", - "initTimeout", - // forgot to add this to the migration when adding the feature way before so now will have to do: - "volumeSliderLabel", - ]), [ - { key: "rememberSongTimeSites", oldDefault: "ytm" }, - { key: "volumeSliderScrollStep", oldDefault: 10 }, - ]); - "removeUpgradeTab" in newData && delete newData.removeUpgradeTab; - "advancedLyricsFilter" in newData && delete newData.advancedLyricsFilter; - return newData; - }, - // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn" - // TODO(v2.2): set autoLikeChannels to true on migration once feature is fully implemented - // 6 -> 7 (v2.1-dev) - 7: (oldData) => { - const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [ - "showToastOnGenericError", "sponsorBlockIntegration", - "themeSongIntegration", "themeSongLightness", - "errorOnLyricsNotFound", "openPluginList", - ]), [ - { key: "toastDuration", oldDefault: 3 }, - ]); - newData.arrowKeySkipBy = UserUtils.clamp(newData.arrowKeySkipBy, 0.5, 30); - return newData; + default: "everywhere", + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + scrollToActiveSongBtn: { + type: "toggle", + category: "songLists", + default: true, + textAdornment: adornments.reload, + }, + clearQueueBtn: { + type: "toggle", + category: "songLists", + default: true, + textAdornment: adornments.reload, + }, + aboveQueueBtnsSticky: { + type: "toggle", + category: "songLists", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + //#region cat:behavior + disableBeforeUnloadPopup: { + type: "toggle", + category: "behavior", + default: false, + textAdornment: adornments.reload, + }, + closeToastsTimeout: { + type: "number", + category: "behavior", + min: 0, + max: 30, + step: 0.5, + default: 3, + unit: "s", + reloadRequired: false, + enable: noop, + }, + rememberSongTime: { + type: "toggle", + category: "behavior", + default: true, + helpText: () => tp("feature_helptext_rememberSongTime", getFeature("rememberSongTimeMinPlayTime"), getFeature("rememberSongTimeMinPlayTime")), + textAdornment: adornments.reload, + }, + rememberSongTimeSites: { + type: "select", + category: "behavior", + options: options.siteSelection, + default: "all", + textAdornment: adornments.reload, + }, + rememberSongTimeDuration: { + type: "number", + category: "behavior", + min: 1, + max: 60 * 60 * 24 * 7, + step: 1, + default: 60, + unit: "s", + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + rememberSongTimeReduction: { + type: "number", + category: "behavior", + min: 0, + max: 30, + step: 0.05, + default: 0.2, + unit: "s", + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + rememberSongTimeMinPlayTime: { + type: "slider", + category: "behavior", + min: 3, + max: 30, + step: 0.5, + default: 10, + unit: "s", + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + //#region cat:input + arrowKeySupport: { + type: "toggle", + category: "input", + default: true, + reloadRequired: false, + enable: noop, + }, + arrowKeySkipBy: { + type: "slider", + category: "input", + min: 0.5, + max: 30, + step: 0.5, + default: 5, + unit: "s", + reloadRequired: false, + enable: noop, + }, + switchBetweenSites: { + type: "toggle", + category: "input", + default: true, + reloadRequired: false, + enable: noop, + }, + switchSitesHotkey: { + type: "hotkey", + category: "input", + default: { + code: "F9", + shift: false, + ctrl: false, + alt: false, }, - // 7 -> 8 (v2.1) - 8: (oldData) => { - if ("showVotesFormat" in oldData) { - oldData.numbersFormat = oldData.showVotesFormat; - delete oldData.showVotesFormat; + reloadRequired: false, + enable: noop, + }, + anchorImprovements: { + type: "toggle", + category: "input", + default: true, + textAdornment: adornments.reload, + }, + numKeysSkipToTime: { + type: "toggle", + category: "input", + default: true, + reloadRequired: false, + enable: noop, + }, + autoLikeChannels: { + type: "toggle", + category: "input", + default: true, + textAdornment: adornments.reload, + }, + autoLikeChannelToggleBtn: { + type: "toggle", + category: "input", + default: true, + reloadRequired: false, + enable: noop, + advanced: true, + textAdornment: adornments.advanced, + }, + // TODO(v2.2): + // autoLikePlayerBarToggleBtn: { + // type: "toggle", + // category: "input", + // default: false, + // textAdornment: adornments.reload, + // }, + autoLikeTimeout: { + type: "slider", + category: "input", + min: 3, + max: 30, + step: 0.5, + default: 5, + unit: "s", + advanced: true, + reloadRequired: false, + enable: noop, + textAdornment: adornments.advanced, + }, + autoLikeShowToast: { + type: "toggle", + category: "input", + default: true, + reloadRequired: false, + advanced: true, + enable: noop, + textAdornment: adornments.advanced, + }, + autoLikeOpenMgmtDialog: { + type: "button", + category: "input", + click: () => getAutoLikeDialog().then(d => d.open()), + }, + //#region cat:lyrics + geniusLyrics: { + type: "toggle", + category: "lyrics", + default: true, + textAdornment: adornments.reload, + }, + errorOnLyricsNotFound: { + type: "toggle", + category: "lyrics", + default: false, + reloadRequired: false, + enable: noop, + }, + geniUrlBase: { + type: "text", + category: "lyrics", + default: "https://api.sv443.net/geniurl", + normalize: (val) => val.trim().replace(/\/+$/, ""), + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + geniUrlToken: { + type: "text", + valueHidden: true, + category: "lyrics", + default: "", + normalize: (val) => val.trim(), + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + lyricsCacheMaxSize: { + type: "slider", + category: "lyrics", + default: 2000, + min: 100, + max: 10000, + step: 100, + unit: (val) => ` ${tp("unit_entries", val)}`, + renderValue: renderLongNumberValue, + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + lyricsCacheTTL: { + type: "slider", + category: "lyrics", + default: 21, + min: 1, + max: 100, + step: 1, + unit: (val) => " " + tp("unit_days", val), + advanced: true, + textAdornment: adornments.advanced, + reloadRequired: false, + enable: noop, + }, + clearLyricsCache: { + type: "button", + category: "lyrics", + async click() { + const entries = getLyricsCache().length; + const formattedEntries = entries.toLocaleString(getLocale(), { style: "decimal", maximumFractionDigits: 0 }); + if (await showPrompt({ type: "confirm", message: tp("lyrics_clear_cache_confirm_prompt", entries, formattedEntries) })) { + await clearLyricsCache(); + await showPrompt({ type: "alert", message: t("lyrics_clear_cache_success") }); } - return useDefaultConfig(oldData, [ - "autoLikeChannels" - ]); }, - // 8 -> 9 (v2.2) - 9: (oldData) => { - oldData.locale = oldData.locale.replace("_", "-"); - if (oldData.locale === "ja-JA") - oldData.locale = "ja-JP"; - if (oldData.locale === "en-GB") - oldData.locale = "en-GB"; - return useDefaultConfig(oldData, [ - "resetEverything", - // TODO(V2.2): - // "autoLikePlayerBarToggleBtn", - ]); + advanced: true, + textAdornment: adornments.advanced, + }, + // advancedLyricsFilter: { + // type: "toggle", + // category: "lyrics", + // default: false, + // change: () => setTimeout(async () => await showPrompt({ type: "confirm", message: t("lyrics_cache_changed_clear_confirm") }) && clearLyricsCache(), 200), + // advanced: true, + // textAdornment: adornments.experimental, + // reloadRequired: false, + // enable: noop, + // }, + //#region cat:integrations + disableDarkReaderSites: { + type: "select", + category: "integrations", + options: options.siteSelectionOrNone, + default: "all", + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + sponsorBlockIntegration: { + type: "toggle", + category: "integrations", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + themeSongIntegration: { + type: "toggle", + category: "integrations", + default: false, + textAdornment: adornments.reload, + }, + themeSongLightness: { + type: "select", + category: "integrations", + options: options.colorLightness, + default: "darker", + textAdornment: adornments.reload, + }, + //#region cat:plugins + openPluginList: { + type: "button", + category: "plugins", + default: undefined, + click: () => getPluginListDialog().then(d => d.open()), + }, + initTimeout: { + type: "number", + category: "plugins", + min: 3, + max: 30, + default: 8, + step: 0.1, + unit: "s", + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + //#region cat:general + locale: { + type: "select", + category: "general", + options: options.locale, + default: getPreferredLocale(), + textAdornment: () => combineAdornments([adornments.globe, adornments.reload]), + }, + localeFallback: { + type: "toggle", + category: "general", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + versionCheck: { + type: "toggle", + category: "general", + default: true, + textAdornment: adornments.reload, + }, + checkVersionNow: { + type: "button", + category: "general", + click: () => doVersionCheck(true), + }, + numbersFormat: { + type: "select", + category: "general", + options: () => [ + { value: "long", label: `${formatNumber(12345678, "long")} (${t("votes_format_long")})` }, + { value: "short", label: `${formatNumber(12345678, "short")} (${t("votes_format_short")})` }, + ], + default: "short", + reloadRequired: false, + enable: noop, + }, + toastDuration: { + type: "slider", + category: "general", + min: 0, + max: 15, + default: 4, + step: 0.5, + unit: "s", + reloadRequired: false, + advanced: true, + textAdornment: adornments.advanced, + enable: noop, + change: () => showIconToast({ + message: t("example_toast"), + iconSrc: getResourceUrl(`img-logo${mode === "development" ? "_dev" : ""}`), + }), + }, + showToastOnGenericError: { + type: "toggle", + category: "general", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + resetConfig: { + type: "button", + category: "general", + click: promptResetConfig, + textAdornment: adornments.reload, + }, + resetEverything: { + type: "button", + category: "general", + click: async () => { + if (await showPrompt({ + type: "confirm", + message: t("reset_everything_confirm"), + })) { + await getStoreSerializer().resetStoresData(); + await reloadTab(); + } }, - }; - /** Uses the default config as the base, then overwrites all values with the passed {@linkcode baseData}, then sets all passed {@linkcode resetKeys} to their default values */ - function useDefaultConfig(baseData, resetKeys) { - var _a; - const newData = Object.assign(Object.assign({}, defaultData), (baseData !== null && baseData !== void 0 ? baseData : {})); - for (const key of resetKeys) // @ts-ignore - newData[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; // typescript funny moments + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, + logLevel: { + type: "select", + category: "general", + options: () => [ + { value: 0, label: t("log_level_debug") }, + { value: 1, label: t("log_level_info") }, + ], + default: 1, + textAdornment: adornments.reload, + }, + advancedMode: { + type: "toggle", + category: "general", + default: false, + textAdornment: () => getFeature("advancedMode") ? adornments.advanced() : undefined, + change: (_key, prevValue, newValue) => prevValue !== newValue && + emitSiteEvent("recreateCfgMenu"), + }, +};/** If this number is incremented, the features object data will be migrated to the new format */ +const formatVersion = 9; +const defaultData = Object.keys(featInfo) + // @ts-ignore + .filter((ftKey) => { var _a; return ((_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[ftKey]) === null || _a === void 0 ? void 0 : _a.default) !== undefined; }) + .reduce((acc, key) => { + var _a; + // @ts-ignore + acc[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; + return acc; +}, {}); +/** Config data format migration dictionary */ +const migrations = { + // 1 -> 2 (<=v1.0) + 2: (oldData) => { + if (typeof oldData !== "object" || oldData === null) + return defaultData; + const queueBtnsEnabled = Boolean(oldData.queueButtons); + delete oldData.queueButtons; + return Object.assign(Object.assign({}, oldData), { deleteFromQueueButton: queueBtnsEnabled, lyricsQueueButton: queueBtnsEnabled }); + }, + // 2 -> 3 (v1.0) + 3: (oldData) => useDefaultConfig(oldData, [ + "removeShareTrackingParam", "numKeysSkipToTime", + "fixSpacing", "scrollToActiveSongBtn", "logLevel", + ]), + // 3 -> 4 (v1.1) + 4: (oldData) => { + var _a, _b, _c, _d; + const oldSwitchSitesHotkey = oldData.switchSitesHotkey; + return Object.assign(Object.assign({}, useDefaultConfig(oldData, [ + "rememberSongTime", "rememberSongTimeSites", + "volumeSliderScrollStep", "locale", "versionCheck", + ])), { arrowKeySkipBy: 10, switchSitesHotkey: { + code: (_a = oldSwitchSitesHotkey.key) !== null && _a !== void 0 ? _a : "F9", + shift: Boolean((_b = oldSwitchSitesHotkey.shift) !== null && _b !== void 0 ? _b : false), + ctrl: Boolean((_c = oldSwitchSitesHotkey.ctrl) !== null && _c !== void 0 ? _c : false), + alt: Boolean((_d = oldSwitchSitesHotkey.meta) !== null && _d !== void 0 ? _d : false), + }, listButtonsPlacement: "queueOnly" }); + }, + // 4 -> 5 (v2.0) + 5: (oldData) => useDefaultConfig(oldData, [ + "localeFallback", "geniUrlBase", "geniUrlToken", + "lyricsCacheMaxSize", "lyricsCacheTTL", + "clearLyricsCache", "advancedMode", + "checkVersionNow", "advancedLyricsFilter", + "rememberSongTimeDuration", "rememberSongTimeReduction", + "rememberSongTimeMinPlayTime", "volumeSharedBetweenTabs", + "setInitialTabVolume", "initialTabVolumeLevel", + "thumbnailOverlayBehavior", "thumbnailOverlayToggleBtnShown", + "thumbnailOverlayShowIndicator", "thumbnailOverlayIndicatorOpacity", + "thumbnailOverlayImageFit", "removeShareTrackingParamSites", + "fixHdrIssues", "clearQueueBtn", + "closeToastsTimeout", "disableDarkReaderSites", + ]), + // 5 -> 6 (v2.1) + 6: (oldData) => { + const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [ + "autoLikeChannels", "autoLikeChannelToggleBtn", + "autoLikeTimeout", "autoLikeShowToast", + "autoLikeOpenMgmtDialog", "showVotes", + "numbersFormat", "toastDuration", + "initTimeout", + // forgot to add this to the migration when adding the feature way before so now will have to do: + "volumeSliderLabel", + ]), [ + { key: "rememberSongTimeSites", oldDefault: "ytm" }, + { key: "volumeSliderScrollStep", oldDefault: 10 }, + ]); + "removeUpgradeTab" in newData && delete newData.removeUpgradeTab; + "advancedLyricsFilter" in newData && delete newData.advancedLyricsFilter; return newData; - } - /** - * Uses {@linkcode oldData} as the base, then sets all keys provided in {@linkcode defaults} to their old default values, as long as their current value is equal to the provided old default. - * This essentially means if someone has changed a feature's value from its old default value, that decision will be respected. Only if it has been left on its old default value, it will be reset to the new default value. - * Returns a copy of the object. - */ - function useNewDefaultIfUnchanged(oldData, defaults) { - var _a; - const newData = Object.assign({}, oldData); - for (const { key, oldDefault } of defaults) { - // @ts-ignore - const defaultVal = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; - if (newData[key] === oldDefault) - newData[key] = defaultVal; // we love TS - } + }, + // TODO(v2.2): use default for "autoLikePlayerBarToggleBtn" + // TODO(v2.2): set autoLikeChannels to true on migration once feature is fully implemented + // 6 -> 7 (v2.1-dev) + 7: (oldData) => { + const newData = useNewDefaultIfUnchanged(useDefaultConfig(oldData, [ + "showToastOnGenericError", "sponsorBlockIntegration", + "themeSongIntegration", "themeSongLightness", + "errorOnLyricsNotFound", "openPluginList", + ]), [ + { key: "toastDuration", oldDefault: 3 }, + ]); + newData.arrowKeySkipBy = UserUtils.clamp(newData.arrowKeySkipBy, 0.5, 30); return newData; + }, + // 7 -> 8 (v2.1) + 8: (oldData) => { + if ("showVotesFormat" in oldData) { + oldData.numbersFormat = oldData.showVotesFormat; + delete oldData.showVotesFormat; + } + return useDefaultConfig(oldData, [ + "autoLikeChannels" + ]); + }, + // 8 -> 9 (v2.2) + 9: (oldData) => { + oldData.locale = oldData.locale.replace("_", "-"); + if (oldData.locale === "ja-JA") + oldData.locale = "ja-JP"; + if (oldData.locale === "en-GB") + oldData.locale = "en-GB"; + return useDefaultConfig(oldData, [ + "resetEverything", + // TODO(V2.2): + // "autoLikePlayerBarToggleBtn", + ]); + }, + // 9 -> 10 (v2.2.1) + 10: (oldData) => useDefaultConfig(oldData, [ + "aboveQueueBtnsSticky", + ]), +}; +/** Uses the default config as the base, then overwrites all values with the passed {@linkcode baseData}, then sets all passed {@linkcode resetKeys} to their default values */ +function useDefaultConfig(baseData, resetKeys) { + var _a; + const newData = Object.assign(Object.assign({}, defaultData), (baseData !== null && baseData !== void 0 ? baseData : {})); + for (const key of resetKeys) // @ts-ignore + newData[key] = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; // typescript funny moments + return newData; +} +/** + * Uses {@linkcode oldData} as the base, then sets all keys provided in {@linkcode defaults} to their old default values, as long as their current value is equal to the provided old default. + * This essentially means if someone has changed a feature's value from its old default value, that decision will be respected. Only if it has been left on its old default value, it will be reset to the new default value. + * Returns a copy of the object. + */ +function useNewDefaultIfUnchanged(oldData, defaults) { + var _a; + const newData = Object.assign({}, oldData); + for (const { key, oldDefault } of defaults) { + // @ts-ignore + const defaultVal = (_a = featInfo === null || featInfo === void 0 ? void 0 : featInfo[key]) === null || _a === void 0 ? void 0 : _a.default; + if (newData[key] === oldDefault) + newData[key] = defaultVal; // we love TS } - let canCompress = true; - const configStore = new UserUtils.DataStore({ - id: "bytm-config", - formatVersion, - defaultData, - migrations, - encodeData: (data) => canCompress ? UserUtils.compress(data, compressionFormat, "string") : data, - decodeData: (data) => canCompress ? UserUtils.decompress(data, compressionFormat, "string") : data, - }); - /** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */ - async function initConfig() { - canCompress = await compressionSupported(); - const oldFmtVer = Number(await GM.getValue(`_uucfgver-${configStore.id}`, NaN)); - // remove extraneous keys - let data = fixCfgKeys(await configStore.loadData()); - await configStore.setData(data); - log(`Initialized feature config DataStore with version ${configStore.formatVersion}`); - if (isNaN(oldFmtVer)) - info(" !- Config data was initialized with default values"); - else if (oldFmtVer !== configStore.formatVersion) { - try { - await configStore.setData(data = fixCfgKeys(data)); - info(` !- Config data was migrated from version ${oldFmtVer} to ${configStore.formatVersion}`); - } - catch (err) { - error(" !- Config data migration failed, falling back to default data:", err); - await configStore.setData(data = configStore.defaultData); - } - } - emitInterface("bytm:configReady"); - return Object.assign({}, data); - } - /** - * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object. - * Returns a copy of the originally passed object if nothing needs to be fixed. - */ - function fixCfgKeys(cfg) { - const newCfg = Object.assign({}, cfg); - const passedKeys = Object.keys(cfg); - const defaultKeys = Object.keys(defaultData); - const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k)); - if (missingKeys.length > 0) { - for (const key of missingKeys) - newCfg[key] = defaultData[key]; - } - const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k)); - if (extraKeys.length > 0) { - for (const key of extraKeys) - delete newCfg[key]; - } - return newCfg; - } - /** Returns the current feature config from the in-memory cache as a copy */ - function getFeatures() { - return configStore.getData(); - } - /** Returns the value of the feature with the given key from the in-memory cache, as a copy */ - function getFeature(key) { - return configStore.getData()[key]; - } - /** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */ - function setFeatures(featureConf) { - const res = configStore.setData(featureConf); - emitSiteEvent("configChanged", configStore.getData()); - info("Saved new feature config:", featureConf); - return res; - } - /** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */ - function setDefaultFeatures() { - const res = configStore.saveDefaultData(); - emitSiteEvent("configChanged", configStore.getData()); - info("Reset feature config to its default values"); - return res; - } - async function promptResetConfig() { - if (await showPrompt({ type: "confirm", message: t("reset_config_confirm") })) { - closeCfgMenu(); - disableBeforeUnload(); - await setDefaultFeatures(); - if (location.pathname.startsWith("/watch")) { - const videoTime = await getVideoTime(0); - const url = new URL(location.href); - url.searchParams.delete("t"); - if (videoTime) - url.searchParams.set("time_continue", String(videoTime)); - location.replace(url.href); - } - else - location.reload(); - } - } - /** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */ - async function clearConfig() { - await configStore.deleteData(); - info("Deleted config from persistent storage"); - } - - const { autoPlural, getUnsafeWindow, randomId, NanoEmitter } = UserUtils__namespace; - /** - * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`) - * If prefixed with /\*🔒\*\/, the function is authenticated and requires a token to be passed as the first argument. - */ - const globalFuncs = { - // meta: - /*🔒*/ getPluginInfo, - // bytm-specific: - getDomain, - getResourceUrl, - getSessionId, - // dom: - setInnerHtml, - addSelectorListener, - onInteraction, - getVideoTime, - getThumbnailUrl, - getBestThumbnailUrl, - waitVideoElementReady, - getCurrentMediaType, - // translations: - /*🔒*/ setLocale: setLocaleInterface, - getLocale, - hasKey, - hasKeyFor, - t, - tp, - tl, - tlp, - // feature config: - /*🔒*/ getFeatures: getFeaturesInterface, - /*🔒*/ saveFeatures: saveFeaturesInterface, - // lyrics: - fetchLyricsUrlTop, - getLyricsCacheEntry, - sanitizeArtists, - sanitizeSong, - // auto-like: - /*🔒*/ getAutoLikeData: getAutoLikeDataInterface, - /*🔒*/ saveAutoLikeData: saveAutoLikeDataInterface, - fetchVideoVotes, - // components: - createHotkeyInput, - createToggleInput, - createCircularBtn, - createRipple, - showToast, - showIconToast, - showPrompt, - // other: - formatNumber, - }; - /** Initializes the BYTM interface */ - function initInterface() { - const props = Object.assign(Object.assign(Object.assign({ - // meta / constants - mode, - branch, - host, - buildNumber, - compressionFormat }, scriptInfo), globalFuncs), { - // classes - NanoEmitter, - BytmDialog, - ExImDialog, - MarkdownDialog, - // libraries - UserUtils: UserUtils__namespace, - compareVersions: compareVersions__namespace }); - for (const [key, value] of Object.entries(props)) - setGlobalProp(key, value); - log("Initialized BYTM interface"); - } - /** Sets a global property on the unsafeWindow.BYTM object - ⚠️ use with caution as these props can be accessed by any script on the page! */ - function setGlobalProp(key, value) { - // use unsafeWindow so the properties are available to plugins outside of the userscript's scope - const win = getUnsafeWindow(); - if (typeof win.BYTM !== "object") - win.BYTM = {}; - win.BYTM[key] = value; - } - /** Emits an event on the BYTM interface */ - function emitInterface(type, ...detail) { - var _a; + return newData; +} +let canCompress = true; +const configStore = new UserUtils.DataStore({ + id: "bytm-config", + formatVersion, + defaultData, + migrations, + encodeData: (data) => canCompress ? UserUtils.compress(data, compressionFormat, "string") : data, + decodeData: (data) => canCompress ? UserUtils.decompress(data, compressionFormat, "string") : data, +}); +/** Initializes the DataStore instance and loads persistent data into memory. Returns a copy of the config object. */ +async function initConfig() { + canCompress = await compressionSupported(); + const oldFmtVer = Number(await GM.getValue(`_uucfgver-${configStore.id}`, NaN)); + // remove extraneous keys + let data = fixCfgKeys(await configStore.loadData()); + await configStore.setData(data); + log(`Initialized feature config DataStore with version ${configStore.formatVersion}`); + if (isNaN(oldFmtVer)) + info(" !- Config data was initialized with default values"); + else if (oldFmtVer !== configStore.formatVersion) { try { - getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: (_a = detail === null || detail === void 0 ? void 0 : detail[0]) !== null && _a !== void 0 ? _a : undefined })); - //@ts-ignore - emitOnPlugins(type, undefined, ...detail); - log(`Emitted interface event '${type}'${detail.length > 0 && (detail === null || detail === void 0 ? void 0 : detail[0]) ? " with data:" : ""}`, ...detail); + await configStore.setData(data = fixCfgKeys(data)); + info(` !- Config data was migrated from version ${oldFmtVer} to ${configStore.formatVersion}`); } catch (err) { - error(`Couldn't emit interface event '${type}' due to an error:\n`, err); + error(" !- Config data migration failed, falling back to default data:", err); + await configStore.setData(data = configStore.defaultData); } } - //#region register plugins - /** Map of plugin ID and all registered plugins */ - const registeredPlugins = new Map(); - /** Map of plugin ID to auth token for plugins that have been registered */ - const registeredPluginTokens = new Map(); - /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */ - function initPlugins() { - // TODO: check perms and ask user for initial activation - const registerPlugin = (def) => { - var _a, _b; - try { - const plKey = getPluginKey(def); - if (registeredPlugins.has(plKey)) - throw new PluginError(`Failed to register plugin '${plKey}': Plugin with the same name and namespace is already registered`); - const validationErrors = validatePluginDef(def); - if (validationErrors) - throw new PluginError(`Failed to register plugin${((_a = def === null || def === void 0 ? void 0 : def.plugin) === null || _a === void 0 ? void 0 : _a.name) ? ` '${(_b = def === null || def === void 0 ? void 0 : def.plugin) === null || _b === void 0 ? void 0 : _b.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`); - const events = new NanoEmitter({ publicEmit: true }); - const token = randomId(32, 36, true); - registeredPlugins.set(plKey, { - def: def, - events, - }); - registeredPluginTokens.set(plKey, token); - info(`Successfully registered plugin '${plKey}'`); - setTimeout(() => emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)), 1); - return { - info: getPluginInfo(token, def), - events, - token, - }; - } - catch (err) { - error(`Failed to register plugin '${getPluginKey(def)}':`, err instanceof PluginError ? err : new PluginError(String(err))); - throw err; - } - }; - emitInterface("bytm:registerPlugin", (def) => registerPlugin(def)); - if (registeredPlugins.size > 0) - log(`Registered ${registeredPlugins.size} ${autoPlural("plugin", registeredPlugins.size)}`); - } - /** Returns the registered plugins as an array of tuples with the items `[id: string, item: PluginItem]` */ - function getRegisteredPlugins() { - return [...registeredPlugins.entries()]; - } - /** Returns the key for a given plugin definition */ - function getPluginKey(plugin) { - return `${plugin.plugin.namespace}/${plugin.plugin.name}`; - } - /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */ - function pluginDefToInfo(plugin) { - return plugin - ? { - name: plugin.plugin.name, - namespace: plugin.plugin.namespace, - version: plugin.plugin.version, - } - : undefined; - } - /** Checks whether two plugins are the same, given their resolvable definition objects */ - function sameDef(def1, def2) { - return getPluginKey(def1) === getPluginKey(def2); - } - /** Emits an event on all plugins that match the predicate (all plugins by default) */ - function emitOnPlugins(event, predicate = true, ...data) { - for (const { def, events } of registeredPlugins.values()) - if (typeof predicate === "boolean" ? predicate : predicate(def)) - events.emit(event, ...data); - } - /** - * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered. - * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. - * @public Intended for general use in plugins. - */ - function getPluginInfo(...args) { - var _a; - if (resolveToken(args[0]) === undefined) - return undefined; - return pluginDefToInfo((_a = registeredPlugins.get(typeof args[1] === "string" && typeof args[2] === "undefined" - ? args[1] - : args.length === 2 - ? `${args[2]}/${args[1]}` - : getPluginKey(args[1]))) === null || _a === void 0 ? void 0 : _a.def); - } - /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */ - function validatePluginDef(pluginDef) { - const errors = []; - const addNoPropErr = (jsonPath, type) => errors.push(t("plugin_validation_error_no_property", jsonPath, type)); - const addInvalidPropErr = (jsonPath, value, examples) => errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`)); - // def.plugin and its properties: - typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object"); - const { plugin } = pluginDef; - !(plugin === null || plugin === void 0 ? void 0 : plugin.name) && addNoPropErr("plugin.name", "string"); - !(plugin === null || plugin === void 0 ? void 0 : plugin.namespace) && addNoPropErr("plugin.namespace", "string"); - if (typeof (plugin === null || plugin === void 0 ? void 0 : plugin.version) !== "string") - addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH"); - else if (!compareVersions__namespace.validateStrict(plugin.version)) - addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]); - return errors.length > 0 ? errors : undefined; - } - /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */ - function resolveToken(token) { - var _a, _b; - return typeof token === "string" && token.length > 0 - ? (_b = (_a = [...registeredPluginTokens.entries()] - .find(([k, t]) => registeredPlugins.has(k) && token === t)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : undefined - : undefined; - } - //#region proxy funcs - /** - * Sets the new locale on the BYTM interface - * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. - */ - function setLocaleInterface(token, locale) { - const pluginId = resolveToken(token); - if (pluginId === undefined) - return; - setLocale(locale); - emitInterface("bytm:setLocale", { pluginId, locale }); - } - /** - * Returns the current feature config, with sensitive values replaced by `undefined` - * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. - */ - function getFeaturesInterface(token) { - if (resolveToken(token) === undefined) - return undefined; - const features = getFeatures(); - for (const ftKey of Object.keys(features)) { - const info = featInfo[ftKey]; - if (info && info.valueHidden) // @ts-ignore - features[ftKey] = undefined; + emitInterface("bytm:configReady"); + return Object.assign({}, data); +} +/** + * Fixes missing keys in the passed config object with their default values or removes extraneous keys and returns a copy of the fixed object. + * Returns a copy of the originally passed object if nothing needs to be fixed. + */ +function fixCfgKeys(cfg) { + const newCfg = Object.assign({}, cfg); + const passedKeys = Object.keys(cfg); + const defaultKeys = Object.keys(defaultData); + const missingKeys = defaultKeys.filter(k => !passedKeys.includes(k)); + if (missingKeys.length > 0) { + for (const key of missingKeys) + newCfg[key] = defaultData[key]; + } + const extraKeys = passedKeys.filter(k => !defaultKeys.includes(k)); + if (extraKeys.length > 0) { + for (const key of extraKeys) + delete newCfg[key]; + } + return newCfg; +} +/** Returns the current feature config from the in-memory cache as a copy */ +function getFeatures() { + return configStore.getData(); +} +/** Returns the value of the feature with the given key from the in-memory cache, as a copy */ +function getFeature(key) { + return configStore.getData()[key]; +} +/** Saves the feature config synchronously to the in-memory cache and asynchronously to the persistent storage */ +function setFeatures(featureConf) { + const res = configStore.setData(featureConf); + emitSiteEvent("configChanged", configStore.getData()); + info("Saved new feature config:", featureConf); + return res; +} +/** Saves the default feature config synchronously to the in-memory cache and asynchronously to persistent storage */ +function setDefaultFeatures() { + const res = configStore.saveDefaultData(); + emitSiteEvent("configChanged", configStore.getData()); + info("Reset feature config to its default values"); + return res; +} +async function promptResetConfig() { + if (await showPrompt({ type: "confirm", message: t("reset_config_confirm") })) { + closeCfgMenu(); + enableDiscardBeforeUnload(); + await setDefaultFeatures(); + if (location.pathname.startsWith("/watch")) { + const videoTime = await getVideoTime(0); + const url = new URL(location.href); + url.searchParams.delete("t"); + if (videoTime) + url.searchParams.set("time_continue", String(videoTime)); + location.replace(url.href); } - return features; - } - /** - * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage. - * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. - */ - function saveFeaturesInterface(token, features) { - if (resolveToken(token) === undefined) - return; - setFeatures(features); - } - /** - * Returns the auto-like data. - * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. - */ - function getAutoLikeDataInterface(token) { - if (resolveToken(token) === undefined) - return; - return autoLikeStore.getData(); + else + await reloadTab(); } - /** - * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage. - * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. - */ - function saveAutoLikeDataInterface(token, data) { - if (resolveToken(token) === undefined) - return; - return autoLikeStore.setData(data); +} +/** Clears the feature config from the persistent storage - since the cache will be out of whack, this should only be run before a site re-/unload */ +async function clearConfig() { + await configStore.deleteData(); + info("Deleted config from persistent storage"); +}const { autoPlural, getUnsafeWindow, randomId, NanoEmitter } = UserUtils__namespace; +/** + * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`) + * If prefixed with /\*🔒\*\/, the function is authenticated and requires a token to be passed as the first argument. + */ +const globalFuncs = { + // meta: + /*🔒*/ getPluginInfo, + // bytm-specific: + getDomain, + getResourceUrl, + getSessionId, + reloadTab, + // dom: + setInnerHtml, + addSelectorListener, + onInteraction, + getVideoTime, + getThumbnailUrl, + getBestThumbnailUrl, + waitVideoElementReady, + getVideoElement, + getVideoSelector, + getCurrentMediaType, + // translations: + /*🔒*/ setLocale: setLocaleInterface, + getLocale, + hasKey, + hasKeyFor, + t, + tp, + tl, + tlp, + // feature config: + /*🔒*/ getFeatures: getFeaturesInterface, + /*🔒*/ saveFeatures: saveFeaturesInterface, + getDefaultFeatures: () => JSON.parse(JSON.stringify(defaultData)), + // lyrics: + fetchLyricsUrlTop, + getLyricsCacheEntry, + sanitizeArtists, + sanitizeSong, + // auto-like: + /*🔒*/ getAutoLikeData: getAutoLikeDataInterface, + /*🔒*/ saveAutoLikeData: saveAutoLikeDataInterface, + fetchVideoVotes, + // components: + createHotkeyInput, + createToggleInput, + createCircularBtn, + createRipple, + showToast, + showIconToast, + showPrompt, + // other: + formatNumber, +}; +/** Initializes the BYTM interface */ +function initInterface() { + const props = Object.assign(Object.assign(Object.assign({ + // meta / constants + mode, + branch, + host, + buildNumber, + initialParams, + compressionFormat, + sessionStorageAvailable }, scriptInfo), globalFuncs), { + // classes + NanoEmitter, + BytmDialog, + ExImDialog, + MarkdownDialog, + // libraries + UserUtils: UserUtils__namespace, + compareVersions: compareVersions__namespace }); + for (const [key, value] of Object.entries(props)) + setGlobalProp(key, value); + log("Initialized BYTM interface"); +} +/** Sets a global property on the unsafeWindow.BYTM object - ⚠️ use with caution as these props can be accessed by any script on the page! */ +function setGlobalProp(key, value) { + // use unsafeWindow so the properties are available to plugins outside of the userscript's scope + const win = getUnsafeWindow(); + if (typeof win.BYTM !== "object") + win.BYTM = {}; + win.BYTM[key] = value; +} +/** Emits an event on the BYTM interface */ +function emitInterface(type, ...detail) { + var _a; + try { + getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: (_a = detail === null || detail === void 0 ? void 0 : detail[0]) !== null && _a !== void 0 ? _a : undefined })); + //@ts-ignore + emitOnPlugins(type, undefined, ...detail); + log(`Emitted interface event '${type}'${detail.length > 0 && (detail === null || detail === void 0 ? void 0 : detail[0]) ? " with data:" : ""}`, ...detail); + } + catch (err) { + error(`Couldn't emit interface event '${type}' due to an error:\n`, err); } - - //#region globals - /** Options that are applied to every SelectorObserver instance */ - const defaultObserverOptions = { - disableOnNoListeners: false, - enableOnAddListener: false, - defaultDebounce: 150, - defaultDebounceEdge: "rising", - }; - /** Global SelectorObserver instances usable throughout the script for improved performance */ - const globservers = {}; - /** Whether all observers have been initialized */ - let globserversReady = false; - //#region add listener func - /** - * Interface function for adding listeners to the {@linkcode globservers} - * If the observers haven't been initialized yet, the function will queue calls until the `bytm:observersReady` event is emitted - * @param selector Relative to the observer's root element, so the selector can only start at of the root element's children at the earliest! - * @param options Options for the listener - * @template TElem The type of the element that the listener will be attached to. If set to `0`, the default type `HTMLElement` will be used. - * @template TDomain This restricts which observers are available with the current domain - */ - function addSelectorListener(observerName, selector, options) { +} +//#region register plugins +/** Map of plugin ID and all registered plugins */ +const registeredPlugins = new Map(); +/** Map of plugin ID to auth token for plugins that have been registered */ +const registeredPluginTokens = new Map(); +/** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */ +function initPlugins() { + // TODO: check perms and ask user for initial activation + const registerPlugin = (def) => { + var _a, _b; try { - if (!globserversReady) { - window.addEventListener("bytm:observersReady", () => addSelectorListener(observerName, selector, options), { once: true }); - return; - } - globservers[observerName].addListener(selector, options); + const plKey = getPluginKey(def); + if (registeredPlugins.has(plKey)) + throw new PluginError(`Failed to register plugin '${plKey}': Plugin with the same name and namespace is already registered`); + const validationErrors = validatePluginDef(def); + if (validationErrors) + throw new PluginError(`Failed to register plugin${((_a = def === null || def === void 0 ? void 0 : def.plugin) === null || _a === void 0 ? void 0 : _a.name) ? ` '${(_b = def === null || def === void 0 ? void 0 : def.plugin) === null || _b === void 0 ? void 0 : _b.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`); + const events = new NanoEmitter({ publicEmit: true }); + const token = randomId(32, 36, true); + registeredPlugins.set(plKey, { + def: def, + events, + }); + registeredPluginTokens.set(plKey, token); + info(`Successfully registered plugin '${plKey}'`); + setTimeout(() => emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)), 1); + return { + info: getPluginInfo(token, def), + events, + token, + }; } catch (err) { - error(`Couldn't add listener to globserver '${observerName}':`, err); - } - } - //#region init - /** Call after DOM load to initialize all SelectorObserver instances */ - function initObservers() { - try { - //#region both sites - //#region body - // -> the entire element - use sparingly due to performance impacts! - // enabled immediately - globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 150, subtree: false })); - globservers.body.enable(); - //#region bytmDialogContainer - // -> the container for all BytmDialog instances - // enabled immediately - const bytmDialogContainerSelector = "#bytm-dialog-container"; - globservers.bytmDialogContainer = new UserUtils.SelectorObserver(bytmDialogContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounceEdge: "falling", defaultDebounce: 100, subtree: true })); - globservers.bytmDialogContainer.enable(); - switch (getDomain()) { - case "ytm": { - //#region YTM - //#region browseResponse - // -> for example the /channel/UC... page# - // enabled by "body" - const browseResponseSelector = "ytmusic-browse-response"; - globservers.browseResponse = new UserUtils.SelectorObserver(browseResponseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true })); - globservers.body.addListener(browseResponseSelector, { - listener: () => globservers.browseResponse.enable(), - }); - //#region navBar - // -> the navigation / title bar at the top of the page - // enabled by "body" - const navBarSelector = "ytmusic-nav-bar"; - globservers.navBar = new UserUtils.SelectorObserver(navBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false })); - globservers.body.addListener(navBarSelector, { - listener: () => globservers.navBar.enable(), - }); - //#region mainPanel - // -> the main content panel - includes things like the video element - // enabled by "body" - const mainPanelSelector = "ytmusic-player-page #main-panel"; - globservers.mainPanel = new UserUtils.SelectorObserver(mainPanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(mainPanelSelector, { - listener: () => globservers.mainPanel.enable(), - }); - //#region sideBar - // -> the sidebar on the left side of the page - // enabled by "body" - const sidebarSelector = "ytmusic-app-layout tp-yt-app-drawer"; - globservers.sideBar = new UserUtils.SelectorObserver(sidebarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(sidebarSelector, { - listener: () => globservers.sideBar.enable(), - }); - //#region sideBarMini - // -> the minimized sidebar on the left side of the page - // enabled by "body" - const sideBarMiniSelector = "ytmusic-app-layout #mini-guide"; - globservers.sideBarMini = new UserUtils.SelectorObserver(sideBarMiniSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(sideBarMiniSelector, { - listener: () => globservers.sideBarMini.enable(), - }); - //#region sidePanel - // -> the side panel on the right side of the /watch page - // enabled by "body" - const sidePanelSelector = "#side-panel"; - globservers.sidePanel = new UserUtils.SelectorObserver(sidePanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(sidePanelSelector, { - listener: () => globservers.sidePanel.enable(), - }); - //#region playerBar - // -> media controls bar at the bottom of the page - // enabled by "body" - const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app"; - globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 })); - globservers.body.addListener(playerBarSelector, { - listener: () => { - globservers.playerBar.enable(); - }, - }); - //#region playerBarInfo - // -> song title, artist, album, etc. inside the player bar - // enabled by "playerBar" - const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`; - globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] })); - globservers.playerBar.addListener(playerBarInfoSelector, { - listener: () => globservers.playerBarInfo.enable(), - }); - //#region playerBarMiddleButtons - // -> the buttons inside the player bar (like, dislike, lyrics, etc.) - // enabled by "playerBar" - const playerBarMiddleButtonsSelector = ".middle-controls .middle-controls-buttons"; - globservers.playerBarMiddleButtons = new UserUtils.SelectorObserver(playerBarMiddleButtonsSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.playerBar.addListener(playerBarMiddleButtonsSelector, { - listener: () => globservers.playerBarMiddleButtons.enable(), - }); - //#region playerBarRightControls - // -> the controls on the right side of the player bar (volume, repeat, shuffle, etc.) - // enabled by "playerBar" - const playerBarRightControls = "#right-controls"; - globservers.playerBarRightControls = new UserUtils.SelectorObserver(playerBarRightControls, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.playerBar.addListener(playerBarRightControls, { - listener: () => globservers.playerBarRightControls.enable(), - }); - //#region popupContainer - // -> the container for popups (e.g. the queue popup) - // enabled by "body" - const popupContainerSelector = "ytmusic-app ytmusic-popup-container"; - globservers.popupContainer = new UserUtils.SelectorObserver(popupContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(popupContainerSelector, { - listener: () => globservers.popupContainer.enable(), - }); - break; - } - case "yt": { - //#region YT - //#region ytGuide - // -> the left sidebar menu - // enabled by "body" - const ytGuideSelector = "#content tp-yt-app-drawer#guide #guide-inner-content"; - globservers.ytGuide = new UserUtils.SelectorObserver(ytGuideSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(ytGuideSelector, { - listener: () => globservers.ytGuide.enable(), - }); - //#region ytdBrowse - // -> channel pages for example - // enabled by "body" - const ytdBrowseSelector = "ytd-app ytd-page-manager ytd-browse"; - globservers.ytdBrowse = new UserUtils.SelectorObserver(ytdBrowseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(ytdBrowseSelector, { - listener: () => globservers.ytdBrowse.enable(), - }); - //#region ytAppHeader - // -> header of the page - // enabled by "ytdBrowse" - const ytAppHeaderSelector = "#header tp-yt-app-header"; - globservers.ytAppHeader = new UserUtils.SelectorObserver(ytAppHeaderSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true })); - globservers.ytdBrowse.addListener(ytAppHeaderSelector, { - listener: () => globservers.ytAppHeader.enable(), - }); - //#region ytWatchFlexy - // -> the main content of the /watch page - // enabled by "body" - const ytWatchFlexySelector = "ytd-app ytd-watch-flexy"; - globservers.ytWatchFlexy = new UserUtils.SelectorObserver(ytWatchFlexySelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(ytWatchFlexySelector, { - listener: () => globservers.ytWatchFlexy.enable(), - }); - //#region ytWatchMetadata - // -> the metadata section of the /watch page (title, channel, views, description, buttons, etc. but not comments) - // enabled by "ytWatchFlexy" - const ytWatchMetadataSelector = "#columns #primary-inner ytd-watch-metadata"; - globservers.ytWatchMetadata = new UserUtils.SelectorObserver(ytWatchMetadataSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.ytWatchFlexy.addListener(ytWatchMetadataSelector, { - listener: () => globservers.ytWatchMetadata.enable(), - }); - //#region ytMasthead - // -> the masthead (title bar) at the top of the page - // enabled by "body" - const mastheadSelector = "#content ytd-masthead#masthead"; - globservers.ytMasthead = new UserUtils.SelectorObserver(mastheadSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); - globservers.body.addListener(mastheadSelector, { - listener: () => globservers.ytMasthead.enable(), - }); - } - } - //#region finalize - globserversReady = true; - emitInterface("bytm:observersReady"); + error(`Failed to register plugin '${getPluginKey(def)}':`, err instanceof PluginError ? err : new PluginError(String(err))); + throw err; } - catch (err) { - error("Failed to initialize observers:", err); + }; + emitInterface("bytm:registerPlugin", (def) => registerPlugin(def)); + if (registeredPlugins.size > 0) + log(`Registered ${registeredPlugins.size} ${autoPlural("plugin", registeredPlugins.size)}`); +} +/** Returns the registered plugins as an array of tuples with the items `[id: string, item: PluginItem]` */ +function getRegisteredPlugins() { + return [...registeredPlugins.entries()]; +} +/** Returns the key for a given plugin definition */ +function getPluginKey(plugin) { + return `${plugin.plugin.namespace}/${plugin.plugin.name}`; +} +/** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */ +function pluginDefToInfo(plugin) { + return plugin + ? { + name: plugin.plugin.name, + namespace: plugin.plugin.namespace, + version: plugin.plugin.version, + } + : undefined; +} +/** Checks whether two plugins are the same, given their resolvable definition objects */ +function sameDef(def1, def2) { + return getPluginKey(def1) === getPluginKey(def2); +} +/** Emits an event on all plugins that match the predicate (all plugins by default) */ +function emitOnPlugins(event, predicate = true, ...data) { + for (const { def, events } of registeredPlugins.values()) + if (typeof predicate === "boolean" ? predicate : predicate(def)) + events.emit(event, ...data); +} +/** + * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered. + * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. + * @public Intended for general use in plugins. + */ +function getPluginInfo(...args) { + var _a; + if (resolveToken(args[0]) === undefined) + return undefined; + return pluginDefToInfo((_a = registeredPlugins.get(typeof args[1] === "string" && typeof args[2] === "undefined" + ? args[1] + : args.length === 2 + ? `${args[2]}/${args[1]}` + : getPluginKey(args[1]))) === null || _a === void 0 ? void 0 : _a.def); +} +/** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */ +function validatePluginDef(pluginDef) { + const errors = []; + const addNoPropErr = (jsonPath, type) => errors.push(t("plugin_validation_error_no_property", jsonPath, type)); + const addInvalidPropErr = (jsonPath, value, examples) => errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`)); + // def.plugin and its properties: + typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object"); + const { plugin } = pluginDef; + !(plugin === null || plugin === void 0 ? void 0 : plugin.name) && addNoPropErr("plugin.name", "string"); + !(plugin === null || plugin === void 0 ? void 0 : plugin.namespace) && addNoPropErr("plugin.namespace", "string"); + if (typeof (plugin === null || plugin === void 0 ? void 0 : plugin.version) !== "string") + addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH"); + else if (!compareVersions__namespace.validateStrict(plugin.version)) + addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]); + return errors.length > 0 ? errors : undefined; +} +/** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */ +function resolveToken(token) { + var _a, _b; + return typeof token === "string" && token.length > 0 + ? (_b = (_a = [...registeredPluginTokens.entries()] + .find(([k, t]) => registeredPlugins.has(k) && token === t)) === null || _a === void 0 ? void 0 : _a[0]) !== null && _b !== void 0 ? _b : undefined + : undefined; +} +//#region proxy funcs +/** + * Sets the new locale on the BYTM interface + * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. + */ +function setLocaleInterface(token, locale) { + const pluginId = resolveToken(token); + if (pluginId === undefined) + return; + setLocale(locale); + emitInterface("bytm:setLocale", { pluginId, locale }); +} +/** + * Returns the current feature config, with sensitive values replaced by `undefined` + * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. + */ +function getFeaturesInterface(token) { + if (resolveToken(token) === undefined) + return undefined; + const features = getFeatures(); + for (const ftKey of Object.keys(features)) { + const info = featInfo[ftKey]; + if (info && info.valueHidden) // @ts-ignore + features[ftKey] = undefined; + } + return features; +} +/** + * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage. + * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. + */ +function saveFeaturesInterface(token, features) { + if (resolveToken(token) === undefined) + return; + setFeatures(features); +} +/** + * Returns the auto-like data. + * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. + */ +function getAutoLikeDataInterface(token) { + if (resolveToken(token) === undefined) + return; + return autoLikeStore.getData(); +} +/** + * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage. + * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration. + */ +function saveAutoLikeDataInterface(token, data) { + if (resolveToken(token) === undefined) + return; + return autoLikeStore.setData(data); +}//#region globals +/** Options that are applied to every SelectorObserver instance */ +const defaultObserverOptions = { + disableOnNoListeners: false, + enableOnAddListener: false, + defaultDebounce: 150, +}; +/** Global SelectorObserver instances usable throughout the script for improved performance */ +const globservers = {}; +/** Whether all observers have been initialized */ +let globserversReady = false; +//#region add listener func +/** + * Interface function for adding listeners to the {@linkcode globservers} + * If the observers haven't been initialized yet, the function will queue calls until the `bytm:observersReady` event is emitted + * @param selector Relative to the observer's root element, so the selector can only start at of the root element's children at the earliest! + * @param options Options for the listener + * @template TElem The type of the element that the listener will be attached to. If set to `0`, the default type `HTMLElement` will be used. + * @template TDomain This restricts which observers are available with the current domain + */ +function addSelectorListener(observerName, selector, options) { + try { + if (!globserversReady) { + window.addEventListener("bytm:observersReady", () => addSelectorListener(observerName, selector, options), { once: true }); + return; } + globservers[observerName].addListener(selector, options); } - - /** Whether the DOM has finished loading and elements can be added or modified */ - let domLoaded = false; - document.addEventListener("DOMContentLoaded", () => domLoaded = true); - //#region vid time & vol. - /** Returns the video element selector string based on the current domain */ - const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video"; - /** Returns the video element based on the current domain */ - function getVideoElement() { - return document.querySelector(getVideoSelector()); - } - let vidElemReady = false; - /** - * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default). - * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6. - * Dispatches mouse movement events in case the video time can't be read from the video or progress bar elements (needs a prior user interaction to work) - * @returns Returns null if the video time is unavailable or no user interaction has happened prior to calling in case of the fallback behavior being used - */ - function getVideoTime(precision = 2) { - return new Promise(async (res) => { - if (!vidElemReady) { - await waitVideoElementReady(); - vidElemReady = true; - } - const resolveWithVal = (time) => res(time && !isNaN(time) - ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision)) - : null); - try { - if (getDomain() === "ytm") { - const vidElem = getVideoElement(); - if (vidElem) - return resolveWithVal(vidElem.currentTime); - addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", { - listener: (pbEl) => resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null) - }); - } - else if (getDomain() === "yt") { - const vidElem = getVideoElement(); - if (vidElem) - return resolveWithVal(vidElem.currentTime); - // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it) - ytForceShowVideoTime(); - const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]"; - let videoTime = -1; - const mut = new MutationObserver(() => { - // .observe() is only called when the element exists - no need to check for null - videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow")); - }); - const observe = (progElem) => { - mut.observe(progElem, { - attributes: true, - attributeFilter: ["aria-valuenow"], - }); - if (videoTime >= 0 && !isNaN(videoTime)) { - resolveWithVal(Math.floor(videoTime)); - mut.disconnect(); - } - else - setTimeout(() => { - resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null); - mut.disconnect(); - }, 500); - }; - addSelectorListener("body", pbSelector, { listener: observe }); - } - } - catch (err) { - error("Couldn't get video time due to error:", err); - res(null); - } - }); - } - /** - * Sends events that force the video controls to become visible for about 3 seconds. - * This only works once (for some reason), then the page needs to be reloaded! - */ - function ytForceShowVideoTime() { - const player = document.querySelector("#movie_player"); - if (!player) - return false; - const defaultProps = { - // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue - view: UserUtils.getUnsafeWindow(), - bubbles: true, - cancelable: false, - }; - player.dispatchEvent(new MouseEvent("mouseenter", defaultProps)); - const { x, y, width, height } = player.getBoundingClientRect(); - const screenY = Math.round(y + height / 2); - const screenX = x + Math.min(50, Math.round(width / 3)); - player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY, - screenX, movementX: 5, movementY: 0 }))); - return true; + catch (err) { + error(`Couldn't add listener to globserver '${observerName}':`, err); } - /** - * Waits for the video element to be in its readyState 4 / canplay state and returns it. - * Could take a very long time to resolve if the `/watch` page isn't open. - * Resolves immediately if the video element is already ready. - */ - function waitVideoElementReady() { - return new Promise(async (res, rej) => { - try { - const vidEl = getVideoElement(); - if ((vidEl === null || vidEl === void 0 ? void 0 : vidEl.readyState) === 4) - return res(vidEl); - if (!location.pathname.startsWith("/watch")) - await siteEvents.once("watchIdChanged"); - addSelectorListener("body", getVideoSelector(), { - listener(vidElem) { - // this is just after YT has finished doing their own shenanigans with the video time and volume - if (vidElem.readyState === 4) - res(vidElem); - else - vidElem.addEventListener("canplay", () => res(vidElem), { once: true }); +} +//#region init +/** Call after DOM load to initialize all SelectorObserver instances */ +function initObservers() { + try { + //#region both sites + //#region body + // -> the entire element - use sparingly due to performance impacts! + // enabled immediately + globservers.body = new UserUtils.SelectorObserver(document.body, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 150, subtree: false })); + globservers.body.enable(); + //#region bytmDialogContainer + // -> the container for all BytmDialog instances + // enabled immediately + const bytmDialogContainerSelector = "#bytm-dialog-container"; + globservers.bytmDialogContainer = new UserUtils.SelectorObserver(bytmDialogContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 100, subtree: true })); + globservers.bytmDialogContainer.enable(); + switch (getDomain()) { + case "ytm": { + //#region YTM + //#region browseResponse + // -> for example the /channel/UC... page# + // enabled by "body" + const browseResponseSelector = "ytmusic-browse-response"; + globservers.browseResponse = new UserUtils.SelectorObserver(browseResponseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true })); + globservers.body.addListener(browseResponseSelector, { + listener: () => globservers.browseResponse.enable(), + }); + //#region navBar + // -> the navigation / title bar at the top of the page + // enabled by "body" + const navBarSelector = "ytmusic-nav-bar"; + globservers.navBar = new UserUtils.SelectorObserver(navBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: false })); + globservers.body.addListener(navBarSelector, { + listener: () => globservers.navBar.enable(), + }); + //#region mainPanel + // -> the main content panel - includes things like the video element + // enabled by "body" + const mainPanelSelector = "ytmusic-player-page #main-panel"; + globservers.mainPanel = new UserUtils.SelectorObserver(mainPanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(mainPanelSelector, { + listener: () => globservers.mainPanel.enable(), + }); + //#region sideBar + // -> the sidebar on the left side of the page + // enabled by "body" + const sidebarSelector = "ytmusic-app-layout tp-yt-app-drawer"; + globservers.sideBar = new UserUtils.SelectorObserver(sidebarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(sidebarSelector, { + listener: () => globservers.sideBar.enable(), + }); + //#region sideBarMini + // -> the minimized sidebar on the left side of the page + // enabled by "body" + const sideBarMiniSelector = "ytmusic-app-layout #mini-guide"; + globservers.sideBarMini = new UserUtils.SelectorObserver(sideBarMiniSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(sideBarMiniSelector, { + listener: () => globservers.sideBarMini.enable(), + }); + //#region sidePanel + // -> the side panel on the right side of the /watch page + // enabled by "body" + const sidePanelSelector = "#side-panel"; + globservers.sidePanel = new UserUtils.SelectorObserver(sidePanelSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(sidePanelSelector, { + listener: () => globservers.sidePanel.enable(), + }); + //#region playerBar + // -> media controls bar at the bottom of the page + // enabled by "body" + const playerBarSelector = "ytmusic-app-layout ytmusic-player-bar.ytmusic-app"; + globservers.playerBar = new UserUtils.SelectorObserver(playerBarSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 200 })); + globservers.body.addListener(playerBarSelector, { + listener: () => { + globservers.playerBar.enable(); }, }); + //#region playerBarInfo + // -> song title, artist, album, etc. inside the player bar + // enabled by "playerBar" + const playerBarInfoSelector = `${playerBarSelector} .middle-controls .content-info-wrapper`; + globservers.playerBarInfo = new UserUtils.SelectorObserver(playerBarInfoSelector, Object.assign(Object.assign({}, defaultObserverOptions), { attributes: true, attributeFilter: ["title"] })); + globservers.playerBar.addListener(playerBarInfoSelector, { + listener: () => globservers.playerBarInfo.enable(), + }); + //#region playerBarMiddleButtons + // -> the buttons inside the player bar (like, dislike, lyrics, etc.) + // enabled by "playerBar" + const playerBarMiddleButtonsSelector = ".middle-controls .middle-controls-buttons"; + globservers.playerBarMiddleButtons = new UserUtils.SelectorObserver(playerBarMiddleButtonsSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.playerBar.addListener(playerBarMiddleButtonsSelector, { + listener: () => globservers.playerBarMiddleButtons.enable(), + }); + //#region playerBarRightControls + // -> the controls on the right side of the player bar (volume, repeat, shuffle, etc.) + // enabled by "playerBar" + const playerBarRightControls = "#right-controls"; + globservers.playerBarRightControls = new UserUtils.SelectorObserver(playerBarRightControls, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.playerBar.addListener(playerBarRightControls, { + listener: () => globservers.playerBarRightControls.enable(), + }); + //#region popupContainer + // -> the container for popups (e.g. the queue popup) + // enabled by "body" + const popupContainerSelector = "ytmusic-app ytmusic-popup-container"; + globservers.popupContainer = new UserUtils.SelectorObserver(popupContainerSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(popupContainerSelector, { + listener: () => globservers.popupContainer.enable(), + }); + break; + } + case "yt": { + //#region YT + //#region ytGuide + // -> the left sidebar menu + // enabled by "body" + const ytGuideSelector = "#content tp-yt-app-drawer#guide #guide-inner-content"; + globservers.ytGuide = new UserUtils.SelectorObserver(ytGuideSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(ytGuideSelector, { + listener: () => globservers.ytGuide.enable(), + }); + //#region ytdBrowse + // -> channel pages for example + // enabled by "body" + const ytdBrowseSelector = "ytd-app ytd-page-manager ytd-browse"; + globservers.ytdBrowse = new UserUtils.SelectorObserver(ytdBrowseSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(ytdBrowseSelector, { + listener: () => globservers.ytdBrowse.enable(), + }); + //#region ytAppHeader + // -> header of the page + // enabled by "ytdBrowse" + const ytAppHeaderSelector = "#header tp-yt-app-header"; + globservers.ytAppHeader = new UserUtils.SelectorObserver(ytAppHeaderSelector, Object.assign(Object.assign({}, defaultObserverOptions), { defaultDebounce: 75, subtree: true })); + globservers.ytdBrowse.addListener(ytAppHeaderSelector, { + listener: () => globservers.ytAppHeader.enable(), + }); + //#region ytWatchFlexy + // -> the main content of the /watch page + // enabled by "body" + const ytWatchFlexySelector = "ytd-app ytd-watch-flexy"; + globservers.ytWatchFlexy = new UserUtils.SelectorObserver(ytWatchFlexySelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(ytWatchFlexySelector, { + listener: () => globservers.ytWatchFlexy.enable(), + }); + //#region ytWatchMetadata + // -> the metadata section of the /watch page (title, channel, views, description, buttons, etc. but not comments) + // enabled by "ytWatchFlexy" + const ytWatchMetadataSelector = "#columns #primary-inner ytd-watch-metadata"; + globservers.ytWatchMetadata = new UserUtils.SelectorObserver(ytWatchMetadataSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.ytWatchFlexy.addListener(ytWatchMetadataSelector, { + listener: () => globservers.ytWatchMetadata.enable(), + }); + //#region ytMasthead + // -> the masthead (title bar) at the top of the page + // enabled by "body" + const mastheadSelector = "#content ytd-masthead#masthead"; + globservers.ytMasthead = new UserUtils.SelectorObserver(mastheadSelector, Object.assign(Object.assign({}, defaultObserverOptions), { subtree: true })); + globservers.body.addListener(mastheadSelector, { + listener: () => globservers.ytMasthead.enable(), + }); + } + } + //#region finalize + globserversReady = true; + emitInterface("bytm:observersReady"); + } + catch (err) { + error("Failed to initialize observers:", err); + } +}/** Whether the DOM has finished loading and elements can be added or modified */ +let domLoaded = false; +document.addEventListener("DOMContentLoaded", () => domLoaded = true); +//#region vid time & vol. +/** Returns the video element selector string based on the current domain */ +function getVideoSelector() { + return getDomain() === "ytm" + ? "ytmusic-player video" + : "#player-container ytd-player video"; +} +/** Returns the video element based on the current domain */ +function getVideoElement() { + return document.querySelector(getVideoSelector()); +} +let vidElemReady = false; +/** + * Returns the current video time in seconds, with the given {@linkcode precision} (2 decimal digits by default). + * Rounds down if the precision is set to 0. The maximum average available precision on YTM is 6. + * Dispatches mouse movement events in case the video time can't be read from the video or progress bar elements (needs a prior user interaction to work) + * @returns Returns null if the video time is unavailable or no user interaction has happened prior to calling in case of the fallback behavior being used + */ +function getVideoTime(precision = 2) { + return new Promise(async (res) => { + if (!vidElemReady) { + await waitVideoElementReady(); + vidElemReady = true; + } + const resolveWithVal = (time) => res(time && !isNaN(time) + ? Number(precision <= 0 ? Math.floor(time) : time.toFixed(precision)) + : null); + try { + if (getDomain() === "ytm") { + const vidElem = getVideoElement(); + if (vidElem && vidElem.readyState > 0) + return resolveWithVal(vidElem.currentTime); + addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", { + listener: (pbEl) => resolveWithVal(!isNaN(Number(pbEl.value)) ? Math.floor(Number(pbEl.value)) : null) + }); } - catch (err) { - rej(err); + else if (getDomain() === "yt") { + const vidElem = getVideoElement(); + if (vidElem && vidElem.readyState > 0) + return resolveWithVal(vidElem.currentTime); + // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it) + ytForceShowVideoTime(); + const pbSelector = ".ytp-chrome-bottom div.ytp-progress-bar[role=\"slider\"]"; + let videoTime = -1; + const mut = new MutationObserver(() => { + // .observe() is only called when the element exists - no need to check for null + videoTime = Number(document.querySelector(pbSelector).getAttribute("aria-valuenow")); + }); + const observe = (progElem) => { + mut.observe(progElem, { + attributes: true, + attributeFilter: ["aria-valuenow"], + }); + if (videoTime >= 0 && !isNaN(videoTime)) { + resolveWithVal(Math.floor(videoTime)); + mut.disconnect(); + } + else + setTimeout(() => { + resolveWithVal(videoTime >= 0 && !isNaN(videoTime) ? Math.floor(videoTime) : null); + mut.disconnect(); + }, 500); + }; + addSelectorListener("body", pbSelector, { listener: observe }); } - }); - } - //#region css utils - /** - * Adds a style element to the DOM at runtime. - * @param css The CSS stylesheet to add - * @param ref A reference string to identify the style element - defaults to a random 5-character string - * @param transform A function to transform the CSS before adding it to the DOM - */ - async function addStyle(css, ref, transform = (c) => c) { - if (!domLoaded) - throw new Error("DOM has not finished loading yet"); - const elem = UserUtils.addGlobalStyle(await transform(css)); - elem.id = `bytm-style-${ref !== null && ref !== void 0 ? ref : UserUtils.randomId(6, 36)}`; - return elem; - } - /** - * Adds a global style element with the contents fetched from the specified resource starting with `css-` - * The CSS can be transformed using the provided function before being added to the DOM. - */ - async function addStyleFromResource(key, transform = (c) => c) { - const css = await fetchCss(key); - if (css) { - await addStyle(transform(css), key.slice(4)); - return true; } + catch (err) { + error("Couldn't get video time due to error:", err); + res(null); + } + }); +} +/** + * Sends events that force the video controls to become visible for about 3 seconds. + * This only works once (for some reason), then the page needs to be reloaded! + */ +function ytForceShowVideoTime() { + const player = document.querySelector("#movie_player"); + if (!player) return false; - } - /** Sets a global CSS variable on the <document> element with the name `--bytm-global-${name}` */ - function setGlobalCssVar(name, value) { - document.documentElement.style.setProperty(`--bytm-global-${name.toLowerCase().trim()}`, String(value)); - } - /** Sets multiple global CSS variables on the <document> element with the name `--bytm-global-${name}` */ - function setGlobalCssVars(vars) { - for (const [name, value] of Object.entries(vars)) - setGlobalCssVar(name, value); - } - //#region other - /** Removes all child nodes of an element without invoking the slow-ish HTML parser */ - function clearInner(element) { - while (element.hasChildNodes()) - clearNode(element.firstChild); - } - /** Removes all child nodes of an element recursively and also removes the element itself */ - function clearNode(element) { - while (element.hasChildNodes()) - clearNode(element.firstChild); - element.parentNode.removeChild(element); - } - /** - * Returns an identifier for the currently playing media type on YTM (song or video). - * Only works on YTM and will throw on YT or if {@linkcode waitVideoElementReady} hasn't been awaited yet. - */ - function getCurrentMediaType() { - if (getDomain() === "yt") - throw new Error("currentMediaType() is only available on YTM!"); - const songImgElem = document.querySelector("ytmusic-player #song-image"); - if (!songImgElem) - throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!"); - return window.getComputedStyle(songImgElem).display !== "none" ? "song" : "video"; - } - /** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */ - function copyToClipboard(text) { + const defaultProps = { + // needed because otherwise YTM errors out - see https://github.com/Sv443/BetterYTM/issues/18#show_issue + view: UserUtils.getUnsafeWindow(), + bubbles: true, + cancelable: false, + }; + player.dispatchEvent(new MouseEvent("mouseenter", defaultProps)); + const { x, y, width, height } = player.getBoundingClientRect(); + const screenY = Math.round(y + height / 2); + const screenX = x + Math.min(50, Math.round(width / 3)); + player.dispatchEvent(new MouseEvent("mousemove", Object.assign(Object.assign({}, defaultProps), { screenY, + screenX, movementX: 5, movementY: 0 }))); + return true; +} +/** + * Waits for the video element to be in its readyState 4 / canplay state and returns it. + * Could take a very long time to resolve if the `/watch` page isn't open. + * Resolves immediately if the video element is already ready. + */ +function waitVideoElementReady() { + return new Promise(async (res, rej) => { + var _a; try { - GM.setClipboard(String(text)); - } - catch (_a) { - showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) }); - } - } - let ttPolicy; - // workaround for supporting `target="_blank"` links without compromising security: - const tempTargetAttrName = `data-tmp-target-${UserUtils.randomId(6, 36)}`; - DOMPurify.addHook("beforeSanitizeAttributes", (node) => { - if (node.tagName === "A") { - if (!node.hasAttribute("target")) - node.setAttribute("target", "_self"); - if (node.hasAttribute("target")) - node.setAttribute(tempTargetAttrName, node.getAttribute("target")); + const vidEl = getVideoElement(); + if (vidEl && ((_a = vidEl === null || vidEl === void 0 ? void 0 : vidEl.readyState) !== null && _a !== void 0 ? _a : 0) > 0) + return res(vidEl); + if (!location.pathname.startsWith("/watch")) + await siteEvents.once("watchIdChanged"); + addSelectorListener("body", getVideoSelector(), { + listener(vidElem) { + // this is just after YT has finished doing their own shenanigans with the video time and volume + if (vidElem.readyState === 4) + res(vidElem); + else + vidElem.addEventListener("canplay", () => res(vidElem), { once: true }); + }, + }); } - }); - DOMPurify.addHook("afterSanitizeAttributes", (node) => { - if (node.tagName === "A" && node.hasAttribute(tempTargetAttrName)) { - node.setAttribute("target", node.getAttribute(tempTargetAttrName)); - node.removeAttribute(tempTargetAttrName); - if (node.getAttribute("target") === "_blank") - node.setAttribute("rel", "noopener noreferrer"); + catch (err) { + rej(err); } }); - /** Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML */ - function setInnerHtml(element, html) { - var _a, _b; - if (!ttPolicy && ((_a = window === null || window === void 0 ? void 0 : window.trustedTypes) === null || _a === void 0 ? void 0 : _a.createPolicy)) { - ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", { - createHTML: (dirty) => DOMPurify.sanitize(dirty, { - RETURN_TRUSTED_TYPE: true, - }), - }); - } - element.innerHTML = (_b = ttPolicy === null || ttPolicy === void 0 ? void 0 : ttPolicy.createHTML(html)) !== null && _b !== void 0 ? _b : DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: false }); - } - /** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */ - function downloadFile(fileName, data, mimeType = "text/plain") { - const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType }); - const a = document.createElement("a"); - a.classList.add("bytm-hidden"); - a.href = URL.createObjectURL(blob); - a.download = fileName; - document.body.appendChild(a); - a.click(); - setTimeout(() => a.remove(), 50); +} +//#region css utils +/** + * Adds a style element to the DOM at runtime. + * @param css The CSS stylesheet to add + * @param ref A reference string to identify the style element - defaults to a random 5-character string + * @param transform A function to transform the CSS before adding it to the DOM + */ +async function addStyle(css, ref, transform = (c) => c) { + if (!domLoaded) + throw new Error("DOM has not finished loading yet"); + const elem = UserUtils.addGlobalStyle(await transform(await UserUtils.consumeStringGen(css))); + elem.id = `bytm-style-${ref !== null && ref !== void 0 ? ref : UserUtils.randomId(6, 36)}`; + return elem; +} +/** + * Adds a global style element with the contents fetched from the specified resource starting with `css-` + * The CSS can be transformed using the provided function before being added to the DOM. + */ +async function addStyleFromResource(key, transform = (c) => c) { + const css = await fetchCss(key); + if (css) { + await addStyle(String(transform(css)), key.slice(4)); + return true; } - - /** - * Constructs a URL from a base URL and a record of query parameters. - * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted. - * All values will be stringified using their `toString()` method and then URI-encoded. - * @returns Returns a string instead of a URL object - */ - function constructUrlString(baseUrl, params) { - return `${baseUrl}?${Object.entries(params) + return false; +} +/** Sets a global CSS variable on the <document> element with the name `--bytm-global-${name}` */ +function setGlobalCssVar(name, value) { + document.documentElement.style.setProperty(`--bytm-global-${name.toLowerCase().trim()}`, String(value)); +} +/** Sets multiple global CSS variables on the <document> element with the name `--bytm-global-${name}` */ +function setGlobalCssVars(vars) { + for (const [name, value] of Object.entries(vars)) + setGlobalCssVar(name, value); +} +//#region other +/** Removes all child nodes of an element without invoking the slow-ish HTML parser */ +function clearInner(element) { + while (element.hasChildNodes()) + clearNode(element.firstChild); +} +/** Removes all child nodes of an element recursively and also removes the element itself */ +function clearNode(element) { + while (element.hasChildNodes()) + clearNode(element.firstChild); + element.parentNode.removeChild(element); +} +/** + * Returns an identifier for the currently playing media type on YTM ("song" or "video"). + * Only works on YTM and will throw if {@linkcode waitVideoElementReady} hasn't been awaited yet. + * On YT, it will always return "video". + */ +function getCurrentMediaType() { + if (getDomain() === "yt") + return "video"; + const songImgElem = document.querySelector("ytmusic-player #song-image"); + if (!songImgElem) + throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!"); + return window.getComputedStyle(songImgElem).display !== "none" ? "song" : "video"; +} +/** Copies the provided text to the clipboard and shows an error message for manual copying if the grant `GM.setClipboard` is not given. */ +function copyToClipboard(text) { + try { + GM.setClipboard(String(text)); + } + catch (_a) { + showPrompt({ type: "alert", message: t("copy_to_clipboard_error", String(text)) }); + } +} +let ttPolicy; +// workaround for supporting `target="_blank"` links without compromising security: +const tempTargetAttrName = `data-tmp-target-${UserUtils.randomId(6, 36)}`; +DOMPurify.addHook("beforeSanitizeAttributes", (node) => { + if (node.tagName === "A") { + if (!node.hasAttribute("target")) + node.setAttribute("target", "_self"); + if (node.hasAttribute("target")) + node.setAttribute(tempTargetAttrName, node.getAttribute("target")); + } +}); +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A" && node.hasAttribute(tempTargetAttrName)) { + node.setAttribute("target", node.getAttribute(tempTargetAttrName)); + node.removeAttribute(tempTargetAttrName); + if (node.getAttribute("target") === "_blank") + node.setAttribute("rel", "noopener noreferrer"); + } +}); +/** + * Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML. + * If no HTML string is given, the element's innerHTML will be set to an empty string. + */ +function setInnerHtml(element, html) { + var _a, _b; + if (!html) + html = ""; + if (!ttPolicy && ((_a = window === null || window === void 0 ? void 0 : window.trustedTypes) === null || _a === void 0 ? void 0 : _a.createPolicy)) { + ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", { + createHTML: (dirty) => DOMPurify.sanitize(dirty, { + RETURN_TRUSTED_TYPE: true, + }), + }); + } + element.innerHTML = (_b = ttPolicy === null || ttPolicy === void 0 ? void 0 : ttPolicy.createHTML(String(html))) !== null && _b !== void 0 ? _b : DOMPurify.sanitize(String(html), { RETURN_TRUSTED_TYPE: false }); +} +/** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */ +function downloadFile(fileName, data, mimeType = "text/plain") { + const blob = data instanceof Blob ? data : new Blob([data], { type: mimeType }); + const a = document.createElement("a"); + a.classList.add("bytm-hidden"); + a.href = URL.createObjectURL(blob); + a.download = fileName; + document.body.appendChild(a); + a.click(); + setTimeout(() => a.remove(), 1); +}/** + * Constructs a URL from a base URL and a record of query parameters. + * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted. + * All values will be stringified using their `toString()` method and then URI-encoded. + * @returns Returns a string instead of a URL object + */ +function constructUrlString(baseUrl, params) { + return `${baseUrl}?${Object.entries(params) .filter(([, v]) => v !== undefined) .map(([key, val]) => `${key}${val === null ? "" : `=${encodeURIComponent(String(val))}`}`) .join("&")}`; +} +/** + * Constructs a URL object from a base URL and a record of query parameters. + * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted. + * All values will be URI-encoded. + * @returns Returns a URL object instead of a string + */ +function constructUrl(base, params) { + return new URL(constructUrlString(base, params)); +} +/** + * Sends a request with the specified parameters and returns the response as a Promise. + * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced. + */ +function sendRequest(details) { + return new Promise((resolve, reject) => { + GM.xmlHttpRequest(Object.assign(Object.assign({ timeout: 10000 }, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject })); + }); +} +/** Fetches a CSS file from the specified resource with a key starting with `css-` */ +async function fetchCss(key) { + try { + const css = await (await UserUtils.fetchAdvanced(await getResourceUrl(key))).text(); + return css !== null && css !== void 0 ? css : undefined; } - /** - * Constructs a URL object from a base URL and a record of query parameters. - * If a value is null, the parameter will be valueless. If a value is undefined, the parameter will be omitted. - * All values will be URI-encoded. - * @returns Returns a URL object instead of a string - */ - function constructUrl(base, params) { - return new URL(constructUrlString(base, params)); - } - /** - * Sends a request with the specified parameters and returns the response as a Promise. - * Ignores [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), contrary to fetch and fetchAdvanced. - */ - function sendRequest(details) { - return new Promise((resolve, reject) => { - GM.xmlHttpRequest(Object.assign(Object.assign({ timeout: 10000 }, details), { onload: resolve, onerror: reject, ontimeout: reject, onabort: reject })); - }); + catch (err) { + error("Couldn't fetch CSS due to an error:", err); + return undefined; } - /** Fetches a CSS file from the specified resource with a key starting with `css-` */ - async function fetchCss(key) { - try { - const css = await (await UserUtils.fetchAdvanced(await getResourceUrl(key))).text(); - return css !== null && css !== void 0 ? css : undefined; +} +/** Cache for the vote data of YouTube videos to prevent some unnecessary requests */ +const voteCache = new Map(); +/** Time-to-live for the vote cache in milliseconds */ +const voteCacheTTL = 1000 * 60 * 10; +/** + * Fetches the votes object for a YouTube video from the [Return YouTube Dislike API.](https://returnyoutubedislike.com/docs) + * @param watchId The watch ID of the video + */ +async function fetchVideoVotes(watchId) { + try { + if (voteCache.has(watchId)) { + const cached = voteCache.get(watchId); + if (Date.now() - cached.timestamp < voteCacheTTL) { + info(`Returning cached video votes for watch ID '${watchId}':`, cached); + return cached; + } + else + voteCache.delete(watchId); } - catch (err) { - error("Couldn't fetch CSS due to an error:", err); + const votesRaw = JSON.parse((await sendRequest({ + method: "GET", + url: `https://returnyoutubedislikeapi.com/votes?videoId=${watchId}`, + })).response); + if (!("id" in votesRaw) || !("likes" in votesRaw) || !("dislikes" in votesRaw) || !("rating" in votesRaw)) { + error("Couldn't parse video votes due to an error:", votesRaw); return undefined; } + const votesObj = { + id: votesRaw.id, + likes: votesRaw.likes, + dislikes: votesRaw.dislikes, + rating: votesRaw.rating, + timestamp: Date.now(), + }; + voteCache.set(votesObj.id, votesObj); + info(`Fetched video votes for watch ID '${watchId}':`, votesObj); + return votesObj; + } + catch (err) { + error("Couldn't fetch video votes due to an error:", err); + return undefined; + } +}let welcomeDialog = null; +/** Creates and/or returns the import dialog */ +async function getWelcomeDialog() { + if (!welcomeDialog) { + welcomeDialog = new BytmDialog({ + id: "welcome", + width: 700, + height: 500, + closeBtnEnabled: true, + closeOnBgClick: false, + closeOnEscPress: true, + destroyOnClose: true, + renderHeader, + renderBody: renderBody$1, + renderFooter, + }); + welcomeDialog.on("render", retranslateWelcomeMenu); } - /** Cache for the vote data of YouTube videos to prevent some unnecessary requests */ - const voteCache = new Map(); - /** Time-to-live for the vote cache in milliseconds */ - const voteCacheTTL = 1000 * 60 * 10; - /** - * Fetches the votes object for a YouTube video from the [Return YouTube Dislike API.](https://returnyoutubedislike.com/docs) - * @param watchId The watch ID of the video - */ - async function fetchVideoVotes(watchId) { - try { - if (voteCache.has(watchId)) { - const cached = voteCache.get(watchId); - if (Date.now() - cached.timestamp < voteCacheTTL) { - info(`Returning cached video votes for watch ID '${watchId}':`, cached); - return cached; - } - else - voteCache.delete(watchId); - } - const votesRaw = JSON.parse((await sendRequest({ - method: "GET", - url: `https://returnyoutubedislikeapi.com/votes?videoId=${watchId}`, - })).response); - if (!("id" in votesRaw) || !("likes" in votesRaw) || !("dislikes" in votesRaw) || !("rating" in votesRaw)) { - error("Couldn't parse video votes due to an error:", votesRaw); - return undefined; - } - const votesObj = { - id: votesRaw.id, - likes: votesRaw.likes, - dislikes: votesRaw.dislikes, - rating: votesRaw.rating, - timestamp: Date.now(), - }; - voteCache.set(votesObj.id, votesObj); - info(`Fetched video votes for watch ID '${watchId}':`, votesObj); - return votesObj; - } - catch (err) { - error("Couldn't fetch video votes due to an error:", err); - return undefined; + return welcomeDialog; +} +async function renderHeader() { + const titleWrapperElem = document.createElement("div"); + titleWrapperElem.id = "bytm-welcome-menu-title-wrapper"; + const titleLogoElem = document.createElement("img"); + titleLogoElem.id = "bytm-welcome-menu-title-logo"; + titleLogoElem.classList.add("bytm-no-select"); + titleLogoElem.src = await getResourceUrl(mode === "development" ? "img-logo_dev" : "img-logo"); + const titleElem = document.createElement("h2"); + titleElem.id = "bytm-welcome-menu-title"; + titleElem.classList.add("bytm-dialog-title"); + titleElem.role = "heading"; + titleElem.ariaLevel = "1"; + titleElem.tabIndex = 0; + titleWrapperElem.appendChild(titleLogoElem); + titleWrapperElem.appendChild(titleElem); + return titleWrapperElem; +} +async function renderBody$1() { + const contentWrapper = document.createElement("div"); + contentWrapper.id = "bytm-welcome-menu-content-wrapper"; + // locale switcher + const localeCont = document.createElement("div"); + localeCont.id = "bytm-welcome-menu-locale-cont"; + const localeImg = document.createElement("img"); + localeImg.id = "bytm-welcome-menu-locale-img"; + localeImg.classList.add("bytm-no-select"); + localeImg.src = await getResourceUrl("icon-globe"); + const localeSelectElem = document.createElement("select"); + localeSelectElem.id = "bytm-welcome-menu-locale-select"; + for (const [locale, { name }] of Object.entries(locales)) { + const localeOptionElem = document.createElement("option"); + localeOptionElem.value = locale; + localeOptionElem.textContent = name; + localeSelectElem.appendChild(localeOptionElem); + } + localeSelectElem.value = getFeature("locale"); + localeSelectElem.addEventListener("change", async () => { + const selectedLocale = localeSelectElem.value; + const feats = Object.assign({}, getFeatures()); + feats.locale = selectedLocale; + setFeatures(feats); + await initTranslations(selectedLocale); + setLocale(selectedLocale); + retranslateWelcomeMenu(); + }); + localeCont.appendChild(localeImg); + localeCont.appendChild(localeSelectElem); + contentWrapper.appendChild(localeCont); + // text + const textCont = document.createElement("div"); + textCont.id = "bytm-welcome-menu-text-cont"; + const textElem = document.createElement("p"); + textElem.id = "bytm-welcome-menu-text"; + const textElems = []; + const line1Elem = document.createElement("span"); + line1Elem.id = "bytm-welcome-text-line1"; + line1Elem.tabIndex = 0; + textElems.push(line1Elem); + const br1Elem = document.createElement("br"); + textElems.push(br1Elem); + const line2Elem = document.createElement("span"); + line2Elem.id = "bytm-welcome-text-line2"; + line2Elem.tabIndex = 0; + textElems.push(line2Elem); + const br2Elem = document.createElement("br"); + textElems.push(br2Elem); + const br3Elem = document.createElement("br"); + textElems.push(br3Elem); + const line3Elem = document.createElement("span"); + line3Elem.id = "bytm-welcome-text-line3"; + line3Elem.tabIndex = 0; + textElems.push(line3Elem); + const br4Elem = document.createElement("br"); + textElems.push(br4Elem); + const line4Elem = document.createElement("span"); + line4Elem.id = "bytm-welcome-text-line4"; + line4Elem.tabIndex = 0; + textElems.push(line4Elem); + const br5Elem = document.createElement("br"); + textElems.push(br5Elem); + const br6Elem = document.createElement("br"); + textElems.push(br6Elem); + const line5Elem = document.createElement("span"); + line5Elem.id = "bytm-welcome-text-line5"; + line5Elem.tabIndex = 0; + textElems.push(line5Elem); + textElems.forEach((elem) => textElem.appendChild(elem)); + textCont.appendChild(textElem); + contentWrapper.appendChild(textCont); + return contentWrapper; +} +/** Retranslates all elements inside the welcome menu */ +function retranslateWelcomeMenu() { + const getLink = (href) => { + return [``, ""]; + }; + const changes = { + "#bytm-welcome-menu-title": (e) => e.textContent = e.ariaLabel = t("welcome_menu_title", scriptInfo.name), + "#bytm-welcome-menu-title-close": (e) => e.ariaLabel = e.title = t("close_menu_tooltip"), + "#bytm-welcome-menu-open-cfg": (e) => { + e.textContent = e.ariaLabel = t("config_menu"); + e.ariaLabel = e.title = t("open_config_menu_tooltip"); + }, + "#bytm-welcome-menu-open-changelog": (e) => { + e.textContent = e.ariaLabel = t("open_changelog"); + e.ariaLabel = e.title = t("open_changelog_tooltip"); + }, + "#bytm-welcome-menu-footer-close": (e) => { + e.textContent = e.ariaLabel = t("close"); + e.ariaLabel = e.title = t("close_menu_tooltip"); + }, + "#bytm-welcome-text-line1": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_1")), + "#bytm-welcome-text-line2": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_2", scriptInfo.name)), + "#bytm-welcome-text-line3": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_3", scriptInfo.name, ...getLink(`${packageJson.hosts.greasyfork}/feedback`), ...getLink(packageJson.hosts.openuserjs))), + "#bytm-welcome-text-line4": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_4", ...getLink(packageJson.funding.url))), + "#bytm-welcome-text-line5": (e) => setInnerHtml(e, e.ariaLabel = t("welcome_text_line_5", ...getLink(packageJson.bugs.url))), + }; + for (const [selector, fn] of Object.entries(changes)) { + const el = document.querySelector(selector); + if (!el) { + warn(`Couldn't find element in welcome menu with selector '${selector}'`); + continue; } + fn(el); } - - //#region cns. watermark +} +async function renderFooter() { + const footerCont = document.createElement("div"); + footerCont.id = "bytm-welcome-menu-footer-cont"; + const openCfgElem = document.createElement("button"); + openCfgElem.id = "bytm-welcome-menu-open-cfg"; + openCfgElem.classList.add("bytm-btn"); + openCfgElem.addEventListener("click", () => { + welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close(); + openCfgMenu(); + }); + const openChangelogElem = document.createElement("button"); + openChangelogElem.id = "bytm-welcome-menu-open-changelog"; + openChangelogElem.classList.add("bytm-btn"); + openChangelogElem.addEventListener("click", async () => { + const dlg = await getChangelogDialog(); + await dlg.mount(); + welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close(); + await dlg.open(); + }); + const closeBtnElem = document.createElement("button"); + closeBtnElem.id = "bytm-welcome-menu-footer-close"; + closeBtnElem.classList.add("bytm-btn"); + closeBtnElem.addEventListener("click", async () => { + welcomeDialog === null || welcomeDialog === void 0 ? void 0 : welcomeDialog.close(); + }); + const leftButtonsCont = document.createElement("div"); + leftButtonsCont.id = "bytm-menu-footer-left-buttons-cont"; + leftButtonsCont.appendChild(openCfgElem); + leftButtonsCont.appendChild(openChangelogElem); + footerCont.appendChild(leftButtonsCont); + footerCont.appendChild(closeBtnElem); + return footerCont; +}let allDataExImDialog; +/** Creates and/or returns the AllDataExIm dialog */ +async function getAllDataExImDialog() { + if (!allDataExImDialog) { + const eximOpts = { + id: "all-data-exim", + width: 800, + height: 1000, + closeBtnEnabled: true, + closeOnBgClick: true, + closeOnEscPress: true, + destroyOnClose: true, + removeListenersOnDestroy: false, + small: true, + verticalAlign: "top", + title: () => t("all_data_exim_title"), + descExport: () => t("all_data_exim_export_desc"), + descImport: () => t("all_data_exim_import_desc"), + exportData: async () => await getStoreSerializer().serialize(), + onImport, + }; + allDataExImDialog = new ExImDialog(Object.assign(Object.assign({}, eximOpts), { renderBody: async () => await renderBody(eximOpts) })); + } + return allDataExImDialog; +} +/** Creates and/or returns the AutoLikeExIm dialog */ +async function onImport(data) { + try { + const serializer = getStoreSerializer(); + await serializer.deserialize(data); + showToast(t("import_success")); + } + catch (err) { + error(err); + showToast(t("import_error")); + } +} +async function renderBody(opts) { + const panesCont = document.createElement("div"); + panesCont.classList.add("bytm-all-data-exim-dialog-panes-cont"); + //#region export + const exportPane = document.createElement("div"); + exportPane.classList.add("bytm-all-data-exim-dialog-pane", "export"); { - // console watermark with sexy gradient - const [styleGradient, gradientContBg] = (() => { - switch (mode) { - case "production": return ["background: rgb(165, 57, 36); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(165, 57, 36) 100%);", "rgb(165, 57, 36)"]; - case "development": return ["background: rgb(72, 66, 178); background: linear-gradient(90deg, rgb(38, 160, 172) 0%, rgb(33, 48, 158) 40%, rgb(72, 66, 178) 100%);", "rgb(72, 66, 178)"]; - } - })(); - const styleCommon = "color: #fff; font-size: 1.3rem;"; - const poweredBy = `Powered by: + const descEl = document.createElement("p"); + descEl.classList.add("bytm-all-data-exim-dialog-desc"); + descEl.role = "note"; + descEl.tabIndex = 0; + descEl.textContent = descEl.ariaLabel = await UserUtils.consumeStringGen(opts.descExport); + const exportPartsCont = document.createElement("div"); + exportPartsCont.classList.add("bytm-all-data-exim-dialog-export-parts-cont"); + const dataEl = document.createElement("textarea"); + dataEl.classList.add("bytm-all-data-exim-dialog-data"); + dataEl.readOnly = true; + dataEl.tabIndex = 0; + dataEl.value = t("click_to_reveal"); + for (const id of getSerializerStoresIds()) { + const rowEl = document.createElement("div"); + rowEl.classList.add("bytm-all-data-exim-dialog-export-part-row"); + rowEl.title = t(`data_stores.disable.${id}`); + const chkEl = document.createElement("input"); + chkEl.type = "checkbox"; + chkEl.id = `bytm-all-data-exim-dialog-export-part-${id}`; + chkEl.dataset.storeId = id; + chkEl.checked = true; + chkEl.title = t(`data_stores.disable.${id}`); + chkEl.addEventListener("change", async () => { + const kwd = chkEl.checked ? "disable" : "enable"; + rowEl.title = t(`data_stores.${kwd}.${id}`); + chkEl.title = t(`data_stores.${kwd}.${id}`); + lblEl.textContent = t(`data_stores.${kwd}.${id}`); + if (dataEl.classList.contains("revealed")) + dataEl.value = filter(await UserUtils.consumeStringGen(opts.exportData)); + }); + const lblEl = document.createElement("label"); + lblEl.htmlFor = chkEl.id; + lblEl.textContent = t(`data_stores.disable.${id}`); + rowEl.append(chkEl, lblEl); + exportPartsCont.appendChild(rowEl); + } + /** Filters out all data stores that are not checked */ + const filter = (data) => { + const exportIds = []; + for (const chkEl of exportPartsCont.querySelectorAll("input[type=checkbox]")) + chkEl.checked && chkEl.dataset.storeId && exportIds.push(chkEl.dataset.storeId); + return JSON.stringify(JSON.parse(data) + .filter(({ id }) => exportIds.includes(id)), undefined, 2); + }; + onInteraction(dataEl, async () => { + dataEl.classList.add("revealed"); + dataEl.value = filter(await UserUtils.consumeStringGen(opts.exportData)); + dataEl.setSelectionRange(0, dataEl.value.length); + }); + const exportCenterBtnCont = document.createElement("div"); + exportCenterBtnCont.classList.add("bytm-all-data-exim-dialog-center-btn-cont"); + const cpBtn = createRipple(await createLongBtn({ + title: t("copy_to_clipboard"), + text: t("copy"), + resourceName: "icon-copy", + async onClick({ shiftKey }) { + const copyData = shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData; + copyToClipboard(filter(await UserUtils.consumeStringGen(copyData))); + await showToast({ message: t("copied_to_clipboard") }); + }, + })); + const dlBtn = createRipple(await createLongBtn({ + title: t("download_file"), + text: t("download"), + resourceName: "icon-arrow_down", + async onClick({ shiftKey }) { + const dlData = filter(await UserUtils.consumeStringGen(shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData)); + copyToClipboard(dlData); + const pad = (num, len = 2) => String(num).padStart(len, "0"); + const d = new Date(); + const dateStr = `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; + const fileName = `BetterYTM ${packageJson.version} data export ${dateStr}.json`; + downloadFile(fileName, dlData, "application/json"); + await showToast({ message: t("downloaded_file_hint") }); + }, + })); + exportCenterBtnCont.append(cpBtn, dlBtn); + exportPane.append(descEl, dataEl, exportPartsCont, exportCenterBtnCont); + } + //#region import + const importPane = document.createElement("div"); + importPane.classList.add("bytm-all-data-exim-dialog-pane", "import"); + { + // TODO: file upload field + // TODO: select which stores to import + const descEl = document.createElement("p"); + descEl.classList.add("bytm-all-data-exim-dialog-desc"); + descEl.role = "note"; + descEl.tabIndex = 0; + descEl.textContent = descEl.ariaLabel = await UserUtils.consumeStringGen(opts.descImport); + const dataEl = document.createElement("textarea"); + dataEl.classList.add("bytm-all-data-exim-dialog-data"); + dataEl.tabIndex = 0; + const importCenterBtnCont = document.createElement("div"); + importCenterBtnCont.classList.add("bytm-all-data-exim-dialog-center-btn-cont"); + const importBtn = createRipple(await createLongBtn({ + title: t("start_import_tooltip"), + text: t("import"), + resourceName: "icon-upload", + onClick: () => opts.onImport(dataEl.value), + })); + importCenterBtnCont.appendChild(importBtn); + importPane.append(descEl, dataEl, importCenterBtnCont); + } + panesCont.append(exportPane, importPane); + return panesCont; +}//#region cns. watermark +{ + // console watermark with sexy gradient + const [styleGradient, gradientContBg] = (() => { + switch (mode) { + case "production": return ["background: rgb(165, 57, 36); background: linear-gradient(90deg, rgb(154, 31, 103) 0%, rgb(135, 31, 31) 40%, rgb(165, 57, 36) 100%);", "rgb(165, 57, 36)"]; + case "development": return ["background: rgb(72, 66, 178); background: linear-gradient(90deg, rgb(38, 160, 172) 0%, rgb(33, 48, 158) 40%, rgb(72, 66, 178) 100%);", "rgb(72, 66, 178)"]; + } + })(); + const styleCommon = "color: #fff; font-size: 1.3rem;"; + const poweredBy = `Powered by: ─ Lots of ambition and dedication ─ My song metadata API: https://api.sv443.net/geniurl ─ My userscript utility library: https://github.com/Sv443-Network/UserUtils @@ -7750,386 +7873,401 @@ ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic ─ This tiny event listener library: https://github.com/ai/nanoevents ─ TypeScript and the tslib runtime: https://github.com/microsoft/TypeScript ─ The Cousine font: https://fonts.google.com/specimen/Cousine`; - console.log(`\ + console.log(`\ %c${scriptInfo.name}%cv${scriptInfo.version}%c • ${scriptInfo.namespace}%c Build #${buildNumber}${mode === "development" ? " (dev mode)" : ""} %c${poweredBy}`, `${styleCommon} ${styleGradient} font-weight: bold; padding-left: 6px; padding-right: 6px;`, `${styleCommon} background-color: ${gradientContBg}; padding-left: 8px; padding-right: 8px;`, "color: #fff; font-size: 1.2rem;", "padding: initial; font-size: 0.9rem;", "padding: initial; font-size: 1rem;"); +} +//#region preInit +/** Stuff that needs to be called ASAP, before anything async happens */ +function preInit() { + var _a, _b; + try { + const unsupportedHandlers = [ + "FireMonkey", + ]; + if (unsupportedHandlers.includes((_b = (_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) !== null && _b !== void 0 ? _b : "_")) + return alert(`BetterYTM does not work when using ${GM.info.scriptHandler} as the userscript manager extension and will be disabled.\nI recommend using either ViolentMonkey, TamperMonkey or GreaseMonkey.`); + log("Session ID:", getSessionId()); + initInterface(); + setLogLevel(defaultLogLevel); + if (getDomain() === "ytm") + initBeforeUnloadHook(); + init(); + } + catch (err) { + return error("Fatal pre-init error:", err); } - //#region preInit - /** Stuff that needs to be called ASAP, before anything async happens */ - function preInit() { - var _a, _b; +} +//#region init +async function init() { + var _a, _b; + try { + const domain = getDomain(); + const features = await initConfig(); + setLogLevel(features.logLevel); + await initLyricsCache(); + await initTranslations((_a = features.locale) !== null && _a !== void 0 ? _a : "en-US"); + setLocale((_b = features.locale) !== null && _b !== void 0 ? _b : "en-US"); try { - const unsupportedHandlers = [ - "FireMonkey", - ]; - if (unsupportedHandlers.includes((_b = (_a = GM === null || GM === void 0 ? void 0 : GM.info) === null || _a === void 0 ? void 0 : _a.scriptHandler) !== null && _b !== void 0 ? _b : "_")) - return alert(`BetterYTM does not work when using ${GM.info.scriptHandler} as the userscript manager extension and will be disabled.\nI recommend using either ViolentMonkey, TamperMonkey or GreaseMonkey.`); - log("Session ID:", getSessionId()); - initInterface(); - setLogLevel(defaultLogLevel); - if (getDomain() === "ytm") - initBeforeUnloadHook(); - init(); + initPlugins(); } catch (err) { - return error("Fatal pre-init error:", err); + error("Plugin loading error:", err); + emitInterface("bytm:fatalError", "Error while loading plugins"); } + if (features.disableBeforeUnloadPopup && domain === "ytm") + enableDiscardBeforeUnload(); + if (features.rememberSongTime) + initRememberSongTime(); + if (!domLoaded) + document.addEventListener("DOMContentLoaded", onDomLoad, { once: true }); + else + onDomLoad(); } - //#region init - async function init() { - var _a, _b; - try { - const domain = getDomain(); - const features = await initConfig(); - setLogLevel(features.logLevel); - await initLyricsCache(); - await initTranslations((_a = features.locale) !== null && _a !== void 0 ? _a : "en-US"); - setLocale((_b = features.locale) !== null && _b !== void 0 ? _b : "en-US"); - try { - initPlugins(); - } - catch (err) { - error("Plugin loading error:", err); - emitInterface("bytm:fatalError", "Error while loading plugins"); - } - if (features.disableBeforeUnloadPopup && domain === "ytm") - disableBeforeUnload(); - if (features.rememberSongTime) - initRememberSongTime(); - if (!domLoaded) - document.addEventListener("DOMContentLoaded", onDomLoad, { once: true }); - else - onDomLoad(); - } - catch (err) { - error("Fatal error:", err); - } + catch (err) { + error("Fatal error:", err); } - //#region onDomLoad - /** Called when the DOM has finished loading and can be queried and altered by the userscript */ - async function onDomLoad() { - const domain = getDomain(); - const feats = getFeatures(); - const ftInit = []; - // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm") - document.body.classList.add(`bytm-dom-${domain}`); - try { - initGlobalCssVars(); - initObservers(); - await Promise.allSettled([ - injectCssBundle(), - initVersionCheck(), - ]); +} +//#region onDomLoad +/** Called when the DOM has finished loading and can be queried and altered by the userscript */ +async function onDomLoad() { + const domain = getDomain(); + const feats = getFeatures(); + const ftInit = []; + // for being able to apply domain-specific styles (prefix any CSS selector with "body.bytm-dom-yt" or "body.bytm-dom-ytm") + document.body.classList.add(`bytm-dom-${domain}`); + try { + initGlobalCss(); + initObservers(); + initSvgSpritesheet(); + Promise.allSettled([ + injectCssBundle(), + initVersionCheck(), + ]); + } + catch (err) { + error("Encountered error in feature pre-init:", err); + } + log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`); + try { + //#region welcome dlg + if (typeof await GM.getValue("bytm-installed") !== "string") { + // open welcome menu with language selector + const dlg = await getWelcomeDialog(); + dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }))); + info("Showing welcome menu"); + await dlg.open(); } - catch (err) { - error("Fatal error in feature pre-init:", err); - return; + if (domain === "ytm") { + //#region (ytm) layout + if (feats.watermarkEnabled) + ftInit.push(["addWatermark", addWatermark()]); + if (feats.fixSpacing) + ftInit.push(["fixSpacing", fixSpacing()]); + ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]); + if (feats.hideCursorOnIdle) + ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]); + if (feats.fixHdrIssues) + ftInit.push(["fixHdrIssues", fixHdrIssues()]); + if (feats.showVotes) + ftInit.push(["showVotes", initShowVotes()]); + //#region (ytm) volume + ftInit.push(["volumeFeatures", initVolumeFeatures()]); + //#region (ytm) song lists + if (feats.lyricsQueueButton || feats.deleteFromQueueButton) + ftInit.push(["queueButtons", initQueueButtons()]); + ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]); + //#region (ytm) behavior + if (feats.closeToastsTimeout > 0) + ftInit.push(["autoCloseToasts", initAutoCloseToasts()]); + //#region (ytm) input + ftInit.push(["arrowKeySkip", initArrowKeySkip()]); + if (feats.anchorImprovements) + ftInit.push(["anchorImprovements", addAnchorImprovements()]); + ftInit.push(["numKeysSkip", initNumKeysSkip()]); + //#region (ytm) lyrics + if (feats.geniusLyrics) + ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]); + // #region (ytm) integrations + if (feats.sponsorBlockIntegration) + ftInit.push(["sponsorBlockIntegration", fixSponsorBlock()]); + const hideThemeSongLogo = addStyleFromResource("css-hide_themesong_logo"); + if (feats.themeSongIntegration) + ftInit.push(["themeSongIntegration", Promise.allSettled([fixThemeSong(), hideThemeSongLogo])]); + else + ftInit.push(["themeSongIntegration", Promise.allSettled([fixPlayerPageTheming(), hideThemeSongLogo])]); } - log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`); + //#region (ytm+yt) cfg menu try { - //#region welcome dlg - if (typeof await GM.getValue("bytm-installed") !== "string") { - // open welcome menu with language selector - const dlg = await getWelcomeDialog(); - dlg.on("close", () => GM.setValue("bytm-installed", JSON.stringify({ timestamp: Date.now(), version: scriptInfo.version }))); - info("Showing welcome menu"); - await dlg.open(); - } if (domain === "ytm") { - //#region (ytm) layout - if (feats.watermarkEnabled) - ftInit.push(["addWatermark", addWatermark()]); - if (feats.fixSpacing) - ftInit.push(["fixSpacing", fixSpacing()]); - ftInit.push(["thumbnailOverlay", initThumbnailOverlay()]); - if (feats.hideCursorOnIdle) - ftInit.push(["hideCursorOnIdle", initHideCursorOnIdle()]); - if (feats.fixHdrIssues) - ftInit.push(["fixHdrIssues", fixHdrIssues()]); - if (feats.showVotes) - ftInit.push(["showVotes", initShowVotes()]); - //#region (ytm) volume - ftInit.push(["volumeFeatures", initVolumeFeatures()]); - //#region (ytm) song lists - if (feats.lyricsQueueButton || feats.deleteFromQueueButton) - ftInit.push(["queueButtons", initQueueButtons()]); - ftInit.push(["aboveQueueBtns", initAboveQueueBtns()]); - //#region (ytm) behavior - if (feats.closeToastsTimeout > 0) - ftInit.push(["autoCloseToasts", initAutoCloseToasts()]); - //#region (ytm) input - ftInit.push(["arrowKeySkip", initArrowKeySkip()]); - if (feats.anchorImprovements) - ftInit.push(["anchorImprovements", addAnchorImprovements()]); - ftInit.push(["numKeysSkip", initNumKeysSkip()]); - //#region (ytm) lyrics - if (feats.geniusLyrics) - ftInit.push(["playerBarLyricsBtn", addPlayerBarLyricsBtn()]); - // #region (ytm) integrations - if (feats.sponsorBlockIntegration) - ftInit.push(["sponsorBlockIntegration", fixSponsorBlock()]); - const hideThemeSongLogo = addStyleFromResource("css-hide_themesong_logo"); - if (feats.themeSongIntegration) - ftInit.push(["themeSongIntegration", Promise.allSettled([fixThemeSong(), hideThemeSongLogo])]); - else - ftInit.push(["themeSongIntegration", Promise.allSettled([fixPlayerPageTheming(), hideThemeSongLogo])]); - } - //#region (ytm+yt) cfg menu - try { - if (domain === "ytm") { - addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { - listener: addConfigMenuOptionYTM, - }); - } - else if (domain === "yt") { - addSelectorListener("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", { - listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement), - }); - } - } - catch (err) { - error("Couldn't add config menu option:", err); - } - if (["ytm", "yt"].includes(domain)) { - //#region general - ftInit.push(["initSiteEvents", initSiteEvents()]); - //#region (ytm+yt) layout - if (feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all")) - ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]); - //#region (ytm+yt) input - ftInit.push(["siteSwitch", initSiteSwitch(domain)]); - if (feats.autoLikeChannels) - ftInit.push(["autoLikeChannels", initAutoLike()]); - //#region (ytm+yt) integrations - if (feats.disableDarkReaderSites !== "none") - ftInit.push(["disableDarkReaderSites", disableDarkReader()]); - } - emitInterface("bytm:featureInitStarted"); - const initStartTs = Date.now(); - // wait for feature init or timeout (in case an init function is hung up on a promise) - await Promise.race([ - UserUtils.pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8000), - Promise.allSettled(ftInit.map(([name, prom]) => new Promise(async (res) => { - const v = await prom; - emitInterface("bytm:featureInitialized", name); - res(v); - }))), - ]); - emitInterface("bytm:ready"); - info(`Done initializing ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`); - try { - registerDevCommands(); + addSelectorListener("body", "tp-yt-iron-dropdown #contentWrapper ytd-multi-page-menu-renderer #container.menu-container", { + listener: addConfigMenuOptionYTM, + }); } - catch (e) { - warn("Couldn't register dev menu commands:", e); + else if (domain === "yt") { + addSelectorListener("ytGuide", "#sections ytd-guide-section-renderer:nth-child(5) #items ytd-guide-entry-renderer:nth-child(1)", { + listener: (el) => el.parentElement && addConfigMenuOptionYT(el.parentElement), + }); } } catch (err) { - error("Feature error:", err); - emitInterface("bytm:fatalError", "Error while initializing features"); + error("Couldn't add config menu option:", err); + } + if (["ytm", "yt"].includes(domain)) { + //#region general + ftInit.push(["initSiteEvents", initSiteEvents()]); + //#region (ytm+yt) layout + if (feats.removeShareTrackingParamSites && (feats.removeShareTrackingParamSites === domain || feats.removeShareTrackingParamSites === "all")) + ftInit.push(["initRemShareTrackParam", initRemShareTrackParam()]); + //#region (ytm+yt) input + ftInit.push(["siteSwitch", initSiteSwitch(domain)]); + if (feats.autoLikeChannels) + ftInit.push(["autoLikeChannels", initAutoLike()]); + //#region (ytm+yt) integrations + if (feats.disableDarkReaderSites !== "none") + ftInit.push(["disableDarkReaderSites", disableDarkReader()]); + } + emitInterface("bytm:featureInitStarted"); + const initStartTs = Date.now(); + // wait for feature init or timeout (in case an init function is hung up on a promise) + await Promise.race([ + UserUtils.pauseFor(feats.initTimeout > 0 ? feats.initTimeout * 1000 : 8000), + Promise.allSettled(ftInit.map(([name, prom]) => new Promise(async (res) => { + const v = await prom; + emitInterface("bytm:featureInitialized", name); + res(v); + }))), + ]); + emitInterface("bytm:ready"); + info(`Done initializing ${ftInit.length} features after ${Math.floor(Date.now() - initStartTs)}ms`); + try { + registerDevCommands(); + } + catch (e) { + warn("Couldn't register dev menu commands:", e); } - } - //#region css - /** Inserts the bundled CSS files imported throughout the script into a + + +
+ SVG: + + + +
+ + + + \ No newline at end of file diff --git a/src/dialogs/allDataExIm.ts b/src/dialogs/allDataExIm.ts new file mode 100644 index 0000000000..6061fa4ff3 --- /dev/null +++ b/src/dialogs/allDataExIm.ts @@ -0,0 +1,204 @@ +import { consumeStringGen, type SerializedDataStore } from "@sv443-network/userutils"; +import { copyToClipboard, downloadFile, error, onInteraction, t } from "../utils/index.js"; +import { ExImDialog, type ExImDialogOpts } from "../components/ExImDialog.js"; +import { getSerializerStoresIds, getStoreSerializer } from "../serializer.js"; +import { showToast } from "../components/toast.js"; +import { createRipple } from "../components/ripple.js"; +import { createLongBtn } from "../components/longButton.js"; +import packageJson from "../../package.json" with { type: "json" }; + +let allDataExImDialog: ExImDialog | undefined; + +/** Creates and/or returns the AllDataExIm dialog */ +export async function getAllDataExImDialog() { + if(!allDataExImDialog) { + const eximOpts: ExImDialogOpts = { + id: "all-data-exim", + width: 800, + height: 1000, + closeBtnEnabled: true, + closeOnBgClick: true, + closeOnEscPress: true, + destroyOnClose: true, + removeListenersOnDestroy: false, + small: true, + verticalAlign: "top", + title: () => t("all_data_exim_title"), + descExport: () => t("all_data_exim_export_desc"), + descImport: () => t("all_data_exim_import_desc"), + exportData: async () => await getStoreSerializer().serialize(), + onImport, + }; + + allDataExImDialog = new ExImDialog({ + ...eximOpts, + renderBody: async () => await renderBody(eximOpts), + }); + } + return allDataExImDialog; +} + +/** Creates and/or returns the AutoLikeExIm dialog */ +async function onImport(data: string) { + try { + const serializer = getStoreSerializer(); + await serializer.deserialize(data); + + showToast(t("import_success")); + } + catch(err) { + error(err); + showToast(t("import_error")); + } +} + +async function renderBody(opts: ExImDialogOpts): Promise { + const panesCont = document.createElement("div"); + panesCont.classList.add("bytm-all-data-exim-dialog-panes-cont"); + + //#region export + + const exportPane = document.createElement("div"); + exportPane.classList.add("bytm-all-data-exim-dialog-pane", "export"); + + { + const descEl = document.createElement("p"); + descEl.classList.add("bytm-all-data-exim-dialog-desc"); + descEl.role = "note"; + descEl.tabIndex = 0; + descEl.textContent = descEl.ariaLabel = await consumeStringGen(opts.descExport); + + const exportPartsCont = document.createElement("div"); + exportPartsCont.classList.add("bytm-all-data-exim-dialog-export-parts-cont"); + + const dataEl = document.createElement("textarea"); + dataEl.classList.add("bytm-all-data-exim-dialog-data"); + dataEl.readOnly = true; + dataEl.tabIndex = 0; + dataEl.value = t("click_to_reveal"); + + for(const id of getSerializerStoresIds()) { + const rowEl = document.createElement("div"); + rowEl.classList.add("bytm-all-data-exim-dialog-export-part-row"); + rowEl.title = t(`data_stores.disable.${id}`); + + const chkEl = document.createElement("input"); + chkEl.type = "checkbox"; + chkEl.id = `bytm-all-data-exim-dialog-export-part-${id}`; + chkEl.dataset.storeId = id; + chkEl.checked = true; + chkEl.title = t(`data_stores.disable.${id}`); + + chkEl.addEventListener("change", async () => { + const kwd = chkEl.checked ? "disable" : "enable"; + rowEl.title = t(`data_stores.${kwd}.${id}`); + chkEl.title = t(`data_stores.${kwd}.${id}`); + lblEl.textContent = t(`data_stores.${kwd}.${id}`); + + if(dataEl.classList.contains("revealed")) + dataEl.value = filter(await consumeStringGen(opts.exportData)); + }); + + const lblEl = document.createElement("label"); + lblEl.htmlFor = chkEl.id; + lblEl.textContent = t(`data_stores.disable.${id}`); + + rowEl.append(chkEl, lblEl); + exportPartsCont.appendChild(rowEl); + } + + /** Filters out all data stores that are not checked */ + const filter = (data: string) => { + const exportIds: string[] = []; + + for(const chkEl of exportPartsCont.querySelectorAll("input[type=checkbox]")) + chkEl.checked && chkEl.dataset.storeId && exportIds.push(chkEl.dataset.storeId); + + return JSON.stringify( + (JSON.parse(data) as SerializedDataStore[]) + .filter(({ id }) => exportIds.includes(id)), + undefined, + 2, + ); + }; + + onInteraction(dataEl, async () => { + dataEl.classList.add("revealed"); + dataEl.value = filter(await consumeStringGen(opts.exportData)); + dataEl.setSelectionRange(0, dataEl.value.length); + }); + + const exportCenterBtnCont = document.createElement("div"); + exportCenterBtnCont.classList.add("bytm-all-data-exim-dialog-center-btn-cont"); + + const cpBtn = createRipple(await createLongBtn({ + title: t("copy_to_clipboard"), + text: t("copy"), + resourceName: "icon-copy", + async onClick({ shiftKey }) { + const copyData = shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData; + copyToClipboard(filter(await consumeStringGen(copyData))); + await showToast({ message: t("copied_to_clipboard") }); + }, + })); + + const dlBtn = createRipple(await createLongBtn({ + title: t("download_file"), + text: t("download"), + resourceName: "icon-arrow_down", + async onClick({ shiftKey }) { + const dlData = filter(await consumeStringGen(shiftKey && opts.exportDataSpecial ? opts.exportDataSpecial : opts.exportData)); + copyToClipboard(dlData); + + const pad = (num: number, len = 2) => String(num).padStart(len, "0"); + + const d = new Date(); + const dateStr = `${pad(d.getFullYear(), 4)}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}`; + const fileName = `BetterYTM ${packageJson.version} data export ${dateStr}.json`; + + downloadFile(fileName, dlData, "application/json"); + await showToast({ message: t("downloaded_file_hint") }); + }, + })); + + exportCenterBtnCont.append(cpBtn, dlBtn); + exportPane.append(descEl, dataEl, exportPartsCont, exportCenterBtnCont); + } + + //#region import + + const importPane = document.createElement("div"); + importPane.classList.add("bytm-all-data-exim-dialog-pane", "import"); + + { + // TODO: file upload field + // TODO: select which stores to import + + const descEl = document.createElement("p"); + descEl.classList.add("bytm-all-data-exim-dialog-desc"); + descEl.role = "note"; + descEl.tabIndex = 0; + descEl.textContent = descEl.ariaLabel = await consumeStringGen(opts.descImport); + + const dataEl = document.createElement("textarea"); + dataEl.classList.add("bytm-all-data-exim-dialog-data"); + dataEl.tabIndex = 0; + + const importCenterBtnCont = document.createElement("div"); + importCenterBtnCont.classList.add("bytm-all-data-exim-dialog-center-btn-cont"); + + const importBtn = createRipple(await createLongBtn({ + title: t("start_import_tooltip"), + text: t("import"), + resourceName: "icon-upload", + onClick: () => opts.onImport(dataEl.value), + })); + + importCenterBtnCont.appendChild(importBtn); + importPane.append(descEl, dataEl, importCenterBtnCont); + } + + panesCont.append(exportPane, importPane); + + return panesCont; +} diff --git a/src/dialogs/autoLike.ts b/src/dialogs/autoLike.ts index a4669b602d..e0237c7023 100644 --- a/src/dialogs/autoLike.ts +++ b/src/dialogs/autoLike.ts @@ -1,16 +1,19 @@ import { compress, debounce } from "@sv443-network/userutils"; import { compressionSupported, error, getDomain, isValidChannelId, log, onInteraction, parseChannelIdFromUrl, t, tryToDecompressAndParse } from "../utils/index.js"; -import { BytmDialog, createCircularBtn, createToggleInput, showToast } from "../components/index.js"; import { autoLikeStore, initAutoLikeStore } from "../features/index.js"; import { emitSiteEvent, siteEvents } from "../siteEvents.js"; import { ExImDialog } from "../components/ExImDialog.js"; import { compressionFormat } from "../constants.js"; import type { AutoLikeData } from "../types.js"; -import "./autoLike.css"; import { showPrompt } from "./prompt.js"; +import { BytmDialog } from "../components/BytmDialog.js"; +import { showToast } from "../components/toast.js"; +import { createToggleInput } from "../components/toggleInput.js"; +import { createCircularBtn } from "../components/circularButton.js"; +import "./autoLike.css"; let autoLikeDialog: BytmDialog | null = null; -let autoLikeImExDialog: ExImDialog | null = null; +let autoLikeExImDialog: ExImDialog | null = null; /** Creates and/or returns the import dialog */ export async function getAutoLikeDialog() { @@ -35,8 +38,8 @@ export async function getAutoLikeDialog() { siteEvents.on("autoLikeChannelsUpdated", async () => { try { - if(autoLikeImExDialog?.isOpen()) - autoLikeImExDialog.unmount(); + if(autoLikeExImDialog?.isOpen()) + autoLikeExImDialog.unmount(); if(autoLikeDialog?.isOpen()) { autoLikeDialog.unmount(); await autoLikeDialog.open(); @@ -51,8 +54,8 @@ export async function getAutoLikeDialog() { autoLikeDialog.on("close", () => emitSiteEvent("autoLikeChannelsUpdated")); } - if(!autoLikeImExDialog) { - autoLikeImExDialog = new ExImDialog({ + if(!autoLikeExImDialog) { + autoLikeExImDialog = new ExImDialog({ id: "auto-like-channels-export-import", width: 800, height: 600, @@ -76,7 +79,7 @@ export async function getAutoLikeDialog() { emitSiteEvent("autoLikeChannelsUpdated"); showToast({ message: t("import_success") }); - autoLikeImExDialog?.unmount(); + autoLikeExImDialog?.unmount(); } catch(err) { error("Couldn't import auto-like channels data:", err); @@ -163,8 +166,7 @@ async function renderBody() { .map((ch) => ch.id === id ? { ...ch, enabled } : ch), }); }, - 250, - "rising" + 250 ); const sortedChannels = autoLikeStore @@ -288,7 +290,7 @@ function renderFooter() { } async function openImportExportAutoLikeChannelsDialog() { - await autoLikeImExDialog?.open(); + await autoLikeExImDialog?.open(); } //#region add prompt diff --git a/src/dialogs/changelog.ts b/src/dialogs/changelog.ts index 58beaa3b08..b819e9a872 100644 --- a/src/dialogs/changelog.ts +++ b/src/dialogs/changelog.ts @@ -1,5 +1,5 @@ import { getChangelogHtmlWithDetails, setInnerHtml, t } from "../utils/index.js"; -import { BytmDialog } from "../components/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; import { scriptInfo } from "../constants.js"; let changelogDialog: BytmDialog | null = null; diff --git a/src/dialogs/dialogs.css b/src/dialogs/dialogs.css index 4d6403f37f..6379a67b35 100644 --- a/src/dialogs/dialogs.css +++ b/src/dialogs/dialogs.css @@ -175,4 +175,5 @@ #bytm-auto-like-channels-dialog-body { padding-left: 0; padding-right: 0; + padding-bottom: 0; } diff --git a/src/dialogs/exportCfg.ts b/src/dialogs/exportCfg.ts index 39d334cd64..0306dec1e3 100644 --- a/src/dialogs/exportCfg.ts +++ b/src/dialogs/exportCfg.ts @@ -1,6 +1,6 @@ import { compress } from "@sv443-network/userutils"; import { compressionSupported, copyToClipboard, onInteraction, t } from "../utils/index.js"; -import { BytmDialog } from "../components/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; import { compressionFormat, scriptInfo } from "../constants.js"; import { formatVersion, getFeatures } from "../config.js"; import { siteEvents } from "../siteEvents.js"; diff --git a/src/dialogs/featHelp.ts b/src/dialogs/featHelp.ts index 9b8ffb308b..872e7540ae 100644 --- a/src/dialogs/featHelp.ts +++ b/src/dialogs/featHelp.ts @@ -1,5 +1,5 @@ import { resourceAsString, setInnerHtml, t } from "../utils/index.js"; -import { BytmDialog } from "../components/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; import { featInfo } from "../features/index.js"; import type { FeatureKey } from "../types.js"; @@ -37,9 +37,7 @@ export async function getFeatHelpDialog({ async function renderHeader() { const headerEl = document.createElement("div"); - const helpIconSvg = await resourceAsString("icon-help"); - if(helpIconSvg) - setInnerHtml(headerEl, helpIconSvg); + setInnerHtml(headerEl, await resourceAsString("icon-help")); return headerEl; } diff --git a/src/dialogs/importCfg.ts b/src/dialogs/importCfg.ts index 9c526f8a74..f75458a3b3 100644 --- a/src/dialogs/importCfg.ts +++ b/src/dialogs/importCfg.ts @@ -1,9 +1,8 @@ -import { error, tryToDecompressAndParse, t, warn, log } from "../utils/index.js"; -import { BytmDialog } from "../components/index.js"; +import { error, tryToDecompressAndParse, t, warn, log, reloadTab } from "../utils/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; import { scriptInfo } from "../constants.js"; import { emitSiteEvent } from "../siteEvents.js"; import { formatVersion, getFeatures, migrations, setFeatures } from "../config.js"; -import { disableBeforeUnload } from "../features/index.js"; import { FeatureConfig } from "src/types.js"; import { showPrompt } from "./prompt.js"; @@ -107,10 +106,8 @@ async function renderFooter() { await setFeatures({ ...getFeatures(), ...parsed.data }); - if(await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) { - disableBeforeUnload(); - return location.reload(); - } + if(await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) + return await reloadTab(); emitSiteEvent("rebuildCfgMenu", parsed.data); diff --git a/src/dialogs/index.ts b/src/dialogs/index.ts deleted file mode 100644 index 9c3a74aca3..0000000000 --- a/src/dialogs/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import "./dialogs.css"; - -export * from "./autoLike.js"; -export * from "./changelog.js"; -export * from "./featConfig.js"; -export * from "./featHelp.js"; -export * from "./pluginList.js"; -export * from "./prompt.js"; -export * from "./versionNotif.js"; -export * from "./welcome.js"; diff --git a/src/dialogs/pluginList.ts b/src/dialogs/pluginList.ts index f4d4259cab..12ca4ba370 100644 --- a/src/dialogs/pluginList.ts +++ b/src/dialogs/pluginList.ts @@ -1,4 +1,4 @@ -import { BytmDialog } from "../components/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; import { getRegisteredPlugins } from "../interface.js"; import { getLocale, t } from "../utils/translations.js"; import { setInnerHtml } from "../utils/dom.js"; @@ -149,7 +149,7 @@ async function renderBody() { const intentEl = document.createElement("div"); intentEl.classList.add("bytm-plugin-list-row-intent-item"); intentEl.tabIndex = 0; - intentEl.textContent = PluginIntent[intent]; + intentEl.textContent = t(`plugin_intent_name_${PluginIntent[intent]}`); intentEl.title = intentEl.ariaLabel = t(`plugin_intent_description_${PluginIntent[intent]}`); rightEl.appendChild(intentEl); } diff --git a/src/dialogs/prompt.ts b/src/dialogs/prompt.ts index 4aedefd36c..16bb29a91c 100644 --- a/src/dialogs/prompt.ts +++ b/src/dialogs/prompt.ts @@ -1,7 +1,7 @@ import type { Emitter } from "nanoevents"; -import type { Stringifiable } from "@sv443-network/userutils"; +import { consumeStringGen, type StringGen, type Stringifiable } from "@sv443-network/userutils"; import { getOS, resourceAsString, setInnerHtml, t } from "../utils/index.js"; -import { BytmDialog, type BytmDialogEvents } from "../components/index.js"; +import { BytmDialog, type BytmDialogEvents } from "../components/BytmDialog.js"; import { addSelectorListener } from "../observers.js"; import "./prompt.css"; @@ -23,7 +23,7 @@ type AlertRenderProps = BaseRenderProps & { type PromptRenderProps = BaseRenderProps & { type: "prompt"; - defaultValue?: string; + defaultValue?: StringGen; }; type BaseRenderProps = { @@ -72,9 +72,7 @@ class PromptDialog extends BytmDialog { protected async renderHeader({ type }: PromptDialogRenderProps) { const headerEl = document.createElement("div"); headerEl.id = "bytm-prompt-dialog-header"; - const iconSvg = await resourceAsString(type === "alert" ? "icon-alert" : "icon-prompt"); - if(iconSvg) - setInnerHtml(headerEl, iconSvg); + setInnerHtml(headerEl, await resourceAsString(type === "alert" ? "icon-alert" : "icon-prompt")); return headerEl; } @@ -101,7 +99,9 @@ class PromptDialog extends BytmDialog { inputElem.type = "text"; inputElem.autocomplete = "off"; inputElem.spellcheck = false; - inputElem.value = "defaultValue" in rest ? rest.defaultValue ?? "" : ""; + inputElem.value = "defaultValue" in rest && rest.defaultValue + ? await consumeStringGen(rest.defaultValue) + : ""; const inputEnterListener = (e: KeyboardEvent) => { if(e.key === "Enter") { diff --git a/src/dialogs/versionNotif.ts b/src/dialogs/versionNotif.ts index 69bb5fa4d1..637bfc7b6f 100644 --- a/src/dialogs/versionNotif.ts +++ b/src/dialogs/versionNotif.ts @@ -1,6 +1,7 @@ import { host, mode, platformNames, scriptInfo } from "../constants.js"; import { getChangelogMd, getResourceUrl, onInteraction, parseMarkdown, setInnerHtml, t } from "../utils/index.js"; -import { BytmDialog, createToggleInput } from "../components/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; +import { createToggleInput } from "../components/toggleInput.js"; import { getFeature, getFeatures, setFeatures } from "../config.js"; import pkg from "../../package.json" with { type: "json" }; import { emitSiteEvent } from "../siteEvents.js"; diff --git a/src/dialogs/welcome.ts b/src/dialogs/welcome.ts index 234f966353..e235918777 100644 --- a/src/dialogs/welcome.ts +++ b/src/dialogs/welcome.ts @@ -1,9 +1,9 @@ import { getResourceUrl, initTranslations, setInnerHtml, setLocale, t, warn, type TrLocale } from "../utils/index.js"; -import { BytmDialog } from "../components/index.js"; +import { BytmDialog } from "../components/BytmDialog.js"; import { openCfgMenu } from "../menu/menu_old.js"; import { mode, scriptInfo } from "../constants.js"; import { getFeature, getFeatures, setFeatures } from "../config.js"; -import { getChangelogDialog } from "./index.js"; +import { getChangelogDialog } from "./changelog.js"; import pkg from "../../package.json" with { type: "json" }; import locales from "../../assets/locales.json" with { type: "json" }; @@ -17,7 +17,7 @@ export async function getWelcomeDialog() { width: 700, height: 500, closeBtnEnabled: true, - closeOnBgClick: true, + closeOnBgClick: false, closeOnEscPress: true, destroyOnClose: true, renderHeader, diff --git a/src/features/behavior.ts b/src/features/behavior.ts index e894e43e5d..1b7195197b 100644 --- a/src/features/behavior.ts +++ b/src/features/behavior.ts @@ -1,5 +1,5 @@ import { clamp, interceptWindowEvent, pauseFor } from "@sv443-network/userutils"; -import { domLoaded, error, getDomain, getVideoTime, getWatchId, info, log, waitVideoElementReady, clearNode, getCurrentMediaType, getVideoElement, warn } from "../utils/index.js"; +import { domLoaded, error, getDomain, getVideoTime, getWatchId, info, log, waitVideoElementReady, clearNode, getCurrentMediaType, getVideoElement } from "../utils/index.js"; import { getFeature } from "../config.js"; import { addSelectorListener } from "../observers.js"; import { initialParams } from "../constants.js"; @@ -7,26 +7,28 @@ import { LogLevel } from "../types.js"; //#region beforeunload popup -let beforeUnloadEnabled = true; +let discardBeforeUnload = false; /** Disables the popup before leaving the site */ -export function disableBeforeUnload() { - beforeUnloadEnabled = false; +export function enableDiscardBeforeUnload() { + discardBeforeUnload = true; info("Disabled popup before leaving the site"); } /** (Re-)enables the popup before leaving the site */ -export function enableBeforeUnload() { - beforeUnloadEnabled = true; +export function disableDiscardBeforeUnload() { + discardBeforeUnload = false; info("Enabled popup before leaving the site"); } /** Adds a spy function into `window.__proto__.addEventListener` to selectively discard `beforeunload` event listeners before they can be called by the site */ export async function initBeforeUnloadHook() { - if(GM?.info?.scriptHandler && GM.info.scriptHandler !== "FireMonkey") - interceptWindowEvent("beforeunload", () => !beforeUnloadEnabled); - else - warn(`Event intercepting is not available in ${GM.info.scriptHandler}, please use a different userscript extension`); + try { + interceptWindowEvent("beforeunload", () => discardBeforeUnload); + } + catch(err) { + error("Error in beforeunload hook:", err); + } } //#region auto close toasts diff --git a/src/features/index.ts b/src/features/index.ts index 1843707ecf..626fe49559 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,14 +1,17 @@ -import { formatNumber, getLocale, getPreferredLocale, getResourceUrl, resourceAsString, t, tp } from "../utils/index.js"; +import { consumeStringGen, type StringGen } from "@sv443-network/userutils"; +import { formatNumber, getLocale, getPreferredLocale, getResourceUrl, reloadTab, resourceAsString, t, tp } from "../utils/index.js"; import { clearLyricsCache, getLyricsCache } from "./lyricsCache.js"; import { doVersionCheck } from "./versionCheck.js"; import { getFeature, promptResetConfig } from "../config.js"; import { FeatureInfo, type ColorLightnessPref, type ResourceKey, type SiteSelection, type SiteSelectionOrNone } from "../types.js"; import { emitSiteEvent } from "../siteEvents.js"; import langMapping from "../../assets/locales.json" with { type: "json" }; -import { getAutoLikeDialog, getPluginListDialog, showPrompt } from "../dialogs/index.js"; -import { showIconToast } from "../components/index.js"; +import { showIconToast } from "../components/toast.js"; import { mode } from "../constants.js"; import { getStoreSerializer } from "../serializer.js"; +import { getAutoLikeDialog } from "../dialogs/autoLike.js"; +import { showPrompt } from "../dialogs/prompt.js"; +import { getPluginListDialog } from "../dialogs/pluginList.js"; //#region re-exports @@ -37,8 +40,11 @@ type AdornmentFunc = | Promise; /** Creates an HTML string for the given adornment properties */ -const getAdornHtml = async (className: string, title: string | undefined, resource: ResourceKey, extraAttributes?: string) => - `${await resourceAsString(resource) ?? ""}`; +const getAdornHtml = async (className: string, title: StringGen | undefined, resource: ResourceKey, extraAttributes?: StringGen) => { + title = title ? await consumeStringGen(title) : undefined; + extraAttributes = extraAttributes ? await consumeStringGen(extraAttributes) : undefined; + return `${await resourceAsString(resource) ?? ""}`; +}; /** Combines multiple async functions or promises that resolve with an adornment HTML string into a single string */ const combineAdornments = ( @@ -68,7 +74,7 @@ const adornments = { advanced: async () => getAdornHtml("bytm-advanced-mode-icon", t("advanced_mode"), "icon-advanced_mode"), experimental: async () => getAdornHtml("bytm-experimental-icon", t("experimental_feature"), "icon-experimental"), globe: async () => getAdornHtml("bytm-locale-icon", undefined, "icon-globe_small"), - alert: async (title: string) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""), + alert: async (title: StringGen) => getAdornHtml("bytm-warning-icon", title, "icon-error", "role=\"alert\""), reload: async () => getFeature("advancedMode") ? getAdornHtml("bytm-reload-icon", t("feature_requires_reload"), "icon-reload") : undefined, } satisfies Record; @@ -392,6 +398,13 @@ export const featInfo = { default: true, textAdornment: adornments.reload, }, + aboveQueueBtnsSticky: { + type: "toggle", + category: "songLists", + default: true, + advanced: true, + textAdornment: () => combineAdornments([adornments.advanced, adornments.reload]), + }, //#region cat:behavior disableBeforeUnloadPopup: { @@ -778,7 +791,7 @@ export const featInfo = { message: t("reset_everything_confirm"), })) { await getStoreSerializer().resetStoresData(); - location.reload(); + await reloadTab(); } }, advanced: true, diff --git a/src/features/input.ts b/src/features/input.ts index 17bee3cbfd..7833da1826 100644 --- a/src/features/input.ts +++ b/src/features/input.ts @@ -1,14 +1,16 @@ import { DataStore, clamp, compress, decompress } from "@sv443-network/userutils"; import { error, getVideoTime, info, log, warn, getDomain, compressionSupported, t, clearNode, resourceAsString, getCurrentChannelId, getCurrentMediaType, sanitizeChannelId, addStyleFromResource, isValidChannelId, getVideoElement, setInnerHtml } from "../utils/index.js"; import type { AutoLikeData, Domain } from "../types.js"; -import { disableBeforeUnload } from "./behavior.js"; +import { enableDiscardBeforeUnload } from "./behavior.js"; import { emitSiteEvent, siteEvents } from "../siteEvents.js"; import { featInfo } from "./index.js"; import { getFeature } from "../config.js"; import { compressionFormat } from "../constants.js"; import { addSelectorListener } from "../observers.js"; -import { createLongBtn, createRipple, showIconToast } from "../components/index.js"; -import { getAutoLikeDialog } from "../dialogs/index.js"; +import { getAutoLikeDialog } from "../dialogs/autoLike.js"; +import { showIconToast } from "../components/toast.js"; +import { createLongBtn } from "../components/longButton.js"; +import { createRipple } from "../components/ripple.js"; import "./input.css"; export const inputIgnoreTagNames = ["INPUT", "TEXTAREA", "SELECT", "BUTTON", "A"]; @@ -43,7 +45,7 @@ export async function initArrowKeySkip() { const vidElem = getVideoElement(); - if(vidElem) + if(vidElem && vidElem.readyState > 0) vidElem.currentTime = clamp(vidElem.currentTime + skipBy, 0, vidElem.duration); }); log("Added arrow key press listener"); @@ -89,7 +91,7 @@ async function switchSite(newDomain: Domain) { if(!subdomain) throw new Error(`Unrecognized domain '${newDomain}'`); - disableBeforeUnload(); + enableDiscardBeforeUnload(); const { pathname, search, hash } = new URL(location.href); @@ -136,7 +138,7 @@ export async function initNumKeysSkip() { return info("Captured valid key to skip video to, but ignored it since this element is currently active:", document.activeElement); const vidElem = getVideoElement(); - if(!vidElem) + if(!vidElem || vidElem.readyState === 0) return warn("Could not find video element, so the keypress is ignored"); const newVidTime = vidElem.duration / (10 / Number(e.key)); @@ -300,7 +302,7 @@ export async function initAutoLike() { subtitle: t("auto_like_click_to_configure"), icon: "icon-auto_like", onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), - }); + }).catch(e => error("Error while showing auto-like toast:", e)); log(`Auto-liked video from channel '${likeChan.name}' (${likeChan.id})`); } } @@ -397,9 +399,7 @@ async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, c const chanId = sanitizeChannelId(buttonEl.dataset.channelId ?? channelId); const imgEl = buttonEl.querySelector(".bytm-generic-btn-img"); - const imgHtml = await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`); - if(imgEl && imgHtml) - setInnerHtml(imgEl, imgHtml); + imgEl && setInnerHtml(imgEl, await resourceAsString(`icon-auto_like${toggled ? "_enabled" : ""}`)); if(autoLikeStore.getData().channels.find((ch) => ch.id === chanId) === undefined) { await autoLikeStore.setData({ @@ -419,8 +419,10 @@ async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, c emitSiteEvent("autoLikeChannelsUpdated"); showIconToast({ message: toggled ? t("auto_like_enabled_toast") : t("auto_like_disabled_toast"), + subtitle: t("auto_like_click_to_configure"), icon: `icon-auto_like${toggled ? "_enabled" : ""}`, - }); + onClick: () => getAutoLikeDialog().then((dlg) => dlg.open()), + }).catch(e => error("Error while showing auto-like toast:", e)); log(`Toggled auto-like for channel '${channelName}' (ID: '${chanId}') to ${toggled ? "enabled" : "disabled"}`); } catch(err) { @@ -445,8 +447,6 @@ async function addAutoLikeToggleBtn(siblingEl: HTMLElement, channelId: string, c buttonEl.classList.remove("toggled"); const imgEl = buttonEl.querySelector(".bytm-generic-btn-img"); - const imgHtml = await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`); - if(imgEl && imgHtml) - setInnerHtml(imgEl, imgHtml); + imgEl && setInnerHtml(imgEl, await resourceAsString(`icon-auto_like${enabled ? "_enabled" : ""}`)); }); } diff --git a/src/features/integrations.css b/src/features/integrations.css index 54ddc2c86b..bb8b7f4fbb 100644 --- a/src/features/integrations.css +++ b/src/features/integrations.css @@ -1,5 +1,5 @@ :root { - --bytm-themed-bg: var(--ts-playerpage-color, #030303); + --bytm-themed-bg-col: var(--ts-playerpage-color, #030303); } #ts-visualizer-container #ThemeSong-Visualizer canvas { diff --git a/src/features/layout.css b/src/features/layout.css index e54b22241d..47a28a9b45 100644 --- a/src/features/layout.css +++ b/src/features/layout.css @@ -5,6 +5,14 @@ --bytm-font-size-monospace: 1.4rem; } +html:not([dark]) { + --bytm-themed-icon-col: #000; +} + +html[dark] { + --bytm-themed-icon-col: #fff; +} + .bytm-disable-scroll { overflow: hidden !important; } @@ -119,6 +127,11 @@ height: 24px; } +.bytm-dom-yt .bytm-generic-btn-img path { + /* YT has both dark and light theme, YTM only dark */ + fill: var(--bytm-themed-icon-col, #fff); +} + .bytm-spinner { animation: rotate 1.2s linear infinite; } diff --git a/src/features/layout.ts b/src/features/layout.ts index d40c485985..a41cfe480a 100644 --- a/src/features/layout.ts +++ b/src/features/layout.ts @@ -2,12 +2,13 @@ import { addParent, autoPlural, debounce, fetchAdvanced, pauseFor } from "@sv443 import { getFeature, getFeatures } from "../config.js"; import { siteEvents } from "../siteEvents.js"; import { addSelectorListener } from "../observers.js"; -import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, getCurrentMediaType, domLoaded, waitVideoElementReady, addStyleFromResource, fetchVideoVotes, getWatchId, tp, getVideoTime, setInnerHtml, formatNumber } from "../utils/index.js"; +import { error, getResourceUrl, log, warn, t, onInteraction, openInTab, getBestThumbnailUrl, getDomain, getCurrentMediaType, domLoaded, waitVideoElementReady, addStyleFromResource, fetchVideoVotes, getWatchId, tp, getVideoTime, setInnerHtml, formatNumber, resourceAsString } from "../utils/index.js"; import { mode, scriptInfo } from "../constants.js"; import { openCfgMenu } from "../menu/menu_old.js"; -import { createCircularBtn, createRipple } from "../components/index.js"; -import type { ResourceKey, VideoVotesObj } from "../types.js"; import { showPrompt } from "../dialogs/prompt.js"; +import { createRipple } from "../components/ripple.js"; +import { createCircularBtn } from "../components/circularButton.js"; +import type { ResourceKey, VideoVotesObj } from "../types.js"; import "./layout.css"; //#region cfg menu btns @@ -370,6 +371,8 @@ export async function initAboveQueueBtns() { if(!await addStyleFromResource("css-above_queue_btns")) error("Couldn't add CSS for above queue buttons"); + else if(getFeature("aboveQueueBtnsSticky")) + addStyleFromResource("css-above_queue_btns_sticky"); const contBtns = [ { @@ -507,10 +510,12 @@ export async function initThumbnailOverlay() { if(getFeature("thumbnailOverlayToggleBtnShown")) { addSelectorListener("playerBarMiddleButtons", "#bytm-thumbnail-overlay-toggle", { async listener(toggleBtnElem) { - const toggleBtnImgElem = toggleBtnElem.querySelector("img"); + const toggleBtnIconElem = toggleBtnElem.querySelector("svg"); - if(toggleBtnImgElem) - toggleBtnImgElem.src = await getResourceUrl(`icon-image${showOverlay ? "_filled" : ""}` as "icon-image" | "icon-image_filled"); + if(toggleBtnIconElem) { + setInnerHtml(toggleBtnElem, await resourceAsString(`icon-image${showOverlay ? "_filled" : ""}` as "icon-image" | "icon-image_filled")); + toggleBtnElem.querySelector("svg")?.classList.add("bytm-generic-btn-img"); + } if(toggleBtnElem) toggleBtnElem.ariaLabel = toggleBtnElem.title = t(`thumbnail_overlay_toggle_btn_tooltip${showOverlay ? "_hide" : "_show"}`); }, @@ -611,13 +616,12 @@ export async function initThumbnailOverlay() { updateOverlayVisibility(); }); - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-generic-btn-img"); - - toggleBtnElem.appendChild(imgElem); + setInnerHtml(toggleBtnElem, await resourceAsString("icon-image")); + toggleBtnElem.querySelector("svg")?.classList.add("bytm-generic-btn-img"); addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { - listener: (likeContainer) => likeContainer.insertAdjacentElement("afterend", toggleBtnElem), + listener: (likeContainer) => + likeContainer.insertAdjacentElement("afterend", toggleBtnElem), }); } @@ -701,7 +705,7 @@ export async function initHideCursorOnIdle() { }; vidContainer.addEventListener("mouseenter", onMove); - vidContainer.addEventListener("mousemove", debounce(onMove, 200, "rising")); + vidContainer.addEventListener("mousemove", debounce(onMove, 200)); vidContainer.addEventListener("mouseleave", () => { cursorHideTimer && clearTimeout(cursorHideTimer); hideTransTimer && clearTimeout(hideTransTimer); diff --git a/src/features/lyrics.ts b/src/features/lyrics.ts index c8cacb2369..6e93fac5b3 100644 --- a/src/features/lyrics.ts +++ b/src/features/lyrics.ts @@ -1,5 +1,5 @@ import { fetchAdvanced } from "@sv443-network/userutils"; -import { error, getResourceUrl, info, log, warn, t, tp, getCurrentMediaType, constructUrl, onInteraction, openInTab, LyricsError } from "../utils/index.js"; +import { error, info, log, warn, t, tp, getCurrentMediaType, constructUrl, onInteraction, openInTab, LyricsError, resourceAsString, setInnerHtml } from "../utils/index.js"; import { emitInterface } from "../interface.js"; import { mode, scriptInfo } from "../constants.js"; import { getFeature } from "../config.js"; @@ -29,10 +29,6 @@ async function addActualLyricsBtn(likeContainer: HTMLElement) { currentSongTitle = songTitleElem.title; - const spinnerIconUrl = await getResourceUrl("icon-spinner"); - const lyricsIconUrl = await getResourceUrl("icon-lyrics"); - const errorIconUrl = await getResourceUrl("icon-error"); - const onMutation = async (mutations: MutationRecord[]) => { for await(const mut of mutations) { const newTitle = (mut.target as HTMLElement).title; @@ -46,16 +42,15 @@ async function addActualLyricsBtn(likeContainer: HTMLElement) { lyricsBtn.style.cursor = "wait"; lyricsBtn.style.pointerEvents = "none"; - const imgElem = lyricsBtn.querySelector("img")!; - imgElem.src = spinnerIconUrl; - imgElem.classList.add("bytm-spinner"); + setInnerHtml(lyricsBtn, await resourceAsString("icon-spinner")); + lyricsBtn.querySelector("svg")?.classList.add("bytm-generic-btn-img", "bytm-spinner"); currentSongTitle = newTitle; const url = await getCurrentLyricsUrl(); // can take a second or two - imgElem.src = lyricsIconUrl; - imgElem.classList.remove("bytm-spinner"); + setInnerHtml(lyricsBtn, await resourceAsString("icon-lyrics")); + lyricsBtn.querySelector("svg")?.classList.add("bytm-generic-btn-img"); if(!url) { let artist, song; @@ -65,7 +60,8 @@ async function addActualLyricsBtn(likeContainer: HTMLElement) { } const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : ""; - imgElem.src = errorIconUrl; + setInnerHtml(lyricsBtn, await resourceAsString("icon-error")); + lyricsBtn.querySelector("svg")?.classList.add("bytm-generic-btn-img"); lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search"); lyricsBtn.style.cursor = "pointer"; @@ -318,10 +314,6 @@ export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden"; linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none"; - const imgElem = document.createElement("img"); - imgElem.classList.add("bytm-generic-btn-img"); - imgElem.src = await getResourceUrl("icon-lyrics"); - onInteraction(linkElem, (e) => { const url = linkElem.href ?? geniusUrl; if(!url || e instanceof MouseEvent) @@ -333,7 +325,8 @@ export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) stopPropagation: false, }); - linkElem.appendChild(imgElem); + setInnerHtml(linkElem, await resourceAsString("icon-lyrics")); + linkElem.querySelector("svg")?.classList.add("bytm-generic-btn-img"); onInteraction(linkElem, async (e) => { if(e.ctrlKey || e.altKey) { diff --git a/src/features/songLists.css b/src/features/songLists.css index 2e66572b21..31c0dab4c0 100644 --- a/src/features/songLists.css +++ b/src/features/songLists.css @@ -9,7 +9,7 @@ background: linear-gradient( 90deg, rgba(0, 0, 0, 0) 0%, - var(--bytm-themed-bg) 15% + var(--bytm-themed-bg-col) 15% ); display: none; position: absolute; diff --git a/src/features/songLists.ts b/src/features/songLists.ts index 1eeb0f7e9c..472a66939e 100644 --- a/src/features/songLists.ts +++ b/src/features/songLists.ts @@ -76,8 +76,6 @@ ytmusic-section-list-renderer[main-page-type="MUSIC_PAGE_TYPE_PLAYLIST"] ytmusic all: true, continuous: true, debounce: 150, - // TODO: switch to longer debounce time and edge type "risingIdle" after UserUtils update - debounceEdge: "falling", listener: checkAddGenericBtns, }); diff --git a/src/features/versionCheck.ts b/src/features/versionCheck.ts index 25b7346006..3afd8ff9f5 100644 --- a/src/features/versionCheck.ts +++ b/src/features/versionCheck.ts @@ -1,8 +1,9 @@ +import { compare } from "compare-versions"; import { scriptInfo } from "../constants.js"; import { getFeature } from "../config.js"; import { error, info, sendRequest, t } from "../utils/index.js"; -import { getVersionNotifDialog, showPrompt } from "../dialogs/index.js"; -import { compare } from "compare-versions"; +import { getVersionNotifDialog } from "../dialogs/versionNotif.js"; +import { showPrompt } from "../dialogs/prompt.js"; import { LogLevel } from "../types.js"; const releaseURL = "https://github.com/Sv443/BetterYTM/releases/latest"; @@ -26,9 +27,9 @@ export async function initVersionCheck() { /** * Checks for a new version of the script and shows a dialog. - * If {@linkcode notifyNoUpdatesFound} is set to true, a dialog is also shown if no updates were found. + * If {@linkcode notifyNoNewVerFound} is set to true, a dialog is also shown if no updates were found. */ -export async function doVersionCheck(notifyNoUpdatesFound = false) { +export async function doVersionCheck(notifyNoNewVerFound = false) { await GM.setValue("bytm-version-check", Date.now()); const res = await sendRequest({ @@ -37,12 +38,12 @@ export async function doVersionCheck(notifyNoUpdatesFound = false) { }); // TODO: small dialog for "no update found" message? - const noUpdateFound = () => notifyNoUpdatesFound ? showPrompt({ type: "alert", message: t("no_updates_found") }) : undefined; + const noNewVerFound = () => notifyNoNewVerFound ? showPrompt({ type: "alert", message: t("no_new_version_found") }) : undefined; const latestTag = res.finalUrl.split("/").pop()?.replace(/[a-zA-Z]/g, ""); if(!latestTag) - return await noUpdateFound(); + return await noNewVerFound(); info("Version check - current version:", scriptInfo.version, "- latest version:", latestTag, LogLevel.Info); @@ -51,5 +52,5 @@ export async function doVersionCheck(notifyNoUpdatesFound = false) { await dialog.open(); return; } - return await noUpdateFound(); + return await noNewVerFound(); } diff --git a/src/features/volume.css b/src/features/volume.css index ebf1f02edf..4cd7a45e0f 100644 --- a/src/features/volume.css +++ b/src/features/volume.css @@ -7,7 +7,7 @@ .bytm-vol-slider-label { --bytm-slider-label-bg-col: var( --ytmusic-player-bar-background, - var(--bytm-themed-bg) + var(--bytm-themed-bg-col) ); position: absolute; top: 50%; diff --git a/src/features/volume.ts b/src/features/volume.ts index 363e444a4e..48061f6ba0 100644 --- a/src/features/volume.ts +++ b/src/features/volume.ts @@ -3,8 +3,8 @@ import { getFeature } from "../config.js"; import { addStyleFromResource, error, log, resourceAsString, setGlobalCssVar, setInnerHtml, t, waitVideoElementReady, warn } from "../utils/index.js"; import { siteEvents } from "../siteEvents.js"; import { featInfo } from "./index.js"; -import "./volume.css"; import { addSelectorListener } from "../observers.js"; +import "./volume.css"; //#region init vol features @@ -36,8 +36,7 @@ export async function initVolumeFeatures() { // the following are only run once: - if(getFeature("setInitialTabVolume")) - setInitialTabVolume(sliderElem); + setInitialTabVolume(sliderElem); if(typeof getFeature("volumeSliderSize") === "number") setVolSliderSize(); @@ -58,11 +57,10 @@ export async function initVolumeFeatures() { addSelectorListener("playerBarRightControls", "ytmusic-player-expanding-menu tp-yt-paper-slider#expand-volume-slider", { listener: (el) => listener("expand", el), - debounceEdge: "falling", }); }; - window.addEventListener("resize", debounce(onResize, 150, "falling")); + window.addEventListener("resize", debounce(onResize, 150)); waitVideoElementReady().then(onResize); onResize(); } @@ -77,7 +75,7 @@ function initScrollStep(volSliderCont: HTMLDivElement, sliderElem: HTMLInputElem // cancels all the other events that would be fired e.stopImmediatePropagation(); - const delta = Number((e as WheelEvent).deltaY ?? (e as CustomEvent).detail ?? 1); + const delta = Number((e as WheelEvent).deltaY ?? (e as CustomEvent)?.detail ?? 1); if(isNaN(delta)) return warn("Invalid scroll delta:", delta); @@ -248,14 +246,26 @@ export async function volumeSharedBetweenTabsDisabled() { /** Sets the volume slider to a set volume level when the session starts */ async function setInitialTabVolume(sliderElem: HTMLInputElement) { + const reloadTabVol = Number(await GM.getValue("bytm-reload-tab-volume", 0)); + GM.deleteValue("bytm-reload-tab-volume").catch(() => void 0); + + if((isNaN(reloadTabVol) || reloadTabVol === 0) && !getFeature("setInitialTabVolume")) + return; + await waitVideoElementReady(); - const initialVol = getFeature("initialTabVolumeLevel"); + + const initialVol = Math.round(!isNaN(reloadTabVol) && reloadTabVol > 0 ? reloadTabVol : getFeature("initialTabVolumeLevel")); + + if(isNaN(initialVol) || initialVol < 0 || initialVol > 100) + return; + if(getFeature("volumeSharedBetweenTabs")) { lastCheckedSharedVolume = ignoreVal = initialVol; if(getFeature("volumeSharedBetweenTabs")) - GM.setValue("bytm-shared-volume", String(initialVol)); + GM.setValue("bytm-shared-volume", String(initialVol)).catch((err) => error("Couldn't save shared volume level due to an error:", err)); } sliderElem.value = String(initialVol); sliderElem.dispatchEvent(new Event("change", { bubbles: true })); - log(`Set initial tab volume to ${initialVol}%`); + + log(`Set initial tab volume to ${initialVol}%${reloadTabVol > 0 ? " (from GM storage)" : " (from configuration)"}`); } diff --git a/src/index.ts b/src/index.ts index 89eeb7dfd0..be6959c310 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,15 @@ -import { compress, decompress, pauseFor, type Stringifiable } from "@sv443-network/userutils"; -import { addStyle, addStyleFromResource, domLoaded, getResourceUrl, setGlobalCssVars, warn } from "./utils/index.js"; -import { clearConfig, fixCfgKeys, getFeatures, initConfig, setFeatures } from "./config.js"; +import { compress, decompress, fetchAdvanced, pauseFor, setInnerHtmlUnsafe, type Stringifiable } from "@sv443-network/userutils"; +import { addStyle, addStyleFromResource, domLoaded, getResourceUrl, reloadTab, setGlobalCssVars, warn } from "./utils/index.js"; +import { clearConfig, getFeatures, initConfig } from "./config.js"; import { buildNumber, compressionFormat, defaultLogLevel, mode, scriptInfo } from "./constants.js"; import { dbg, error, getDomain, info, getSessionId, log, setLogLevel, initTranslations, setLocale } from "./utils/index.js"; import { initSiteEvents } from "./siteEvents.js"; import { emitInterface, initInterface, initPlugins } from "./interface.js"; import { initObservers, addSelectorListener, globservers } from "./observers.js"; -import { getWelcomeDialog, showPrompt } from "./dialogs/index.js"; -import type { FeatureConfig } from "./types.js"; +import { downloadData, getStoreSerializer } from "./serializer.js"; +import { MarkdownDialog } from "./components/MarkdownDialog.js"; +import { getWelcomeDialog } from "./dialogs/welcome.js"; +import { showPrompt } from "./dialogs/prompt.js"; import { // layout addWatermark, initRemShareTrackParam, @@ -19,7 +21,7 @@ import { // song lists initQueueButtons, initAboveQueueBtns, // behavior - initBeforeUnloadHook, disableBeforeUnload, + initBeforeUnloadHook, enableDiscardBeforeUnload, initAutoCloseToasts, initRememberSongTime, // input initArrowKeySkip, initSiteSwitch, @@ -35,8 +37,7 @@ import { // menu addConfigMenuOptionYT, addConfigMenuOptionYTM, } from "./features/index.js"; -import { downloadData, getStoreSerializer } from "./serializer.js"; -import { MarkdownDialog } from "./components/index.js"; +import { getAllDataExImDialog } from "./dialogs/allDataExIm.js"; //#region cns. watermark @@ -125,7 +126,7 @@ async function init() { } if(features.disableBeforeUnloadPopup && domain === "ytm") - disableBeforeUnload(); + enableDiscardBeforeUnload(); if(features.rememberSongTime) initRememberSongTime(); @@ -152,17 +153,17 @@ async function onDomLoad() { document.body.classList.add(`bytm-dom-${domain}`); try { - initGlobalCssVars(); + initGlobalCss(); initObservers(); + initSvgSpritesheet(); - await Promise.allSettled([ + Promise.allSettled([ injectCssBundle(), initVersionCheck(), ]); } catch(err) { - error("Fatal error in feature pre-init:", err); - return; + error("Encountered error in feature pre-init:", err); } log(`DOM loaded and feature pre-init finished, now initializing all features for domain "${domain}"...`); @@ -308,6 +309,13 @@ async function onDomLoad() { catch(e) { warn("Couldn't register dev menu commands:", e); } + + try { + runDevTreatments(); + } + catch(e) { + warn("Couldn't run dev treatments:", e); + } } catch(err) { error("Feature error:", err); @@ -323,10 +331,10 @@ async function injectCssBundle() { error("Couldn't inject CSS bundle due to an error"); } -/** Initializes global CSS variables */ -function initGlobalCssVars() { +/** Initializes global CSS values */ +function initGlobalCss() { try { - loadFonts(); + initFonts(); const applyVars = () => { setGlobalCssVars({ @@ -341,36 +349,48 @@ function initGlobalCssVars() { applyVars(); } catch(err) { - error("Couldn't initialize global CSS variables:", err); + error("Couldn't initialize global CSS:", err); } } -async function loadFonts() { +async function initFonts() { const fonts = { "Cousine": { woff: await getResourceUrl("font-cousine_woff"), woff2: await getResourceUrl("font-cousine_woff2"), - ttf: await getResourceUrl("font-cousine_ttf"), + truetype: await getResourceUrl("font-cousine_ttf"), }, }; let css = ""; - for(const [font, urls] of Object.entries(fonts)) + for(const [fontName, urls] of Object.entries(fonts)) css += `\ @font-face { - font-family: "${font}"; - src: url("${urls.woff2}") format("woff2"), - url("${urls.woff}") format("woff"), - url("${urls.ttf}") format("truetype"); + font-family: "${fontName}"; + src: ${ + Object.entries(urls) + .map(([type, url]) => `url("${url}") format("${type}")`) + .join(", ") +}; font-weight: normal; font-style: normal; font-display: swap; -} -`; +}`; addStyle(css, "fonts"); } +//#region svg spritesheet + +/** Initializes the SVG spritesheet */ +async function initSvgSpritesheet() { + const svgUrl = await getResourceUrl("doc-svg_spritesheet"); + const div = document.createElement("div"); + div.style.display = "none"; + setInnerHtmlUnsafe(div, await (await fetchAdvanced(svgUrl)).text()); + document.body.appendChild(div); +} + //#region dev menu cmds /** Registers dev commands using `GM.registerMenuCommand` */ @@ -381,17 +401,8 @@ function registerDevCommands() { GM.registerMenuCommand("Reset config", async () => { if(confirm("Reset the configuration to its default values?\nThis will automatically reload the page.")) { await clearConfig(); - disableBeforeUnload(); - location.reload(); + await reloadTab(); } - }, "r"); - - GM.registerMenuCommand("Fix config values", async () => { - const oldFeats = JSON.parse(JSON.stringify(getFeatures())) as FeatureConfig; - await setFeatures(fixCfgKeys(oldFeats)); - dbg("Fixed missing or extraneous config values.\nFrom:", oldFeats, "\n\nTo:", getFeatures()); - if(confirm("All missing or config values were set to their default values and extraneous ones were removed.\nDo you want to reload the page now?")) - location.reload(); }); GM.registerMenuCommand("List GM values in console with decompression", async () => { @@ -414,7 +425,7 @@ function registerDevCommands() { const lengthStr = String(finalVal).length > 50 ? `(${String(finalVal).length} chars) ` : ""; dbg(` "${key}"${" ".repeat(longestKey - key.length)} -${isEncoded ? "-[decoded]-" : ""}> ${lengthStr}${finalVal}`); } - }, "l"); + }); GM.registerMenuCommand("List GM values in console, without decompression", async () => { const keys = await GM.listValues(); @@ -447,7 +458,7 @@ function registerDevCommands() { dbg(` Deleted ${key}`); } } - }, "d"); + }); GM.registerMenuCommand("Delete GM values by name (comma separated)", async () => { const keys = await showPrompt({ type: "prompt", message: "Enter the name(s) of the GM value to delete (comma separated).\nEmpty input cancels the operation." }); @@ -461,17 +472,17 @@ function registerDevCommands() { dbg(`Deleted GM value '${key}' with previous value '${oldVal && String(oldVal).length > truncLength ? String(oldVal).substring(0, truncLength) + `… (${String(oldVal).length} / ${truncLength} chars.)` : oldVal}'`); } } - }, "n"); + }); GM.registerMenuCommand("Reset install timestamp", async () => { await GM.deleteValue("bytm-installed"); dbg("Reset install time."); - }, "t"); + }); GM.registerMenuCommand("Reset version check timestamp", async () => { await GM.deleteValue("bytm-version-check"); dbg("Reset version check time."); - }, "v"); + }); GM.registerMenuCommand("List active selector listeners in console", async () => { const lines = [] as string[]; @@ -488,7 +499,7 @@ function registerDevCommands() { }); } dbg(`Showing currently active listeners for ${Object.keys(globservers).length} observers with ${listenersAmt} total listeners:\n${lines.join("\n")}`); - }, "s"); + }); GM.registerMenuCommand("Compress value", async () => { const input = await showPrompt({ type: "prompt", message: "Enter the value to compress.\nSee console for output." }); @@ -506,6 +517,8 @@ function registerDevCommands() { } }); + GM.registerMenuCommand("Download DataStoreSerializer file", () => downloadData()); + GM.registerMenuCommand("Export all data using DataStoreSerializer", async () => { const ser = await getStoreSerializer().serialize(); dbg("Serialized data stores:", JSON.stringify(JSON.parse(ser))); @@ -520,9 +533,7 @@ function registerDevCommands() { } }); - GM.registerMenuCommand("Throw specific Error", () => error("Test error thrown by user command:", new SyntaxError("Test error"))); - - GM.registerMenuCommand("Throw generic Error", () => error()); + GM.registerMenuCommand("Throw error (toast example)", () => error("Test error thrown by user command:", new SyntaxError("Test error"))); GM.registerMenuCommand("Example MarkdownDialog", async () => { const mdDlg = new MarkdownDialog({ @@ -540,9 +551,22 @@ function registerDevCommands() { await mdDlg.open(); }); - GM.registerMenuCommand("Download DataStoreSerializer file", () => downloadData()); + GM.registerMenuCommand("Toggle dev treatments", async () => { + const val = !await GM.getValue("bytm-dev-treatments", false); + await GM.setValue("bytm-dev-treatments", val); + alert(`Dev treatments are now ${val ? "enabled" : "disabled"}. Page will reload.`); + await reloadTab(); + }); log("Registered dev menu commands"); } +async function runDevTreatments() { + if(mode !== "development" || !await GM.getValue("bytm-dev-treatments", false)) + return; + + const dlg = await getAllDataExImDialog(); + await dlg.open(); +} + preInit(); diff --git a/src/interface.ts b/src/interface.ts index b9c86ab2c4..878a7971ce 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,14 +1,21 @@ import * as UserUtils from "@sv443-network/userutils"; import * as compareVersions from "compare-versions"; -import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants.js"; -import { getDomain, waitVideoElementReady, getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, t, tp, type TrLocale, info, error, onInteraction, getThumbnailUrl, getBestThumbnailUrl, fetchVideoVotes, setInnerHtml, getCurrentMediaType, tl, tlp, PluginError, formatNumber } from "./utils/index.js"; +import { mode, branch, host, buildNumber, compressionFormat, scriptInfo, initialParams, sessionStorageAvailable } from "./constants.js"; +import { getDomain, waitVideoElementReady, getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, t, tp, type TrLocale, info, error, onInteraction, getThumbnailUrl, getBestThumbnailUrl, fetchVideoVotes, setInnerHtml, getCurrentMediaType, tl, tlp, PluginError, formatNumber, reloadTab, getVideoElement, getVideoSelector } from "./utils/index.js"; import { addSelectorListener } from "./observers.js"; -import { getFeatures, setFeatures } from "./config.js"; +import { defaultData, getFeatures, setFeatures } from "./config.js"; import { autoLikeStore, featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong } from "./features/index.js"; import { allSiteEvents, type SiteEventsMap } from "./siteEvents.js"; import { type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable, type PluginEventMap, type PluginItem, type BytmObject, type AutoLikeData, type InterfaceFunctions } from "./types.js"; -import { BytmDialog, ExImDialog, MarkdownDialog, createCircularBtn, createHotkeyInput, createRipple, createToggleInput, showIconToast, showToast } from "./components/index.js"; import { showPrompt } from "./dialogs/prompt.js"; +import { BytmDialog } from "./components/BytmDialog.js"; +import { createHotkeyInput } from "./components/hotkeyInput.js"; +import { createToggleInput } from "./components/toggleInput.js"; +import { createCircularBtn } from "./components/circularButton.js"; +import { createRipple } from "./components/ripple.js"; +import { showIconToast, showToast } from "./components/toast.js"; +import { ExImDialog } from "./components/ExImDialog.js"; +import { MarkdownDialog } from "./components/MarkdownDialog.js"; const { autoPlural, getUnsafeWindow, randomId, NanoEmitter } = UserUtils; @@ -109,6 +116,7 @@ const globalFuncs: InterfaceFunctions = { getDomain, getResourceUrl, getSessionId, + reloadTab, // dom: setInnerHtml, @@ -118,6 +126,8 @@ const globalFuncs: InterfaceFunctions = { getThumbnailUrl, getBestThumbnailUrl, waitVideoElementReady, + getVideoElement, + getVideoSelector, getCurrentMediaType, // translations: @@ -133,6 +143,7 @@ const globalFuncs: InterfaceFunctions = { // feature config: /*🔒*/ getFeatures: getFeaturesInterface, /*🔒*/ saveFeatures: saveFeaturesInterface, + getDefaultFeatures: () => JSON.parse(JSON.stringify(defaultData)), // lyrics: fetchLyricsUrlTop, @@ -166,7 +177,9 @@ export function initInterface() { branch, host, buildNumber, + initialParams, compressionFormat, + sessionStorageAvailable, ...scriptInfo, // functions ...globalFuncs, diff --git a/src/menu/menu_old.css b/src/menu/menu_old.css index 259060d779..304391235f 100644 --- a/src/menu/menu_old.css +++ b/src/menu/menu_old.css @@ -261,7 +261,7 @@ font-size: 20px; margin-top: 32px; margin-bottom: 8px; - padding: 0px 15px; + padding: 0px 20px; } .bytm-ftconf-category-header:first-of-type { diff --git a/src/menu/menu_old.ts b/src/menu/menu_old.ts index d371d4c0d0..389a1b3ab2 100644 --- a/src/menu/menu_old.ts +++ b/src/menu/menu_old.ts @@ -1,13 +1,18 @@ -import { compress, debounce, isScrollable, type Stringifiable } from "@sv443-network/userutils"; +import { compress, debounce, isScrollable, openDialogs, type Stringifiable } from "@sv443-network/userutils"; import { type defaultData, formatVersion, getFeature, getFeatures, migrations, setFeatures } from "../config.js"; import { buildNumber, compressionFormat, host, mode, scriptInfo } from "../constants.js"; -import { featInfo, disableBeforeUnload } from "../features/index.js"; -import { error, getResourceUrl, info, log, resourceAsString, getLocale, hasKey, initTranslations, setLocale, t, arrayWithSeparators, tp, type TrKey, onInteraction, getDomain, copyToClipboard, warn, compressionSupported, tryToDecompressAndParse, setInnerHtml, type TrLocale, tl } from "../utils/index.js"; +import { featInfo } from "../features/index.js"; +import { error, getResourceUrl, info, log, resourceAsString, getLocale, hasKey, initTranslations, setLocale, t, arrayWithSeparators, tp, type TrKey, onInteraction, getDomain, copyToClipboard, warn, compressionSupported, tryToDecompressAndParse, setInnerHtml, type TrLocale, tl, reloadTab } from "../utils/index.js"; import { emitSiteEvent, siteEvents } from "../siteEvents.js"; -import { getChangelogDialog, getFeatHelpDialog, showPrompt } from "../dialogs/index.js"; -import type { FeatureCategory, FeatureKey, FeatureConfig, HotkeyObj, FeatureInfo } from "../types.js"; -import { BytmDialog, ExImDialog, createHotkeyInput, createToggleInput, openDialogs, setCurrentDialogId } from "../components/index.js"; import { emitInterface } from "../interface.js"; +import { showPrompt } from "../dialogs/prompt.js"; +import { getFeatHelpDialog } from "../dialogs/featHelp.js"; +import { getChangelogDialog } from "../dialogs/changelog.js"; +import { BytmDialog, setCurrentDialogId } from "../components/BytmDialog.js"; +import { ExImDialog } from "../components/ExImDialog.js"; +import { createHotkeyInput } from "../components/hotkeyInput.js"; +import { createToggleInput } from "../components/toggleInput.js"; +import type { FeatureCategory, FeatureKey, FeatureConfig, HotkeyObj, FeatureInfo } from "../types.js"; import pkg from "../../package.json" with { type: "json" }; import localeMapping from "../../assets/locales.json" with { type: "json" }; import "./menu_old.css"; @@ -184,8 +189,7 @@ async function mountCfgMenu() { reloadTxtEl.ariaLabel = reloadTxtEl.title = t("reload_tooltip"); reloadTxtEl.addEventListener("click", () => { closeCfgMenu(); - disableBeforeUnload(); - location.reload(); + reloadTab(); }); reloadFooterEl.appendChild(reloadTxtEl); @@ -243,8 +247,8 @@ async function mountCfgMenu() { await setFeatures({ ...getFeatures(), ...parsed.data }); if(await showPrompt({ type: "confirm", message: t("import_success_confirm_reload") })) { - disableBeforeUnload(); - return location.reload(); + log("Reloading tab after importing configuration"); + return reloadTab(); } exImDlg.unmount(); @@ -280,8 +284,8 @@ async function mountCfgMenu() { const onCfgChange = async ( key: keyof typeof defaultData, - initialVal: string | number | boolean | HotkeyObj, - newVal: string | number | boolean | HotkeyObj, + initialVal: string | number | boolean | HotkeyObj | undefined, + newVal: string | number | boolean | HotkeyObj | undefined, ) => { try { const fmt = (val: unknown) => typeof val === "object" ? JSON.stringify(val) : String(val); @@ -332,8 +336,8 @@ async function mountCfgMenu() { denyBtnTooltip: (type) => `${t(type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")} / ${tl(initLocale!, type === "alert" ? "click_to_close_tooltip" : "click_to_cancel_tooltip")}`, })) { closeCfgMenu(); - disableBeforeUnload(); - location.reload(); + log("Reloading tab after changing language"); + await reloadTab(); } } else if(getLocale() !== featConf.locale) @@ -348,7 +352,7 @@ async function mountCfgMenu() { }; /** Call whenever the feature config is changed */ - const confChanged = debounce(onCfgChange, 333, "falling"); + const confChanged = debounce(onCfgChange, 333); const featureCfg = getFeatures(); const featureCfgWithCategories = Object.entries(featInfo) @@ -407,7 +411,7 @@ async function mountCfgMenu() { const step = "step" in ftInfo ? ftInfo.step : undefined; const val = featureCfg[featKey as FeatureKey]; - const initialVal = val ?? ftDefault ?? undefined; + const initialVal = val ?? ftDefault; const ftConfElem = document.createElement("div"); ftConfElem.classList.add("bytm-ftitem"); @@ -851,7 +855,7 @@ async function mountCfgMenu() { (document.querySelector("#bytm-dialog-container") ?? document.body).appendChild(backgroundElem); - window.addEventListener("resize", debounce(checkToggleScrollIndicator, 250, "rising")); + window.addEventListener("resize", debounce(checkToggleScrollIndicator, 250)); log("Added menu element"); @@ -927,13 +931,13 @@ export async function openCfgMenu() { emitInterface("bytm:dialogOpened", undefined as unknown as BytmDialog); emitInterface("bytm:dialogOpened:cfg-menu" as "bytm:dialogOpened:id", undefined as unknown as BytmDialog); - checkToggleScrollIndicator(); - if(!menuBg) return warn("Couldn't open config menu because background element couldn't be found. The config menu is considered open but might still be closed. In this case please reload the page. If the issue persists, please create an issue on GitHub."); menuBg.style.visibility = "visible"; menuBg.style.display = "block"; + + checkToggleScrollIndicator(); } //#region chk scroll indicator diff --git a/src/observers.ts b/src/observers.ts index 0d713ab46e..470b56c1fd 100644 --- a/src/observers.ts +++ b/src/observers.ts @@ -51,7 +51,6 @@ const defaultObserverOptions: SelectorObserverOptions = { disableOnNoListeners: false, enableOnAddListener: false, defaultDebounce: 150, - defaultDebounceEdge: "rising", }; /** Global SelectorObserver instances usable throughout the script for improved performance */ @@ -105,7 +104,6 @@ export function initObservers() { // enabled immediately globservers.body = new SelectorObserver(document.body, { ...defaultObserverOptions, - defaultDebounceEdge: "falling", defaultDebounce: 150, subtree: false, }); @@ -118,7 +116,6 @@ export function initObservers() { const bytmDialogContainerSelector = "#bytm-dialog-container"; globservers.bytmDialogContainer = new SelectorObserver(bytmDialogContainerSelector, { ...defaultObserverOptions, - defaultDebounceEdge: "falling", defaultDebounce: 100, subtree: true, }); diff --git a/src/serializer.ts b/src/serializer.ts index b08e57c555..655beef991 100644 --- a/src/serializer.ts +++ b/src/serializer.ts @@ -6,18 +6,25 @@ import { showPrompt } from "./dialogs/prompt.js"; import { t } from "./utils/translations.js"; import { error } from "./utils/logging.js"; import { downloadFile } from "./utils/dom.js"; +import { reloadTab } from "./utils/misc.js"; import packageJson from "../package.json" with { type: "json" }; /** Central serializer for all data stores */ let serializer: DataStoreSerializer | undefined; +/** Array of all data stores that are included in the DataStoreSerializer instance */ +export const getSerializerStores = () => [ + configStore, + autoLikeStore, +]; + +/** Array of IDs of all stores included in the DataStoreSerializer instance */ +export const getSerializerStoresIds = () => getSerializerStores().map(store => store.id); + /** Returns the serializer for all data stores */ export function getStoreSerializer() { if(!serializer) { - serializer = new DataStoreSerializer([ - configStore, - autoLikeStore, - ], { + serializer = new DataStoreSerializer(getSerializerStores(), { addChecksum: true, ensureIntegrity: true, }); @@ -38,7 +45,7 @@ export async function importData(blob: File | Blob) { message: t("import_success_confirm_reload"), }); - reload && location.reload(); + reload && await reloadTab(); } catch(err) { error(err); diff --git a/src/stories/Ripple.stories.ts b/src/stories/Ripple.stories.ts index e2b310abd4..2e99f996fd 100644 --- a/src/stories/Ripple.stories.ts +++ b/src/stories/Ripple.stories.ts @@ -1,6 +1,8 @@ import type { StoryObj, Meta } from "@storybook/html"; import { fn } from "@storybook/test"; -import { createCircularBtn, createLongBtn, createRipple } from "../components/index.js"; +import { createRipple } from "../components/ripple.js"; +import { createCircularBtn } from "../components/circularButton.js"; +import { createLongBtn } from "../components/longButton.js"; import "../components/ripple.css"; import "../features/layout.css"; @@ -16,7 +18,7 @@ const meta = { onClick: { action: "onClick" }, padding: { control: "text" }, fontSize: { control: "text" }, - speed: { control: { type: "select" }, options: ["faster", "fast", "normal", "slow", "slower"] }, + speed: { control: { type: "select" }, options: ["fastest", "fast", "normal", "slow", "slowest"] }, }, // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args args: { onClick: fn() }, @@ -32,7 +34,7 @@ type RippleProps = { onClick: (e: MouseEvent | KeyboardEvent) => void; padding: string; fontSize: string; - speed: "faster" | "fast" | "normal" | "slow" | "slower"; + speed: "fastest" | "fast" | "normal" | "slow" | "slowest"; }; type Story = StoryObj; diff --git a/src/tools/gen-readme.ts b/src/tools/gen-readme.ts index ffb18b06b0..7a4430824b 100644 --- a/src/tools/gen-readme.ts +++ b/src/tools/gen-readme.ts @@ -87,17 +87,22 @@ async function modifyReadme(readmeLines: string[], changes: Record } async function genHeader() { - const langStr = [ ...Object.values(locales) ] - .sort((a, b) => a.nameEnglish.localeCompare(b.nameEnglish)) - .reduce((acc, { nameEnglish, emoji }, i) => { - return `${acc}${i > 0 ? ", " : ""}${emoji} ${nameEnglish}`; + const langStr = [ ...Object.entries(locales) ] + .sort(([, a], [, b]) => a.nameEnglish.localeCompare(b.nameEnglish)) + .reduce((acc, [locale, { emoji, nameEnglish }], i) => { + const countryCode = locale.split("-")[1]; + return `${acc}${i > 0 ? ", " : ""}${emoji} ${countryCode}`; }, ""); return `\


${pkgJson.userscriptName}

### ${pkgJson.description} -${langStr}\ + +

Available in these languages: ${langStr}

+ +--- +#### [**Features**](#features) • [**Installation**](#installation) • [**Integrations**](#integrations) • [**Plugins**](#plugins) • [**Support**](#support) • [**Privacy**](#privacy) • [**Development**](#development) • [**Attributions**](#attributions) • [**Disclaimers**](#disclaimers)\ `; } diff --git a/src/tools/post-build.ts b/src/tools/post-build.ts index 3c76805983..5cbb7211b0 100644 --- a/src/tools/post-build.ts +++ b/src/tools/post-build.ts @@ -2,14 +2,15 @@ import { access, readFile, writeFile, constants as fsconst } from "node:fs/promi import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { exec } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; import k from "kleur"; import "dotenv/config"; +import type { RollupArgs } from "../types.js"; import { outputDir as rollupCfgOutputDir, outputFile as rollupCfgOutputFile } from "../../rollup.config.mjs"; -import locales from "../../assets/locales.json" with { type: "json" }; +import localesJson from "../../assets/locales.json" with { type: "json" }; +import resourcesJson from "../../assets/resources.json" with { type: "json" }; import pkg from "../../package.json" with { type: "json" }; -import type { RollupArgs } from "../types.js"; -import { createHash } from "node:crypto"; -import { createReadStream } from "node:fs"; const { argv, env, exit, stdout } = process; @@ -42,7 +43,7 @@ type CliArg> = Required[TNa const mode = getCliArg>("mode", "development"); const branch = getCliArg>("branch", (mode === "production" ? "main" : "develop")); const host = getCliArg>("host", "github"); -const assetSource = getCliArg>("assetSource", "github"); +const assetSource = getCliArg>("assetSource", "jsdelivr"); const suffix = getCliArg>("suffix", ""); const envPort = Number(env.DEV_SERVER_PORT); @@ -87,8 +88,7 @@ const devDirectives = mode === "development" ? `\ // @name ${pkg.userscriptName} // @namespace ${pkg.homepage} // @version ${pkg.version} -// @description ${pkg.description}\ -${localizedDescriptions ? "\n" + localizedDescriptions : ""}\ +// @description ${pkg.description} // @homepageURL ${pkg.homepage}#readme // @supportURL ${pkg.bugs.url} // @license ${pkg.license} @@ -97,12 +97,14 @@ ${localizedDescriptions ? "\n" + localizedDescriptions : ""}\ // @icon ${getResourceUrl(`images/logo/logo${mode === "development" ? "_dev" : ""}_48.png`, buildNbr)} // @match https://music.youtube.com/* // @match https://www.youtube.com/* -// @run-at document-start +// @run-at document-start\ +${localizedDescriptions ? "\n" + localizedDescriptions : ""}\ // @connect api.sv443.net // @connect github.com // @connect raw.githubusercontent.com // @connect youtube.com // @connect returnyoutubedislikeapi.com +// @noframes // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue @@ -110,8 +112,7 @@ ${localizedDescriptions ? "\n" + localizedDescriptions : ""}\ // @grant GM.setClipboard // @grant GM.xmlHttpRequest // @grant GM.openInTab -// @grant unsafeWindow -// @noframes\ +// @grant unsafeWindow\ ${resourcesDirectives ? "\n" + resourcesDirectives : ""}\ ${requireDirectives ? "\n" + requireDirectives : ""}\ ${devDirectives ? "\n" + devDirectives : ""} @@ -144,6 +145,8 @@ I welcome every contribution on GitHub! BRANCH: branch, HOST: host, BUILD_NUMBER: buildNbr, + ASSET_SOURCE: assetSource, + DEV_SERVER_PORT: devServerPort, }, ); @@ -185,6 +188,8 @@ I welcome every contribution on GitHub! } } + await createSvgSpritesheet(); + console.info([ "", `Successfully built for ${envText} - build number (last commit SHA): ${buildNbr}`, @@ -254,8 +259,8 @@ async function exists(path: string) { } /** Resolves the value of an entry in resources.json */ -function resolveVal(value: string, buildNbr: string) { - if(!value.includes("$")) +function resolveResourceVal(value: string, buildNbr: string) { + if(!(/\$/.test(value))) return value; const replacements = [ @@ -272,24 +277,39 @@ function resolveVal(value: string, buildNbr: string) { /** Returns a string of resource directives, as defined in `assets/resources.json` or undefined if the file doesn't exist or is invalid */ async function getResourceDirectives(ref: string) { try { - const directives: string[] = []; - const resourcesFile = String(await readFile(join(assetFolderPath, "resources.json"))); - const resources = JSON.parse(resourcesFile) as Record | Record; + const directives: string[] = [], + resourcesRaw = JSON.parse(String(await readFile(join(assetFolderPath, "resources.json")))), + resources = "resources" in resourcesRaw + ? resourcesRaw.resources as Record | Record + : undefined, + resourcesHashed = {} as Record & Partial>>; - const resourcesHashed = {} as Record & Partial>>; + if(!resources) + throw new Error("No resources found in 'assets/resources.json'"); + const externalAssetRegexes = resourcesJson.alwaysExternalAssetPatterns.map((p) => new RegExp(p)); for(const [name, val] of Object.entries(resources)) { + // skip over all external assets + if(externalAssetRegexes.some((re) => re.test(name))) + continue; + const pathVal = typeof val === "object" ? val.path : val; - const hash = assetSource !== "local" && !pathVal.match(/^https?:\/\//) + const hash = ( + assetSource !== "local" + && (typeof val === "object" && "integrity" in val ? val.integrity !== false : true) + && !pathVal.match(/^https?:\/\//) + ) ? await getFileHashSha256(pathVal.replace(/\?.+/g, "")) : undefined; resourcesHashed[name] = typeof val === "object" - ? { path: resolveVal(val.path, ref), ref: resolveVal(val.ref, ref), hash } - : { path: getResourceUrl(resolveVal(val, ref), ref), ref, hash }; + ? { path: resolveResourceVal(val.path, ref), ref: resolveResourceVal(val.ref, ref), hash } + : { path: getResourceUrl(resolveResourceVal(val, ref), ref), ref, hash }; } const addResourceHashed = async (name: string, path: string, ref: string) => { try { + if(externalAssetRegexes.some((re) => re.test(name))) + return; if(assetSource === "local" || path.match(/^https?:\/\//)) { resourcesHashed[name] = { path: getResourceUrl(path, ref), ref, hash: undefined }; return; @@ -303,7 +323,7 @@ async function getResourceDirectives(ref: string) { await addResourceHashed("css-bundle", "/dist/BetterYTM.css", ref); - for(const [locale] of Object.entries(locales)) + for(const [locale] of Object.entries(localesJson)) await addResourceHashed(`trans-${locale}`, `translations/${locale}.json`, ref); let longestName = 0; @@ -364,7 +384,7 @@ function getRequireEntry(entry: RequireObjPkg) { function getLocalizedDescriptions() { try { const descriptions: string[] = []; - for(const [locale, { userscriptDesc, ...rest }] of Object.entries(locales)) { + for(const [locale, { userscriptDesc, ...rest }] of Object.entries(localesJson)) { let loc = locale; if(loc.length < 5) loc += " ".repeat(5 - loc.length); @@ -395,9 +415,15 @@ function getResourceUrl(path: string, ghRef?: string) { let assetPath = "/assets/"; if(path.startsWith("/")) assetPath = ""; + assetPath += path; + const finalPath = `${ghRef ?? `v${pkg.version}`}${assetPath}`; return assetSource === "local" - ? `http://localhost:${devServerPort}${assetPath}${path}?b=${buildUid}` - : `https://raw.githubusercontent.com/${repo}/${ghRef ?? `v${pkg.version}`}${assetPath}${path}`; + ? `http://localhost:${devServerPort}${assetPath}?b=${buildUid}` + : ( + assetSource === "github" + ? `https://raw.githubusercontent.com/${repo}/${finalPath}` + : `https://cdn.jsdelivr.net/gh/${repo}@${finalPath}` + ); } /** @@ -453,13 +479,12 @@ function schedExit(code: number) { /** Generates a random ID of the given {@linkcode length} and {@linkcode radix} */ function randomId(length = 16, radix = 16, randomCase = true) { - const arr = Array.from( + let arr = Array.from( { length }, () => Math.floor(Math.random() * radix).toString(radix) ); - randomCase && arr.forEach((v, i) => { - arr[i] = v[Math.random() > 0.5 ? "toUpperCase" : "toLowerCase"](); - }); + if(randomCase) + arr = arr.map((v) => v[Math.random() > 0.5 ? "toUpperCase" : "toLowerCase"]()); return arr.join(""); } @@ -478,3 +503,30 @@ function getFileHashSha256(path: string): Promise { stream.on("error", rej); }); } + +/** Compiles all `icon-*` assets into a single SVG spritesheet file and writes it to `assets/spritesheet.svg` */ +async function createSvgSpritesheet() { + try { + const sprites: string[] = []; + + for(const [name, val] of Object.entries(resourcesJson.resources)) { + if(!/^icon-/.test(name)) + continue; + + const iconPath = resolveResourcePath(typeof val === "string" ? val : val.path); + const iconSvg = String(await readFile(iconPath)).replace(/\n/g, ""); + + sprites.push(`\n ${iconSvg}\n `); + } + + await writeFile(resolveResourcePath("spritesheet.svg"), `\ +` + ); + } + catch(err) { + console.error(k.red("Error while creating SVG spritesheet:"), err); + return schedExit(1); + } +} diff --git a/src/tools/serve.ts b/src/tools/serve.ts index d0bf2a89c6..f98f5cb84c 100644 --- a/src/tools/serve.ts +++ b/src/tools/serve.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { Server } from "node:http"; import express, { NextFunction, Request, Response } from "express"; +import cors from "cors"; import k from "kleur"; import "dotenv/config"; import { outputDir } from "../../rollup.config.mjs"; @@ -15,11 +16,14 @@ const devServerPort = isNaN(envPort) || envPort === 0 ? 8710 : envPort; /** Whether to log requests to the console */ const enableLogging = false; -const autoExitRaw = argv.find(arg => arg.startsWith("--auto-exit-time="))?.split("=")[1]; +const autoExitRaw = Number(argv.find(arg => arg.startsWith("--auto-exit-time="))?.split("=")[1]); /** Time in milliseconds after which the process should automatically exit */ -const autoExitTime: number | undefined = !isNaN(Number(autoExitRaw)) ? Number(autoExitRaw) * 1000 : undefined; +const autoExitTime: number | undefined = !isNaN(autoExitRaw) ? autoExitRaw * 1000 : undefined; const app = express(); + +app.use(cors()); + let server: Server; enableLogging && app.use((_req, _res, next) => { diff --git a/src/tools/tr-format.ts b/src/tools/tr-format.ts index 29ed32738e..3f3618f12f 100644 --- a/src/tools/tr-format.ts +++ b/src/tools/tr-format.ts @@ -33,13 +33,13 @@ async function run() { if(!includeBased && localeObj.base) continue; - for(const k of Object.keys(enUS_obj.translations)) { - const val = localeObj?.translations?.[k]; + for(const k of Object.keys(enUS_obj)) { + const val = localeObj?.[k]; if(val) localeFile = localeFile.replace(new RegExp(`"${k}":\\s+".*"`, "m"), `"${k}": "${escapeJsonVal(val).trim()}"`); else { if(prepTranslate) - localeFile = localeFile.replace(new RegExp(`\\n\\s+"${k}":\\s+".*",?`, "m"), `\n "${k}": "",\n "${k}": "${escapeJsonVal(enUS_obj.translations[k]).trim()}",`); + localeFile = localeFile.replace(new RegExp(`\\n\\s+"${k}":\\s+".*",?`, "m"), `\n "${k}": "",\n "${k}": "${escapeJsonVal(enUS_obj[k]).trim()}",`); else localeFile = localeFile.replace(new RegExp(`\\n\\s+"${k}":\\s+".*",?`, "m"), ""); } diff --git a/src/types.ts b/src/types.ts index d977426099..9799b051c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,31 +1,33 @@ -import type { NanoEmitter, Stringifiable } from "@sv443-network/userutils"; +import type { NanoEmitter, Prettify } from "@sv443-network/userutils"; import type * as consts from "./constants.js"; import type { scriptInfo } from "./constants.js"; import type { addSelectorListener } from "./observers.js"; -import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp, fetchVideoVotes, onInteraction, getThumbnailUrl, getBestThumbnailUrl, getLocale, hasKey, hasKeyFor, getDomain, waitVideoElementReady, setInnerHtml, getCurrentMediaType, tl, tlp, formatNumber } from "./utils/index.js"; +import type { getResourceUrl, getSessionId, getVideoTime, TrLocale, t, tp, fetchVideoVotes, onInteraction, getThumbnailUrl, getBestThumbnailUrl, getLocale, hasKey, hasKeyFor, getDomain, waitVideoElementReady, setInnerHtml, getCurrentMediaType, tl, tlp, formatNumber, getVideoElement, getVideoSelector, reloadTab } from "./utils/index.js"; import type { SiteEventsMap } from "./siteEvents.js"; import type { InterfaceEventsMap, getAutoLikeDataInterface, getFeaturesInterface, getPluginInfo, saveAutoLikeDataInterface, saveFeaturesInterface, setLocaleInterface } from "./interface.js"; -import type { BytmDialog, ExImDialog, createCircularBtn, createHotkeyInput, createRipple, createToggleInput, showIconToast, showToast } from "./components/index.js"; import type { fetchLyricsUrlTop, sanitizeArtists, sanitizeSong } from "./features/lyrics.js"; import type { getLyricsCacheEntry } from "./features/lyricsCache.js"; import type { showPrompt } from "./dialogs/prompt.js"; -import type resources from "../assets/resources.json"; -import type locales from "../assets/locales.json"; +import type { BytmDialog } from "./components/BytmDialog.js"; +import type { ExImDialog } from "./components/ExImDialog.js"; +import type { createHotkeyInput } from "./components/hotkeyInput.js"; +import type { createToggleInput } from "./components/toggleInput.js"; +import type { createCircularBtn } from "./components/circularButton.js"; +import type { createRipple } from "./components/ripple.js"; +import type { showIconToast, showToast } from "./components/toast.js"; +import resources from "../assets/resources.json" with { type: "json" }; +import locales from "../assets/locales.json" with { type: "json" }; + +void ["type imports only", resources, locales]; //#region other -/** - * Value that is either a string (or stringifiable value) or a sync or async function that returns a string (or a stringifiable value) - * Use `await consumeStringGen(strGen)` to get the actual string value from this type - */ -export type StringGen = Stringifiable | (() => Stringifiable | Promise); - /** Custom CLI args passed to rollup */ export type RollupArgs = Partial<{ "config-mode": "development" | "production"; "config-branch": "main" | "develop"; "config-host": "greasyfork" | "github" | "openuserjs"; - "config-assetSource": "local" | "github"; + "config-assetSource": "local" | "github" | "jsdelivr"; "config-suffix": string; }>; @@ -45,7 +47,7 @@ export type SiteSelection = Domain | "all"; export type SiteSelectionOrNone = SiteSelection | "none"; /** Key of a resource in `assets/resources.json` and extra keys defined by `tools/post-build.ts` */ -export type ResourceKey = keyof typeof resources | `trans-${keyof typeof locales}` | "changelog" | "css-bundle"; +export type ResourceKey = keyof typeof resources["resources"] | `trans-${keyof typeof locales}` | "css-bundle"; /** Describes a single hotkey */ export type HotkeyObj = { @@ -120,7 +122,7 @@ export type BytmObject = // information from the userscript header & typeof scriptInfo // certain variables from `src/constants.ts` - & Pick + & Pick // global functions exposed through the interface in `src/interface.ts` & InterfaceFunctions // others @@ -260,11 +262,14 @@ export type PluginEventMap = & InterfaceEventsMap; /** A plugin in either the queue or registered map */ -export type PluginItem = +export type PluginItem = Prettify< & { def: PluginDef; } - & Pick; + & Pick +>; + +//#region plugin interface /** All functions exposed by the interface on the global `BYTM` object */ export type InterfaceFunctions = { @@ -285,6 +290,8 @@ export type InterfaceFunctions = { getResourceUrl: typeof getResourceUrl; /** Returns the unique session ID for the current tab */ getSessionId: typeof getSessionId; + /** Smarter version of `location.reload()` that remembers video time and volume and makes other features like initial tab volume stand down if used */ + reloadTab: typeof reloadTab; // dom: /** Sets the innerHTML property of the provided element to a sanitized version of the provided HTML string */ @@ -305,6 +312,10 @@ export type InterfaceFunctions = { getBestThumbnailUrl: typeof getBestThumbnailUrl; /** Resolves the returned promise when the video element is queryable in the DOM */ waitVideoElementReady: typeof waitVideoElementReady; + /** Returns the video element on the current page for both YTM and YT - returns null if it couldn't be found */ + getVideoElement: typeof getVideoElement; + /** Returns the CSS selector to the video element for both YTM and YT */ + getVideoSelector: typeof getVideoSelector; /** (On YTM only) returns the current media type (video or song) */ getCurrentMediaType: typeof getCurrentMediaType; @@ -331,6 +342,8 @@ export type InterfaceFunctions = { getFeatures: typeof getFeaturesInterface; /** 🔒 Overwrites the feature configuration with the provided one */ saveFeatures: typeof saveFeaturesInterface; + /** Returns the default feature configuration */ + getDefaultFeatures: () => FeatureConfig; // lyrics: /** Sanitizes the provided artist string - this needs to be done before calling other lyrics related functions! */ @@ -563,6 +576,8 @@ export interface FeatureConfig { rememberSongTimeReduction: number; /** Minimum time in seconds the song needs to be played before it is remembered */ rememberSongTimeMinPlayTime: number; + /** Whether the above queue button container should use sticky positioning */ + aboveQueueBtnsSticky: boolean; //#region input /** Arrow keys skip forwards and backwards */ diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 5566635675..92b2585478 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,4 +1,4 @@ -import { addGlobalStyle, getUnsafeWindow, randomId, type Stringifiable } from "@sv443-network/userutils"; +import { addGlobalStyle, consumeStringGen, getUnsafeWindow, randomId, type StringGen, type Stringifiable } from "@sv443-network/userutils"; import DOMPurify from "dompurify"; import { error, fetchCss, getDomain, t } from "./index.js"; import { addSelectorListener } from "../observers.js"; @@ -13,7 +13,11 @@ document.addEventListener("DOMContentLoaded", () => domLoaded = true); //#region vid time & vol. /** Returns the video element selector string based on the current domain */ -export const getVideoSelector = () => getDomain() === "ytm" ? "ytmusic-player video" : "#player-container ytd-player video"; +export function getVideoSelector() { + return getDomain() === "ytm" + ? "ytmusic-player video" + : "#player-container ytd-player video"; +} /** Returns the video element based on the current domain */ export function getVideoElement() { @@ -44,7 +48,7 @@ export function getVideoTime(precision = 2) { try { if(getDomain() === "ytm") { const vidElem = getVideoElement(); - if(vidElem) + if(vidElem && vidElem.readyState > 0) return resolveWithVal(vidElem.currentTime); addSelectorListener("playerBar", "tp-yt-paper-slider#progress-bar tp-yt-paper-progress#sliderBar", { @@ -54,7 +58,7 @@ export function getVideoTime(precision = 2) { } else if(getDomain() === "yt") { const vidElem = getVideoElement(); - if(vidElem) + if(vidElem && vidElem.readyState > 0) return resolveWithVal(vidElem.currentTime); // YT doesn't update the progress bar when it's hidden (contrary to YTM which never hides it) @@ -137,7 +141,7 @@ export function waitVideoElementReady(): Promise { return new Promise(async (res, rej) => { try { const vidEl = getVideoElement(); - if(vidEl?.readyState === 4) + if(vidEl && (vidEl?.readyState ?? 0) > 0) return res(vidEl); if(!location.pathname.startsWith("/watch")) @@ -167,10 +171,10 @@ export function waitVideoElementReady(): Promise { * @param ref A reference string to identify the style element - defaults to a random 5-character string * @param transform A function to transform the CSS before adding it to the DOM */ -export async function addStyle(css: string, ref?: string, transform: (css: string) => string | Promise = (c) => c) { +export async function addStyle(css: StringGen, ref?: string, transform: (css: string) => string | Promise = (c) => c) { if(!domLoaded) throw new Error("DOM has not finished loading yet"); - const elem = addGlobalStyle(await transform(css)); + const elem = addGlobalStyle(await transform(await consumeStringGen(css))); elem.id = `bytm-style-${ref ?? randomId(6, 36)}`; return elem; } @@ -179,10 +183,10 @@ export async function addStyle(css: string, ref?: string, transform: (css: strin * Adds a global style element with the contents fetched from the specified resource starting with `css-` * The CSS can be transformed using the provided function before being added to the DOM. */ -export async function addStyleFromResource(key: ResourceKey & `css-${string}`, transform: (css: string) => string = (c) => c) { +export async function addStyleFromResource(key: ResourceKey & `css-${string}`, transform: (css: string) => Stringifiable = (c) => c) { const css = await fetchCss(key); if(css) { - await addStyle(transform(css), key.slice(4)); + await addStyle(String(transform(css)), key.slice(4)); return true; } return false; @@ -215,12 +219,13 @@ export function clearNode(element: Element) { } /** - * Returns an identifier for the currently playing media type on YTM (song or video). - * Only works on YTM and will throw on YT or if {@linkcode waitVideoElementReady} hasn't been awaited yet. + * Returns an identifier for the currently playing media type on YTM ("song" or "video"). + * Only works on YTM and will throw if {@linkcode waitVideoElementReady} hasn't been awaited yet. + * On YT, it will always return "video". */ export function getCurrentMediaType(): "video" | "song" { if(getDomain() === "yt") - throw new Error("currentMediaType() is only available on YTM!"); + return "video"; const songImgElem = document.querySelector("ytmusic-player #song-image"); if(!songImgElem) throw new Error("Couldn't find the song image element. Use this function only after awaiting `waitVideoElementReady()`!"); @@ -260,8 +265,14 @@ DOMPurify.addHook("afterSanitizeAttributes", (node) => { } }); -/** Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML */ -export function setInnerHtml(element: HTMLElement, html: string) { +/** + * Sets innerHTML directly on Firefox and Safari, while on Chromium a [Trusted Types policy](https://developer.mozilla.org/en-US/docs/Web/API/Trusted_Types_API) is used to set the HTML. + * If no HTML string is given, the element's innerHTML will be set to an empty string. + */ +export function setInnerHtml(element: HTMLElement, html?: Stringifiable | null) { + if(!html) + html = ""; + if(!ttPolicy && window?.trustedTypes?.createPolicy) { ttPolicy = window.trustedTypes.createPolicy("bytm-sanitize-html", { createHTML: (dirty: string) => DOMPurify.sanitize(dirty, { @@ -270,8 +281,8 @@ export function setInnerHtml(element: HTMLElement, html: string) { }); } - element.innerHTML = ttPolicy?.createHTML(html) - ?? DOMPurify.sanitize(html, { RETURN_TRUSTED_TYPE: false }); + element.innerHTML = ttPolicy?.createHTML(String(html)) + ?? DOMPurify.sanitize(String(html), { RETURN_TRUSTED_TYPE: false }); } /** Creates an invisible link element and clicks it to download the provided string or Blob data as a file */ @@ -284,5 +295,5 @@ export function downloadFile(fileName: string, data: string | Blob, mimeType = " document.body.appendChild(a); a.click(); - setTimeout(() => a.remove(), 50); + setTimeout(() => a.remove(), 1); } diff --git a/src/utils/logging.ts b/src/utils/logging.ts index b3ba99bf6b..a450ec4b46 100644 --- a/src/utils/logging.ts +++ b/src/utils/logging.ts @@ -1,8 +1,9 @@ import { clamp, debounce } from "@sv443-network/userutils"; +import { showIconToast } from "../components/toast.js"; +import { MarkdownDialog } from "../components/MarkdownDialog.js"; import { scriptInfo } from "../constants.js"; import { setGlobalProp } from "../interface.js"; import { LogLevel } from "../types.js"; -import { MarkdownDialog, showIconToast } from "../components/index.js"; import { t } from "./translations.js"; import { getFeature } from "../config.js"; import packageJson from "../../package.json" with { type: "json" }; @@ -103,18 +104,25 @@ ${t("generic_error_dialog_open_console_note", consPrefix, packageJson.bugs.url)} }); } -//#region rrror classes +//#region error classes -export class LyricsError extends Error { - constructor(message: string) { - super(message); - this.name = "LyricsError"; +export class CustomError extends Error { + public readonly time: number; + constructor(name: string, message: string, opts?: ErrorOptions) { + super(message, opts); + this.name = name; + this.time = Date.now(); } } -export class PluginError extends Error { - constructor(message: string) { - super(message); - this.name = "PluginError"; +export class LyricsError extends CustomError { + constructor(message: string, opts?: ErrorOptions) { + super("LyricsError", message, opts); + } +} + +export class PluginError extends CustomError { + constructor(message: string, opts?: ErrorOptions) { + super("PluginError", message, opts); } } diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 9e5a37068e..64fcd1ecf8 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -1,8 +1,9 @@ -import { compress, decompress, fetchAdvanced, openInNewTab, pauseFor, randomId, randRange } from "@sv443-network/userutils"; +import { compress, consumeStringGen, decompress, fetchAdvanced, getUnsafeWindow, openInNewTab, pauseFor, randomId, randRange, type Prettify, type StringGen } from "@sv443-network/userutils"; import { marked } from "marked"; -import { branch, compressionFormat, repo, sessionStorageAvailable } from "../constants.js"; -import { type Domain, type NumberLengthFormat, type ResourceKey, type StringGen } from "../types.js"; -import { error, type TrLocale, warn, sendRequest, getLocale, log } from "./index.js"; +import { assetSource, buildNumber, changelogUrl, compressionFormat, devServerPort, repo, sessionStorageAvailable } from "../constants.js"; +import { type Domain, type NumberLengthFormat, type ResourceKey } from "../types.js"; +import { error, type TrLocale, warn, sendRequest, getLocale, log, getVideoElement, getVideoTime } from "./index.js"; +import { enableDiscardBeforeUnload } from "../features/behavior.js"; import { getFeature } from "../config.js"; import langMapping from "../../assets/locales.json" with { type: "json" }; import resourcesJson from "../../assets/resources.json" with { type: "json" }; @@ -114,18 +115,18 @@ export function sanitizeChannelId(channelId: string) { /** Tests whether a string is a valid channel ID in the format `@User` or `UC...` */ export function isValidChannelId(channelId: string) { - return channelId.match(/^(UC|@)[\w-]+$/) !== null; + return channelId.match(/^(UC|@)[a-zA-Z0-9_-]+$/) !== null; } /** Quality identifier for a thumbnail - from highest to lowest res: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default` */ -type ThumbQuality = `${"maxres" | "sd" | "hq" | "mq" | ""}default`; +type ThumbQuality = `${"maxres" | "sd" | "hq" | "mq"}default` | "default"; /** Returns the thumbnail URL for a video with the given watch ID and quality (defaults to "hqdefault") */ export function getThumbnailUrl(watchId: string, quality?: ThumbQuality): string /** Returns the thumbnail URL for a video with the given watch ID and index (0 is low quality thumbnail, 1-3 are low quality frames from the video) */ -export function getThumbnailUrl(watchId: string, index: 0 | 1 | 2 | 3): string +export function getThumbnailUrl(watchId: string, index?: 0 | 1 | 2 | 3): string /** Returns the thumbnail URL for a video with either a given quality identifier or index */ -export function getThumbnailUrl(watchId: string, qualityOrIndex: ThumbQuality | 0 | 1 | 2 | 3 = "maxresdefault") { +export function getThumbnailUrl(watchId: string, qualityOrIndex: Prettify = "maxresdefault") { return `https://img.youtube.com/vi/${watchId}/${qualityOrIndex}.jpg`; } @@ -164,15 +165,16 @@ export function openInTab(href: string, background = false) { } /** Tries to parse an uncompressed or compressed input string as a JSON object */ -export async function tryToDecompressAndParse>(input: string): Promise { +export async function tryToDecompressAndParse>(input: StringGen): Promise { let parsed: TData | null = null; + const val = await consumeStringGen(input); try { - parsed = JSON.parse(input); + parsed = JSON.parse(val); } catch { try { - parsed = JSON.parse(await decompress(input, compressionFormat, "string")); + parsed = JSON.parse(await decompress(val, compressionFormat, "string")); } catch(err) { error("Couldn't decompress and parse data due to an error:", err); @@ -193,17 +195,6 @@ export function getOS() { return "other"; } -/** Turns the passed StringGen (either a string, stringifiable object or a sync or async function returning a string or stringifiable object) into a string */ -export async function consumeStringGen(strGen: StringGen): Promise { - return typeof strGen === "string" - ? strGen - : String( - typeof strGen === "function" - ? await strGen() - : strGen - ); -} - /** Formats a number based on the config or the passed {@linkcode notation} */ export function formatNumber(num: number, notation?: NumberLengthFormat): string { return num.toLocaleString( @@ -221,58 +212,116 @@ export function formatNumber(num: number, notation?: NumberLengthFormat): string ); } +/** add `time_continue` param only if current video time is greater than this value */ +const reloadTabVideoTimeThreshold = 3; + +/** Reloads the tab. If a video is currently playing, its time and volume will be preserved through the URL parameter `time_continue` and `bytm-reload-tab-volume` in GM storage */ +export async function reloadTab() { + const win = getUnsafeWindow(); + try { + enableDiscardBeforeUnload(); + + if((getVideoElement()?.readyState ?? 0) > 0) { + const time = await getVideoTime(0) ?? 0; + const volume = Math.round(getVideoElement()!.volume * 100); + + const url = new URL(win.location.href); + + if(!isNaN(time) && time > reloadTabVideoTimeThreshold) + url.searchParams.set("time_continue", String(time)); + if(!isNaN(volume) && volume > 0) + await GM.setValue("bytm-reload-tab-volume", String(volume)); + + return win.location.replace(url); + } + + win.location.reload(); + } + catch(err) { + error("Couldn't save video time and volume before reloading tab:", err); + win.location.reload(); + } +} + +/** Checks if the passed value is a {@linkcode StringGen} */ +export function isStringGen(val: unknown): val is StringGen { + return typeof val === "string" + || typeof val === "function" + || (typeof val === "object" && val !== null && "toString" in val && !val.toString().startsWith("[object")) + || val instanceof Promise; +} + //#region resources /** * Returns the blob-URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl) - * Falls back to a `raw.githubusercontent.com` URL or base64-encoded data URI if the resource is not available in the GM resource cache - * @param uncached Set to true to fetch from the `raw.githubusercontent.com` URL instead of the GM resource cache + * Falls back to a CDN URL or base64-encoded data URI if the resource is not available in the GM resource cache + * @param name The name / key of the resource as defined in `assets/resources.json` - you can use `as "_"` to make TypeScript shut up if the name can not be typed as `ResourceKey` + * @param uncached Set to true to always fetch from the CDN URL instead of the GM resource cache */ export async function getResourceUrl(name: ResourceKey | "_", uncached = false) { let url = !uncached && await GM.getResourceUrl(name); + if(!url || url.length === 0) { - const resObjOrStr = resourcesJson?.[name as keyof typeof resourcesJson]; + const resObjOrStr = resourcesJson.resources?.[name as keyof typeof resourcesJson.resources]; + if(typeof resObjOrStr === "object" || typeof resObjOrStr === "string") { - const pathname = typeof resObjOrStr === "object" && "path" in resObjOrStr ? resObjOrStr.path : resObjOrStr; - const ref = typeof resObjOrStr === "object" && "ref" in resObjOrStr ? resObjOrStr.ref : branch; - - if(pathname && pathname.startsWith("/") && pathname.length > 1) - return `https://raw.githubusercontent.com/${repo}/${ref}${pathname}`; - else if(pathname && pathname.startsWith("http")) - return pathname; - else if(pathname && pathname.length > 0) - return `https://raw.githubusercontent.com/${repo}/${ref}/assets/${pathname}`; + const pathName = typeof resObjOrStr === "object" && "path" in resObjOrStr ? resObjOrStr?.path : resObjOrStr; + const ghRef = typeof resObjOrStr === "object" && "ref" in resObjOrStr ? resObjOrStr?.ref : buildNumber; + + if(pathName) { + return pathName.startsWith("http") + ? pathName + : (() => { + let path = pathName; + if(path.startsWith("/")) + path = path.slice(1); + switch(assetSource) { + case "jsdelivr": + return `https://cdn.jsdelivr.net/gh/${repo}@${ghRef}/assets/${path}`; + case "github": + return `https://raw.githubusercontent.com/${repo}/${ghRef}/assets/${path}`; + case "local": + return `http://localhost:${devServerPort}/assets/${path}`; + } + })(); + } } - warn(`Couldn't get blob URL nor external URL for @resource '${name}', trying to use base64-encoded fallback`); + + warn(`Couldn't get blob URL nor external URL for @resource '${name}', attempting to use base64-encoded fallback`); // @ts-ignore url = await GM.getResourceUrl(name, false); } + return url; } /** - * Returns the preferred locale of the user, provided it is supported by the userscript. - * Prioritizes `navigator.language`, then `navigator.languages`, then `"en-US"` as a fallback. + * Resolves the preferred locale of the user given their browser's language settings, as long as it is supported by the userscript directly or via the `altLocales` prop in `locales.json` + * Prioritizes any supported value of `navigator.language`, then `navigator.languages`, then goes over them again, trimming off the part after the hyphen, then falls back to `"en-US"` */ export function getPreferredLocale(): TrLocale { - const nvLangs = navigator.languages - .filter(lang => lang.match(/^[a-z]{2}(-|_)[A-Z]$/) !== null); - - if(Object.entries(langMapping).find(([key]) => key === navigator.language)) - return navigator.language as TrLocale; - - for(const loc of nvLangs) { - if(Object.entries(langMapping).find(([key]) => key === loc)) - return loc as TrLocale; - } - - // if navigator.languages has entries that aren't locale codes in the format xx-XX - if(navigator.languages.some(lang => lang.match(/^[a-z]{2}$/))) { - for(const lang of nvLangs) { - const foundLoc = Object.entries(langMapping).find(([ key ]) => key.startsWith(lang))?.[0]; - if(foundLoc) - return foundLoc as TrLocale; - } + const sanEq = (str1: string, str2: string) => str1.trim().toLowerCase() === str2.trim().toLowerCase(); + + const allNvLocs = [...new Set([navigator.language, ...navigator.languages])] + .map((v) => v.replace(/_/g, "-")); + + for(const nvLoc of allNvLocs) { + const resolvedLoc = Object.entries(langMapping) + .find(([key, { altLocales }]) => + sanEq(key, nvLoc) || altLocales.find(al => sanEq(al, nvLoc)) + )?.[0]; + if(resolvedLoc) + return resolvedLoc.trim() as TrLocale; + + const trimmedNvLoc = nvLoc.split("-")[0]; + const resolvedFallbackLoc = Object.entries(langMapping) + .find(([key, { altLocales }]) => + sanEq(key.split("-")[0], trimmedNvLoc) || altLocales.find(al => sanEq(al.split("-")[0], trimmedNvLoc)) + )?.[0]; + + if(resolvedFallbackLoc) + return resolvedFallbackLoc.trim() as TrLocale; } return "en-US"; @@ -280,14 +329,14 @@ export function getPreferredLocale(): TrLocale { /** Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property */ export async function resourceAsString(resource: ResourceKey | "_") { + const resourceUrl = await getResourceUrl(resource); try { - const resourceUrl = await getResourceUrl(resource); if(!resourceUrl) throw new Error(`Couldn't find URL for resource '${resource}'`); return await (await fetchAdvanced(resourceUrl)).text(); } catch(err) { - error("Couldn't get SVG element from resource:", err); + error(`Couldn't get SVG element '${resource}' from resource at URL '${resourceUrl}':`, err); return null; } } @@ -302,7 +351,7 @@ export function parseMarkdown(mdString: string) { /** Returns the content of the changelog markdown file */ export async function getChangelogMd() { - const clRes = await fetchAdvanced(await getResourceUrl("doc-changelog", true)); + const clRes = await fetchAdvanced(changelogUrl); log("Fetched changelog:", clRes); return await clRes.text(); } diff --git a/src/utils/translations.ts b/src/utils/translations.ts index 087c1c644f..57ddc1ec9c 100644 --- a/src/utils/translations.ts +++ b/src/utils/translations.ts @@ -2,18 +2,24 @@ import { tr, Stringifiable, fetchAdvanced } from "@sv443-network/userutils"; import { error, getResourceUrl, info } from "./index.js"; import { emitInterface, setGlobalProp } from "../interface.js"; import { getFeature } from "../config.js"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars import langMapping from "../../assets/locales.json" with { type: "json" }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import tr_enUS from "../../assets/translations/en-US.json"; +import tr_enUS from "../../assets/translations/en-US.json" with { type: "json" }; + +void [langMapping, tr_enUS]; export type TrLocale = keyof typeof langMapping; -export type TrKey = keyof (typeof tr_enUS["translations"]); +export type TrKey = keyof typeof tr_enUS; type TFuncKey = TrKey | (string & {}); /** Contains the identifiers of all initialized and loaded translation locales */ const initializedLocales = new Set(); +/** The currently active locale */ +let activeLocale: TrLocale = "en-US"; + +tr.addTransform(tr.transforms.percent); +tr.addTransform(tr.transforms.templateLiteral); + /** Initializes the translations */ export async function initTranslations(locale: TrLocale) { if(initializedLocales.has(locale)) @@ -30,15 +36,19 @@ export async function initTranslations(locale: TrLocale) { fallbackTrans = await fetchLocaleJson("en-US"); // merge with base translations if specified - const baseTransFile = transFile.base ? await fetchLocaleJson(transFile.base) : undefined; - - const translations: typeof tr_enUS["translations"] = { - ...(fallbackTrans?.translations ?? {}), - ...(baseTransFile?.translations ?? {}), - ...transFile.translations, + const baseTransFile = typeof transFile?.meta === "object" && "base" in transFile.meta && typeof transFile.meta.base === "string" + ? await fetchLocaleJson(transFile.base as TrLocale) + : undefined; + + const translations: typeof tr_enUS = { + ...(fallbackTrans ?? {}), + ...(baseTransFile ?? {}), + ...transFile, }; - tr.addLanguage(locale, translations); + const { meta: { authors: _authors, ...meta }, ...trans } = translations; + + tr.addTranslations(locale, { ...meta, ...trans }); info(`Loaded translations for locale '${locale}'`); } @@ -61,14 +71,14 @@ async function fetchLocaleJson(locale: TrLocale) { /** Sets the current language for translations */ export function setLocale(locale: TrLocale) { - tr.setLanguage(locale); + activeLocale = locale; setGlobalProp("locale", locale); emitInterface("bytm:setLocale", { locale }); } /** Returns the currently set language */ export function getLocale() { - return tr.getLanguage() as TrLocale; + return activeLocale; } /** Returns whether the given translation key exists in the current locale */ @@ -83,7 +93,7 @@ export function hasKeyFor(locale: TrLocale, key: TFuncKey) { /** Returns the translated string for the given key, after optionally inserting values */ export function t(key: TFuncKey, ...values: Stringifiable[]) { - return tr(key, ...values); + return tl(activeLocale, key, ...values); } /** @@ -97,7 +107,7 @@ export function tp(key: TFuncKey, num: number | unknown[] | NodeList, ...values: /** Returns the translated string for the given key in the specified locale, after optionally inserting values */ export function tl(locale: TrLocale, key: TFuncKey, ...values: Stringifiable[]) { - return tr.forLang(locale, key, ...values); + return tr.for(locale, key, ...values); } /** diff --git a/tsconfig.json b/tsconfig.json index 9e88d728b8..38d5b25511 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,7 @@ "target": "ES2017", "outDir": "dist/out", "lib": [ - "ES5", - "ES6", + "ES2022", "DOM", "DOM.Iterable" ], @@ -25,10 +24,6 @@ "useDefineForClassFields": true, "noImplicitThis": false, }, - "ts-node": { - "esm": true, - "preferTsExts": true, - }, "include": [ "src/**/*.ts", "src/**/*.d.ts",