mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41:47 +00:00
Compare commits
301 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca04c4561b | |||
| 7d8fbf49a8 | |||
| 75e15b948d | |||
| 125b69fd75 | |||
| a68d6b64dd | |||
| a60d4264dc | |||
| 9e2c6b1afa | |||
| 14965a93e9 | |||
| f62664b6a5 | |||
| 60cb7f32f1 | |||
| 008b3ad710 | |||
| 8cae64f496 | |||
| 5cdc1bc762 | |||
| 15c455105b | |||
| 14407a98c9 | |||
| 0d004d5caf | |||
| 4b75a2405c | |||
| 1f7e28b6fb | |||
| c41b2ce861 | |||
| 7f02afc5a6 | |||
| 496b3ffc1b | |||
| e9a395f67a | |||
| 0660f0b7ce | |||
| 3ac09b9dc1 | |||
| fe4904a4af | |||
| d8c8bd17ec | |||
| e9d4d5ba14 | |||
| 5b2e69588f | |||
| c1591402a0 | |||
| e2e9c03895 | |||
| deac4ef56b | |||
| 7c39e658ce | |||
| 6b026f57bc | |||
| dc07cbda6f | |||
| 1cf43fcd42 | |||
| e2cf550bed | |||
| 2917da1138 | |||
| b74eeb5688 | |||
| 0b084a6441 | |||
| 865efa1b12 | |||
| 6a248e5336 | |||
| eb9c256a5d | |||
| 4bd54dcb2d | |||
| 17b035d317 | |||
| 28bcd1fefc | |||
| 59bb1d9124 | |||
| d9255c1cec | |||
| 4ab4bb4cb3 | |||
| a6c8b887e3 | |||
| 1db0abf32d | |||
| ff899b8720 | |||
| 18004c4441 | |||
| ce1cde72bd | |||
| 453f4d92c9 | |||
| 37740e78b4 | |||
| 8ace123179 | |||
| bcdb9de41a | |||
| 9fdb6eb7e5 | |||
| 88cd1d2390 | |||
| 943bcd322d | |||
| 7774128d7e | |||
| b9b9e2ba00 | |||
| 0ce4f20ec5 | |||
| 51b87312c4 | |||
| 9ffd7af8a7 | |||
| 4a453a4f3d | |||
| dc8a472cdb | |||
| d2eabaa4bb | |||
| 39c8ca66d1 | |||
| 806098a5ef | |||
| 5f6cfd9558 | |||
| b4b7ad824b | |||
| 7b5d602f63 | |||
| 7eeeb89457 | |||
| 6e8447b5d1 | |||
| a6445bacf0 | |||
| bd9b4f1b1a | |||
| 9a816b3f07 | |||
| 4dcac23688 | |||
| 97ef6ff997 | |||
| 244a656671 | |||
| f8a2829adb | |||
| 24daadbef8 | |||
| bf2ac88847 | |||
| e42423b100 | |||
| de0c02efaf | |||
| b8290417f8 | |||
| b92205a228 | |||
| 5f642007ba | |||
| ee40d278d4 | |||
| 02d2e8ea92 | |||
| 70715e5e8a | |||
| 5ae4f564b7 | |||
| 123eabd77a | |||
| fd3438a20d | |||
| c8554a12f6 | |||
| 4a687ade9c | |||
| f77aa372cc | |||
| 0b9eef94c4 | |||
| 71b2f69f98 | |||
| 4b61c5307e | |||
| a617b91263 | |||
| fc79bdd0f3 | |||
| e5e1e547d5 | |||
| edac9b0c20 | |||
| dfaf3cf95a | |||
| bae90ce8f3 | |||
| 188e56ce30 | |||
| 19b48b123f | |||
| 549961f297 | |||
| ba7bc68ac3 | |||
| bbfe272d41 | |||
| 8a3e0a31ca | |||
| 8fbda97885 | |||
| 1856deb0f5 | |||
| fec7c5c130 | |||
| 936b4b28bb | |||
| f3092d0778 | |||
| fc1adfae6c | |||
| e279aaed64 | |||
| 4d346a9471 | |||
| cfc504da34 | |||
| 0919a4b9b7 | |||
| f46ad2ea0e | |||
| 252719bc71 | |||
| 45f49361ea | |||
| c4a74c6c7e | |||
| 05f197948d | |||
| 5a1d230538 | |||
| a7ad260a00 | |||
| ef068cccd9 | |||
| 166067920d | |||
| 8227853cf9 | |||
| 324a539b89 | |||
| ce7557353c | |||
| 7b7923fe9b | |||
| 105d5c78e7 | |||
| b25183a8f5 | |||
| adde33d1f5 | |||
| ad325ccb10 | |||
| 2e7ea6969c | |||
| 7401cf69ad | |||
| 7f71c36dc0 | |||
| a3104fda4b | |||
| 44c42310f1 | |||
| a22a8ac5c9 | |||
| aa5c3bac4e | |||
| 30b3beee18 | |||
| b059e43fb1 | |||
| 3b04d0ba19 | |||
| 959f99beae | |||
| ed402933d3 | |||
| ef8bb95884 | |||
| 1b79d2e429 | |||
| ec786748be | |||
| 06f1c7effe | |||
| d78da237fc | |||
| 4c0cce89ee | |||
| 888ced8fd1 | |||
| e1690720b3 | |||
| bbff0a6bc2 | |||
| 5db759150c | |||
| ae239f6700 | |||
| 1d26d10e57 | |||
| da70a4ce7e | |||
| 75ae9f4fad | |||
| 8f7933c111 | |||
| 29a0dedcce | |||
| 4d62993177 | |||
| 8714f33fa2 | |||
| 5dacd50ff6 | |||
| 8d06dcc7b6 | |||
| b8f6dd2584 | |||
| 0650205b86 | |||
| 3e8a0ec49a | |||
| 04d7b32d3f | |||
| eaaf170cc8 | |||
| 09450fb8c7 | |||
| ac0b78eefb | |||
| 90103d9853 | |||
| bf27c73f1d | |||
| 845c9365be | |||
| 91cf5f5c25 | |||
| 783a892e26 | |||
| 41d8f86962 | |||
| 252349579e | |||
| 99b1cfbde4 | |||
| 3f70d912d7 | |||
| bf33c4e7b4 | |||
| 3152842a30 | |||
| d84416b27c | |||
| cc38978bd3 | |||
| 7a76079ff4 | |||
| 2fe28cf126 | |||
| 3ffbfbe0e3 | |||
| 4fad456619 | |||
| 7591f13505 | |||
| 11d06c50a5 | |||
| e0a3489640 | |||
| e55a1d3076 | |||
| 563d431c00 | |||
| 3a1b77ebd8 | |||
| 3f8030a9c5 | |||
| e12e67af0e | |||
| 3ab4cd5d05 | |||
| 738adbed98 | |||
| 365a078600 | |||
| 04fc43e18b | |||
| 54273baec7 | |||
| 51e62ef47b | |||
| a330ebcda7 | |||
| a023fff2d0 | |||
| abb25ea6fb | |||
| ef49bcdb5f | |||
| b4f1b112d6 | |||
| f24ec0ae9d | |||
| ebb51fe37b | |||
| e8ee18f903 | |||
| a593de705c | |||
| 03dd024704 | |||
| 528c3535dd | |||
| 0e0f80a2d0 | |||
| 6b67fb136a | |||
| 9fe1c14869 | |||
| 8a96dddf54 | |||
| 230422c98b | |||
| d16ffc531f | |||
| f614199ea5 | |||
| 55a1c2e9e3 | |||
| bee1f77812 | |||
| fdf982ada5 | |||
| ff02fc7855 | |||
| 01ed289400 | |||
| aedb2db655 | |||
| 10a54b9de0 | |||
| ccd029c040 | |||
| 3a431841b7 | |||
| deceae8354 | |||
| c8628670cf | |||
| ffe53d5596 | |||
| a4f4ecb569 | |||
| 2097f42efb | |||
| 9c59f56aac | |||
| dfcc4107b7 | |||
| ef71abfff1 | |||
| bc916f3a6e | |||
| c7ff0dcbf6 | |||
| 7242f9bfd0 | |||
| bb2e865880 | |||
| 6ab3cf9ac9 | |||
| b77f5c9ecc | |||
| b470dbd6b9 | |||
| 1f96b6b44d | |||
| de0b228ae8 | |||
| f35d192650 | |||
| 794d00ce9e | |||
| 739e7a448b | |||
| 7fa8a454b6 | |||
| 5cd1d9abe8 | |||
| e0e17cac99 | |||
| 840039330f | |||
| 734409dc3f | |||
| 34564c8c55 | |||
| afe6accab8 | |||
| b6e7e75ae8 | |||
| 06dc0e80f0 | |||
| 47cccbce7c | |||
| 269352af97 | |||
| fa62f79dce | |||
| 9f88b37f41 | |||
| 55ae9eac1e | |||
| 05564d4a58 | |||
| 59426c56db | |||
| 18cd4c0c9a | |||
| a0e2a33e28 | |||
| 7bdb46e161 | |||
| f560b62de0 | |||
| adc1f6822b | |||
| 2da29fcfa7 | |||
| c5d0314db6 | |||
| 8c052faedd | |||
| 37067ff950 | |||
| 6366dc026e | |||
| 6e52178074 | |||
| 47f38cc690 | |||
| fdd6d9929f | |||
| 1707261f49 | |||
| 6712fced6d | |||
| 6dabfaa9ba | |||
| a41db79c35 | |||
| 87786d9aef | |||
| 22f5866050 | |||
| 04894fbcf5 | |||
| c17c624ba4 | |||
| bfe7249df8 | |||
| 13c570efe9 | |||
| b299846f0f | |||
| 59e9289d27 | |||
| 8dc29caa1b | |||
| 7fedf88654 | |||
| 5da0202425 |
@ -1,3 +1 @@
|
|||||||
.eslintrc.js
|
.eslintrc.js
|
||||||
rollup.main.config.ts
|
|
||||||
rollup.preload.config.ts
|
|
||||||
|
|||||||
13
.eslintrc.js
13
.eslintrc.js
@ -7,7 +7,7 @@ module.exports = {
|
|||||||
'plugin:@typescript-eslint/recommended',
|
'plugin:@typescript-eslint/recommended',
|
||||||
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
'plugin:@typescript-eslint/recommended-requiring-type-checking',
|
||||||
],
|
],
|
||||||
plugins: ['@typescript-eslint', 'import'],
|
plugins: ['prettier', '@typescript-eslint', 'import'],
|
||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: './tsconfig.json',
|
project: './tsconfig.json',
|
||||||
@ -26,6 +26,7 @@ module.exports = {
|
|||||||
'import/newline-after-import': 'error',
|
'import/newline-after-import': 'error',
|
||||||
'import/no-default-export': 'off',
|
'import/no-default-export': 'off',
|
||||||
'import/no-duplicates': 'error',
|
'import/no-duplicates': 'error',
|
||||||
|
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
|
||||||
'import/order': [
|
'import/order': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@ -66,4 +67,14 @@ module.exports = {
|
|||||||
es6: true,
|
es6: true,
|
||||||
},
|
},
|
||||||
ignorePatterns: ['dist', 'node_modules'],
|
ignorePatterns: ['dist', 'node_modules'],
|
||||||
|
root: true,
|
||||||
|
settings: {
|
||||||
|
'import/parsers': {
|
||||||
|
'@typescript-eslint/parser': ['.ts']
|
||||||
|
},
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {},
|
||||||
|
exports: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
16
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -57,10 +57,17 @@ body:
|
|||||||
label: Last Known Working YouTube Music (Application) version
|
label: Last Known Working YouTube Music (Application) version
|
||||||
description: (If applicable) What is the last version of YouTube Music this worked in?
|
description: (If applicable) What is the last version of YouTube Music this worked in?
|
||||||
placeholder: 1.20.0
|
placeholder: 1.20.0
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: Provide steps to reproduce the issue.
|
||||||
|
placeholder: 1. Enable the X plugin.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Expected Behavior
|
||||||
description: A clear and concise description of what you expected to happen. (Add a replication step if applicable)
|
description: A clear and concise description of what you expected to happen.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@ -69,6 +76,13 @@ body:
|
|||||||
description: A clear description of what actually happens.
|
description: A clear description of what actually happens.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Enabled plugins
|
||||||
|
description: Provide the list of plugins you enabled.
|
||||||
|
placeholder: 1. Album Color Theme
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional Information
|
label: Additional Information
|
||||||
|
|||||||
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@ -35,15 +35,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup NodeJS for macOS
|
- name: Setup NodeJS for macOS
|
||||||
if: startsWith(matrix.os, 'macOS')
|
if: startsWith(matrix.os, 'macOS')
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Only rollup build without release if it is a fork
|
# Only vite build without release if it is a fork, or it is a pull-request
|
||||||
- name: Rollup Build
|
- name: Vite Build
|
||||||
if: github.repository == 'th-ch/youtube-music' && github.event_name == 'pull_request'
|
if: github.repository == 'th-ch/youtube-music' && github.event_name == 'pull_request'
|
||||||
run: |
|
run: |
|
||||||
pnpm build
|
pnpm build
|
||||||
@ -96,14 +96,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup NodeJS
|
- name: Setup NodeJS
|
||||||
if: startsWith(matrix.os, 'macOS') != true
|
if: startsWith(matrix.os, 'macOS') != true
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Setup NodeJS for macOS
|
- name: Setup NodeJS for macOS
|
||||||
if: startsWith(matrix.os, 'macOS')
|
if: startsWith(matrix.os, 'macOS')
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
@ -146,6 +146,8 @@ jobs:
|
|||||||
|
|
||||||
Thanks to all contributors! 🏅
|
Thanks to all contributors! 🏅
|
||||||
|
|
||||||
|
(Note for Windows: `YouTube-Music-Web-Setup-${{ env.VERSION_TAG }}.exe` is an installer, and `YouTube-Music-${{ env.VERSION_TAG }}.exe` is a portable version)
|
||||||
|
|
||||||
- name: Update changelog
|
- name: Update changelog
|
||||||
if: ${{ env.VERSION_HASH == '' }}
|
if: ${{ env.VERSION_HASH == '' }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
6
.github/workflows/winget-submission.yml
vendored
6
.github/workflows/winget-submission.yml
vendored
@ -15,12 +15,16 @@ jobs:
|
|||||||
name: Publish winget package
|
name: Publish winget package
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Set winget version env
|
||||||
|
env:
|
||||||
|
TAG_NAME: ${{ inputs.tag_name || github.event.release.tag_name }}
|
||||||
|
run: echo "WINGET_TAG_NAME=$(echo ${TAG_NAME#v})" >> $GITHUB_ENV
|
||||||
- name: Submit package to Windows Package Manager Community Repository
|
- name: Submit package to Windows Package Manager Community Repository
|
||||||
uses: vedantmgoyal2009/winget-releaser@v2
|
uses: vedantmgoyal2009/winget-releaser@v2
|
||||||
with:
|
with:
|
||||||
identifier: th-ch.YouTubeMusic
|
identifier: th-ch.YouTubeMusic
|
||||||
installers-regex: '^YouTube-Music-Web-Setup-[\d\.]+\.exe$'
|
installers-regex: '^YouTube-Music-Web-Setup-[\d\.]+\.exe$'
|
||||||
version: ${{ inputs.tag_name || github.event.release.tag_name }}
|
version: ${{ env.WINGET_TAG_NAME }}
|
||||||
release-tag: ${{ inputs.tag_name || github.event.release.tag_name }}
|
release-tag: ${{ inputs.tag_name || github.event.release.tag_name }}
|
||||||
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||||
fork-user: youtube-music-winget
|
fork-user: youtube-music-winget
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,3 +12,4 @@ electron-builder.yml
|
|||||||
!.yarn/releases
|
!.yarn/releases
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
|
.vite-inspect
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
[](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
|
[](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
|
||||||
[](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
|
[](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
|
||||||
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
[](https://snyk.io/test/github/th-ch/youtube-music)
|
|
||||||
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
||||||
|
[](https://snyk.io/test/github/th-ch/youtube-music)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -20,12 +20,23 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
Read this in other languages: [🇰🇷](./docs/readme/README-ko.md)
|
||||||
|
|
||||||
**Electron wrapper around YouTube Music featuring:**
|
**Electron wrapper around YouTube Music featuring:**
|
||||||
|
|
||||||
- Native look & feel, aims at keeping the original interface
|
- Native look & feel, aims at keeping the original interface
|
||||||
- Framework for custom plugins: change YouTube Music to your needs (style, content, features), enable/disable plugins in
|
- Framework for custom plugins: change YouTube Music to your needs (style, content, features), enable/disable plugins in
|
||||||
one click
|
one click
|
||||||
|
|
||||||
|
## Translation
|
||||||
|
|
||||||
|
You can help with translation on [Hosted Weblate](https://hosted.weblate.org/projects/youtube-music/).
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/youtube-music/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/multi-auto.svg" alt="translation status" />
|
||||||
|
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/287x66-black.png" alt="translation status 2" />
|
||||||
|
</a>
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
You can check out the [latest release](https://github.com/th-ch/youtube-music/releases/latest) to quickly find the
|
You can check out the [latest release](https://github.com/th-ch/youtube-music/releases/latest) to quickly find the
|
||||||
@ -38,7 +49,12 @@ this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Insta
|
|||||||
|
|
||||||
### MacOS
|
### MacOS
|
||||||
|
|
||||||
If you get an error "is damaged and can’t be opened." when launching the app, run the following in the Terminal:
|
You can install the app using Homebrew:
|
||||||
|
```bash
|
||||||
|
brew install --cask https://raw.githubusercontent.com/th-ch/youtube-music/master/youtube-music.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
If you install the app manually and get an error "is damaged and can’t be opened." when launching the app, run the following in the Terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
xattr -cr /Applications/YouTube\ Music.app
|
xattr -cr /Applications/YouTube\ Music.app
|
||||||
@ -75,6 +91,14 @@ winget install th-ch.YouTubeMusic
|
|||||||
- Place them in the **same directory**.
|
- Place them in the **same directory**.
|
||||||
- Run the installer.
|
- Run the installer.
|
||||||
|
|
||||||
|
## Features:
|
||||||
|
|
||||||
|
- **Auto confirm when paused** (Always Enabled): disable
|
||||||
|
the ["Continue Watching?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png)
|
||||||
|
popup that pause music after a certain time
|
||||||
|
|
||||||
|
- And more ...
|
||||||
|
|
||||||
## Available plugins:
|
## Available plugins:
|
||||||
|
|
||||||
- **Ad Blocker**: Block all ads and tracking out of the box
|
- **Ad Blocker**: Block all ads and tracking out of the box
|
||||||
@ -164,15 +188,6 @@ winget install th-ch.YouTubeMusic
|
|||||||
|
|
||||||
- **Visualizer**: Different music visualizers
|
- **Visualizer**: Different music visualizers
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- **Auto confirm when paused** (Always Enabled): disable
|
|
||||||
the ["Continue Watching?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png)
|
|
||||||
popup that pause music after a certain time
|
|
||||||
|
|
||||||
> If `Hide Menu` option is on - you can show the menu with the <kbd>alt</kbd> key (or <kbd>\`</kbd> [backtick] if using
|
|
||||||
> the in-app-menu plugin)
|
|
||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
You can load CSS files to change the look of the application (Options > Visual Tweaks > Themes).
|
You can load CSS files to change the look of the application (Options > Visual Tweaks > Themes).
|
||||||
@ -185,7 +200,7 @@ Some predefined themes are available in https://github.com/kerichdev/themes-for-
|
|||||||
git clone https://github.com/th-ch/youtube-music
|
git clone https://github.com/th-ch/youtube-music
|
||||||
cd youtube-music
|
cd youtube-music
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
pnpm start
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build your own plugins
|
## Build your own plugins
|
||||||
@ -199,47 +214,70 @@ Using plugins, you can:
|
|||||||
|
|
||||||
Create a folder in `plugins/YOUR-PLUGIN-NAME`:
|
Create a folder in `plugins/YOUR-PLUGIN-NAME`:
|
||||||
|
|
||||||
- if you need to manipulate the BrowserWindow, create a file with the following template:
|
- `index.ts`: the main file of the plugin
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// file: back.ts
|
import style from './style.css?inline'; // import style as inline
|
||||||
export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
|
|
||||||
// something
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
then, register the plugin in `index.ts`:
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
```typescript
|
export default createPlugin({
|
||||||
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back';
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // if value is true, ytmusic show restart dialog
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // your custom config
|
||||||
|
stylesheets: [style], // your custom style,
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
// All *Config methods are wrapped Promise<T>
|
||||||
|
const config = await getConfig();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'menu',
|
||||||
|
submenu: [1, 2, 3].map((value) => ({
|
||||||
|
label: `value ${value}`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.value === value,
|
||||||
|
click() {
|
||||||
|
setConfig({ value });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
start({ window, ipc }) {
|
||||||
|
window.maximize();
|
||||||
|
|
||||||
// ...
|
// you can communicate with renderer plugin
|
||||||
|
ipc.handle('some-event', () => {
|
||||||
const mainPlugins = {
|
return 'hello';
|
||||||
// ...
|
});
|
||||||
'YOUR-PLUGIN-NAME': yourPlugin,
|
},
|
||||||
};
|
// it fired when config changed
|
||||||
```
|
onConfigChange(newConfig) { /* ... */ },
|
||||||
|
// it fired when plugin disabled
|
||||||
- if you need to change the front, create a file with the following template:
|
stop(context) { /* ... */ },
|
||||||
|
},
|
||||||
```typescript
|
renderer: {
|
||||||
// file: front.ts
|
async start(context) {
|
||||||
export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
|
console.log(await context.ipc.invoke('some-event'));
|
||||||
// This function will be called as a preload script
|
},
|
||||||
// So you can use front features like `document.querySelector`
|
// Only renderer available hook
|
||||||
};
|
onPlayerApiReady(api: YoutubePlayer, context: RendererContext) {
|
||||||
```
|
// set plugin config easily
|
||||||
|
context.setConfig({ myConfig: api.getVolume() });
|
||||||
then, register the plugin in `preload.ts`:
|
},
|
||||||
|
onConfigChange(newConfig) { /* ... */ },
|
||||||
```typescript
|
stop(_context) { /* ... */ },
|
||||||
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/front';
|
},
|
||||||
|
preload: {
|
||||||
const rendererPlugins: PluginMapper<'renderer'> = {
|
async start({ getConfig }) {
|
||||||
// ...
|
const config = await getConfig();
|
||||||
'YOUR-PLUGIN-NAME': yourPlugin,
|
},
|
||||||
};
|
onConfigChange(newConfig) {},
|
||||||
|
stop(_context) {},
|
||||||
|
},
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Common use cases
|
### Common use cases
|
||||||
@ -247,27 +285,42 @@ const rendererPlugins: PluginMapper<'renderer'> = {
|
|||||||
- injecting custom CSS: create a `style.css` file in the same folder then:
|
- injecting custom CSS: create a `style.css` file in the same folder then:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import path from 'node:path';
|
// index.ts
|
||||||
import { injectCSS } from '../utils';
|
import style from './style.css?inline'; // import style as inline
|
||||||
|
|
||||||
// back.ts
|
import { createPlugin } from '@/utils';
|
||||||
export default (win: Electron.BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
|
const builder = createPlugin({
|
||||||
};
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // if value is true, ytmusic show restart dialog
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // your custom config
|
||||||
|
stylesheets: [style], // your custom style
|
||||||
|
renderer() {} // define renderer hook
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- changing the HTML:
|
- If you want to change the HTML:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// front.ts
|
import { createPlugin } from '@/utils';
|
||||||
export default () => {
|
|
||||||
// Remove the login button
|
const builder = createPlugin({
|
||||||
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
name: 'Plugin Label',
|
||||||
};
|
restartNeeded: true, // if value is true, ytmusic show restart dialog
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // your custom config
|
||||||
|
renderer() {
|
||||||
|
// Remove the login button
|
||||||
|
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
||||||
|
} // define renderer hook
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- communicating between the front and back: can be done using the ipcMain module from electron. See `utils.js` file and
|
- communicating between the front and back: can be done using the ipcMain module from electron. See `index.ts` file and
|
||||||
example in `navigation` plugin.
|
example in `sponsorblock` plugin.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@ -283,6 +336,12 @@ export default () => {
|
|||||||
Builds the app for macOS, Linux, and Windows,
|
Builds the app for macOS, Linux, and Windows,
|
||||||
using [electron-builder](https://github.com/electron-userland/electron-builder).
|
using [electron-builder](https://github.com/electron-userland/electron-builder).
|
||||||
|
|
||||||
|
## Production Preview
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -294,3 +353,10 @@ Uses [Playwright](https://playwright.dev/) to test the app.
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
MIT © [th-ch](https://github.com/th-ch/youtube-music)
|
MIT © [th-ch](https://github.com/th-ch/youtube-music)
|
||||||
|
|
||||||
|
## Most asked questions
|
||||||
|
|
||||||
|
### Why apps menu isn't showing up?
|
||||||
|
|
||||||
|
If `Hide Menu` option is on - you can show the menu with the <kbd>alt</kbd> key (or <kbd>\`</kbd> [backtick] if using
|
||||||
|
the in-app-menu plugin)
|
||||||
Binary file not shown.
52
changelog.md
52
changelog.md
@ -2,8 +2,60 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||||
|
|
||||||
|
#### [v3.0.1](https://github.com/th-ch/youtube-music/compare/v3.0.0...v3.0.1)
|
||||||
|
|
||||||
|
- hotfix(adblocker): fix #1475 [`#1475`](https://github.com/th-ch/youtube-music/issues/1475)
|
||||||
|
- Translated using Weblate (French) [`7f02afc`](https://github.com/th-ch/youtube-music/commit/7f02afc5a6839adfe8437d4e2cc8dee13a93b311)
|
||||||
|
- Update changelog for v3.0.0 [`d8c8bd1`](https://github.com/th-ch/youtube-music/commit/d8c8bd17ecfbdf96ebd29eb4c5748c07876ee242)
|
||||||
|
- Translated using Weblate (German) [`0660f0b`](https://github.com/th-ch/youtube-music/commit/0660f0b7ce6895ef5800f48ade1da2d7f8e0c1f7)
|
||||||
|
|
||||||
|
### [v3.0.0](https://github.com/th-ch/youtube-music/compare/v2.2.0...v3.0.0)
|
||||||
|
|
||||||
|
> 2 December 2023
|
||||||
|
|
||||||
|
- Add text to Translation section [`#1470`](https://github.com/th-ch/youtube-music/pull/1470)
|
||||||
|
- fix(deps): update dependency youtubei.js to v8 [`#1473`](https://github.com/th-ch/youtube-music/pull/1473)
|
||||||
|
- chore(deps): update dependency electron to v27.1.3 [`#1471`](https://github.com/th-ch/youtube-music/pull/1471)
|
||||||
|
- fix(deps): update dependency @xhayper/discord-rpc to v1.1.1 [`#1472`](https://github.com/th-ch/youtube-music/pull/1472)
|
||||||
|
- feat: add support i18n [`#1468`](https://github.com/th-ch/youtube-music/pull/1468)
|
||||||
|
- chore(deps): update dependency electron to v27.1.2 [`#1441`](https://github.com/th-ch/youtube-music/pull/1441)
|
||||||
|
- Nicer Readme [`#1439`](https://github.com/th-ch/youtube-music/pull/1439)
|
||||||
|
- Windows Zoom, ScaleFactor [`#1402`](https://github.com/th-ch/youtube-music/pull/1402)
|
||||||
|
- chore(deps): bump axios from 1.5.1 to 1.6.1 [`#1400`](https://github.com/th-ch/youtube-music/pull/1400)
|
||||||
|
- Updated mac icon to better reflect the Mac styling [`#1395`](https://github.com/th-ch/youtube-music/pull/1395)
|
||||||
|
- feat: rename plugins to clarify context [`#1392`](https://github.com/th-ch/youtube-music/pull/1392)
|
||||||
|
- feat: refactor plugin utils [`#1391`](https://github.com/th-ch/youtube-music/pull/1391)
|
||||||
|
- feat: plugin auto-importer with `vite-plugin-resolve` [`#1385`](https://github.com/th-ch/youtube-music/pull/1385)
|
||||||
|
- feat: migrate from `rollup` to `electron-vite` [`#1364`](https://github.com/th-ch/youtube-music/pull/1364)
|
||||||
|
- feat: enable `context-isolation` [`#1361`](https://github.com/th-ch/youtube-music/pull/1361)
|
||||||
|
- fix: add workaround for `podcast` type video [`#1362`](https://github.com/th-ch/youtube-music/pull/1362)
|
||||||
|
- fix: fix broken menu-layout [`#1360`](https://github.com/th-ch/youtube-music/pull/1360)
|
||||||
|
- Add Homebrew cask install option for MacOS. [`#1357`](https://github.com/th-ch/youtube-music/pull/1357)
|
||||||
|
- feat: changed Zoom shortcuts to standard [`#1458`](https://github.com/th-ch/youtube-music/issues/1458)
|
||||||
|
- fix(in-app-menu): fix #1436 [`#1436`](https://github.com/th-ch/youtube-music/issues/1436)
|
||||||
|
- fix(discord): update application client-id [`#1431`](https://github.com/th-ch/youtube-music/issues/1431)
|
||||||
|
- chore(deps): update dependency electron to v27.0.4 [`#1324`](https://github.com/th-ch/youtube-music/issues/1324)
|
||||||
|
- fix(in-app-menu): panel should close with the window when it is closed [`#1389`](https://github.com/th-ch/youtube-music/issues/1389)
|
||||||
|
- fix: change titleBarOverlay height based on zoomFactor [`#1375`](https://github.com/th-ch/youtube-music/issues/1375)
|
||||||
|
- fix: fixed an issue if "Always on top" is enabled, the dialog is displayed below the window [`#1379`](https://github.com/th-ch/youtube-music/issues/1379)
|
||||||
|
- fix: fix winget version (fix #1363) [`#1363`](https://github.com/th-ch/youtube-music/issues/1363)
|
||||||
|
- feat: run prettier [`a3104fd`](https://github.com/th-ch/youtube-music/commit/a3104fda4b0d58b076d0c737111636a66e468acc)
|
||||||
|
- Translated using Weblate (Korean) [`b4b7ad8`](https://github.com/th-ch/youtube-music/commit/b4b7ad824b8c489ae483eba139b46e5b200231fc)
|
||||||
|
- Translated using Weblate (English) [`d2eabaa`](https://github.com/th-ch/youtube-music/commit/d2eabaa4bbccd89eae529eae52cec035e8e2620c)
|
||||||
|
|
||||||
|
#### [v2.2.0](https://github.com/th-ch/youtube-music/compare/v2.1.3...v2.2.0)
|
||||||
|
|
||||||
|
> 27 October 2023
|
||||||
|
|
||||||
|
- feat(ambient-mode): add config for `ambient-mode` plugin [`#1349`](https://github.com/th-ch/youtube-music/pull/1349)
|
||||||
|
- bump deps [`4248d20`](https://github.com/th-ch/youtube-music/commit/4248d20e8ef926ce7b1d07eb83743755a341d9f6)
|
||||||
|
- Update changelog for v2.1.3 [`dc73561`](https://github.com/th-ch/youtube-music/commit/dc73561c8a8acfc8ba91aff2dc78e4267869f2fd)
|
||||||
|
- Bump version to 2.2.0 [`6288d0b`](https://github.com/th-ch/youtube-music/commit/6288d0b171a65ea015922cdf3af6c7bd9a1f269b)
|
||||||
|
|
||||||
#### [v2.1.3](https://github.com/th-ch/youtube-music/compare/v2.1.2...v2.1.3)
|
#### [v2.1.3](https://github.com/th-ch/youtube-music/compare/v2.1.2...v2.1.3)
|
||||||
|
|
||||||
|
> 23 October 2023
|
||||||
|
|
||||||
- fix: fixed bugs in downloader [`#1342`](https://github.com/th-ch/youtube-music/pull/1342)
|
- fix: fixed bugs in downloader [`#1342`](https://github.com/th-ch/youtube-music/pull/1342)
|
||||||
- feat(discord): rename `Listen Along` to `Play on YTM` [`#1341`](https://github.com/th-ch/youtube-music/issues/1341)
|
- feat(discord): rename `Listen Along` to `Play on YTM` [`#1341`](https://github.com/th-ch/youtube-music/issues/1341)
|
||||||
- chore(deps): bump deps [`4333891`](https://github.com/th-ch/youtube-music/commit/4333891ccabe42aedf756fd48618be715db13262)
|
- chore(deps): bump deps [`4333891`](https://github.com/th-ch/youtube-music/commit/4333891ccabe42aedf756fd48618be715db13262)
|
||||||
|
|||||||
327
docs/readme/README-ko.md
Normal file
327
docs/readme/README-ko.md
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
# 유튜브 뮤직 (YouTube Music)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/th-ch/youtube-music/releases/)
|
||||||
|
[](https://github.com/th-ch/youtube-music/blob/master/LICENSE)
|
||||||
|
[](https://github.com/th-ch/youtube-music/blob/master/.eslintrc.js)
|
||||||
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
|
[](https://GitHub.com/th-ch/youtube-music/releases/)
|
||||||
|
[](https://aur.archlinux.org/packages/youtube-music-bin)
|
||||||
|
[](https://snyk.io/test/github/th-ch/youtube-music)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="https://github.com/th-ch/youtube-music/releases/latest">
|
||||||
|
<img src="../../web/youtube-music.svg" width="400" height="100" alt="YouTube Music SVG">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
**유튜브 뮤직의 Electron 래퍼; 기능:**
|
||||||
|
|
||||||
|
- 원래의 인터페이스를 유지하는 것을 목표로 하는 네이티브 디자인 및 느낌
|
||||||
|
- 맞춤 플러그인을 위한 프레임워크: 스타일, 콘텐츠, 기능 등 필요에 따라 유튜브 뮤직을 변경하고, 클릭 한 번으로 플러그인을 활성화/비활성화할 수 있습니다.
|
||||||
|
|
||||||
|
## 번역
|
||||||
|
|
||||||
|
[Hosted Weblate](https://hosted.weblate.org/projects/youtube-music/)에서 번역을 도울 수 있습니다.
|
||||||
|
|
||||||
|
<a href="https://hosted.weblate.org/engage/youtube-music/">
|
||||||
|
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/multi-auto.svg" alt="번역 상태" />
|
||||||
|
<img src="https://hosted.weblate.org/widget/youtube-music/i18n/287x66-black.png" alt="번역 상태 2" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## 다운로드
|
||||||
|
|
||||||
|
[최신 릴리즈](https://github.com/th-ch/youtube-music/releases/latest)를 확인하여 최신 버전을 빠르게 찾을 수 있습니다.
|
||||||
|
|
||||||
|
### Arch Linux
|
||||||
|
|
||||||
|
AUR에서 `youtube-music-bin` 패키지를 설치합니다. AUR 설치 지침은 [이 위키 페이지](https://wiki.archlinux.org/index.php/Arch_User_Repository#Installing_packages)를 참조하세요.
|
||||||
|
|
||||||
|
### MacOS
|
||||||
|
|
||||||
|
Homebrew를 사용하여 앱을 설치할 수 있습니다:
|
||||||
|
```bash
|
||||||
|
brew install --cask https://raw.githubusercontent.com/th-ch/youtube-music/master/youtube-music.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
(앱을 수동으로 설치하고) 앱을 실행할 때 `손상되었기 때문에 열 수 없습니다.`라는 오류가 발생하면 터미널에서 다음을 실행하세요:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
xattr -cr /Applications/YouTube\ Music.app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
[Scoop 패키지 매니저](https://scoop.sh)를 사용하여 [`extras` 버킷](https://github.com/ScoopInstaller/Extras)에서 `youtube-music` 패키지를 설치할 수 있습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scoop bucket add extras
|
||||||
|
scoop install extras/youtube-music
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 Windows 11의 공식 CLI 패키지 관리자인 [Winget](https://learn.microsoft.com/en-us/windows/package-manager/winget/)을 사용하여 `th-ch.YouTubeMusic` 패키지를 설치할 수 있습니다.
|
||||||
|
|
||||||
|
*참고: "알 수 없는 게시자"의 파일이기 때문에 Microsoft Defender의 SmartScreen에서 설치를 차단할 수 있습니다. 이는 GitHub에서 동일 파일을 수동으로 다운로드한 후 실행 파일(.exe)을 실행하려고 할 때도 마찬가지로 발생합니다.*
|
||||||
|
|
||||||
|
```bash
|
||||||
|
winget install th-ch.YouTubeMusic
|
||||||
|
```
|
||||||
|
|
||||||
|
#### (Windows에서) 네트워크에 연결하지 않고 설치하는 방법은 무엇인가요?
|
||||||
|
|
||||||
|
- [릴리즈 페이지](https://github.com/th-ch/youtube-music/releases/latest)에서 _본인 기기 아키텍처_에 맞는 `*.nsis.7z` 파일을 다운로드하세요.
|
||||||
|
- `x64`는 64비트 Windows 용입니다.
|
||||||
|
- `ia32`는 32비트 Windows 용입니다.
|
||||||
|
- `arm64`는 ARM64 Windows 용입니다.
|
||||||
|
- 릴리즈 페이지에서 설치기를 다운로드하세요. (`*-Setup.exe`)
|
||||||
|
- 두 파일을 **동일한 위치**에 놓아주세요.
|
||||||
|
- 설치기를 실행하세요.
|
||||||
|
|
||||||
|
## 기능:
|
||||||
|
|
||||||
|
- **일시 정지 시 자동 확인** (항상 활성화 됨): 일정 시간이 지나면 음악을 일시 정지하는 ["계속 시청하시겠습니까?"](https://user-images.githubusercontent.com/61631665/129977894-01c60740-7ec6-4bf0-9a2c-25da24491b0e.png) 팝업을 비활성화합니다.
|
||||||
|
|
||||||
|
- 이외에 더 많은 기능 ...
|
||||||
|
|
||||||
|
## 사용 가능한 플러그인:
|
||||||
|
|
||||||
|
- **애드블록**: 모든 광고와 트래커를 즉시 차단합니다
|
||||||
|
|
||||||
|
- **앨범 컬러 기반 테마**: 앨범 색상 팔레트를 기반으로 동적 테마 및 시각 효과를 적용합니다
|
||||||
|
|
||||||
|
- **앰비언트 모드**: 영상의 간접 조명을 화면 배경에 투사합니다.
|
||||||
|
|
||||||
|
- **오디오 컴프레서**: 오디오에 컴프레서를 적용합니다 (신호에서 가장 시끄러운 부분의 음량을 낮추고 가장 조용한 부분의 음량을 높임)
|
||||||
|
|
||||||
|
- **네비게이션 바 흐림 효과**: 내비게이션 바를 투명하고 흐릿하게 만듭니다
|
||||||
|
|
||||||
|
- **나이 제한 우회**: 유튜브의 나이 제한을 우회합니다
|
||||||
|
|
||||||
|
- **자막 선택기**: 자막을 활성화합니다
|
||||||
|
|
||||||
|
- **컴팩트 사이드바**: 사이드바를 항상 컴팩트 모드로 설정합니다
|
||||||
|
|
||||||
|
- **크로스페이드**: 노래 사이에 크로스페이드 효과를 적용합니다
|
||||||
|
|
||||||
|
- **자동 재생 해제**: 노래를 '일시 정지' 모드로 시작하게 합니다
|
||||||
|
|
||||||
|
- [**디스코드 활동 상태**](https://discord.com/): [활동 상태 (Rich Presence)](https://user-images.githubusercontent.com/28219076/104362104-a7a0b980-5513-11eb-9744-bb89eabe0016.png)를 사용하여 친구들에게 내가 듣는 음악을 보여주세요
|
||||||
|
|
||||||
|
- **다운로더**: UI에서 [직접](https://user-images.githubusercontent.com/61631665/129977677-83a7d067-c192-45e1-98ae-b5a4927393be.png) MP3/소스 오디오를 다운로드하세요
|
||||||
|
|
||||||
|
- **지수 볼륨**: 음량 슬라이더를 [지수적](https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/)으로 만들어 더 낮은 음량을 쉽게 선택할 수 있도록 합니다.
|
||||||
|
|
||||||
|
- **인앱 메뉴**: [메뉴 표시줄을 더 멋지게, 그리고 다크 또는 앨범의 색상으로 만듭니다](https://user-images.githubusercontent.com/78568641/112215894-923dbf00-8c29-11eb-95c3-3ce15db27eca.png)
|
||||||
|
|
||||||
|
> (이 플러그인 및 메뉴 숨기기 옵션을 활성화한 후 메뉴에 액세스하는 데 문제가 있는 경우 [이 글](https://github.com/th-ch/youtube-music/issues/410#issuecomment-952060709)을 참조하세요)
|
||||||
|
|
||||||
|
- [**Last.fm**](https://www.last.fm/): Last.fm에 대한 스크러블 지원을 추가합니다
|
||||||
|
|
||||||
|
- **Lumia Stream**: [Lumia Stream](https://lumiastream.com/) 지원을 추가합니다
|
||||||
|
|
||||||
|
- **Genius 가사**: 더 많은 곡에 대해 가사 지원을 추가합니다
|
||||||
|
|
||||||
|
- **네비게이션**: 브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표를 추가합니다
|
||||||
|
|
||||||
|
- **Google 로그인 제거**: UI에서 Google 로그인 버튼 및 링크 제거하기
|
||||||
|
|
||||||
|
- **알림**: 노래 재생이 시작되면 알림을 표시 (Windows에서는 [대화형 알림](https://user-images.githubusercontent.com/78568641/114102651-63ce0e00-98d0-11eb-9dfe-c5a02bb54f9c.png) 사용 가능)
|
||||||
|
|
||||||
|
- **PiP**: 앱을 PiP 모드로 전환할 수 있게 허용합니다
|
||||||
|
|
||||||
|
- **재생 속도**: 빨리 듣거나, 천천히 들어보세요! [노래 속도를 제어하는 슬라이더를 추가합니다](https://user-images.githubusercontent.com/61631665/129976003-e55db5ba-bf42-448c-a059-26a009775e68.png)
|
||||||
|
|
||||||
|
- **정확한 음량**: 사용자 지정 HUD와 사용자 지정 음량 단계 및 마우스 휠/단축키를 사용하여 음량을 정확하게 제어하세요
|
||||||
|
|
||||||
|
- **영상 품질 체인저**: 영상 오버레이의 [버튼](https://user-images.githubusercontent.com/78568641/138574366-70324a5e-2d64-4f6a-acdd-dc2a2b9cecc5.png)으로 영상 품질을 변경할 수 있게 합니다
|
||||||
|
|
||||||
|
- **단축키 (& MPRIS)**: 재생을 위한 전역 단축키 설정 허용 (재생/일시 정지/다음/이전) + 미디어 키를 재정의하여 [미디어 osd](https://user-images.githubusercontent.com/84923831/128601225-afa38c1f-dea8-4209-9f72-0f84c1dd8b54.png) 비활성화 + Ctrl/CMD + F 검색 활성화 + 미디어 키에 대한 리눅스 MPRIS 지원 활성화 + [고급 사용자](https://github.com/th-ch/youtube-music/issues/106#issuecomment-952156902)를 위한 [사용자 지정 단축키](https://github.com/Araxeus/youtube-music/blob/1e591d6a3df98449bcda6e63baab249b28026148/providers/song-controls.js#L13-L50) 지원
|
||||||
|
|
||||||
|
- **무음 건너뛰기** - 노래의 무음 부분을 자동으로 건너뜁니다
|
||||||
|
|
||||||
|
- [**SponsorBlock**](https://github.com/ajayyy/SponsorBlock): 인트로/아웃트로와 같은 음악이 아닌 부분이나, 노래가 재생되지 않는 뮤직 비디오의 일부를 자동으로 건너뜁니다
|
||||||
|
|
||||||
|
- **작업표시줄 미디어 컨트롤**: [Windows 작업표시줄](https://user-images.githubusercontent.com/78568641/111916130-24a35e80-8a82-11eb-80c8-5021c1aa27f4.png)에서 재생을 제어하세요
|
||||||
|
|
||||||
|
- **TouchBar**: macOS 사용자를 위한 TouchBar 위젯을 추가합니다
|
||||||
|
|
||||||
|
- **Tuna-OBS**: [OBS](https://obsproject.com/)의 플러그인, [Tuna](https://obsproject.com/forum/resources/tuna.843/)와 통합을 활성화합니다
|
||||||
|
|
||||||
|
- **영상 전환**: 영상/노래 모드를 전환하는 [버튼](https://user-images.githubusercontent.com/28893833/173663950-63e6610e-a532-49b7-9afa-54cb57ddfc15.png)을 추가합니다. 선택적으로 전체 영상 탭을 제거할 수도 있습니다
|
||||||
|
|
||||||
|
- **비주얼라이저**: 플레이어에 시각화 도구 추가
|
||||||
|
|
||||||
|
## 테마
|
||||||
|
|
||||||
|
CSS 파일을 로드하여 애플리케이션의 모양을 변경할 수 있습니다(설정 > 시각적 변경 > 테마).
|
||||||
|
|
||||||
|
일부 사전 정의 테마는 https://github.com/kerichdev/themes-for-ytmdesktop-player 에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/th-ch/youtube-music
|
||||||
|
cd youtube-music
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 나만의 플러그인 만들기
|
||||||
|
|
||||||
|
플러그인을 사용하면 할 수 있는 것들:
|
||||||
|
|
||||||
|
- 앱 조작 - Electron에서 `BrowserWindow`가 플러그인 핸들러로 전달
|
||||||
|
- HTML/CSS를 조작하여 프론트엔드를 변경
|
||||||
|
|
||||||
|
### 플러그인 만들기
|
||||||
|
|
||||||
|
`plugins/나만의-플러그인-이름`에 폴더를 만듭니다:
|
||||||
|
|
||||||
|
- `index.ts`: 플러그인의 메인 파일입니다.
|
||||||
|
```typescript
|
||||||
|
import style from './style.css?inline'; // 스타일을 인라인으로 가져옵니다
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // 나의 커스텀 config
|
||||||
|
stylesheets: [style], // 나의 스타일
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
// 모든 *Config 메서드는 Promise<T>로 래핑됩니다
|
||||||
|
const config = await getConfig();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'menu',
|
||||||
|
submenu: [1, 2, 3].map((value) => ({
|
||||||
|
label: `value ${value}`,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.value === value,
|
||||||
|
click() {
|
||||||
|
setConfig({ value });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
start({ window, ipc }) {
|
||||||
|
window.maximize();
|
||||||
|
|
||||||
|
// 이를 사용하여 렌더러 플러그인과 통신할 수 있습니다
|
||||||
|
ipc.handle('some-event', () => {
|
||||||
|
return 'hello';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// config가 변경되면 실행됩니다
|
||||||
|
onConfigChange(newConfig) { /* ... */ },
|
||||||
|
// 플러그인이 비활성화되면 실행됩니다
|
||||||
|
stop(context) { /* ... */ },
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
async start(context) {
|
||||||
|
console.log(await context.ipc.invoke('some-event'));
|
||||||
|
},
|
||||||
|
// 렌더러에서만 사용 가능한 훅입니다
|
||||||
|
onPlayerApiReady(api: YoutubePlayer, context: RendererContext<T>) {
|
||||||
|
// 플러그인의 config를 간단하게 설정할 수 있습니다
|
||||||
|
context.setConfig({ myConfig: api.getVolume() });
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) { /* ... */ },
|
||||||
|
stop(_context) { /* ... */ },
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
async start({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {},
|
||||||
|
stop(_context) {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 일반적인 사용 예
|
||||||
|
|
||||||
|
- 사용자 정의 CSS 삽입: 같은 폴더에 `style.css` 파일을 생성합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// index.ts
|
||||||
|
import style from './style.css?inline'; // 스타일을 인라인으로 가져옵니다
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
const builder = createPlugin({
|
||||||
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // 나의 커스텀 config
|
||||||
|
stylesheets: [style], // 나의 커스텀 스타일
|
||||||
|
renderer() {} // 렌더러 훅 정의
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- HTML을 변경하려는 경우:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
const builder = createPlugin({
|
||||||
|
name: 'Plugin Label',
|
||||||
|
restartNeeded: true, // 값이 true면, YTM은 재시작 다이얼로그를 표시합니다
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
}, // 나의 커스텀 config
|
||||||
|
renderer() {
|
||||||
|
// 로그인 버튼을 제거합니다
|
||||||
|
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
||||||
|
} // 렌더러 훅 정의
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- 프론트엔드와 백엔드 간의 통신: Electron의 `ipcMain` 모듈을 사용하여 수행할 수 있습니다. `SponsorBlock` 플러그인의 `index.ts` 파일과 예제를 참조하세요.
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
1. 레포지토리를 복제 (clone) 합니다
|
||||||
|
2. [이 가이드](https://pnpm.io/installation)에 따라 `pnpm`을 설치합니다.
|
||||||
|
3. `pnpm install --frozen-lockfile`을 실행하여 종속성을 설치합니다.
|
||||||
|
4. `pnpm build:OS`을 실행합니다.
|
||||||
|
|
||||||
|
- `pnpm dist:win` - Windows
|
||||||
|
- `pnpm dist:linux` - Linux
|
||||||
|
- `pnpm dist:mac` - MacOS
|
||||||
|
|
||||||
|
[electron-builder](https://github.com/electron-userland/electron-builder)를 사용하여 macOS, Linux 및 Windows용 앱을 빌드합니다.
|
||||||
|
|
||||||
|
## 프로덕션 빌드 미리보기
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
|
[Playwright](https://playwright.dev/)를 사용하여 앱을 테스트합니다.
|
||||||
|
|
||||||
|
## 라이선스
|
||||||
|
|
||||||
|
MIT © [th-ch](https://github.com/th-ch/youtube-music)
|
||||||
|
|
||||||
|
## 자주 묻는 질문
|
||||||
|
|
||||||
|
### 앱 메뉴가 표시되지 않는 이유는 무엇인가요?
|
||||||
|
|
||||||
|
`메뉴 숨기기` 옵션이 켜져 있는 경우 - <kbd>alt</kbd> 키(또는 인앱 메뉴 플러그인을 사용하는 경우 <kbd>\`</kbd> [백틱] 키)로 메뉴를 표시할 수 있습니다.
|
||||||
155
electron.vite.config.ts
Normal file
155
electron.vite.config.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
import { defineConfig, defineViteConfig } from 'electron-vite';
|
||||||
|
import builtinModules from 'builtin-modules';
|
||||||
|
import viteResolve from 'vite-plugin-resolve';
|
||||||
|
import Inspect from 'vite-plugin-inspect';
|
||||||
|
|
||||||
|
import { pluginVirtualModuleGenerator } from './vite-plugins/plugin-importer';
|
||||||
|
import pluginLoader from './vite-plugins/plugin-loader';
|
||||||
|
|
||||||
|
import type { UserConfig } from 'vite';
|
||||||
|
import { i18nImporter } from './vite-plugins/i18n-importer';
|
||||||
|
|
||||||
|
const resolveAlias = {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
'@assets': resolve(__dirname, './assets'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: defineViteConfig(({ mode }) => {
|
||||||
|
const commonConfig: UserConfig = {
|
||||||
|
plugins: [
|
||||||
|
pluginLoader('backend'),
|
||||||
|
viteResolve({
|
||||||
|
'virtual:i18n': i18nImporter(),
|
||||||
|
'virtual:plugins': pluginVirtualModuleGenerator('main'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
publicDir: 'assets',
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/index.ts',
|
||||||
|
formats: ['cjs'],
|
||||||
|
},
|
||||||
|
outDir: 'dist/main',
|
||||||
|
commonjsOptions: {
|
||||||
|
ignoreDynamicRequires: true,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
||||||
|
input: './src/index.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: resolveAlias,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'development') {
|
||||||
|
commonConfig.plugins?.push(
|
||||||
|
Inspect({ build: true, outputDir: '.vite-inspect/backend' }),
|
||||||
|
);
|
||||||
|
return commonConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonConfig,
|
||||||
|
build: {
|
||||||
|
...commonConfig.build,
|
||||||
|
minify: true,
|
||||||
|
cssMinify: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
preload: defineViteConfig(({ mode }) => {
|
||||||
|
const commonConfig: UserConfig = {
|
||||||
|
plugins: [
|
||||||
|
pluginLoader('preload'),
|
||||||
|
viteResolve({
|
||||||
|
'virtual:i18n': i18nImporter(),
|
||||||
|
'virtual:plugins': pluginVirtualModuleGenerator('preload'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/preload.ts',
|
||||||
|
formats: ['cjs'],
|
||||||
|
},
|
||||||
|
outDir: 'dist/preload',
|
||||||
|
commonjsOptions: {
|
||||||
|
ignoreDynamicRequires: true,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
||||||
|
input: './src/preload.ts',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: resolveAlias,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'development') {
|
||||||
|
commonConfig.plugins?.push(
|
||||||
|
Inspect({ build: true, outputDir: '.vite-inspect/preload' }),
|
||||||
|
);
|
||||||
|
return commonConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonConfig,
|
||||||
|
build: {
|
||||||
|
...commonConfig.build,
|
||||||
|
minify: true,
|
||||||
|
cssMinify: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
renderer: defineViteConfig(({ mode }) => {
|
||||||
|
const commonConfig: UserConfig = {
|
||||||
|
plugins: [
|
||||||
|
pluginLoader('renderer'),
|
||||||
|
viteResolve({
|
||||||
|
'virtual:i18n': i18nImporter(),
|
||||||
|
'virtual:plugins': pluginVirtualModuleGenerator('renderer'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
root: './src/',
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/index.html',
|
||||||
|
formats: ['iife'],
|
||||||
|
name: 'renderer',
|
||||||
|
},
|
||||||
|
outDir: 'dist/renderer',
|
||||||
|
commonjsOptions: {
|
||||||
|
ignoreDynamicRequires: true,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['electron', ...builtinModules],
|
||||||
|
input: './src/index.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: resolveAlias,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mode === 'development') {
|
||||||
|
commonConfig.plugins?.push(
|
||||||
|
Inspect({ build: true, outputDir: '.vite-inspect/renderer' }),
|
||||||
|
);
|
||||||
|
return commonConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...commonConfig,
|
||||||
|
build: {
|
||||||
|
...commonConfig.build,
|
||||||
|
minify: true,
|
||||||
|
cssMinify: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
131
package.json
131
package.json
@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "youtube-music",
|
"name": "youtube-music",
|
||||||
"productName": "YouTube Music",
|
"productName": "YouTube Music",
|
||||||
"version": "2.2.0",
|
"version": "3.0.2",
|
||||||
"description": "YouTube Music Desktop App - including custom plugins",
|
"description": "YouTube Music Desktop App - including custom plugins",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/main/index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "th-ch/youtube-music",
|
"repository": "th-ch/youtube-music",
|
||||||
"author": {
|
"author": {
|
||||||
@ -17,6 +17,7 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"!*",
|
"!*",
|
||||||
"dist",
|
"dist",
|
||||||
|
"assets",
|
||||||
"license",
|
"license",
|
||||||
"!node_modules",
|
"!node_modules",
|
||||||
"node_modules/custom-electron-prompt/**",
|
"node_modules/custom-electron-prompt/**",
|
||||||
@ -25,6 +26,9 @@
|
|||||||
"!node_modules/**/*.map",
|
"!node_modules/**/*.map",
|
||||||
"!node_modules/**/*.ts"
|
"!node_modules/**/*.ts"
|
||||||
],
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"assets"
|
||||||
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"identity": null,
|
"identity": null,
|
||||||
"target": [
|
"target": [
|
||||||
@ -89,24 +93,24 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
|
"test:debug": "cross-env DEBUG=pw:*,-pw:test:protocol playwright test",
|
||||||
"rollup:preload": "rollup -c rollup.preload.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs",
|
"build": "electron-vite build",
|
||||||
"rollup:main": "rollup -c rollup.main.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs",
|
"vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect",
|
||||||
"build": "yarpm-pnpm run rollup:preload && yarpm-pnpm run rollup:main",
|
"start": "electron-vite preview",
|
||||||
"start": "yarpm-pnpm run build && electron ./dist/index.js",
|
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
|
||||||
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start",
|
"dev": "electron-vite dev",
|
||||||
"postinstall": "patch-package",
|
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
|
||||||
"clean": "del-cli dist && del-cli pack",
|
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
|
||||||
"dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never",
|
"dist": "pnpm clean && pnpm build && electron-builder --win --mac --linux -p never",
|
||||||
"dist:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p never",
|
"dist:linux": "pnpm clean && pnpm build && electron-builder --linux -p never",
|
||||||
"dist:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:x64 -p never",
|
"dist:mac": "pnpm clean && pnpm build && electron-builder --mac dmg:x64 -p never",
|
||||||
"dist:mac:arm64": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac dmg:arm64 -p never",
|
"dist:mac:arm64": "pnpm clean && pnpm build && electron-builder --mac dmg:arm64 -p never",
|
||||||
"dist:win": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win -p never",
|
"dist:win": "pnpm clean && pnpm build && electron-builder --win -p never",
|
||||||
"dist:win:x64": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win nsis-web:x64 -p never",
|
"dist:win:x64": "pnpm clean && pnpm build && electron-builder --win nsis-web:x64 -p never",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"changelog": "auto-changelog",
|
"changelog": "npx --yes auto-changelog",
|
||||||
"release:linux": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --linux -p always -c.snap.publish=github",
|
"release:linux": "pnpm clean && pnpm build && electron-builder --linux -p always -c.snap.publish=github",
|
||||||
"release:mac": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --mac -p always",
|
"release:mac": "pnpm clean && pnpm build && electron-builder --mac -p always",
|
||||||
"release:win": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win -p always",
|
"release:win": "pnpm clean && pnpm build && electron-builder --win -p always",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -114,87 +118,86 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rollup": "4.1.4",
|
"usocket": "1.0.1",
|
||||||
"node-gyp": "9.4.0",
|
"rollup": "4.6.1",
|
||||||
|
"node-gyp": "10.0.1",
|
||||||
"xml2js": "0.6.2",
|
"xml2js": "0.6.2",
|
||||||
"node-fetch": "2.7.0",
|
"node-fetch": "3.3.2",
|
||||||
"@electron/universal": "1.4.2",
|
"@electron/universal": "2.0.0",
|
||||||
"@babel/runtime": "7.23.2"
|
"@babel/runtime": "7.23.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"rollup": "4.1.4",
|
|
||||||
"node-gyp": "9.4.0",
|
|
||||||
"xml2js": "0.6.2",
|
|
||||||
"node-fetch": "2.7.0",
|
|
||||||
"@electron/universal": "1.4.2",
|
|
||||||
"@babel/runtime": "7.23.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cliqz/adblocker-electron": "1.26.8",
|
"@cliqz/adblocker-electron": "1.26.12",
|
||||||
"@cliqz/adblocker-electron-preload": "1.26.8",
|
"@cliqz/adblocker-electron-preload": "1.26.12",
|
||||||
|
"@electron-toolkit/tsconfig": "1.0.1",
|
||||||
|
"@electron/remote": "2.1.0",
|
||||||
"@ffmpeg.wasm/core-mt": "0.12.0",
|
"@ffmpeg.wasm/core-mt": "0.12.0",
|
||||||
"@ffmpeg.wasm/main": "0.12.0",
|
"@ffmpeg.wasm/main": "0.12.0",
|
||||||
"@foobar404/wave": "2.0.4",
|
"@foobar404/wave": "2.0.4",
|
||||||
"@jellybrick/electron-better-web-request": "1.0.4",
|
"@jellybrick/electron-better-web-request": "1.0.4",
|
||||||
"@jellybrick/mpris-service": "2.1.4",
|
"@jellybrick/mpris-service": "2.1.4",
|
||||||
"@xhayper/discord-rpc": "1.0.24",
|
"@xhayper/discord-rpc": "1.1.1",
|
||||||
"async-mutex": "0.4.0",
|
"async-mutex": "0.4.0",
|
||||||
"butterchurn": "3.0.0-beta.4",
|
"butterchurn": "3.0.0-beta.4",
|
||||||
"butterchurn-presets": "3.0.0-beta.4",
|
"butterchurn-presets": "3.0.0-beta.4",
|
||||||
"conf": "10.2.0",
|
"conf": "10.2.0",
|
||||||
"custom-electron-prompt": "1.5.7",
|
"custom-electron-prompt": "1.5.7",
|
||||||
|
"dbus-next": "0.10.2",
|
||||||
|
"deepmerge-ts": "5.1.0",
|
||||||
"electron-debug": "3.2.0",
|
"electron-debug": "3.2.0",
|
||||||
"electron-is": "3.0.0",
|
"electron-is": "3.0.0",
|
||||||
"electron-localshortcut": "3.2.1",
|
"electron-localshortcut": "3.2.1",
|
||||||
"electron-store": "8.1.0",
|
"electron-store": "8.1.0",
|
||||||
"electron-unhandled": "4.0.1",
|
"electron-unhandled": "4.0.1",
|
||||||
"electron-updater": "6.1.4",
|
"electron-updater": "6.1.7",
|
||||||
"fast-average-color": "9.4.0",
|
"fast-average-color": "9.4.0",
|
||||||
|
"fast-equals": "5.0.1",
|
||||||
"filenamify": "6.0.0",
|
"filenamify": "6.0.0",
|
||||||
"howler": "2.2.4",
|
"howler": "2.2.4",
|
||||||
"html-to-text": "9.0.5",
|
"html-to-text": "9.0.5",
|
||||||
|
"i18next": "23.7.7",
|
||||||
"keyboardevent-from-electron-accelerator": "2.0.0",
|
"keyboardevent-from-electron-accelerator": "2.0.0",
|
||||||
"keyboardevents-areequal": "0.2.2",
|
"keyboardevents-areequal": "0.2.2",
|
||||||
|
"node-html-parser": "6.1.11",
|
||||||
"node-id3": "0.2.6",
|
"node-id3": "0.2.6",
|
||||||
"simple-youtube-age-restriction-bypass": "git+https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.8",
|
"serve": "14.2.1",
|
||||||
|
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
|
||||||
|
"ts-morph": "20.0.0",
|
||||||
"vudio": "2.1.1",
|
"vudio": "2.1.1",
|
||||||
"x11": "2.3.0",
|
"x11": "2.3.0",
|
||||||
"youtubei.js": "6.4.1"
|
"youtubei.js": "8.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@milahu/patch-package": "6.4.14",
|
"@playwright/test": "1.40.1",
|
||||||
"@playwright/test": "1.39.0",
|
|
||||||
"@rollup/plugin-commonjs": "25.0.7",
|
|
||||||
"@rollup/plugin-image": "3.0.3",
|
|
||||||
"@rollup/plugin-json": "6.0.1",
|
|
||||||
"@rollup/plugin-node-resolve": "15.2.3",
|
|
||||||
"@rollup/plugin-terser": "0.4.4",
|
|
||||||
"@rollup/plugin-typescript": "11.1.5",
|
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
|
||||||
"@total-typescript/ts-reset": "0.5.1",
|
"@total-typescript/ts-reset": "0.5.1",
|
||||||
"@types/electron-localshortcut": "3.1.2",
|
"@types/electron-localshortcut": "3.1.3",
|
||||||
"@types/howler": "2.2.10",
|
"@types/howler": "2.2.11",
|
||||||
"@types/html-to-text": "9.0.3",
|
"@types/html-to-text": "9.0.4",
|
||||||
"@typescript-eslint/eslint-plugin": "6.9.0",
|
"@typescript-eslint/eslint-plugin": "6.13.1",
|
||||||
"auto-changelog": "2.4.0",
|
"bufferutil": "4.0.8",
|
||||||
"builtin-modules": "^3.3.0",
|
"builtin-modules": "3.3.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"del-cli": "5.1.0",
|
"del-cli": "5.1.0",
|
||||||
"electron": "27.0.2",
|
"electron": "27.1.3",
|
||||||
"electron-builder": "24.6.4",
|
"electron-builder": "24.9.1",
|
||||||
"electron-devtools-installer": "3.2.0",
|
"electron-devtools-installer": "3.2.0",
|
||||||
"eslint": "8.52.0",
|
"electron-vite": "1.0.29",
|
||||||
|
"eslint": "8.55.0",
|
||||||
|
"eslint-import-resolver-exports": "1.0.0-beta.5",
|
||||||
|
"eslint-import-resolver-typescript": "3.6.1",
|
||||||
"eslint-plugin-import": "2.29.0",
|
"eslint-plugin-import": "2.29.0",
|
||||||
"eslint-plugin-prettier": "5.0.1",
|
"eslint-plugin-prettier": "5.0.1",
|
||||||
"node-gyp": "9.4.0",
|
"glob": "10.3.10",
|
||||||
"playwright": "1.39.0",
|
"node-gyp": "10.0.1",
|
||||||
"rollup": "4.1.4",
|
"playwright": "1.40.1",
|
||||||
"rollup-plugin-copy": "3.5.0",
|
"rollup": "4.6.1",
|
||||||
"rollup-plugin-import-css": "3.3.5",
|
"typescript": "5.3.2",
|
||||||
"rollup-plugin-string": "3.0.0",
|
"utf-8-validate": "6.0.3",
|
||||||
"typescript": "5.2.2",
|
"vite": "4.5.0",
|
||||||
"yarpm": "1.2.0"
|
"vite-plugin-inspect": "0.8.1",
|
||||||
|
"vite-plugin-resolve": "2.5.1",
|
||||||
|
"ws": "8.14.2"
|
||||||
},
|
},
|
||||||
"auto-changelog": {
|
"auto-changelog": {
|
||||||
"hideCredit": true,
|
"hideCredit": true,
|
||||||
@ -202,5 +205,5 @@
|
|||||||
"unreleased": true,
|
"unreleased": true,
|
||||||
"output": "changelog.md"
|
"output": "changelog.md"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@8.9.2"
|
"packageManager": "pnpm@8.11.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
diff --git a/node_modules/youtubei.js/bundle/node.cjs b/node_modules/youtubei.js/bundle/node.cjs
|
|
||||||
index 7e3072e..bf5be6a 100644
|
|
||||||
--- a/node_modules/youtubei.js/bundle/node.cjs
|
|
||||||
+++ b/node_modules/youtubei.js/bundle/node.cjs
|
|
||||||
@@ -16969,7 +16969,13 @@ var _Cache_createCache;
|
|
||||||
var meta_url = import_meta.url;
|
|
||||||
var is_cjs = !meta_url;
|
|
||||||
var __dirname__ = is_cjs ? __dirname : import_path.default.dirname((0, import_url.fileURLToPath)(meta_url));
|
|
||||||
-var package_json = JSON.parse((0, import_fs.readFileSync)(import_path.default.resolve(__dirname__, is_cjs ? "../package.json" : "../../package.json"), "utf-8"));
|
|
||||||
+var package_json = {
|
|
||||||
+ homepage: "https://github.com/LuanRT/YouTube.js#readme",
|
|
||||||
+ version: "6.4.1",
|
|
||||||
+ bugs: {
|
|
||||||
+ url: "https://github.com/LuanRT/YouTube.js/issues"
|
|
||||||
+ }
|
|
||||||
+};
|
|
||||||
var repo_url = (_a3 = package_json.homepage) === null || _a3 === void 0 ? void 0 : _a3.split("#")[0];
|
|
||||||
var Cache = class {
|
|
||||||
constructor(persistent = false, persistent_directory) {
|
|
||||||
diff --git a/node_modules/youtubei.js/dist/src/platform/node.js b/node_modules/youtubei.js/dist/src/platform/node.js
|
|
||||||
index 0e1b2ca..17b437c 100644
|
|
||||||
--- a/node_modules/youtubei.js/dist/src/platform/node.js
|
|
||||||
+++ b/node_modules/youtubei.js/dist/src/platform/node.js
|
|
||||||
@@ -16,7 +16,13 @@ import evaluate from './jsruntime/jinter.js';
|
|
||||||
const meta_url = import.meta.url;
|
|
||||||
const is_cjs = !meta_url;
|
|
||||||
const __dirname__ = is_cjs ? __dirname : path.dirname(fileURLToPath(meta_url));
|
|
||||||
-const package_json = JSON.parse(readFileSync(path.resolve(__dirname__, is_cjs ? '../package.json' : '../../package.json'), 'utf-8'));
|
|
||||||
+const package_json = {
|
|
||||||
+ homepage: "https://github.com/LuanRT/YouTube.js#readme",
|
|
||||||
+ version: "6.4.1",
|
|
||||||
+ bugs: {
|
|
||||||
+ url: "https://github.com/LuanRT/YouTube.js/issues"
|
|
||||||
+ }
|
|
||||||
+};
|
|
||||||
const repo_url = (_a = package_json.homepage) === null || _a === void 0 ? void 0 : _a.split('#')[0];
|
|
||||||
class Cache {
|
|
||||||
constructor(persistent = false, persistent_directory) {
|
|
||||||
2966
pnpm-lock.yaml
generated
2966
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
8
renovate.json
Normal file
8
renovate.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:base"
|
||||||
|
],
|
||||||
|
"labels": ["dependencies"],
|
||||||
|
"postUpdateOptions": ["pnpmDedupe"]
|
||||||
|
}
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { defineConfig } from 'rollup';
|
|
||||||
import builtinModules from 'builtin-modules';
|
|
||||||
import typescript from '@rollup/plugin-typescript';
|
|
||||||
import commonjs from '@rollup/plugin-commonjs';
|
|
||||||
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
|
|
||||||
import json from '@rollup/plugin-json';
|
|
||||||
import terser from '@rollup/plugin-terser';
|
|
||||||
import { string } from 'rollup-plugin-string';
|
|
||||||
import css from 'rollup-plugin-import-css';
|
|
||||||
import wasmPlugin from '@rollup/plugin-wasm';
|
|
||||||
import copy from 'rollup-plugin-copy';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
typescript({
|
|
||||||
module: 'ESNext',
|
|
||||||
}),
|
|
||||||
nodeResolvePlugin({
|
|
||||||
browser: false,
|
|
||||||
preferBuiltins: true,
|
|
||||||
exportConditions: ['node', 'default', 'module', 'import'],
|
|
||||||
}),
|
|
||||||
commonjs({
|
|
||||||
ignoreDynamicRequires: true,
|
|
||||||
}),
|
|
||||||
wasmPlugin({
|
|
||||||
maxFileSize: 0,
|
|
||||||
targetEnv: 'browser',
|
|
||||||
}),
|
|
||||||
json(),
|
|
||||||
string({
|
|
||||||
include: '**/*.html',
|
|
||||||
}),
|
|
||||||
css(),
|
|
||||||
copy({
|
|
||||||
targets: [
|
|
||||||
{ src: 'src/error.html', dest: 'dist/' },
|
|
||||||
{ src: 'assets', dest: 'dist/' },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
terser({
|
|
||||||
ecma: 2020,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
closeBundle() {
|
|
||||||
if (!process.env.ROLLUP_WATCH) {
|
|
||||||
setTimeout(() => process.exit(0));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
name: 'force-close',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
input: './src/index.ts',
|
|
||||||
output: {
|
|
||||||
format: 'cjs',
|
|
||||||
name: '[name].js',
|
|
||||||
dir: './dist',
|
|
||||||
},
|
|
||||||
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
|
||||||
});
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import { defineConfig } from 'rollup';
|
|
||||||
import builtinModules from 'builtin-modules';
|
|
||||||
import typescript from '@rollup/plugin-typescript';
|
|
||||||
import commonjs from '@rollup/plugin-commonjs';
|
|
||||||
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
|
|
||||||
import json from '@rollup/plugin-json';
|
|
||||||
import terser from '@rollup/plugin-terser';
|
|
||||||
import { string } from 'rollup-plugin-string';
|
|
||||||
import css from 'rollup-plugin-import-css';
|
|
||||||
import wasmPlugin from '@rollup/plugin-wasm';
|
|
||||||
import image from '@rollup/plugin-image';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
typescript({
|
|
||||||
module: 'ESNext',
|
|
||||||
}),
|
|
||||||
nodeResolvePlugin({
|
|
||||||
browser: false,
|
|
||||||
preferBuiltins: true,
|
|
||||||
}),
|
|
||||||
commonjs({
|
|
||||||
ignoreDynamicRequires: true,
|
|
||||||
}),
|
|
||||||
json(),
|
|
||||||
string({
|
|
||||||
include: '**/*.html',
|
|
||||||
}),
|
|
||||||
css(),
|
|
||||||
wasmPlugin({
|
|
||||||
maxFileSize: 0,
|
|
||||||
targetEnv: 'browser',
|
|
||||||
}),
|
|
||||||
image({ dom: true }),
|
|
||||||
terser({
|
|
||||||
ecma: 2020,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
closeBundle() {
|
|
||||||
if (!process.env.ROLLUP_WATCH) {
|
|
||||||
setTimeout(() => process.exit(0));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
name: 'force-close',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
input: './src/preload.ts',
|
|
||||||
output: {
|
|
||||||
format: 'cjs',
|
|
||||||
name: '[name].js',
|
|
||||||
dir: './dist',
|
|
||||||
},
|
|
||||||
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
|
||||||
});
|
|
||||||
@ -1,24 +1,20 @@
|
|||||||
import { blockers } from '../plugins/adblocker/blocker-types';
|
|
||||||
|
|
||||||
import { DefaultPresetList } from '../plugins/downloader/types';
|
|
||||||
|
|
||||||
export interface WindowSizeConfig {
|
export interface WindowSizeConfig {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WindowPositionConfig {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DefaultConfig {
|
export interface DefaultConfig {
|
||||||
'window-size': {
|
'window-size': WindowSizeConfig;
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
'window-maximized': boolean;
|
'window-maximized': boolean;
|
||||||
'window-position': {
|
'window-position': WindowPositionConfig;
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
url: string;
|
url: string;
|
||||||
options: {
|
options: {
|
||||||
|
language?: string;
|
||||||
tray: boolean;
|
tray: boolean;
|
||||||
appVisible: boolean;
|
appVisible: boolean;
|
||||||
autoUpdates: boolean;
|
autoUpdates: boolean;
|
||||||
@ -37,10 +33,11 @@ export interface DefaultConfig {
|
|||||||
startingPage: string;
|
startingPage: string;
|
||||||
overrideUserAgent: boolean;
|
overrideUserAgent: boolean;
|
||||||
themes: string[];
|
themes: string[];
|
||||||
}
|
};
|
||||||
|
plugins: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig: DefaultConfig = {
|
||||||
'window-size': {
|
'window-size': {
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 550,
|
height: 550,
|
||||||
@ -69,229 +66,9 @@ const defaultConfig = {
|
|||||||
proxy: '',
|
proxy: '',
|
||||||
startingPage: '',
|
startingPage: '',
|
||||||
overrideUserAgent: false,
|
overrideUserAgent: false,
|
||||||
themes: [] as string[],
|
themes: [],
|
||||||
},
|
|
||||||
/** please order alphabetically */
|
|
||||||
'plugins': {
|
|
||||||
'adblocker': {
|
|
||||||
enabled: true,
|
|
||||||
cache: true,
|
|
||||||
blocker: blockers.InPlayer as string,
|
|
||||||
additionalBlockLists: [], // Additional list of filters, e.g "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"
|
|
||||||
disableDefaultLists: false,
|
|
||||||
},
|
|
||||||
'album-color-theme': {},
|
|
||||||
'ambient-mode': {
|
|
||||||
enabled: false,
|
|
||||||
quality: 50,
|
|
||||||
buffer: 30,
|
|
||||||
interpolationTime: 1500,
|
|
||||||
blur: 100,
|
|
||||||
size: 100,
|
|
||||||
opacity: 1,
|
|
||||||
fullscreen: false,
|
|
||||||
},
|
|
||||||
'audio-compressor': {},
|
|
||||||
'blur-nav-bar': {},
|
|
||||||
'bypass-age-restrictions': {},
|
|
||||||
'captions-selector': {
|
|
||||||
enabled: false,
|
|
||||||
disableCaptions: false,
|
|
||||||
autoload: false,
|
|
||||||
lastCaptionsCode: '',
|
|
||||||
},
|
|
||||||
'compact-sidebar': {},
|
|
||||||
'crossfade': {
|
|
||||||
enabled: false,
|
|
||||||
fadeInDuration: 1500, // Ms
|
|
||||||
fadeOutDuration: 5000, // Ms
|
|
||||||
secondsBeforeEnd: 10, // S
|
|
||||||
fadeScaling: 'linear', // 'linear', 'logarithmic' or a positive number in dB
|
|
||||||
},
|
|
||||||
'disable-autoplay': {
|
|
||||||
applyOnce: false,
|
|
||||||
},
|
|
||||||
'discord': {
|
|
||||||
enabled: false,
|
|
||||||
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
|
|
||||||
activityTimoutEnabled: true, // If enabled, the discord rich presence gets cleared when music paused after the time specified below
|
|
||||||
activityTimoutTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
playOnYouTubeMusic: true, // Add a "Play on YouTube Music" button to rich presence
|
|
||||||
hideGitHubButton: false, // Disable the "View App On GitHub" button
|
|
||||||
hideDurationLeft: false, // Hides the start and end time of the song to rich presence
|
|
||||||
},
|
|
||||||
'downloader': {
|
|
||||||
enabled: false,
|
|
||||||
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
|
|
||||||
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
|
||||||
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
|
||||||
skipExisting: false,
|
|
||||||
playlistMaxItems: undefined as number | undefined,
|
|
||||||
},
|
|
||||||
'exponential-volume': {},
|
|
||||||
'in-app-menu': {
|
|
||||||
/**
|
|
||||||
* true in Windows, false in Linux and macOS (see youtube-music/config/store.ts)
|
|
||||||
*/
|
|
||||||
enabled: false,
|
|
||||||
hideDOMWindowControls: false,
|
|
||||||
},
|
|
||||||
'last-fm': {
|
|
||||||
enabled: false,
|
|
||||||
token: undefined as string | undefined, // Token used for authentication
|
|
||||||
session_key: undefined as string | undefined, // Session key used for scrobbling
|
|
||||||
api_root: 'http://ws.audioscrobbler.com/2.0/',
|
|
||||||
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
|
|
||||||
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
|
||||||
},
|
|
||||||
'lumiastream': {},
|
|
||||||
'lyrics-genius': {
|
|
||||||
romanizedLyrics: false,
|
|
||||||
},
|
|
||||||
'navigation': {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
'no-google-login': {},
|
|
||||||
'notifications': {
|
|
||||||
enabled: false,
|
|
||||||
unpauseNotification: false,
|
|
||||||
urgency: 'normal', // Has effect only on Linux
|
|
||||||
// the following has effect only on Windows
|
|
||||||
interactive: true,
|
|
||||||
toastStyle: 1, // See plugins/notifications/utils for more info
|
|
||||||
refreshOnPlayPause: false,
|
|
||||||
trayControls: true,
|
|
||||||
hideButtonText: false,
|
|
||||||
},
|
|
||||||
'picture-in-picture': {
|
|
||||||
'enabled': false,
|
|
||||||
'alwaysOnTop': true,
|
|
||||||
'savePosition': true,
|
|
||||||
'saveSize': false,
|
|
||||||
'hotkey': 'P',
|
|
||||||
'pip-position': [10, 10],
|
|
||||||
'pip-size': [450, 275],
|
|
||||||
'isInPiP': false,
|
|
||||||
'useNativePiP': false,
|
|
||||||
},
|
|
||||||
'playback-speed': {},
|
|
||||||
'precise-volume': {
|
|
||||||
enabled: false,
|
|
||||||
steps: 1, // Percentage of volume to change
|
|
||||||
arrowsShortcut: true, // Enable ArrowUp + ArrowDown local shortcuts
|
|
||||||
globalShortcuts: {
|
|
||||||
volumeUp: '',
|
|
||||||
volumeDown: '',
|
|
||||||
},
|
|
||||||
savedVolume: undefined as number | undefined, // Plugin save volume between session here
|
|
||||||
},
|
|
||||||
'quality-changer': {},
|
|
||||||
'shortcuts': {
|
|
||||||
enabled: false,
|
|
||||||
overrideMediaKeys: false,
|
|
||||||
global: {
|
|
||||||
previous: '',
|
|
||||||
playPause: '',
|
|
||||||
next: '',
|
|
||||||
} as Record<string, string>,
|
|
||||||
local: {
|
|
||||||
previous: '',
|
|
||||||
playPause: '',
|
|
||||||
next: '',
|
|
||||||
} as Record<string, string>,
|
|
||||||
},
|
|
||||||
'skip-silences': {
|
|
||||||
onlySkipBeginning: false,
|
|
||||||
},
|
|
||||||
'sponsorblock': {
|
|
||||||
enabled: false,
|
|
||||||
apiURL: 'https://sponsor.ajay.app',
|
|
||||||
categories: [
|
|
||||||
'sponsor',
|
|
||||||
'intro',
|
|
||||||
'outro',
|
|
||||||
'interaction',
|
|
||||||
'selfpromo',
|
|
||||||
'music_offtopic',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
'taskbar-mediacontrol': {},
|
|
||||||
'touchbar': {},
|
|
||||||
'tuna-obs': {},
|
|
||||||
'video-toggle': {
|
|
||||||
enabled: false,
|
|
||||||
hideVideo: false,
|
|
||||||
mode: 'custom',
|
|
||||||
forceHide: false,
|
|
||||||
align: '',
|
|
||||||
},
|
|
||||||
'visualizer': {
|
|
||||||
enabled: false,
|
|
||||||
type: 'butterchurn',
|
|
||||||
// Config per visualizer
|
|
||||||
butterchurn: {
|
|
||||||
preset: 'martin [shadow harlequins shape code] - fata morgana',
|
|
||||||
renderingFrequencyInMs: 500,
|
|
||||||
blendTimeInSeconds: 2.7,
|
|
||||||
},
|
|
||||||
vudio: {
|
|
||||||
effect: 'lighting',
|
|
||||||
accuracy: 128,
|
|
||||||
lighting: {
|
|
||||||
maxHeight: 160,
|
|
||||||
maxSize: 12,
|
|
||||||
lineWidth: 1,
|
|
||||||
color: '#49f3f7',
|
|
||||||
shadowBlur: 2,
|
|
||||||
shadowColor: 'rgba(244,244,244,.5)',
|
|
||||||
fadeSide: true,
|
|
||||||
prettify: false,
|
|
||||||
horizontalAlign: 'center',
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
dottify: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
wave: {
|
|
||||||
animations: [
|
|
||||||
{
|
|
||||||
type: 'Cubes',
|
|
||||||
config: {
|
|
||||||
bottom: true,
|
|
||||||
count: 30,
|
|
||||||
cubeHeight: 5,
|
|
||||||
fillColor: { gradient: ['#FAD961', '#F76B1C'] },
|
|
||||||
lineColor: 'rgba(0,0,0,0)',
|
|
||||||
radius: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Cubes',
|
|
||||||
config: {
|
|
||||||
top: true,
|
|
||||||
count: 12,
|
|
||||||
cubeHeight: 5,
|
|
||||||
fillColor: { gradient: ['#FAD961', '#F76B1C'] },
|
|
||||||
lineColor: 'rgba(0,0,0,0)',
|
|
||||||
radius: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'Circles',
|
|
||||||
config: {
|
|
||||||
lineColor: {
|
|
||||||
gradient: ['#FAD961', '#FAD961', '#F76B1C'],
|
|
||||||
rotate: 90,
|
|
||||||
},
|
|
||||||
lineWidth: 4,
|
|
||||||
diameter: 20,
|
|
||||||
count: 10,
|
|
||||||
frequencyBand: 'base',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
'plugins': {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defaultConfig;
|
export default defaultConfig;
|
||||||
|
|||||||
@ -1,241 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/require-await */
|
|
||||||
|
|
||||||
import { ipcMain, ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import defaultConfig from './defaults';
|
|
||||||
|
|
||||||
import { getOptions, setMenuOptions, setOptions } from './plugins';
|
|
||||||
|
|
||||||
|
|
||||||
import { sendToFront } from '../providers/app-controls';
|
|
||||||
import { Entries } from '../utils/type-utils';
|
|
||||||
|
|
||||||
export type DefaultPluginsConfig = typeof defaultConfig.plugins;
|
|
||||||
export type OneOfDefaultConfigKey = keyof DefaultPluginsConfig;
|
|
||||||
export type OneOfDefaultConfig = typeof defaultConfig.plugins[OneOfDefaultConfigKey];
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const activePlugins: { [key in OneOfDefaultConfigKey]?: PluginConfig<any> } = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [!IMPORTANT!]
|
|
||||||
* The method is **sync** in the main process and **async** in the renderer process.
|
|
||||||
*/
|
|
||||||
export const getActivePlugins
|
|
||||||
= process.type === 'renderer'
|
|
||||||
? async () => ipcRenderer.invoke('get-active-plugins')
|
|
||||||
: () => activePlugins;
|
|
||||||
|
|
||||||
if (process.type === 'browser') {
|
|
||||||
ipcMain.handle('get-active-plugins', getActivePlugins);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [!IMPORTANT!]
|
|
||||||
* The method is **sync** in the main process and **async** in the renderer process.
|
|
||||||
*/
|
|
||||||
export const isActive
|
|
||||||
= process.type === 'renderer'
|
|
||||||
? async (plugin: string) =>
|
|
||||||
plugin in (await ipcRenderer.invoke('get-active-plugins'))
|
|
||||||
: (plugin: string): boolean => plugin in activePlugins;
|
|
||||||
|
|
||||||
interface PluginConfigOptions {
|
|
||||||
enableFront: boolean;
|
|
||||||
initialOptions?: OneOfDefaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This class is used to create a dynamic synced config for plugins.
|
|
||||||
*
|
|
||||||
* [!IMPORTANT!]
|
|
||||||
* The methods are **sync** in the main process and **async** in the renderer process.
|
|
||||||
*
|
|
||||||
* @param {string} name - The name of the plugin.
|
|
||||||
* @param {boolean} [options.enableFront] - Whether the config should be available in front.js. Default: false.
|
|
||||||
* @param {object} [options.initialOptions] - The initial options for the plugin. Default: loaded from store.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const { PluginConfig } = require("../../config/dynamic");
|
|
||||||
* const config = new PluginConfig("plugin-name", { enableFront: true });
|
|
||||||
* module.exports = { ...config };
|
|
||||||
*
|
|
||||||
* // or
|
|
||||||
*
|
|
||||||
* module.exports = (win, options) => {
|
|
||||||
* const config = new PluginConfig("plugin-name", {
|
|
||||||
* enableFront: true,
|
|
||||||
* initialOptions: options,
|
|
||||||
* });
|
|
||||||
* setupMyPlugin(win, config);
|
|
||||||
* };
|
|
||||||
*/
|
|
||||||
export type ConfigType<T extends OneOfDefaultConfigKey> = typeof defaultConfig.plugins[T];
|
|
||||||
type ValueOf<T> = T[keyof T];
|
|
||||||
type Mode<T, Mode extends 'r' | 'm'> = Mode extends 'r' ? Promise<T> : T;
|
|
||||||
export class PluginConfig<T extends OneOfDefaultConfigKey> {
|
|
||||||
private readonly name: string;
|
|
||||||
private readonly config: ConfigType<T>;
|
|
||||||
private readonly defaultConfig: ConfigType<T>;
|
|
||||||
private readonly enableFront: boolean;
|
|
||||||
|
|
||||||
private subscribers: { [key in keyof ConfigType<T>]?: (config: ConfigType<T>) => void } = {};
|
|
||||||
private allSubscribers: ((config: ConfigType<T>) => void)[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
name: T,
|
|
||||||
options: PluginConfigOptions = {
|
|
||||||
enableFront: false,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const pluginDefaultConfig = defaultConfig.plugins[name] ?? {};
|
|
||||||
const pluginConfig = options.initialOptions || getOptions(name) || {};
|
|
||||||
|
|
||||||
this.name = name;
|
|
||||||
this.enableFront = options.enableFront;
|
|
||||||
this.defaultConfig = pluginDefaultConfig;
|
|
||||||
this.config = { ...pluginDefaultConfig, ...pluginConfig };
|
|
||||||
|
|
||||||
if (this.enableFront) {
|
|
||||||
this.setupFront();
|
|
||||||
}
|
|
||||||
|
|
||||||
activePlugins[name] = this;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<Key extends keyof ConfigType<T> = keyof ConfigType<T>>(key: Key): ConfigType<T>[Key] {
|
|
||||||
return this.config?.[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
|
|
||||||
this.config[key] = value;
|
|
||||||
this.onChange(key);
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): ConfigType<T> {
|
|
||||||
return { ...this.config };
|
|
||||||
}
|
|
||||||
|
|
||||||
setAll(options: Partial<ConfigType<T>>) {
|
|
||||||
if (!options || typeof options !== 'object') {
|
|
||||||
throw new Error('Options must be an object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = false;
|
|
||||||
for (const [key, value] of Object.entries(options) as Entries<typeof options>) {
|
|
||||||
if (this.config[key] !== value) {
|
|
||||||
if (value !== undefined) this.config[key] = value;
|
|
||||||
this.onChange(key, false);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
for (const fn of this.allSubscribers) {
|
|
||||||
fn(this.config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.save();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultConfig() {
|
|
||||||
return this.defaultConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this method to set an option and restart the app if `appConfig.restartOnConfigChange === true`
|
|
||||||
*
|
|
||||||
* Used for options that require a restart to take effect.
|
|
||||||
*/
|
|
||||||
setAndMaybeRestart(key: keyof ConfigType<T>, value: ValueOf<ConfigType<T>>) {
|
|
||||||
this.config[key] = value;
|
|
||||||
setMenuOptions(this.name, this.config);
|
|
||||||
this.onChange(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe(valueName: keyof ConfigType<T>, fn: (config: ConfigType<T>) => void) {
|
|
||||||
this.subscribers[valueName] = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribeAll(fn: (config: ConfigType<T>) => void) {
|
|
||||||
this.allSubscribers.push(fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called only from back */
|
|
||||||
private save() {
|
|
||||||
setOptions(this.name, this.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onChange(valueName: keyof ConfigType<T>, single: boolean = true) {
|
|
||||||
this.subscribers[valueName]?.(this.config[valueName] as ConfigType<T>);
|
|
||||||
if (single) {
|
|
||||||
for (const fn of this.allSubscribers) {
|
|
||||||
fn(this.config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupFront() {
|
|
||||||
const ignoredMethods = ['subscribe', 'subscribeAll'];
|
|
||||||
|
|
||||||
if (process.type === 'renderer') {
|
|
||||||
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
|
|
||||||
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-return
|
|
||||||
this[fnName] = (async (...args: any) => await ipcRenderer.invoke(
|
|
||||||
`${this.name}-config-${String(fnName)}`,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
|
||||||
...args,
|
|
||||||
)) as typeof this[keyof this];
|
|
||||||
|
|
||||||
this.subscribe = (valueName, fn: (config: ConfigType<T>) => void) => {
|
|
||||||
if (valueName in this.subscribers) {
|
|
||||||
console.error(`Already subscribed to ${String(valueName)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.subscribers[valueName] = fn;
|
|
||||||
ipcRenderer.on(
|
|
||||||
`${this.name}-config-changed-${String(valueName)}`,
|
|
||||||
(_, value: ConfigType<T>) => {
|
|
||||||
fn(value);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ipcRenderer.send(`${this.name}-config-subscribe`, valueName);
|
|
||||||
};
|
|
||||||
|
|
||||||
this.subscribeAll = (fn: (config: ConfigType<T>) => void) => {
|
|
||||||
ipcRenderer.on(`${this.name}-config-changed`, (_, value: ConfigType<T>) => {
|
|
||||||
fn(value);
|
|
||||||
});
|
|
||||||
ipcRenderer.send(`${this.name}-config-subscribe-all`);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (process.type === 'browser') {
|
|
||||||
for (const [fnName, fn] of Object.entries(this) as Entries<this>) {
|
|
||||||
if (typeof fn !== 'function' || fn.name in ignoredMethods) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-return
|
|
||||||
ipcMain.handle(`${this.name}-config-${String(fnName)}`, (_, ...args) => fn(...args));
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.on(`${this.name}-config-subscribe`, (_, valueName: keyof ConfigType<T>) => {
|
|
||||||
this.subscribe(valueName, (value) => {
|
|
||||||
sendToFront(`${this.name}-config-changed-${String(valueName)}`, value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on(`${this.name}-config-subscribe-all`, () => {
|
|
||||||
this.subscribeAll((value) => {
|
|
||||||
sendToFront(`${this.name}-config-changed`, value);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,15 +1,20 @@
|
|||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
|
||||||
import defaultConfig from './defaults';
|
import defaultConfig from './defaults';
|
||||||
import plugins from './plugins';
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
|
import plugins from './plugins';
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
import { restart } from '@/providers/app-controls';
|
||||||
|
|
||||||
|
|
||||||
const set = (key: string, value: unknown) => {
|
const set = (key: string, value: unknown) => {
|
||||||
store.set(key, value);
|
store.set(key, value);
|
||||||
};
|
};
|
||||||
|
const setPartial = (key: string, value: object, defaultValue?: object) => {
|
||||||
|
const newValue = deepmerge(defaultValue ?? {}, store.get(key) ?? {}, value);
|
||||||
|
store.set(key, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
function setMenuOption(key: string, value: unknown) {
|
function setMenuOption(key: string, value: unknown) {
|
||||||
set(key, value);
|
set(key, value);
|
||||||
@ -20,34 +25,65 @@ function setMenuOption(key: string, value: unknown) {
|
|||||||
|
|
||||||
// MAGIC OF TYPESCRIPT
|
// MAGIC OF TYPESCRIPT
|
||||||
|
|
||||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
|
type Prev = [
|
||||||
11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ...0[]]
|
never,
|
||||||
type Join<K, P> = K extends string | number ?
|
0,
|
||||||
P extends string | number ?
|
1,
|
||||||
`${K}${'' extends P ? '' : '.'}${P}`
|
2,
|
||||||
: never : never;
|
3,
|
||||||
type Paths<T, D extends number = 10> = [D] extends [never] ? never : T extends object ?
|
4,
|
||||||
{ [K in keyof T]-?: K extends string | number ?
|
5,
|
||||||
`${K}` | Join<K, Paths<T[K], Prev[D]>>
|
6,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
17,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
...0[],
|
||||||
|
];
|
||||||
|
type Join<K, P> = K extends string | number
|
||||||
|
? P extends string | number
|
||||||
|
? `${K}${'' extends P ? '' : '.'}${P}`
|
||||||
: never
|
: never
|
||||||
}[keyof T] : ''
|
: never;
|
||||||
|
type Paths<T, D extends number = 10> = [D] extends [never]
|
||||||
|
? never
|
||||||
|
: T extends object
|
||||||
|
? {
|
||||||
|
[K in keyof T]-?: K extends string | number
|
||||||
|
? `${K}` | Join<K, Paths<T[K], Prev[D]>>
|
||||||
|
: never;
|
||||||
|
}[keyof T]
|
||||||
|
: '';
|
||||||
|
|
||||||
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
|
type SplitKey<K> = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
|
||||||
type PathValue<T, K extends string> =
|
type PathValue<T, K extends string> = SplitKey<K> extends [
|
||||||
SplitKey<K> extends [infer A extends keyof T, infer B extends string]
|
infer A extends keyof T,
|
||||||
? PathValue<T[A], B>
|
infer B extends string,
|
||||||
: T;
|
]
|
||||||
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>;
|
? PathValue<T[A], B>
|
||||||
|
: T;
|
||||||
|
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) =>
|
||||||
|
store.get(key) as PathValue<typeof defaultConfig, typeof key>;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
defaultConfig,
|
defaultConfig,
|
||||||
get,
|
get,
|
||||||
set,
|
set,
|
||||||
|
setPartial,
|
||||||
setMenuOption,
|
setMenuOption,
|
||||||
edit: () => store.openInEditor(),
|
edit: () => store.openInEditor(),
|
||||||
watch(cb: Parameters<Store['onDidChange']>[1]) {
|
watch(cb: Parameters<Store['onDidAnyChange']>[0]) {
|
||||||
store.onDidChange('options', cb);
|
store.onDidAnyChange(cb);
|
||||||
store.onDidChange('plugins', cb);
|
|
||||||
},
|
},
|
||||||
plugins,
|
plugins,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,24 +1,21 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
import store from './store';
|
import store from './store';
|
||||||
import defaultConfig from './defaults';
|
|
||||||
|
|
||||||
import { restart } from '../providers/app-controls';
|
import { restart } from '@/providers/app-controls';
|
||||||
import { Entries } from '../utils/type-utils';
|
|
||||||
|
|
||||||
interface Plugin {
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultPluginsConfig = typeof defaultConfig.plugins;
|
export function getPlugins() {
|
||||||
|
return store.get('plugins') as Record<string, PluginConfig>;
|
||||||
export function getEnabled() {
|
|
||||||
const plugins = store.get('plugins') as DefaultPluginsConfig;
|
|
||||||
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([plugin]) =>
|
|
||||||
isEnabled(plugin),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEnabled(plugin: string) {
|
export function isEnabled(plugin: string) {
|
||||||
const pluginConfig = (store.get('plugins') as Record<string, Plugin>)[plugin];
|
const pluginConfig = deepmerge(
|
||||||
|
allPlugins[plugin].config ?? { enabled: false },
|
||||||
|
(store.get('plugins') as Record<string, PluginConfig>)[plugin] ?? {},
|
||||||
|
);
|
||||||
return pluginConfig !== undefined && pluginConfig.enabled;
|
return pluginConfig !== undefined && pluginConfig.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,7 +25,11 @@ export function isEnabled(plugin: string) {
|
|||||||
* @param options Options to set
|
* @param options Options to set
|
||||||
* @param exclude Options to exclude from the options object
|
* @param exclude Options to exclude from the options object
|
||||||
*/
|
*/
|
||||||
export function setOptions<T>(plugin: string, options: T, exclude: string[] = ['enabled']) {
|
export function setOptions<T>(
|
||||||
|
plugin: string,
|
||||||
|
options: T,
|
||||||
|
exclude: string[] = ['enabled'],
|
||||||
|
) {
|
||||||
const plugins = store.get('plugins') as Record<string, T>;
|
const plugins = store.get('plugins') as Record<string, T>;
|
||||||
// HACK: This is a workaround for preventing changed options from being overwritten
|
// HACK: This is a workaround for preventing changed options from being overwritten
|
||||||
exclude.forEach((key) => {
|
exclude.forEach((key) => {
|
||||||
@ -45,7 +46,11 @@ export function setOptions<T>(plugin: string, options: T, exclude: string[] = ['
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setMenuOptions<T>(plugin: string, options: T, exclude: string[] = ['enabled']) {
|
export function setMenuOptions<T>(
|
||||||
|
plugin: string,
|
||||||
|
options: T,
|
||||||
|
exclude: string[] = ['enabled'],
|
||||||
|
) {
|
||||||
setOptions(plugin, options, exclude);
|
setOptions(plugin, options, exclude);
|
||||||
if (store.get('options.restartOnConfigChanges')) {
|
if (store.get('options.restartOnConfigChanges')) {
|
||||||
restart();
|
restart();
|
||||||
@ -66,7 +71,7 @@ export function disable(plugin: string) {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
isEnabled,
|
isEnabled,
|
||||||
getEnabled,
|
getPlugins,
|
||||||
enable,
|
enable,
|
||||||
disable,
|
disable,
|
||||||
setOptions,
|
setOptions,
|
||||||
|
|||||||
@ -1,25 +1,33 @@
|
|||||||
import Store from 'electron-store';
|
import Store from 'electron-store';
|
||||||
import Conf from 'conf';
|
import Conf from 'conf';
|
||||||
import is from 'electron-is';
|
|
||||||
|
|
||||||
import defaults from './defaults';
|
import defaults from './defaults';
|
||||||
|
|
||||||
import { DefaultPresetList, type Preset } from '../plugins/downloader/types';
|
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
||||||
|
|
||||||
const getDefaults = () => {
|
|
||||||
if (is.windows()) {
|
|
||||||
defaults.plugins['in-app-menu'].enabled = true;
|
|
||||||
}
|
|
||||||
return defaults;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setDefaultPluginOptions = (store: Conf<Record<string, unknown>>, plugin: keyof typeof defaults.plugins) => {
|
|
||||||
if (!store.get(`plugins.${plugin}`)) {
|
|
||||||
store.set(`plugins.${plugin}`, defaults.plugins[plugin]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const migrations = {
|
const migrations = {
|
||||||
|
'>=3.0.0'(store: Conf<Record<string, unknown>>) {
|
||||||
|
const discordConfig = store.get('plugins.discord') as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
if (discordConfig) {
|
||||||
|
const oldActivityTimoutEnabled = store.get(
|
||||||
|
'plugins.discord.activityTimoutEnabled',
|
||||||
|
) as boolean | undefined;
|
||||||
|
const oldActivityTimoutTime = store.get(
|
||||||
|
'plugins.discord.activityTimoutTime',
|
||||||
|
) as number | undefined;
|
||||||
|
if (oldActivityTimoutEnabled !== undefined) {
|
||||||
|
discordConfig.activityTimeoutEnabled = oldActivityTimoutEnabled;
|
||||||
|
store.set('plugins.discord', discordConfig);
|
||||||
|
}
|
||||||
|
if (oldActivityTimoutTime !== undefined) {
|
||||||
|
discordConfig.activityTimeoutTime = oldActivityTimoutTime;
|
||||||
|
store.set('plugins.discord', discordConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
'>=2.1.3'(store: Conf<Record<string, unknown>>) {
|
'>=2.1.3'(store: Conf<Record<string, unknown>>) {
|
||||||
const listenAlong = store.get('plugins.discord.listenAlong');
|
const listenAlong = store.get('plugins.discord.listenAlong');
|
||||||
if (listenAlong !== undefined) {
|
if (listenAlong !== undefined) {
|
||||||
@ -28,19 +36,24 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
|
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
|
||||||
const originalPreset = store.get('plugins.downloader.preset') as string | undefined;
|
const originalPreset = store.get('plugins.downloader.preset') as
|
||||||
|
| string
|
||||||
|
| undefined;
|
||||||
if (originalPreset) {
|
if (originalPreset) {
|
||||||
if (originalPreset !== 'opus') {
|
if (originalPreset !== 'opus') {
|
||||||
store.set('plugins.downloader.selectedPreset', 'Custom');
|
store.set('plugins.downloader.selectedPreset', 'Custom');
|
||||||
store.set('plugins.downloader.customPresetSetting', {
|
store.set('plugins.downloader.customPresetSetting', {
|
||||||
extension: 'mp3',
|
extension: 'mp3',
|
||||||
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
|
ffmpegArgs:
|
||||||
|
(store.get('plugins.downloader.ffmpegArgs') as string[]) ??
|
||||||
|
DefaultPresetList['mp3 (256kbps)'].ffmpegArgs,
|
||||||
} satisfies Preset);
|
} satisfies Preset);
|
||||||
} else {
|
} else {
|
||||||
store.set('plugins.downloader.selectedPreset', 'Source');
|
store.set('plugins.downloader.selectedPreset', 'Source');
|
||||||
store.set('plugins.downloader.customPresetSetting', {
|
store.set('plugins.downloader.customPresetSetting', {
|
||||||
extension: null,
|
extension: null,
|
||||||
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [],
|
ffmpegArgs:
|
||||||
|
(store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [],
|
||||||
} satisfies Preset);
|
} satisfies Preset);
|
||||||
}
|
}
|
||||||
store.delete('plugins.downloader.preset');
|
store.delete('plugins.downloader.preset');
|
||||||
@ -48,12 +61,11 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.20.0'(store: Conf<Record<string, unknown>>) {
|
||||||
setDefaultPluginOptions(store, 'visualizer');
|
store.delete('plugins.visualizer'); // default value is now in the plugin
|
||||||
|
|
||||||
if (store.get('plugins.notifications.toastStyle') === undefined) {
|
if (store.get('plugins.notifications.toastStyle') === undefined) {
|
||||||
const pluginOptions = store.get('plugins.notifications') || {};
|
const pluginOptions = store.get('plugins.notifications') || {};
|
||||||
store.set('plugins.notifications', {
|
store.set('plugins.notifications', {
|
||||||
...defaults.plugins.notifications,
|
|
||||||
...pluginOptions,
|
...pluginOptions,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -64,7 +76,7 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.17.0'(store: Conf<Record<string, unknown>>) {
|
||||||
setDefaultPluginOptions(store, 'picture-in-picture');
|
store.delete('plugins.picture-in-picture'); // default value is now in the plugin
|
||||||
|
|
||||||
if (store.get('plugins.video-toggle.mode') === undefined) {
|
if (store.get('plugins.video-toggle.mode') === undefined) {
|
||||||
store.set('plugins.video-toggle.mode', 'custom');
|
store.set('plugins.video-toggle.mode', 'custom');
|
||||||
@ -88,31 +100,41 @@ const migrations = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
||||||
const options = store.get('plugins.shortcuts') as Record<string, {
|
const options = store.get('plugins.shortcuts') as
|
||||||
action: string;
|
| Record<
|
||||||
shortcut: unknown;
|
string,
|
||||||
}[] | Record<string, unknown>>;
|
| {
|
||||||
let updated = false;
|
action: string;
|
||||||
for (const optionType of ['global', 'local']) {
|
shortcut: unknown;
|
||||||
if (Array.isArray(options[optionType])) {
|
}[]
|
||||||
const optionsArray = options[optionType] as {
|
| Record<string, unknown>
|
||||||
action: string;
|
>
|
||||||
shortcut: unknown;
|
| undefined;
|
||||||
}[];
|
if (options) {
|
||||||
const updatedOptions: Record<string, unknown> = {};
|
let updated = false;
|
||||||
for (const optionObject of optionsArray) {
|
for (const optionType of ['global', 'local']) {
|
||||||
if (optionObject.action && optionObject.shortcut) {
|
if (
|
||||||
updatedOptions[optionObject.action] = optionObject.shortcut;
|
Object.hasOwn(options, optionType) &&
|
||||||
|
Array.isArray(options[optionType])
|
||||||
|
) {
|
||||||
|
const optionsArray = options[optionType] as {
|
||||||
|
action: string;
|
||||||
|
shortcut: unknown;
|
||||||
|
}[];
|
||||||
|
const updatedOptions: Record<string, unknown> = {};
|
||||||
|
for (const optionObject of optionsArray) {
|
||||||
|
if (optionObject.action && optionObject.shortcut) {
|
||||||
|
updatedOptions[optionObject.action] = optionObject.shortcut;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options[optionType] = updatedOptions;
|
||||||
|
updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
options[optionType] = updatedOptions;
|
|
||||||
updated = true;
|
|
||||||
}
|
}
|
||||||
}
|
if (updated) {
|
||||||
|
store.set('plugins.shortcuts', options);
|
||||||
if (updated) {
|
}
|
||||||
store.set('plugins.shortcuts', options);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
|
'>=1.11.0'(store: Conf<Record<string, unknown>>) {
|
||||||
@ -155,7 +177,10 @@ const migrations = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default new Store({
|
export default new Store({
|
||||||
defaults: getDefaults(),
|
defaults: {
|
||||||
|
...defaults,
|
||||||
|
// README: 'plugin' uses deepmerge to populate the default values, so it is not necessary to include it here
|
||||||
|
},
|
||||||
clearInvalidConfig: false,
|
clearInvalidConfig: false,
|
||||||
migrations,
|
migrations,
|
||||||
});
|
});
|
||||||
|
|||||||
50
src/custom-electron-prompt.d.ts
vendored
50
src/custom-electron-prompt.d.ts
vendored
@ -53,33 +53,45 @@ declare module 'custom-electron-prompt' {
|
|||||||
export interface CounterPromptOptions extends BasePromptOptions<'counter'> {
|
export interface CounterPromptOptions extends BasePromptOptions<'counter'> {
|
||||||
counterOptions: CounterOptions;
|
counterOptions: CounterOptions;
|
||||||
}
|
}
|
||||||
export interface MultiInputPromptOptions extends BasePromptOptions<'multiInput'> {
|
export interface MultiInputPromptOptions
|
||||||
|
extends BasePromptOptions<'multiInput'> {
|
||||||
multiInputOptions: InputOptions[];
|
multiInputOptions: InputOptions[];
|
||||||
}
|
}
|
||||||
export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> {
|
export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> {
|
||||||
keybindOptions: KeybindOptions[];
|
keybindOptions: KeybindOptions[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptOptions<T extends string> = (
|
export type PromptOptions<T extends string> = T extends 'input'
|
||||||
T extends 'input' ? InputPromptOptions :
|
? InputPromptOptions
|
||||||
T extends 'select' ? SelectPromptOptions :
|
: T extends 'select'
|
||||||
T extends 'counter' ? CounterPromptOptions :
|
? SelectPromptOptions
|
||||||
T extends 'keybind' ? KeybindPromptOptions :
|
: T extends 'counter'
|
||||||
T extends 'multiInput' ? MultiInputPromptOptions :
|
? CounterPromptOptions
|
||||||
never
|
: T extends 'keybind'
|
||||||
);
|
? KeybindPromptOptions
|
||||||
|
: T extends 'multiInput'
|
||||||
|
? MultiInputPromptOptions
|
||||||
|
: never;
|
||||||
|
|
||||||
type PromptResult<T extends string> = T extends 'input' ? string :
|
type PromptResult<T extends string> = T extends 'input'
|
||||||
T extends 'select' ? string :
|
? string
|
||||||
T extends 'counter' ? number :
|
: T extends 'select'
|
||||||
T extends 'keybind' ? {
|
? string
|
||||||
value: string;
|
: T extends 'counter'
|
||||||
accelerator: string
|
? number
|
||||||
}[] :
|
: T extends 'keybind'
|
||||||
T extends 'multiInput' ? string[] :
|
? {
|
||||||
never;
|
value: string;
|
||||||
|
accelerator: string;
|
||||||
|
}[]
|
||||||
|
: T extends 'multiInput'
|
||||||
|
? string[]
|
||||||
|
: never;
|
||||||
|
|
||||||
const prompt: <T extends Type>(options?: PromptOptions<T> & { type: T }, parent?: BrowserWindow) => Promise<PromptResult<T> | null>;
|
const prompt: <T extends Type>(
|
||||||
|
options?: PromptOptions<T> & { type: T },
|
||||||
|
parent?: BrowserWindow,
|
||||||
|
) => Promise<PromptResult<T> | null>;
|
||||||
|
|
||||||
export default prompt;
|
export default prompt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +1,50 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8" />
|
||||||
<title>Cannot load YouTube Music</title>
|
<title>Cannot load YouTube Music</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Roboto, Arial, sans-serif;
|
font-family: Roboto, Arial, sans-serif;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
margin-right: -50%;
|
margin-right: -50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
background: #065fd4;
|
background: #065fd4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: white;
|
color: white;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 8px 22px;
|
padding: 8px 22px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p>Cannot load YouTube Music… Internet disconnected?</p>
|
<p>Cannot load YouTube Music… Internet disconnected?</p>
|
||||||
<a class="button" href="#" onclick="reload()">Retry</a>
|
<a class="button" href="#" onclick="reload()">Retry</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
18
src/i18n/index.ts
Normal file
18
src/i18n/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import i18next, { init, t as i18t, changeLanguage } from 'i18next';
|
||||||
|
|
||||||
|
import { languageResources } from 'virtual:i18n';
|
||||||
|
|
||||||
|
export const loadI18n = async () =>
|
||||||
|
await init({
|
||||||
|
resources: languageResources,
|
||||||
|
lng: 'en',
|
||||||
|
fallbackLng: 'en',
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setLanguage = async (language: string) =>
|
||||||
|
await changeLanguage(language);
|
||||||
|
|
||||||
|
export const t = i18t.bind(i18next);
|
||||||
11
src/i18n/resources/@types/index.ts
Normal file
11
src/i18n/resources/@types/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export interface LanguageResources {
|
||||||
|
[lang: string]: {
|
||||||
|
translation: Record<string, unknown> & {
|
||||||
|
language?: {
|
||||||
|
name: string;
|
||||||
|
'local-name': string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
425
src/i18n/resources/cs.json
Normal file
425
src/i18n/resources/cs.json
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"console": {
|
||||||
|
"plugins": {
|
||||||
|
"load-all": "Načítání všech pluginů",
|
||||||
|
"loaded": "Plugin \"{{pluginName}}\" načten"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"code": "cs",
|
||||||
|
"local-name": "Čeština",
|
||||||
|
"name": "Czech"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"i18n": {
|
||||||
|
"loaded": "i18n načteno"
|
||||||
|
},
|
||||||
|
"second-instance": {
|
||||||
|
"receive-command": "Received command přes protokol: \"{{command}}\""
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "CSS soubor \"{{cssFile}}\" neexistuje, ignoring"
|
||||||
|
},
|
||||||
|
"when-ready": {
|
||||||
|
"clearing-cache-after-20s": "Čištění mezipaměti aplikace"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"need-to-restart": {
|
||||||
|
"buttons": {
|
||||||
|
"later": "Později",
|
||||||
|
"restart-now": "Restartovat nyní"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"buttons": {
|
||||||
|
"quit": "Ukončení",
|
||||||
|
"relaunch": "Spustit znovu",
|
||||||
|
"wait": "Počkat"
|
||||||
|
},
|
||||||
|
"detail": "Omlouváme se za způsobené nepříjemnosti! prosím vyberte, co dělat:",
|
||||||
|
"message": "Aplikace nereaguje"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"disable": "Vypnout aktualizace",
|
||||||
|
"download": "Stáhnout",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"message": "Nová verze je dostupná",
|
||||||
|
"title": "Aktualizace k dispozici"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"about": "O Aplikaci",
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navigace",
|
||||||
|
"submenu": {
|
||||||
|
"copy-current-url": "Kopírovat aktuální URL adresu",
|
||||||
|
"go-back": "Jít zpátky",
|
||||||
|
"go-forward": "Jít dopředu",
|
||||||
|
"restart": "Restartovat aplikaci"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "Možnosti",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"label": "Pokročilé možnosti",
|
||||||
|
"submenu": {
|
||||||
|
"disable-hardware-acceleration": "Vypnout hardware zrychlení",
|
||||||
|
"edit-config-json": "Upravit config.json",
|
||||||
|
"override-user-agent": "Přepsat User-Agent",
|
||||||
|
"restart-on-config-changes": "Restartovat na změny v configu",
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "Nastavit proxy",
|
||||||
|
"prompt": {
|
||||||
|
"placeholder": "Příklad: socks5://127.0.0.1:9999",
|
||||||
|
"title": "Nastavit proxy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"always-on-top": "Vždy na vrchu",
|
||||||
|
"auto-update": "Automatické aktualizace",
|
||||||
|
"hide-menu": {
|
||||||
|
"label": "Skrýt menu"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "Jazyk bude změněn po restartu",
|
||||||
|
"title": "Jazyk změněn"
|
||||||
|
},
|
||||||
|
"label": "Jazyk",
|
||||||
|
"submenu": {
|
||||||
|
"to-help-translate": "Chcete pomoc s překladem? Klikněte zde"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"submenu": {
|
||||||
|
"enabled-and-hide-app": "Povolit a skrýt aplikaci",
|
||||||
|
"play-pause-on-click": "Přehrát/Pozastavit na kliknutí"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "Výchozí",
|
||||||
|
"hide": "Schovat",
|
||||||
|
"label": "Like tlačítka"
|
||||||
|
},
|
||||||
|
"remove-upgrade-button": "Remove upgrade tlačítko",
|
||||||
|
"theme": {
|
||||||
|
"label": "Motiv",
|
||||||
|
"submenu": {
|
||||||
|
"import-css-file": "Import custom CSS soubor",
|
||||||
|
"no-theme": "Žádný motiv"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"enabled": "Povoleno",
|
||||||
|
"label": "Pluginy"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"submenu": {
|
||||||
|
"zoom-in": "Přiblížit",
|
||||||
|
"zoom-out": "Oddálit"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"next": "Další",
|
||||||
|
"play-pause": "Hrát/Zastavit",
|
||||||
|
"previous": "Minulý",
|
||||||
|
"quit": "Ukončit",
|
||||||
|
"restart": "Restartovat aplikaci",
|
||||||
|
"show": "Ukázat okno"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "Blokuje všechny reklamy a sledování ihned od začátku",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "Blokátor"
|
||||||
|
},
|
||||||
|
"name": "Blokovač reklam"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "Použije dynamický motiv a visuální efekty based na paletě barev alba",
|
||||||
|
"name": "Album Color Motiv"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "Applies a lighting efekty by casting gentle colors z videa, into your screen’s pozadí.",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} pixelů"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "Vyrovnávací paměť",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "Neprůhlednost",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "Kvalita",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} pixelů"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "Velikost",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"label": "Plynulý přechod",
|
||||||
|
"submenu": {
|
||||||
|
"during": "Během {{interpolationTime}}s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Ambientní režim"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "Udělá navigační panel průhledným a rozmazaným"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "Obejít ověření věku na YouTube",
|
||||||
|
"name": "Obejít věková omezení"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"description": "Titulkový selector pro YouTube Music audio tracks",
|
||||||
|
"menu": {
|
||||||
|
"autoload": "Automaticky vybrat naposledy použité titulky",
|
||||||
|
"disable-captions": "Žádné titulky ve vychozím nastavení"
|
||||||
|
},
|
||||||
|
"name": "Titulkový selector",
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"label": "Aktuální jazyk titulků: {{language}}",
|
||||||
|
"none": "Žádný",
|
||||||
|
"title": "Vybrat jazyk titulků"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Otevřít titulový selector"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compact-sidebar": {
|
||||||
|
"description": "Vždy set the sidebar in compact mode"
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"description": "Crossfade mezi písničkami",
|
||||||
|
"menu": {
|
||||||
|
"advanced": "Pokročilý"
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-scaling": {
|
||||||
|
"linear": "Lineární",
|
||||||
|
"logarithmic": "Logaritmické"
|
||||||
|
},
|
||||||
|
"seconds-before-end": "Crossfade N sekund před koncem"
|
||||||
|
},
|
||||||
|
"title": "Možnosti prolínání"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"name": "Zrušit automatické přehrávání"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"backend": {
|
||||||
|
"connected": "Připojeno k Discordu",
|
||||||
|
"disconnected": "Odpojeno od Discordu"
|
||||||
|
},
|
||||||
|
"description": "Ukažte svým přátelům, co posloucháte s Rich Presence",
|
||||||
|
"menu": {
|
||||||
|
"connected": "Připojeno",
|
||||||
|
"disconnected": "Odpojeno",
|
||||||
|
"hide-github-button": "Skrýt tlačítko s odkazem na GitHub",
|
||||||
|
"play-on-youtube-music": "Hrát na YouTube Music"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"message": "Argh! Omlouvám se, stáhnutí selhalo…",
|
||||||
|
"title": "Chyba ve stáhování!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "({{playlistSize}} písničky)",
|
||||||
|
"message": "Stahování Playlistu {{playlistTitle}}",
|
||||||
|
"title": "Stahování začalo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"done": "Hotovo: {{filePath}}",
|
||||||
|
"download-info": "Stahování {{artist}} - {{title}} [{{videoId}}",
|
||||||
|
"downloading": "Stahování…",
|
||||||
|
"downloading-counter": "Stahování {{current}}/{{total}}…",
|
||||||
|
"downloading-playlist": "Downloading playlist \"{{playlistTitle}}\" - {{playlistSize}} písničky ({{playlistId}})",
|
||||||
|
"folder-already-exists": "Složka {{playlistFolder}} již existuje",
|
||||||
|
"loading": "Načítání…",
|
||||||
|
"playlist-has-only-one-song": "Playlist má jenom jeden položku, downloading it directly",
|
||||||
|
"playlist-id-not-found": "Žádný playlist ID nenalezen",
|
||||||
|
"playlist-is-empty": "Playlist je prázdný",
|
||||||
|
"preparing-file": "Připravování souboru…",
|
||||||
|
"saving": "Ukládání…",
|
||||||
|
"video-id-not-found": "Video nebylo nalezeno"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"choose-download-folder": "Vybrat download složku",
|
||||||
|
"download-playlist": "Stáhnout playlist",
|
||||||
|
"skip-existing": "Přeskočit existující soubory"
|
||||||
|
},
|
||||||
|
"name": "Stahovač",
|
||||||
|
"templates": {
|
||||||
|
"button": "Stáhnout"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exponential-volume": {
|
||||||
|
"name": "Exponenciální hlasitost"
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"lyrics-genius": {
|
||||||
|
"description": "Přidat lyrics podporu pro většinu písniček"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"name": "Navigace"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"description": "Odstranit Google login tlačítka a odkazy z rozhraní",
|
||||||
|
"name": "Žádné Google přihlášení"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"description": "Display oznámení when a písnička starts hraje (interactive notifications are available on Windows)",
|
||||||
|
"menu": {
|
||||||
|
"interactive-settings": {
|
||||||
|
"label": "Interactive Nastavení",
|
||||||
|
"submenu": {
|
||||||
|
"hide-button-text": "Skrýt text tlačítka",
|
||||||
|
"tray-controls": "Otevřít/Zavřít on tray click"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priority": "Priorita Oznámení"
|
||||||
|
},
|
||||||
|
"name": "Oznámení"
|
||||||
|
},
|
||||||
|
"picture-in-picture": {
|
||||||
|
"menu": {
|
||||||
|
"always-on-top": "Vždy na vrchu",
|
||||||
|
"hotkey": {
|
||||||
|
"label": "Klávesová zkratka",
|
||||||
|
"prompt": {
|
||||||
|
"keybind-options": {
|
||||||
|
"hotkey": "Klávesová zkratka"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save-window-position": "Uložit pozici okna",
|
||||||
|
"save-window-size": "Uložit velikost okna",
|
||||||
|
"use-native-pip": "Použít browser native PiP"
|
||||||
|
},
|
||||||
|
"name": "Obrázek v obrázku",
|
||||||
|
"templates": {
|
||||||
|
"button": "Obrázek v obrázku"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback-speed": {
|
||||||
|
"description": "Posloiuchej rychle, poslouchej pomalu! Adds a slider, který kontroluje rychlost písníčky",
|
||||||
|
"name": "Playback rychlost",
|
||||||
|
"templates": {
|
||||||
|
"button": "Rychlost"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"precise-volume": {
|
||||||
|
"menu": {
|
||||||
|
"global-shortcuts": "Globální klávesové zkratky"
|
||||||
|
},
|
||||||
|
"name": "Precise hlasitost",
|
||||||
|
"prompt": {
|
||||||
|
"global-shortcuts": {
|
||||||
|
"keybind-options": {
|
||||||
|
"decrease": "Snížit hlasitost",
|
||||||
|
"increase": "Zvýšit hlasitost"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality-changer": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"quality-changer": {
|
||||||
|
"detail": "Aktuální kvalita: {{quality}}",
|
||||||
|
"message": "Vybrat kvalitu videa:",
|
||||||
|
"title": "Vybrat kvalitu videa"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"name": "Zkratky (& MPRIS)",
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "Další",
|
||||||
|
"play-pause": "Přehrát / Pozastavit",
|
||||||
|
"previous": "Předchozí"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skip-silences": {
|
||||||
|
"description": "Automaticky přeskakovat tichá místa v písničkách",
|
||||||
|
"name": "Přeskočit Tichá místa"
|
||||||
|
},
|
||||||
|
"taskbar-mediacontrol": {
|
||||||
|
"description": "Kontrolovat playback z vašeho Windows taskbar"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"label": "Zarovnání",
|
||||||
|
"submenu": {
|
||||||
|
"left": "Vlevo",
|
||||||
|
"right": "Pravo"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"label": "Režim"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Písnička"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
594
src/i18n/resources/de.json
Normal file
594
src/i18n/resources/de.json
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"console": {
|
||||||
|
"plugins": {
|
||||||
|
"execute-failed": "Erweiterung {{pluginName}}::{{contextName}} konnte nicht ausgeführt werden",
|
||||||
|
"executed-at-ms": "Erweiterung {{pluginName}}::{{contextName}} ausgeführt in {{ms}}ms",
|
||||||
|
"initialize-failed": "Initialisierung der Erweiterung \"{{pluginName}}\" fehlgeschlagen",
|
||||||
|
"load-all": "Lade alle Erweiterungen",
|
||||||
|
"load-failed": "Laden der Erweiterung \"{{pluginName}}\" fehlgeschlagen",
|
||||||
|
"loaded": "Erweiterung \"{{pluginName}}\" geladen",
|
||||||
|
"unload-failed": "Entladen der Erweiterung \"{{pluginName}}\" fehlgeschlagen",
|
||||||
|
"unloaded": "Erweiterung \"{{pluginName}}\" entladen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"code": "de",
|
||||||
|
"local-name": "Deutsch",
|
||||||
|
"name": "German"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"did-finish-load": {
|
||||||
|
"dev-tools": "Laden fertiggestellt. Entwicklerwerkzeuge geöffnet"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"loaded": "i18n geladen"
|
||||||
|
},
|
||||||
|
"second-instance": {
|
||||||
|
"receive-command": "Befehl über Protokoll empfangen: \"{{command}}\""
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "CSS-Datei \"{{cssFile}}\" existiert nicht, ignoriere"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"details": "Nicht reagierender Fehler!\n{{error}}"
|
||||||
|
},
|
||||||
|
"when-ready": {
|
||||||
|
"clearing-cache-after-20s": "Leere Anwendungscache"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"tried-to-render-offscreen": "Fenster vesucht außerhalb des Bildschirms zu rendern, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"hide-menu-enabled": {
|
||||||
|
"detail": "Menü ist versteckt, nutze 'Alt', um es zu zeigen (oder 'Escape' beim Verwenden des In-App-Menüs)",
|
||||||
|
"message": "Menü verstecken ist aktiviert",
|
||||||
|
"title": "Menü Verstecken Aktiviert"
|
||||||
|
},
|
||||||
|
"need-to-restart": {
|
||||||
|
"buttons": {
|
||||||
|
"later": "Später",
|
||||||
|
"restart-now": "Jetzt neustarten"
|
||||||
|
},
|
||||||
|
"detail": "\"{{pluginName}}\"-Erweiterung erfordert einen Neustart, um in Kraft zu treten",
|
||||||
|
"message": "\"{{pluginName}}\" muss neugestartet werden",
|
||||||
|
"title": "Neustart Erforderlich"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"buttons": {
|
||||||
|
"quit": "Verlassen",
|
||||||
|
"relaunch": "Neustarten",
|
||||||
|
"wait": "Warten"
|
||||||
|
},
|
||||||
|
"detail": "Wir entschuldigen uns für die Unannehmlichkeiten! Bitte entscheide, was du tun möchtest:",
|
||||||
|
"message": "Die Anwendung reagiert nicht",
|
||||||
|
"title": "Fenster reagiert nicht"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"disable": "Aktualisierungen deaktivieren",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "Eine neue Version ist verfügbar und kann unter {{downloadLink}} heruntergeladen werden",
|
||||||
|
"message": "Eine neue Version ist verfügbar",
|
||||||
|
"title": "Aktualisierung Verfügbar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"about": "Über",
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navigation",
|
||||||
|
"submenu": {
|
||||||
|
"copy-current-url": "Aktuelle URL kopieren",
|
||||||
|
"go-back": "Zurück gehen",
|
||||||
|
"go-forward": "Vorwärts gehen",
|
||||||
|
"quit": "Beenden",
|
||||||
|
"restart": "Anwendung Neustarten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "Einstellungen",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"label": "Erweiterte Einstellungen",
|
||||||
|
"submenu": {
|
||||||
|
"auto-reset-app-cache": "Anwendungscache beim Start der Anwendung zurücksetzen",
|
||||||
|
"disable-hardware-acceleration": "Hardware-Beschleunigung deaktivieren",
|
||||||
|
"edit-config-json": "config.json ändern",
|
||||||
|
"override-user-agent": "User-Agent außer Kraft setzen",
|
||||||
|
"restart-on-config-changes": "Neustarten bei Änderungen der Konfiguration",
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "Proxy setzen",
|
||||||
|
"prompt": {
|
||||||
|
"label": "Proxy-Adresse eingeben: (leer lassen zum Ausschalten)",
|
||||||
|
"placeholder": "Beispiel: socks5://127.0.0.1:9999",
|
||||||
|
"title": "Proxy setzen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toggle-dev-tools": "Entwicklerwerkzeuge umschalten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"always-on-top": "Immer im Vordergrund",
|
||||||
|
"auto-update": "Automatisch Aktualisieren",
|
||||||
|
"hide-menu": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "Menü wird beim nächsten Start versteckt, verwende [Alt], um es zu zeigen (oder Backtick [`], wenn du das In-App-Menü benutzt)",
|
||||||
|
"title": "Menü Verstecken Aktiviert"
|
||||||
|
},
|
||||||
|
"label": "Menü Verstecken"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "Sprache wird nach Neustart geändert",
|
||||||
|
"title": "Sprache Geändert"
|
||||||
|
},
|
||||||
|
"label": "Sprache",
|
||||||
|
"submenu": {
|
||||||
|
"to-help-translate": "Willst du beim Übersetzen helfen? Klicke hier"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resume-on-start": "Letztes Lied weiter abspielen, wenn Anwendung startet",
|
||||||
|
"single-instance-lock": "Sperren einer einzelnen Instanz",
|
||||||
|
"start-at-login": "Start beim Einschalten",
|
||||||
|
"starting-page": {
|
||||||
|
"label": "Startseite",
|
||||||
|
"unset": "Ungesetzt"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"label": "Tray",
|
||||||
|
"submenu": {
|
||||||
|
"disabled": "Deaktiviert",
|
||||||
|
"enabled-and-hide-app": "Aktiviert und verstecke Anwendung",
|
||||||
|
"enabled-and-show-app": "Aktiviert und zeige Anwendung",
|
||||||
|
"play-pause-on-click": "Abspielen/Pausieren durch Klick"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"label": "Visuelle Optimierungen",
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "Standard",
|
||||||
|
"force-show": "Zeigen erzwungen",
|
||||||
|
"hide": "Versteckt",
|
||||||
|
"label": "Gefällt mir-Knopf"
|
||||||
|
},
|
||||||
|
"remove-upgrade-button": "Upgrade-Schaltfläche entfernen",
|
||||||
|
"theme": {
|
||||||
|
"label": "Thema",
|
||||||
|
"submenu": {
|
||||||
|
"import-css-file": "Importiere eigene CSS-Datei",
|
||||||
|
"no-theme": "Kein Thema"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"label": "Erweiterungen"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "Ansicht",
|
||||||
|
"submenu": {
|
||||||
|
"force-reload": "Neuladen erzwingen",
|
||||||
|
"reload": "Neu laden",
|
||||||
|
"reset-zoom": "Tatsächliche Größe",
|
||||||
|
"toggle-fullscreen": "Vollbild umschalten",
|
||||||
|
"zoom-in": "Vergrößern",
|
||||||
|
"zoom-out": "Verkleinern"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"next": "Nächstes",
|
||||||
|
"play-pause": "Weiter/Pause",
|
||||||
|
"previous": "Vorheriges",
|
||||||
|
"quit": "Beenden",
|
||||||
|
"restart": "Anwendung neu starten",
|
||||||
|
"show": "Fenster anzeigen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "Blockiere jegliche Werbung und Tracker",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "Abfangmethode"
|
||||||
|
},
|
||||||
|
"name": "Werbeblocker"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "Wendet ein dynamisches Farbthema und visuelle Effekte auf Basis der Farbpalette des Albumcovers an",
|
||||||
|
"name": "Thema aus Albumfarbe"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "Fügt einen Lichteffekt durch sanftes Abstreifen der Farben des Videos in deinen Bildschirmhintergrund hinzu.",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"label": "Unschärfemenge",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} Pixel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "Puffer",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "Durchsichtigkeit",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "Qualität",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} Pixel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "Größe",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"label": "Glatter Übergang",
|
||||||
|
"submenu": {
|
||||||
|
"during": "Während {{interpolationTime}}s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use-fullscreen": {
|
||||||
|
"label": "Vollbild nutzen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Ambiente-Modus"
|
||||||
|
},
|
||||||
|
"audio-compressor": {
|
||||||
|
"description": "Kompressor auf Audio anwenden (senkt die Lautstärke der lautesten Teile des Signals und hebt die Lautstärke der leisesten Teile an)",
|
||||||
|
"name": "Audio-Komprimierer"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "Macht Navigationsleiste durchsichtig und unscharf",
|
||||||
|
"name": "Verschwommene Navigationsleiste"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "Youtubes Altersbestätigung umgehen",
|
||||||
|
"name": "Altersbeschränkungen umgehen"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"description": "Untertitelwähler für YouTube Music-Audio-Lieder",
|
||||||
|
"menu": {
|
||||||
|
"autoload": "Wähle automatisch den zuletzt verwendeten Untertitel",
|
||||||
|
"disable-captions": "Standartmäßig keine Untertitel"
|
||||||
|
},
|
||||||
|
"name": "Untertitelwähler",
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"label": "Aktuelle Untertitelsprache: {{language}}",
|
||||||
|
"none": "Keine",
|
||||||
|
"title": "Wähle Untertitelsprache"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Untertitelwähler öffnen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compact-sidebar": {
|
||||||
|
"description": "Seitenleiste immer in den kompakten Modus setzen",
|
||||||
|
"name": "Kompakte Seitenleiste"
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"description": "Übergang zwischen Liedern",
|
||||||
|
"menu": {
|
||||||
|
"advanced": "Erweitert"
|
||||||
|
},
|
||||||
|
"name": "Übergang [Beta]",
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-in-duration": "Einblendezeit (Millisekunden)",
|
||||||
|
"fade-out-duration": "Ausblendezeit (Millisekunden)",
|
||||||
|
"fade-scaling": {
|
||||||
|
"label": "Übergangsskalierung",
|
||||||
|
"linear": "Linear",
|
||||||
|
"logarithmic": "Logarithmisch"
|
||||||
|
},
|
||||||
|
"seconds-before-end": "Übergang N Sekunden vor dem Ende starten"
|
||||||
|
},
|
||||||
|
"title": "Übergangseinstellungen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"description": "Startet Lied im pausierten Modus",
|
||||||
|
"menu": {
|
||||||
|
"apply-once": "Nur beim Start der Anwendung anwenden"
|
||||||
|
},
|
||||||
|
"name": "Deaktiviere automatisches Abspielen"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"backend": {
|
||||||
|
"already-connected": "Verbindungsaufbau bei aktiver Verbindung versucht",
|
||||||
|
"connected": "Mit Discord verbunden",
|
||||||
|
"disconnected": "Verbindung zu Discord getrennt"
|
||||||
|
},
|
||||||
|
"description": "Zeige deinen Freunden, was du hörst mit Discords Aktivitätsstatus",
|
||||||
|
"menu": {
|
||||||
|
"auto-reconnect": "Automatisch erneut verbinden",
|
||||||
|
"clear-activity": "Aktivität leeren",
|
||||||
|
"clear-activity-after-timeout": "Aktivität nach Timeout leeren",
|
||||||
|
"connected": "Verbunden",
|
||||||
|
"disconnected": "Getrennt",
|
||||||
|
"hide-duration-left": "Verbleibende Zeit verstecken",
|
||||||
|
"hide-github-button": "Knopf mit Link zu GitHub ausblenden",
|
||||||
|
"play-on-youtube-music": "Auf YouTube Music abspielen",
|
||||||
|
"set-inactivity-timeout": "Inaktivitätstimeout setzen"
|
||||||
|
},
|
||||||
|
"name": "Discords Aktivitätsstatus",
|
||||||
|
"prompt": {
|
||||||
|
"set-inactivity-timeout": {
|
||||||
|
"label": "Inaktivitätstimeout in Sekunden eingeben:",
|
||||||
|
"title": "Inaktivitätstimeout setzen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"message": "Argh! Entschuldigung, herunterladen fehlgeschlagen…",
|
||||||
|
"title": "Fehler beim Herunterladen!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "({{playlistSize}} Lieder)",
|
||||||
|
"message": "Lade Playlist {{playlistTitle}} herunter",
|
||||||
|
"title": "Download begonnen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"conversion-progress": "Konvertieren: {{percent}}%",
|
||||||
|
"converting": "Konvertiere…",
|
||||||
|
"done": "Abgeschlossen: {{filePath}}",
|
||||||
|
"download-info": "Lade {{artist}} - {{title}} [{{videoId}} herunter",
|
||||||
|
"download-progress": "Herunterladen: {{percent}}%",
|
||||||
|
"downloading": "Lade herunter…",
|
||||||
|
"downloading-counter": "Lade herunter {{current}}/{{total}}…",
|
||||||
|
"downloading-playlist": "Lade Playlist \"{{playlistTitle}}\" herunter - {{playlistSize}} Lieder ({{playlistId}})",
|
||||||
|
"error-while-downloading": "Fehler beim Herunterladen \"{{author}} - {{title}}\": {{error}}",
|
||||||
|
"folder-already-exists": "Der Ordner {{playlistFolder}} existiert bereits",
|
||||||
|
"getting-playlist-info": "Hole Playlist-Informationen…",
|
||||||
|
"loading": "Lade…",
|
||||||
|
"playlist-has-only-one-song": "Playlist hat nur ein Element, wird direkt heruntergeladen",
|
||||||
|
"playlist-id-not-found": "Keine Playlist-ID gefunden",
|
||||||
|
"playlist-is-empty": "Playlist ist leer",
|
||||||
|
"playlist-is-mix-or-private": "Fehler beim Sammeln der Playlist-Informationen: stelle sicher, dass es keine private oder \"Mixed for you\"-Playlist ist\n\n{{error}}",
|
||||||
|
"preparing-file": "Bereite Datei vor…",
|
||||||
|
"saving": "Speichere…",
|
||||||
|
"trying-to-get-playlist-id": "Versuche Playlist-ID zu bekommen: {{playlistId}}",
|
||||||
|
"video-id-not-found": "Video nicht gefunden",
|
||||||
|
"writing-id3": "Schreibe ID3 tags…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Lädt MP3-/Original-Audio direkt von der Schnittstelle herunter",
|
||||||
|
"menu": {
|
||||||
|
"choose-download-folder": "Downloadordner wählen",
|
||||||
|
"download-playlist": "Wiedergabeliste herunterladen",
|
||||||
|
"presets": "Voreinstellungen",
|
||||||
|
"skip-existing": "Vorhandene Dateien überspringen"
|
||||||
|
},
|
||||||
|
"name": "Downloader",
|
||||||
|
"renderer": {
|
||||||
|
"can-not-update-progress": "Fortschritt kann nicht aktualisiert werden"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Herunterladen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exponential-volume": {
|
||||||
|
"description": "Macht den Lautstärkeregler exponentiell, damit es einfacher ist leise Lautstärken zu wählen.",
|
||||||
|
"name": "Exponentielle Lautstärke"
|
||||||
|
},
|
||||||
|
"in-app-menu": {
|
||||||
|
"description": "Verleiht den Menüleisten ein schickes, dunkles oder albumfarbenes Aussehen",
|
||||||
|
"menu": {
|
||||||
|
"hide-dom-window-controls": "DOM-Fenster-Steuerelemente ausblenden"
|
||||||
|
},
|
||||||
|
"name": "In-App Menü"
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"description": "Scrobbling-Unterstützung für Last.fm hinzufügen",
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"lumiastream": {
|
||||||
|
"description": "Fügt Unterstützung für Lumia Stream hinzu",
|
||||||
|
"name": "Lumia Stream [Beta]"
|
||||||
|
},
|
||||||
|
"lyrics-genius": {
|
||||||
|
"description": "Für Songtextunterstützung für die meisten Lieder hinzu",
|
||||||
|
"menu": {
|
||||||
|
"romanized-lyrics": "Romanisierte Songtexte"
|
||||||
|
},
|
||||||
|
"name": "Liedtexte von Genius",
|
||||||
|
"renderer": {
|
||||||
|
"fetched-lyrics": "Liedtexte für Genius abgerufen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"description": "Vorwärts/Zurück Navigationspfeile direkt in die Oberfläche integriert - wie in deinem geliebten Browser",
|
||||||
|
"name": "Navigation"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"description": "Googles Anmelden-Knöpfe und -Links von der Oberfläche entfernen",
|
||||||
|
"name": "Keine Google-Anmeldung"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"description": "Zeige eine Benachrichtigung, wenn ein Lied beginnt zu spielen (interaktive Benachrichtigungen sind unter Windows verfügbar)",
|
||||||
|
"menu": {
|
||||||
|
"interactive": "Interaktive Benachrichtigungen",
|
||||||
|
"interactive-settings": {
|
||||||
|
"label": "Interaktivitätseinstellungen",
|
||||||
|
"submenu": {
|
||||||
|
"hide-button-text": "Text der Knöpfe verstecken",
|
||||||
|
"refresh-on-play-pause": "Aktualisieren bei Wiedergabe/Pause",
|
||||||
|
"tray-controls": "Öffnen/Schließen beim Klicken des Tray-Icons"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priority": "Benachrichtigungspriorität",
|
||||||
|
"toast-style": "Toast-Stil",
|
||||||
|
"unpause-notification": "Benachrichtigungen beim Pausieren anzeigen"
|
||||||
|
},
|
||||||
|
"name": "Benachrichtigungen"
|
||||||
|
},
|
||||||
|
"picture-in-picture": {
|
||||||
|
"description": "Erlaubt die App in den Bild-im-Bild-Modus zu wechseln",
|
||||||
|
"menu": {
|
||||||
|
"always-on-top": "Immer im Vordergrund",
|
||||||
|
"hotkey": {
|
||||||
|
"label": "Tastenkürzel",
|
||||||
|
"prompt": {
|
||||||
|
"keybind-options": {
|
||||||
|
"hotkey": "Tastenkürzel"
|
||||||
|
},
|
||||||
|
"label": "Tastenkürzel für Bild-im-Bild wählen",
|
||||||
|
"title": "Bild-im-Bild Tastenkürzel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save-window-position": "Fensterposition speichern",
|
||||||
|
"save-window-size": "Fenstergröße speichern",
|
||||||
|
"use-native-pip": "Browsereigenes PiP verwenden"
|
||||||
|
},
|
||||||
|
"name": "Bild im Bild",
|
||||||
|
"templates": {
|
||||||
|
"button": "Bild im Bild"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback-speed": {
|
||||||
|
"description": "Schnell hören, langsam hören! Fügt einen Schieberegler zur Steuerung der Songgeschwindigkeit hinzu",
|
||||||
|
"name": "Wiedergabegeschwindigkeit",
|
||||||
|
"templates": {
|
||||||
|
"button": "Geschwindigkeit"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"precise-volume": {
|
||||||
|
"description": "Präzise Steuerung der Lautstärke mit dem Mausrad/Numpad mit einem benutzerdefinierten HUD und benutzerdefinierten Lautstärkestufen",
|
||||||
|
"menu": {
|
||||||
|
"arrows-shortcuts": "Lokale Pfeiltasten als Steuerung",
|
||||||
|
"custom-volume-steps": "Eigene Lautstärkestufen setzen",
|
||||||
|
"global-shortcuts": "Globale Tastenkürzel"
|
||||||
|
},
|
||||||
|
"name": "Genaue Lautstärke",
|
||||||
|
"prompt": {
|
||||||
|
"global-shortcuts": {
|
||||||
|
"keybind-options": {
|
||||||
|
"decrease": "Lautstärke senken",
|
||||||
|
"increase": "Lautstärke erhöhen"
|
||||||
|
},
|
||||||
|
"label": "Wähle globale Tastenkombinationen für Lautstärke:",
|
||||||
|
"title": "Globale Lautstärketastenbelegungen"
|
||||||
|
},
|
||||||
|
"volume-steps": {
|
||||||
|
"label": "Wähle Schritte zur Lautstärkehebung/-senkung",
|
||||||
|
"title": "Lautstärkestufen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality-changer": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"quality-changer": {
|
||||||
|
"detail": "Aktuelle Videoqualität: {{quality}}",
|
||||||
|
"message": "Wähle Videoqualität:",
|
||||||
|
"title": "Videoqualität wählen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Erlaubt die Videoqualität über einen Knopf auf dem Video",
|
||||||
|
"name": "Videoqualitätsänderer"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"description": "Ermöglicht das Festlegen globaler Hotkeys für die Wiedergabe (Abspielen/Pause/Nächstes/Vorheriges) + Deaktivieren des Medien-OSD durch Überschreiben der Medientasten + Aktivieren von Strg/CMD + F zum Suchen + Aktivieren der Linux mpris-Unterstützung für Medientasten + Angepasste Tastenkürzel für fortgeschrittene Benutzer",
|
||||||
|
"menu": {
|
||||||
|
"override-media-keys": "Medienschlüssel überschreiben",
|
||||||
|
"set-keybinds": "Globale Liedsteuerung setzen"
|
||||||
|
},
|
||||||
|
"name": "Abkürzungen (& MPRIS)",
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "Nächstes",
|
||||||
|
"play-pause": "Weiter / Pause",
|
||||||
|
"previous": "Vorheriges"
|
||||||
|
},
|
||||||
|
"label": "Wähle globale Tastenkombinationen für die Liedsteuerung:",
|
||||||
|
"title": "Globale Tastenkombinationen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skip-silences": {
|
||||||
|
"description": "Automatisch stille Abschnitte in Liedern überspringen",
|
||||||
|
"name": "Stille überspringen"
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"description": "Überspringt automatisch nicht-musikalische Teile wie Intro/Outro oder Teile von Musikvideos, in denen der Song nicht gespielt wird",
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"taskbar-mediacontrol": {
|
||||||
|
"description": "Wiedergabe aus der Windows Taskleiste kontrollieren",
|
||||||
|
"name": "Mediensteuerung in der Taskleiste"
|
||||||
|
},
|
||||||
|
"touchbar": {
|
||||||
|
"description": "Fügt ein TouchBar-Widget für macOS-Benutzer hinzu",
|
||||||
|
"name": "TouchBar"
|
||||||
|
},
|
||||||
|
"tuna-obs": {
|
||||||
|
"description": "Integration mit dem OBS-Plugin Tuna",
|
||||||
|
"name": "Tuna OBS"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"description": "Fügt einen Knopf hinzu, um zwischen Video-/Liedmodus zu wechseln. kann auch genutzt werden, um den ganzen Videoabschnitt zu entfernen",
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"label": "Ausrichtung",
|
||||||
|
"submenu": {
|
||||||
|
"left": "Links",
|
||||||
|
"middle": "Mitte",
|
||||||
|
"right": "Rechts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force-hide": "Entfernen des Videoabschnitts erzwingen",
|
||||||
|
"mode": {
|
||||||
|
"label": "Modus",
|
||||||
|
"submenu": {
|
||||||
|
"custom": "Angepasster Schalter",
|
||||||
|
"disabled": "Deaktiviert",
|
||||||
|
"native": "Eingebauter Schalter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Videoumschalter",
|
||||||
|
"templates": {
|
||||||
|
"button": "Lied"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualizer": {
|
||||||
|
"description": "Fügt einen Visualisierer zum Player hinzu",
|
||||||
|
"menu": {
|
||||||
|
"visualizer-type": "Visualisierertyp"
|
||||||
|
},
|
||||||
|
"name": "Visualisierer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
242
src/i18n/resources/el.json
Normal file
242
src/i18n/resources/el.json
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"code": "el",
|
||||||
|
"local-name": "Ελληνικά",
|
||||||
|
"name": "Greek"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"dialog": {
|
||||||
|
"hide-menu-enabled": {
|
||||||
|
"message": "Απόκρυψη μενού είναι ενεργοποιημένο"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"download": "Download",
|
||||||
|
"ok": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navigation"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "Options",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"submenu": {
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "Set proxy",
|
||||||
|
"prompt": {
|
||||||
|
"title": "Set proxy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auto-update": "Auto Update",
|
||||||
|
"language": {
|
||||||
|
"label": "Γλώσσα"
|
||||||
|
},
|
||||||
|
"start-at-login": "Start at login",
|
||||||
|
"tray": {
|
||||||
|
"label": "Tray",
|
||||||
|
"submenu": {
|
||||||
|
"enabled-and-hide-app": "Ενεργοποιημένο και απόκρυψη της εφαρμογής",
|
||||||
|
"play-pause-on-click": "Play/Pause στο πάτημα"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "Default"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "Theme",
|
||||||
|
"submenu": {
|
||||||
|
"no-theme": "No theme"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"label": "Plugins"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "View"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "Αποκλεισμός όλων των διαφημίσεων και tracker",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "Μέθοδος αποκλεισμού"
|
||||||
|
},
|
||||||
|
"name": "Adblocker"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "Εφαρμόζει ένα δυναμικό θέμα και εφέ με βάση τη χρωματική παλέτα του άλμπουμ",
|
||||||
|
"name": "Album Color Theme"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "Εφαρμόζει ένα εφέ φωτισμού ρίχνοντας απαλά χρώματα από το βίντεο, στο φόντο της οθόνης σας.",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} pixels"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "Buffer",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "Ποσότητα αδιαφάνειας",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} pixels"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"submenu": {
|
||||||
|
"during": "Σε {{interpolationTime}} δεύτερα"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audio-compressor": {
|
||||||
|
"description": "Συμπίεση ήχου (μειώνει την ένταση των πιο δυνατών τμημάτων του κύματος και αυξάνει την ένταση των πιο μαλακών τμημάτων)"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "Κάνει τη γραμμή πλοήγησης διαφανή και θολή"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "Παράκαμψη της επαλήθευσης ηλικίας του YouTube"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compact-sidebar": {
|
||||||
|
"description": "Να είναι πάντα συμπαγές το sidebar"
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"menu": {
|
||||||
|
"advanced": "Για προχωρημένους"
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-scaling": {
|
||||||
|
"linear": "Γραμμική",
|
||||||
|
"logarithmic": "Λογαριθμική"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"description": "Κάνει τα τραγούδια να είναι αυτόματα σε παύση",
|
||||||
|
"menu": {
|
||||||
|
"apply-once": "Εφαρμόζεται μόνο στο πρώτο τραγούδι"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"description": "Δείξτε στους φίλους σας τι ακούτε με το Rich Presence",
|
||||||
|
"menu": {
|
||||||
|
"hide-duration-left": "Απόκρυψη της διάρκειας που απομένει",
|
||||||
|
"hide-github-button": "Απόκρυψη του συνδέσμου προς GitHub",
|
||||||
|
"set-inactivity-timeout": "Ορισμός χρονικού ορίου αδράνειας"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"title": "Error in download!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"message": "Λήψη λίστας αναπαραγωγής {{playlistTitle}}",
|
||||||
|
"title": "Λήψη ξεκίνησε"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"conversion-progress": "Μετατροπή: {{percent}}%",
|
||||||
|
"download-progress": "Download: {{percent}}%",
|
||||||
|
"preparing-file": "Προετοιμασία αρχείου…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Download"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"name": "Navigation"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"name": "No Google Login"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"name": "Notifications"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"touchbar": {
|
||||||
|
"name": "TouchBar"
|
||||||
|
},
|
||||||
|
"tuna-obs": {
|
||||||
|
"name": "Tuna OBS"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"submenu": {
|
||||||
|
"middle": "Middle",
|
||||||
|
"right": "Right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"label": "Mode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Song"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
594
src/i18n/resources/en.json
Normal file
594
src/i18n/resources/en.json
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"console": {
|
||||||
|
"plugins": {
|
||||||
|
"execute-failed": "Failed to execute plugin {{pluginName}}::{{contextName}}",
|
||||||
|
"executed-at-ms": "Plugin {{pluginName}}::{{contextName}} executed at {{ms}}ms",
|
||||||
|
"initialize-failed": "Failed to initialize plugin \"{{pluginName}}\"",
|
||||||
|
"load-all": "Loading all plugins",
|
||||||
|
"load-failed": "Failed to load plugin \"{{pluginName}}\"",
|
||||||
|
"loaded": "Plugin \"{{pluginName}}\" loaded",
|
||||||
|
"unload-failed": "Failed to unload plugin \"{{pluginName}}\"",
|
||||||
|
"unloaded": "Plugin \"{{pluginName}}\" unloaded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"code": "en",
|
||||||
|
"local-name": "English",
|
||||||
|
"name": "English"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"did-finish-load": {
|
||||||
|
"dev-tools": "Finished loading. DevTools opened"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"loaded": "i18n loaded"
|
||||||
|
},
|
||||||
|
"second-instance": {
|
||||||
|
"receive-command": "Received command over protocol: \"{{command}}\""
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "CSS file \"{{cssFile}}\" does not exist, ignoring"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"details": "Unresponsive Error!\n{{error}}"
|
||||||
|
},
|
||||||
|
"when-ready": {
|
||||||
|
"clearing-cache-after-20s": "Clearing app cache"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"tried-to-render-offscreen": "Window tried to render offscreen, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"hide-menu-enabled": {
|
||||||
|
"detail": "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
||||||
|
"message": "Hide Menu is enabled",
|
||||||
|
"title": "Hide Menu Enabled"
|
||||||
|
},
|
||||||
|
"need-to-restart": {
|
||||||
|
"buttons": {
|
||||||
|
"later": "Later",
|
||||||
|
"restart-now": "Restart Now"
|
||||||
|
},
|
||||||
|
"detail": "\"{{pluginName}}\" plugin requires a restart to take effect",
|
||||||
|
"message": "\"{{pluginName}}\" needs to restart",
|
||||||
|
"title": "Restart Required"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"buttons": {
|
||||||
|
"quit": "Quit",
|
||||||
|
"relaunch": "Relaunch",
|
||||||
|
"wait": "Wait"
|
||||||
|
},
|
||||||
|
"detail": "We are sorry for the inconvenience! please choose what to do:",
|
||||||
|
"message": "The Application is Unresponsive",
|
||||||
|
"title": "Window Unresponsive"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"disable": "Disable Updates",
|
||||||
|
"download": "Download",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "A new version is available and can be downloaded at {{downloadLink}}",
|
||||||
|
"message": "A new version is available",
|
||||||
|
"title": "Update Available"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"about": "About",
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navigation",
|
||||||
|
"submenu": {
|
||||||
|
"copy-current-url": "Copy current URL",
|
||||||
|
"go-back": "Go back",
|
||||||
|
"go-forward": "Go forward",
|
||||||
|
"quit": "Exit",
|
||||||
|
"restart": "Restart App"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "Options",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"label": "Advanced options",
|
||||||
|
"submenu": {
|
||||||
|
"auto-reset-app-cache": "Reset App cache when app starts",
|
||||||
|
"disable-hardware-acceleration": "Disable hardware acceleration",
|
||||||
|
"edit-config-json": "Edit config.json",
|
||||||
|
"override-user-agent": "Override User-Agent",
|
||||||
|
"restart-on-config-changes": "Restart on config changes",
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "Set proxy",
|
||||||
|
"prompt": {
|
||||||
|
"label": "Enter Proxy Address: (leave empty to disable)",
|
||||||
|
"placeholder": "Example: socks5://127.0.0.1:9999",
|
||||||
|
"title": "Set proxy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toggle-dev-tools": "Toggle DevTools"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"always-on-top": "Always on top",
|
||||||
|
"auto-update": "Auto Update",
|
||||||
|
"hide-menu": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)",
|
||||||
|
"title": "Hide Menu Enabled"
|
||||||
|
},
|
||||||
|
"label": "Hide Menu"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "Language will be changed after restart",
|
||||||
|
"title": "Language Changed"
|
||||||
|
},
|
||||||
|
"label": "Language",
|
||||||
|
"submenu": {
|
||||||
|
"to-help-translate": "Want to help translate? Click here"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resume-on-start": "Resume last song when app starts",
|
||||||
|
"single-instance-lock": "Single Instance Lock",
|
||||||
|
"start-at-login": "Start at login",
|
||||||
|
"starting-page": {
|
||||||
|
"label": "Starting page",
|
||||||
|
"unset": "Unset"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"label": "Tray",
|
||||||
|
"submenu": {
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"enabled-and-hide-app": "Enabled and hide app",
|
||||||
|
"enabled-and-show-app": "Enabled and show app",
|
||||||
|
"play-pause-on-click": "Play/Pause on click"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"label": "Visual Tweaks",
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "Default",
|
||||||
|
"force-show": "Force show",
|
||||||
|
"hide": "Hide",
|
||||||
|
"label": "Like buttons"
|
||||||
|
},
|
||||||
|
"remove-upgrade-button": "Remove upgrade button",
|
||||||
|
"theme": {
|
||||||
|
"label": "Theme",
|
||||||
|
"submenu": {
|
||||||
|
"import-css-file": "Import custom CSS file",
|
||||||
|
"no-theme": "No theme"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"label": "Plugins"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "View",
|
||||||
|
"submenu": {
|
||||||
|
"force-reload": "Force Reload",
|
||||||
|
"reload": "Reload",
|
||||||
|
"reset-zoom": "Actual Size",
|
||||||
|
"toggle-fullscreen": "Toggle Full Screen",
|
||||||
|
"zoom-in": "Zoom In",
|
||||||
|
"zoom-out": "Zoom Out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"next": "Next",
|
||||||
|
"play-pause": "Play/Pause",
|
||||||
|
"previous": "Previous",
|
||||||
|
"quit": "Exit",
|
||||||
|
"restart": "Restart App",
|
||||||
|
"show": "Show window"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "Block all ads and tracking out of the box",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "Blocker"
|
||||||
|
},
|
||||||
|
"name": "Adblocker"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "Applies a dynamic theme and visual effects based on the album color palette",
|
||||||
|
"name": "Album Color Theme"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "Applies a lighting effect by casting gentle colors from the video, into your screen’s background.",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"label": "Blur amount",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} pixels"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "Buffer",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "Opacity",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "Quality",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} pixels"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "Size",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"label": "Smoothness transition",
|
||||||
|
"submenu": {
|
||||||
|
"during": "During {{interpolationTime}}s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use-fullscreen": {
|
||||||
|
"label": "Using fullscreen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Ambient Mode"
|
||||||
|
},
|
||||||
|
"audio-compressor": {
|
||||||
|
"description": "Apply compression to audio (lowers the volume of the loudest parts of the signal and raises the volume of the softest parts)",
|
||||||
|
"name": "Audio Compressor"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "Makes navigation bar transparent and blurry",
|
||||||
|
"name": "Blur Navigation Bar"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "Bypass YouTube's age verification",
|
||||||
|
"name": "Bypass Age Restrictions"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"description": "Caption selector for YouTube Music audio tracks",
|
||||||
|
"menu": {
|
||||||
|
"autoload": "Automatically select last used caption",
|
||||||
|
"disable-captions": "No captions by default"
|
||||||
|
},
|
||||||
|
"name": "Captions Selector",
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"label": "Current caption language: {{language}}",
|
||||||
|
"none": "None",
|
||||||
|
"title": "Select caption language"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Open captions selector"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compact-sidebar": {
|
||||||
|
"description": "Always set the sidebar in compact mode",
|
||||||
|
"name": "Compact Sidebar"
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"description": "Crossfade between songs",
|
||||||
|
"menu": {
|
||||||
|
"advanced": "Advanced"
|
||||||
|
},
|
||||||
|
"name": "Crossfade [beta]",
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-in-duration": "Fade in duration (milliseconds)",
|
||||||
|
"fade-out-duration": "Fade out duration (milliseconds)",
|
||||||
|
"fade-scaling": {
|
||||||
|
"label": "Fade scaling",
|
||||||
|
"linear": "Linear",
|
||||||
|
"logarithmic": "Logarithmic"
|
||||||
|
},
|
||||||
|
"seconds-before-end": "Crossfade N seconds before end"
|
||||||
|
},
|
||||||
|
"title": "Crossfade options"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"description": "Makes song start in \"paused\" mode",
|
||||||
|
"menu": {
|
||||||
|
"apply-once": "Applies only on startup"
|
||||||
|
},
|
||||||
|
"name": "Disable Autoplay"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"backend": {
|
||||||
|
"already-connected": "Attempted to connect with active connection",
|
||||||
|
"connected": "Connected to Discord",
|
||||||
|
"disconnected": "Disconnected from Discord"
|
||||||
|
},
|
||||||
|
"description": "Show your friends what you listen to with Rich Presence",
|
||||||
|
"menu": {
|
||||||
|
"auto-reconnect": "Auto reconnect",
|
||||||
|
"clear-activity": "Clear activity",
|
||||||
|
"clear-activity-after-timeout": "Clear activity after timeout",
|
||||||
|
"connected": "Connected",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"hide-duration-left": "Hide duration left",
|
||||||
|
"hide-github-button": "Hide GitHub link Button",
|
||||||
|
"play-on-youtube-music": "Play on YouTube Music",
|
||||||
|
"set-inactivity-timeout": "Set inactivity timeout"
|
||||||
|
},
|
||||||
|
"name": "Discord Rich Presence",
|
||||||
|
"prompt": {
|
||||||
|
"set-inactivity-timeout": {
|
||||||
|
"label": "Enter inactivity timeout in seconds:",
|
||||||
|
"title": "Set inactivity timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"message": "Argh! Apologies, download failed…",
|
||||||
|
"title": "Error in download!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "({{playlistSize}} songs)",
|
||||||
|
"message": "Downloading Playlist {{playlistTitle}}",
|
||||||
|
"title": "Download started"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"conversion-progress": "Conversion: {{percent}}%",
|
||||||
|
"converting": "Converting…",
|
||||||
|
"done": "Done: {{filePath}}",
|
||||||
|
"download-info": "Downloading {{artist}} - {{title}} [{{videoId}}",
|
||||||
|
"download-progress": "Download: {{percent}}%",
|
||||||
|
"downloading": "Downloading…",
|
||||||
|
"downloading-counter": "Downloading {{current}}/{{total}}…",
|
||||||
|
"downloading-playlist": "Downloading playlist \"{{playlistTitle}}\" - {{playlistSize}} songs ({{playlistId}})",
|
||||||
|
"error-while-downloading": "Error downloading \"{{author}} - {{title}}\": {{error}}",
|
||||||
|
"folder-already-exists": "The folder {{playlistFolder}} already exists",
|
||||||
|
"getting-playlist-info": "Getting playlist info…",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"playlist-has-only-one-song": "Playlist has only one item, downloading it directly",
|
||||||
|
"playlist-id-not-found": "No playlist ID found",
|
||||||
|
"playlist-is-empty": "Playlist is empty",
|
||||||
|
"playlist-is-mix-or-private": "Error getting playlist info: make sure it isn't a private or \"Mixed for you\" playlist\n\n{{error}}",
|
||||||
|
"preparing-file": "Preparing file…",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"trying-to-get-playlist-id": "Trying to get playlist ID: {{playlistId}}",
|
||||||
|
"video-id-not-found": "Video not found",
|
||||||
|
"writing-id3": "Writing ID3 tags…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Downloads MP3 / source audio directly from the interface",
|
||||||
|
"menu": {
|
||||||
|
"choose-download-folder": "Choose download folder",
|
||||||
|
"download-playlist": "Download playlist",
|
||||||
|
"presets": "Presets",
|
||||||
|
"skip-existing": "Skip existing files"
|
||||||
|
},
|
||||||
|
"name": "Downloader",
|
||||||
|
"renderer": {
|
||||||
|
"can-not-update-progress": "Cannot update progress"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Download"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exponential-volume": {
|
||||||
|
"description": "Makes the volume slider exponential so it's easier to select lower volumes.",
|
||||||
|
"name": "Exponential Volume"
|
||||||
|
},
|
||||||
|
"in-app-menu": {
|
||||||
|
"description": "Gives menu-bars a fancy, dark or album-color look",
|
||||||
|
"menu": {
|
||||||
|
"hide-dom-window-controls": "Hide DOM window controls"
|
||||||
|
},
|
||||||
|
"name": "In-App Menu"
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"description": "Add scrobbling support for Last.fm",
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"lumiastream": {
|
||||||
|
"description": "Adds Lumia Stream support",
|
||||||
|
"name": "Lumia Stream [beta]"
|
||||||
|
},
|
||||||
|
"lyrics-genius": {
|
||||||
|
"description": "Adds lyrics support for most songs",
|
||||||
|
"menu": {
|
||||||
|
"romanized-lyrics": "Romanized Lyrics"
|
||||||
|
},
|
||||||
|
"name": "Lyrics Genius",
|
||||||
|
"renderer": {
|
||||||
|
"fetched-lyrics": "Fetched lyrics for Genius"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"description": "Next/Back navigation arrows directly integrated in the interface, like in your favorite browser",
|
||||||
|
"name": "Navigation"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"description": "Remove Google login buttons and links from the interface",
|
||||||
|
"name": "No Google Login"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"description": "Display a notification when a song starts playing (interactive notifications are available on Windows)",
|
||||||
|
"menu": {
|
||||||
|
"interactive": "Interactive Notifications",
|
||||||
|
"interactive-settings": {
|
||||||
|
"label": "Interactive Settings",
|
||||||
|
"submenu": {
|
||||||
|
"hide-button-text": "Hide button text",
|
||||||
|
"refresh-on-play-pause": "Refresh on Play/Pause",
|
||||||
|
"tray-controls": "Open/Close on tray click"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priority": "Notification Priority",
|
||||||
|
"toast-style": "Toast style",
|
||||||
|
"unpause-notification": "Show notification on unpause"
|
||||||
|
},
|
||||||
|
"name": "Notifications"
|
||||||
|
},
|
||||||
|
"picture-in-picture": {
|
||||||
|
"description": "Allows to switch the app to picture-in-picture mode",
|
||||||
|
"menu": {
|
||||||
|
"always-on-top": "Always on top",
|
||||||
|
"hotkey": {
|
||||||
|
"label": "Hotkey",
|
||||||
|
"prompt": {
|
||||||
|
"keybind-options": {
|
||||||
|
"hotkey": "Hotkey"
|
||||||
|
},
|
||||||
|
"label": "Choose a hotkey for toggle Picture in Picture",
|
||||||
|
"title": "Picture in Picture Hotkey"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save-window-position": "Save window position",
|
||||||
|
"save-window-size": "Save window size",
|
||||||
|
"use-native-pip": "Use browser native PiP"
|
||||||
|
},
|
||||||
|
"name": "Picture in Picture",
|
||||||
|
"templates": {
|
||||||
|
"button": "Picture in Picture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback-speed": {
|
||||||
|
"description": "Listen fast, listen slow! Adds a slider that controls song speed",
|
||||||
|
"name": "Playback Speed",
|
||||||
|
"templates": {
|
||||||
|
"button": "Speed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"precise-volume": {
|
||||||
|
"description": "Control the volume precisely using mousewheel/hotkeys, with a custom HUD and customizable volume steps",
|
||||||
|
"menu": {
|
||||||
|
"arrows-shortcuts": "Local Arrow-keys Controls",
|
||||||
|
"custom-volume-steps": "Set Custom Volume Steps",
|
||||||
|
"global-shortcuts": "Global Hotkeys"
|
||||||
|
},
|
||||||
|
"name": "Precise Volume",
|
||||||
|
"prompt": {
|
||||||
|
"global-shortcuts": {
|
||||||
|
"keybind-options": {
|
||||||
|
"decrease": "Decrease Volume",
|
||||||
|
"increase": "Increase Volume"
|
||||||
|
},
|
||||||
|
"label": "Choose Global Volume Keybinds:",
|
||||||
|
"title": "Global Volume Keybinds"
|
||||||
|
},
|
||||||
|
"volume-steps": {
|
||||||
|
"label": "Choose Volume Increase/Decrease Steps",
|
||||||
|
"title": "Volume Steps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality-changer": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"quality-changer": {
|
||||||
|
"detail": "Current Quality: {{quality}}",
|
||||||
|
"message": "Choose Video Quality:",
|
||||||
|
"title": "Choose Video Quality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Allows changing the video quality with a button on the video overlay",
|
||||||
|
"name": "Video Quality Changer"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"description": "Allows setting global hotkeys for playback (play/pause/next/previous) + disable media osd by overriding media keys + enable Ctrl/CMD + F to search + enable linux mpris support for mediakeys + custom hotkeys for advanced users",
|
||||||
|
"menu": {
|
||||||
|
"override-media-keys": "Override Media Keys",
|
||||||
|
"set-keybinds": "Set Global Song Controls"
|
||||||
|
},
|
||||||
|
"name": "Shortcuts (& MPRIS)",
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "Next",
|
||||||
|
"play-pause": "Play / Pause",
|
||||||
|
"previous": "Previous"
|
||||||
|
},
|
||||||
|
"label": "Choose Global Keybinds for Songs Control:",
|
||||||
|
"title": "Global Keybinds"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skip-silences": {
|
||||||
|
"description": "Automatically skip silences sections in songs",
|
||||||
|
"name": "Skip Silences"
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"description": "Automatically Skips non-music parts like intro/outro or parts of music videos where the song isn't playing",
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"taskbar-mediacontrol": {
|
||||||
|
"description": "Control playback from your Windows taskbar",
|
||||||
|
"name": "Taskbar Media Control"
|
||||||
|
},
|
||||||
|
"touchbar": {
|
||||||
|
"description": "Adds a TouchBar widget for macOS users",
|
||||||
|
"name": "TouchBar"
|
||||||
|
},
|
||||||
|
"tuna-obs": {
|
||||||
|
"description": "Integration with OBS's plugin Tuna",
|
||||||
|
"name": "Tuna OBS"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"description": "Adds a button to switch between Video/Song mode. can also optionally remove the whole video tab",
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"label": "Alignment",
|
||||||
|
"submenu": {
|
||||||
|
"left": "Left",
|
||||||
|
"middle": "Middle",
|
||||||
|
"right": "Right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force-hide": "Force remove video tab",
|
||||||
|
"mode": {
|
||||||
|
"label": "Mode",
|
||||||
|
"submenu": {
|
||||||
|
"custom": "Custom toggle",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"native": "Native toggle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Video Toggle",
|
||||||
|
"templates": {
|
||||||
|
"button": "Song"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualizer": {
|
||||||
|
"description": "Adds a visualizer to the player",
|
||||||
|
"menu": {
|
||||||
|
"visualizer-type": "Visualizer Type"
|
||||||
|
},
|
||||||
|
"name": "Visualizer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/i18n/resources/fr.json
Normal file
112
src/i18n/resources/fr.json
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"code": "fr",
|
||||||
|
"local-name": "Français",
|
||||||
|
"name": "French"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "Le fichier de CSS \"{{cssFile}}\" n'existe pas, ignorer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"download": "Sauvegarder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"options": {
|
||||||
|
"label": "Paramètres",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"submenu": {
|
||||||
|
"edit-config-json": "Modifier config.json",
|
||||||
|
"set-proxy": {
|
||||||
|
"prompt": {
|
||||||
|
"placeholder": "Exemple: socks5://127.0.0.1:9999"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Langue"
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"hide": "Cacher"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"ambient-mode": {
|
||||||
|
"menu": {
|
||||||
|
"buffer": {
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"none": "Aucun"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-scaling": {
|
||||||
|
"linear": "Linéaire",
|
||||||
|
"logarithmic": "Logarithmique"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"start-download-playlist": {
|
||||||
|
"detail": "({{playlistSize}} chansons)",
|
||||||
|
"title": "Téléchargement a commencé"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"download-progress": "Télécharger: {{percent}}%",
|
||||||
|
"downloading": "Télécharge…",
|
||||||
|
"downloading-counter": "Télécharge {{current}}/{{total}}…",
|
||||||
|
"preparing-file": "Péparer des fichier…",
|
||||||
|
"saving": "Sauvegarde…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Téléchargeur",
|
||||||
|
"templates": {
|
||||||
|
"button": "Télécharger"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"name": "Last.fm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
594
src/i18n/resources/ja.json
Normal file
594
src/i18n/resources/ja.json
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"console": {
|
||||||
|
"plugins": {
|
||||||
|
"execute-failed": "プラグイン・{{pluginName}}:{{contextName}}を実行できませんでした",
|
||||||
|
"executed-at-ms": "プラグイン {{pluginName}}::{{contextName}} は {{ms}}ms で実行されました",
|
||||||
|
"initialize-failed": "プラグイン \"{{pluginName}}\" の初期化に失敗",
|
||||||
|
"load-all": "すべてのプラグインをロード中",
|
||||||
|
"load-failed": "プラグイン”{{pluginName}}”のロードが失敗しました",
|
||||||
|
"loaded": "プラグイン”{{pluginName}}”ロード完了",
|
||||||
|
"unload-failed": "プラグインのアンロードに失敗 \"{{pluginName}}\"",
|
||||||
|
"unloaded": "プラグイン {{pluginName}} がアンロードされました"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"code": "ja",
|
||||||
|
"local-name": "日本語",
|
||||||
|
"name": "Japanese"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"did-finish-load": {
|
||||||
|
"dev-tools": "ロード完了。デベロッパーツールが開きました"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"loaded": "翻訳ロード完了"
|
||||||
|
},
|
||||||
|
"second-instance": {
|
||||||
|
"receive-command": "プロトコルより命令を受けました:”{{command}}”"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "CSSファイル”{{cssFile}}”が存在しません。無視します"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"details": "応答なしエラー!\n{{error}}"
|
||||||
|
},
|
||||||
|
"when-ready": {
|
||||||
|
"clearing-cache-after-20s": "アプリのキャッシュを削除中"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"tried-to-render-offscreen": "ウィンドウは画面外をレンダリングしようとしました, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"hide-menu-enabled": {
|
||||||
|
"detail": "メニューは非表示です。'Alt'で表示します。(アプリ内メニューには'Escape'を使用します)",
|
||||||
|
"message": "メニューの非表示が有効です",
|
||||||
|
"title": "メニューの非表示が有効"
|
||||||
|
},
|
||||||
|
"need-to-restart": {
|
||||||
|
"buttons": {
|
||||||
|
"later": "あとで",
|
||||||
|
"restart-now": "今すぐ再起動する"
|
||||||
|
},
|
||||||
|
"detail": "プラグイン ”{{pluginName}}” を有効にするには再起動が必要です",
|
||||||
|
"message": "”{{pluginName}}”は再起動が必要です",
|
||||||
|
"title": "再起動が必要"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"buttons": {
|
||||||
|
"quit": "閉じる",
|
||||||
|
"relaunch": "再起動",
|
||||||
|
"wait": "待つ"
|
||||||
|
},
|
||||||
|
"detail": "ご不便をおかけして申し訳ございません! 何をするか選んでください:",
|
||||||
|
"message": "アプリケーションは応答しません",
|
||||||
|
"title": "ウィンドウが応答しません"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"disable": "更新を無効化",
|
||||||
|
"download": "ダウンロード",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "新しいバージョンが利用可能です。{{downloadLink}} からダウンロードできます",
|
||||||
|
"message": "新しいバージョンが利用可能",
|
||||||
|
"title": "アップデートが利用可能"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"about": "このアプリについて",
|
||||||
|
"navigation": {
|
||||||
|
"label": "移動",
|
||||||
|
"submenu": {
|
||||||
|
"copy-current-url": "現在のURLをコピー",
|
||||||
|
"go-back": "戻る",
|
||||||
|
"go-forward": "進む",
|
||||||
|
"quit": "終了",
|
||||||
|
"restart": "アプリを再起動"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "設定",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"label": "高度な設定",
|
||||||
|
"submenu": {
|
||||||
|
"auto-reset-app-cache": "アプリの開始時にキャッシュをリセット",
|
||||||
|
"disable-hardware-acceleration": "ハードウェアアクセラレーションの無効化",
|
||||||
|
"edit-config-json": "config.json を編集する",
|
||||||
|
"override-user-agent": "ユーザーエージェントの上書き",
|
||||||
|
"restart-on-config-changes": "設定変更時に再起動",
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "プロキシ",
|
||||||
|
"prompt": {
|
||||||
|
"label": "プロキシのアドレスを入力: (空にすると無効化)",
|
||||||
|
"placeholder": "例: socks5://127.0.0.1:9999",
|
||||||
|
"title": "プロキシ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toggle-dev-tools": "DevToolsの切り替え"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"always-on-top": "常に最前面に表示",
|
||||||
|
"auto-update": "自動アップデート",
|
||||||
|
"hide-menu": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "メニューは次の起動から非表示になります。表示するには[Alt]キーを使用します (in-app-menuを使用している場合は[`]を使用します)",
|
||||||
|
"title": "メニューの非表示が有効"
|
||||||
|
},
|
||||||
|
"label": "メニューの非表示"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "言語は再起動後に変更されます",
|
||||||
|
"title": "言語が変更されました"
|
||||||
|
},
|
||||||
|
"label": "言語設定",
|
||||||
|
"submenu": {
|
||||||
|
"to-help-translate": "翻訳をサポートしたいですか?こちらをクリック"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resume-on-start": "起動時に最後の曲を再開する",
|
||||||
|
"single-instance-lock": "単一インスタンスロック",
|
||||||
|
"start-at-login": "windowsのログイン時に起動",
|
||||||
|
"starting-page": {
|
||||||
|
"label": "スターティングページ",
|
||||||
|
"unset": "未設定"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"label": "トレイアイコン",
|
||||||
|
"submenu": {
|
||||||
|
"disabled": "無効",
|
||||||
|
"enabled-and-hide-app": "有効 + アプリを非表示",
|
||||||
|
"enabled-and-show-app": "有効 + アプリを表示",
|
||||||
|
"play-pause-on-click": "クリックで再生/一時停止"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"label": "見た目の微調整",
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "デフォルト",
|
||||||
|
"force-show": "強制的に表示",
|
||||||
|
"hide": "非表示",
|
||||||
|
"label": "いいねボタン"
|
||||||
|
},
|
||||||
|
"remove-upgrade-button": "アップグレードボタンを削除",
|
||||||
|
"theme": {
|
||||||
|
"label": "テーマ",
|
||||||
|
"submenu": {
|
||||||
|
"import-css-file": "CSSファイルをインポート",
|
||||||
|
"no-theme": "テーマなし"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"enabled": "有効",
|
||||||
|
"label": "プラグイン"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "表示",
|
||||||
|
"submenu": {
|
||||||
|
"force-reload": "強制再読み込み",
|
||||||
|
"reload": "再読み込み",
|
||||||
|
"reset-zoom": "実際のサイズ",
|
||||||
|
"toggle-fullscreen": "全画面表示を切り替え",
|
||||||
|
"zoom-in": "拡大",
|
||||||
|
"zoom-out": "縮小"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"next": "次の曲",
|
||||||
|
"play-pause": "再生/一時停止",
|
||||||
|
"previous": "前の曲",
|
||||||
|
"quit": "終了",
|
||||||
|
"restart": "アプリを再起動",
|
||||||
|
"show": "ウィンドウを表示"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "すべての広告とトラッカーをブロックj",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "ブロッカー"
|
||||||
|
},
|
||||||
|
"name": "Adblocker"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "アルバムカバーの色をベースにして動的テーマと視覚効果を適用します",
|
||||||
|
"name": "アルバムカラーベースのテーマ"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "動画の間接照明を画面背景に投射します。",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"label": "ぼかしの強さ",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} ピクセル"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "バッファリング",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "不透明度",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "品質",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} ピクセル"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "大きさ",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"label": "スムーズな切り替えり",
|
||||||
|
"submenu": {
|
||||||
|
"during": "{{interpolationTime}}秒間切り替えり"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use-fullscreen": {
|
||||||
|
"label": "全体画面モード使用"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "アンビエント モード"
|
||||||
|
},
|
||||||
|
"audio-compressor": {
|
||||||
|
"description": "オーディオにコンプレッサーを適用します(信号での一番大きい部分の音量を下げ、小さい部分の音量を上げる)",
|
||||||
|
"name": "オーディオコンプレッサー"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "ナビゲーションバーを透明かつぼやけにします",
|
||||||
|
"name": "ナビゲーションバーの曇り効果"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "ユーチューブの年齢制限を迂回します",
|
||||||
|
"name": "年齢制限迂回"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"description": "YouTube Musicトラック用字幕選択機",
|
||||||
|
"menu": {
|
||||||
|
"autoload": "最後の字幕を自動に選択",
|
||||||
|
"disable-captions": "デフォルトで字幕を無効化"
|
||||||
|
},
|
||||||
|
"name": "字幕選択機",
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"label": "選択した字幕言語: {{language}}",
|
||||||
|
"none": "なし",
|
||||||
|
"title": "字幕の言語を選択"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "字幕選択機を開く"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compact-sidebar": {
|
||||||
|
"description": "サイドバーを常にコンパクトモードに設定します",
|
||||||
|
"name": "コンパクトなサイドバー"
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"description": "曲の間にクロスフェード効果を適用します",
|
||||||
|
"menu": {
|
||||||
|
"advanced": "詳細設定"
|
||||||
|
},
|
||||||
|
"name": "クロスフェード[ベータ]",
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-in-duration": "フェードイン持続時間(ミリ秒)",
|
||||||
|
"fade-out-duration": "フェードアウト持続時間(ミリ秒)",
|
||||||
|
"fade-scaling": {
|
||||||
|
"label": "フェードスケーリング",
|
||||||
|
"linear": "線形",
|
||||||
|
"logarithmic": "対数スケール"
|
||||||
|
},
|
||||||
|
"seconds-before-end": "終了N秒前にクロスフェードを適用"
|
||||||
|
},
|
||||||
|
"title": "クロスフェード設定"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"description": "曲を「一時停止」モードで始めさせます",
|
||||||
|
"menu": {
|
||||||
|
"apply-once": "起動時のみ適用"
|
||||||
|
},
|
||||||
|
"name": "自動再生を無効化"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"backend": {
|
||||||
|
"already-connected": "すでに有効になっている接続に接続を試みました",
|
||||||
|
"connected": "ディスコードに接続中",
|
||||||
|
"disconnected": "Discordから切断されました"
|
||||||
|
},
|
||||||
|
"description": "アクティビティ ステータスで、あなたが聴いている曲を友達に見せましょう",
|
||||||
|
"menu": {
|
||||||
|
"auto-reconnect": "自動再接続",
|
||||||
|
"clear-activity": "アクティビティの削除",
|
||||||
|
"clear-activity-after-timeout": "タイムアウト発生時にアクティビティを削除",
|
||||||
|
"connected": "接続済み",
|
||||||
|
"disconnected": "切断済み",
|
||||||
|
"hide-duration-left": "残りの再生時間を隠す",
|
||||||
|
"hide-github-button": "GitHubリンクボタンを隠す",
|
||||||
|
"play-on-youtube-music": "YouTube Musicで再生",
|
||||||
|
"set-inactivity-timeout": "タイムアウト時間を設定"
|
||||||
|
},
|
||||||
|
"name": "Discordアクティビティステータス",
|
||||||
|
"prompt": {
|
||||||
|
"set-inactivity-timeout": {
|
||||||
|
"label": "非アクティブ時のタイムアウトを秒単位で入力:",
|
||||||
|
"title": "非アクティブタイムアウト"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"message": "ダウンロード失敗!ごめんね…",
|
||||||
|
"title": "ダウンロード中にエラーが発生しました!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "({{playlistSize}}曲)",
|
||||||
|
"message": "プレイリスト {{playlistTitle}} をダウンロード中",
|
||||||
|
"title": "ダウンロード開始"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"conversion-progress": "変換:{{percent}}%",
|
||||||
|
"converting": "変換中…",
|
||||||
|
"done": "完了:{{filePath}}",
|
||||||
|
"download-info": "{{artist}}ー{{title}} {{videoId}} をダウンロード中",
|
||||||
|
"download-progress": "ダウンロード:{{percent}}%",
|
||||||
|
"downloading": "ダウンロード中…",
|
||||||
|
"downloading-counter": "ダウンロード中:{{current}}/{{total}}…",
|
||||||
|
"downloading-playlist": "プレイリストをダウンロード中:\"{{playlistTitle}}\" -{{playlistSize}}曲({{playlistId}})",
|
||||||
|
"error-while-downloading": "\"{{author}}ー{{title}}\"ダウンロード中にエラー発生:{{error}}",
|
||||||
|
"folder-already-exists": "フォルダー {{playlistFolder}}が既に存在します",
|
||||||
|
"getting-playlist-info": "プレイリスト情報を取得中…",
|
||||||
|
"loading": "ロード中…",
|
||||||
|
"playlist-has-only-one-song": "プレイリストに1曲しかありません。直接ダウンロードします",
|
||||||
|
"playlist-id-not-found": "プレイリストIDが見つかりません",
|
||||||
|
"playlist-is-empty": "プレイリストは空です",
|
||||||
|
"playlist-is-mix-or-private": "プレイリスト情報をダウンロード中にエラーが発生しました: プレイリストが非公開ではないこと、\"Mixed for you\"ではないことを確認してください\n\n{{error}}",
|
||||||
|
"preparing-file": "ファイルを準備中…",
|
||||||
|
"saving": "保存中…",
|
||||||
|
"trying-to-get-playlist-id": "プレイリストIDを取得中:{{playlistId}}",
|
||||||
|
"video-id-not-found": "動画が見つかりません",
|
||||||
|
"writing-id3": "ID3タグ作成中…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "UIから直にMP3・ソースオーディオをダウンロードします",
|
||||||
|
"menu": {
|
||||||
|
"choose-download-folder": "ダウンロードフォルダ",
|
||||||
|
"download-playlist": "プレイリストをダウンロード",
|
||||||
|
"presets": "プリセット",
|
||||||
|
"skip-existing": "存在するファイルをスキップ"
|
||||||
|
},
|
||||||
|
"name": "ダウンローダー",
|
||||||
|
"renderer": {
|
||||||
|
"can-not-update-progress": "進捗を更新できません"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "ダウンロード"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exponential-volume": {
|
||||||
|
"description": "音量スライダを指数関数的にさせ、低い音量に設定しやすくなります。",
|
||||||
|
"name": "指数音量"
|
||||||
|
},
|
||||||
|
"in-app-menu": {
|
||||||
|
"description": "メニューバーをファンシー、ダーク、またはアルバムカラーの外観にする",
|
||||||
|
"menu": {
|
||||||
|
"hide-dom-window-controls": "DOMウィンドウコントロールを隠す"
|
||||||
|
},
|
||||||
|
"name": "アプリ内メニュー"
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"description": "Last.fmのscrobblingサポートを追加",
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"lumiastream": {
|
||||||
|
"description": "Lumia Streamのサポートを追加",
|
||||||
|
"name": "Lumia Stream [ベータ]"
|
||||||
|
},
|
||||||
|
"lyrics-genius": {
|
||||||
|
"description": "より広い範囲の曲に歌詞を付けます",
|
||||||
|
"menu": {
|
||||||
|
"romanized-lyrics": "ローマ字歌詞"
|
||||||
|
},
|
||||||
|
"name": "Genius 歌詞",
|
||||||
|
"renderer": {
|
||||||
|
"fetched-lyrics": "Geniusから歌詞取得完了"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"description": "ブラウザの戻る・進むボタンのようにUIからコントロールできるボタン",
|
||||||
|
"name": "ナビゲーション"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"description": "インターフェースからGoogleのログインボタンとリンクを削除",
|
||||||
|
"name": "No Google Login"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"description": "曲の再生開始時に通知を表示する(Windowsではインタラクティブ通知が利用可能)",
|
||||||
|
"menu": {
|
||||||
|
"interactive": "インタラクティブ通知",
|
||||||
|
"interactive-settings": {
|
||||||
|
"label": "インタラクティブ通知 設定",
|
||||||
|
"submenu": {
|
||||||
|
"hide-button-text": "ボタンのテキストを非表示",
|
||||||
|
"refresh-on-play-pause": "再生/一時停止時に更新",
|
||||||
|
"tray-controls": "トレイアイコンのクリック時に開閉"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priority": "通知の優先度",
|
||||||
|
"toast-style": "トーストのスタイル",
|
||||||
|
"unpause-notification": "再生再開時に通知を表示"
|
||||||
|
},
|
||||||
|
"name": "通知"
|
||||||
|
},
|
||||||
|
"picture-in-picture": {
|
||||||
|
"description": "アプリでピクチャ・イン・ピクチャを切り替えられるようになります",
|
||||||
|
"menu": {
|
||||||
|
"always-on-top": "常に最前面に表示",
|
||||||
|
"hotkey": {
|
||||||
|
"label": "ホットキー",
|
||||||
|
"prompt": {
|
||||||
|
"keybind-options": {
|
||||||
|
"hotkey": "ホットキー"
|
||||||
|
},
|
||||||
|
"label": "ピクチャインピクチャを切り替えるためのホットキーを選択",
|
||||||
|
"title": "ピクチャインピクチャのホットキー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save-window-position": "ウィンドウの位置を保存",
|
||||||
|
"save-window-size": "ウィンドウのサイズを保存",
|
||||||
|
"use-native-pip": "ブラウザ標準のPiPを使用"
|
||||||
|
},
|
||||||
|
"name": "ピクチャインピクチャ",
|
||||||
|
"templates": {
|
||||||
|
"button": "ピクチャインピクチャ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback-speed": {
|
||||||
|
"description": "速く聴く、遅く聴く!曲のスピードをコントロールするスライダーを追加",
|
||||||
|
"name": "再生速度",
|
||||||
|
"templates": {
|
||||||
|
"button": "速度"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"precise-volume": {
|
||||||
|
"description": "カスタムHUDとカスタマイズ可能な音量ステップで、マウスホイール/ホットキーを使って音量を正確にコントロールします",
|
||||||
|
"menu": {
|
||||||
|
"arrows-shortcuts": "ローカル矢印キー操作",
|
||||||
|
"custom-volume-steps": "カスタム音量ステップを設定",
|
||||||
|
"global-shortcuts": "グローバル ホットキー"
|
||||||
|
},
|
||||||
|
"name": "正確な音量",
|
||||||
|
"prompt": {
|
||||||
|
"global-shortcuts": {
|
||||||
|
"keybind-options": {
|
||||||
|
"decrease": "音量を下げる",
|
||||||
|
"increase": "音量を上げる"
|
||||||
|
},
|
||||||
|
"label": "グローバルキーバインドを選択:",
|
||||||
|
"title": "グローバル 音量 キーバインド"
|
||||||
|
},
|
||||||
|
"volume-steps": {
|
||||||
|
"label": "音量の増減ステップを選択",
|
||||||
|
"title": "音量ステップ"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality-changer": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"quality-changer": {
|
||||||
|
"detail": "現在の品質: {{quality}}",
|
||||||
|
"message": "ビデオ品質を選択:",
|
||||||
|
"title": "ビデオ品質を選択:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "ビデオオーバーレイのボタンを使用してビデオ品質を変更できるようにします",
|
||||||
|
"name": "ビデオ品質チェンジャー"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"description": "再生用のグローバル ホットキー (再生/一時停止/次/前) の設定 + メディア キーをオーバーライドしてメディア OSD を無効にする + Ctrl/CMD + F による検索を有効にする + メディアキーの Linux mpris サポートを有効にする + 上級ユーザー向けのカスタム ホットキー を可能にします",
|
||||||
|
"menu": {
|
||||||
|
"override-media-keys": "メディアキーを上書き",
|
||||||
|
"set-keybinds": "グローバルソングコントロールを設定する"
|
||||||
|
},
|
||||||
|
"name": "ショートカット (および MPRIS)",
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "次",
|
||||||
|
"play-pause": "再生/一時停止",
|
||||||
|
"previous": "前の"
|
||||||
|
},
|
||||||
|
"label": "曲コントロールのグローバルキーバインドを選択:",
|
||||||
|
"title": "グローバル キーバインド"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skip-silences": {
|
||||||
|
"description": "曲の無音区間を自動でスキップ",
|
||||||
|
"name": "無音区間をスキップ"
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"description": "イントロ/アウトロなどの音楽以外の部分や、曲が再生されていないミュージック ビデオの部分を自動的にスキップします",
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"taskbar-mediacontrol": {
|
||||||
|
"description": "Windowsタスクバーから再生をコントロール",
|
||||||
|
"name": "Taskbar Media Control"
|
||||||
|
},
|
||||||
|
"touchbar": {
|
||||||
|
"description": "masOSユーザー向けにTouchBarウィジェットを追加",
|
||||||
|
"name": "TouchBar"
|
||||||
|
},
|
||||||
|
"tuna-obs": {
|
||||||
|
"description": "OBSのプラグインTunaの統合",
|
||||||
|
"name": "Tuna OBS"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"description": "ビデオ/ソングモードを切り替えるボタンを追加します。オプションでビデオタブ全体を削除することもできます",
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"label": "位置",
|
||||||
|
"submenu": {
|
||||||
|
"left": "左",
|
||||||
|
"middle": "中央",
|
||||||
|
"right": "右"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force-hide": "強制的にビデオタブを削除",
|
||||||
|
"mode": {
|
||||||
|
"label": "モード",
|
||||||
|
"submenu": {
|
||||||
|
"custom": "カスタム切り替え",
|
||||||
|
"disabled": "無効",
|
||||||
|
"native": "標準の切り替え"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "動画の切り替え",
|
||||||
|
"templates": {
|
||||||
|
"button": "曲"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualizer": {
|
||||||
|
"description": "視覚効果(ビジュアライザー)をプレイヤーに追加します",
|
||||||
|
"menu": {
|
||||||
|
"visualizer-type": "ビジュアライザーの種類"
|
||||||
|
},
|
||||||
|
"name": "視覚効果"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
594
src/i18n/resources/ko.json
Normal file
594
src/i18n/resources/ko.json
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"console": {
|
||||||
|
"plugins": {
|
||||||
|
"execute-failed": "확장 {{pluginName}}::{{contextName}}을(를) 실행하지 못했습니다",
|
||||||
|
"executed-at-ms": "확장 {{pluginName}}::{{contextName}}이 {{ms}}ms 만에 실행됨",
|
||||||
|
"initialize-failed": "확장 \"{{pluginName}}\"을(를) 초기화하지 못했습니다",
|
||||||
|
"load-all": "모든 확장 로드 중",
|
||||||
|
"load-failed": "확장 \"{{pluginName}}\"을(를) 로드하지 못했습니다",
|
||||||
|
"loaded": "확장 \"{{pluginName}}\" 로드됨",
|
||||||
|
"unload-failed": "확장 \"{{pluginName}}\"을(를) 언로드하지 못했습니다",
|
||||||
|
"unloaded": "확장 \"{{pluginName}}\" 언로드 됨"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"code": "ko",
|
||||||
|
"local-name": "한국어",
|
||||||
|
"name": "Korean"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"did-finish-load": {
|
||||||
|
"dev-tools": "로드가 완료되었습니다. 개발자 도구가 열렸습니다"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"loaded": "국제화 로드됨"
|
||||||
|
},
|
||||||
|
"second-instance": {
|
||||||
|
"receive-command": "프로토콜을 통해 명령을 받았습니다: \"{{command}}\""
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "CSS 파일 \"{{cssFile}}\"이(가) 존재하지 않습니다. 무시합니다"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"details": "응답 없음 오류!\n{{error}}"
|
||||||
|
},
|
||||||
|
"when-ready": {
|
||||||
|
"clearing-cache-after-20s": "앱 캐시 지우기"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"tried-to-render-offscreen": "창이 오프스크린 렌더링을 시도했습니다. windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"hide-menu-enabled": {
|
||||||
|
"detail": "'Alt' 키를 눌러 숨겨진 메뉴를 표시할 수 있습니다 (인앱 메뉴를 사용하는 경우 'Esc' 키를 사용)",
|
||||||
|
"message": "메뉴 숨기기가 활성화되어 있습니다",
|
||||||
|
"title": "메뉴 숨기기 활성화됨"
|
||||||
|
},
|
||||||
|
"need-to-restart": {
|
||||||
|
"buttons": {
|
||||||
|
"later": "나중에 하기",
|
||||||
|
"restart-now": "지금 재시작하기"
|
||||||
|
},
|
||||||
|
"detail": "\"{{pluginName}}\" 확장을 적용하려면 재시작해야 합니다",
|
||||||
|
"message": "\"{{pluginName}}\"은(는) 재시작이 필요합니다",
|
||||||
|
"title": "재시작 필요"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"buttons": {
|
||||||
|
"quit": "종료",
|
||||||
|
"relaunch": "재시작",
|
||||||
|
"wait": "기다리기"
|
||||||
|
},
|
||||||
|
"detail": "불편을 드려 죄송합니다! 방법을 선택해 주세요:",
|
||||||
|
"message": "애플리케이션이 응답하지 않습니다",
|
||||||
|
"title": "창이 응답하지 않음"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"disable": "업데이트 비활성화",
|
||||||
|
"download": "다운로드",
|
||||||
|
"ok": "확인"
|
||||||
|
},
|
||||||
|
"detail": "새 버전이 출시되었습니다. {{downloadLink}}에서 다운로드할 수 있습니다",
|
||||||
|
"message": "새 버전을 사용할 수 있습니다",
|
||||||
|
"title": "업데이트 사용 가능"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"about": "정보",
|
||||||
|
"navigation": {
|
||||||
|
"label": "탐색",
|
||||||
|
"submenu": {
|
||||||
|
"copy-current-url": "현재 URL 복사",
|
||||||
|
"go-back": "뒤로 가기",
|
||||||
|
"go-forward": "앞으로 가기",
|
||||||
|
"quit": "종료",
|
||||||
|
"restart": "앱 재시작"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "설정",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"label": "고급 설정",
|
||||||
|
"submenu": {
|
||||||
|
"auto-reset-app-cache": "앱 시작 시 앱 캐시 초기화",
|
||||||
|
"disable-hardware-acceleration": "하드웨어 가속 비활성화",
|
||||||
|
"edit-config-json": "config.json 편집",
|
||||||
|
"override-user-agent": "User-Agent 재정의",
|
||||||
|
"restart-on-config-changes": "설정 변경 시 재시작",
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "프록시 설정",
|
||||||
|
"prompt": {
|
||||||
|
"label": "프록시 주소를 입력하세요: (비워두면 비활성화됨)",
|
||||||
|
"placeholder": "예제: socks5://127.0.0.1:9999",
|
||||||
|
"title": "프록시 설정"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toggle-dev-tools": "DevTools 열기"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"always-on-top": "항상 최상단에 표시",
|
||||||
|
"auto-update": "자동 업데이트",
|
||||||
|
"hide-menu": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "다음 실행 시 메뉴가 숨겨집니다. 표시하려면 [Alt] 키를 사용하세요 (인앱 메뉴를 사용하는 경우 백틱 [`] 키를 사용하세요)",
|
||||||
|
"title": "메뉴 숨기기 활성화됨"
|
||||||
|
},
|
||||||
|
"label": "메뉴 숨기기"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "재시작 후 언어가 변경됩니다",
|
||||||
|
"title": "언어 변경됨"
|
||||||
|
},
|
||||||
|
"label": "언어",
|
||||||
|
"submenu": {
|
||||||
|
"to-help-translate": "번역을 돕고 싶으신가요? 여기를 누르세요"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resume-on-start": "앱 시작 시 마지막 곡 다시 듣기",
|
||||||
|
"single-instance-lock": "단일 인스턴스 잠금",
|
||||||
|
"start-at-login": "로그온 시 자동 실행",
|
||||||
|
"starting-page": {
|
||||||
|
"label": "시작 페이지",
|
||||||
|
"unset": "지정 안 됨"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"label": "트레이",
|
||||||
|
"submenu": {
|
||||||
|
"disabled": "비활성화",
|
||||||
|
"enabled-and-hide-app": "활성화 및 앱 숨기기",
|
||||||
|
"enabled-and-show-app": "활성화 및 앱 표시",
|
||||||
|
"play-pause-on-click": "클릭 시 재생/일시 정지"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"label": "시각적 변경",
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "기본",
|
||||||
|
"force-show": "강제로 표시",
|
||||||
|
"hide": "숨기기",
|
||||||
|
"label": "좋아요 버튼"
|
||||||
|
},
|
||||||
|
"remove-upgrade-button": "업그레이드 버튼 제거",
|
||||||
|
"theme": {
|
||||||
|
"label": "테마",
|
||||||
|
"submenu": {
|
||||||
|
"import-css-file": "사용자 정의 CSS 파일 가져오기",
|
||||||
|
"no-theme": "테마 없음"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"enabled": "활성화",
|
||||||
|
"label": "확장"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "보기",
|
||||||
|
"submenu": {
|
||||||
|
"force-reload": "강제 새로고침",
|
||||||
|
"reload": "새로고침",
|
||||||
|
"reset-zoom": "원래 크기",
|
||||||
|
"toggle-fullscreen": "전체 화면 전환",
|
||||||
|
"zoom-in": "확대",
|
||||||
|
"zoom-out": "축소"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"next": "다음",
|
||||||
|
"play-pause": "재생/일시정지",
|
||||||
|
"previous": "이전",
|
||||||
|
"quit": "종료",
|
||||||
|
"restart": "앱 재시작",
|
||||||
|
"show": "창 표시"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "모든 광고와 트래커를 즉시 차단합니다",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "애드블록 타입"
|
||||||
|
},
|
||||||
|
"name": "애드블록"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "앨범 색상 팔레트를 기반으로 동적 테마 및 시각 효과를 적용합니다",
|
||||||
|
"name": "앨범 컬러 기반 테마"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "영상의 간접 조명을 화면 배경에 투사합니다.",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"label": "흐림 효과 강도",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} 픽셀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "버퍼링",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "불투명도",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "품질",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} 픽셀"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "크기",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"label": "부드러운 전환",
|
||||||
|
"submenu": {
|
||||||
|
"during": "{{interpolationTime}}초 동안 전환"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use-fullscreen": {
|
||||||
|
"label": "전체 화면 모드 사용"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "앰비언트 모드"
|
||||||
|
},
|
||||||
|
"audio-compressor": {
|
||||||
|
"description": "오디오에 컴프레서를 적용합니다 (신호에서 가장 시끄러운 부분의 음량을 낮추고 가장 조용한 부분의 음량을 높임)",
|
||||||
|
"name": "오디오 컴프레서"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "탐색 바를 투명하고 흐릿하게 만듭니다",
|
||||||
|
"name": "탐색 바 흐림 효과"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "유튜브의 나이 제한을 우회합니다",
|
||||||
|
"name": "나이 제한 우회"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"description": "YouTube Music 트랙용 자막 선택기입니다",
|
||||||
|
"menu": {
|
||||||
|
"autoload": "마지막으로 사용한 자막을 자동으로 선택",
|
||||||
|
"disable-captions": "기본 자막 제거"
|
||||||
|
},
|
||||||
|
"name": "자막 선택기",
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"label": "현재 선택된 언어: {{language}}",
|
||||||
|
"none": "없음",
|
||||||
|
"title": "자막 언어 선택"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "자막 선택기 열기"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compact-sidebar": {
|
||||||
|
"description": "사이드바를 항상 컴팩트 모드로 설정합니다",
|
||||||
|
"name": "컴팩트 사이드바"
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"description": "노래 사이에 크로스페이드 효과를 적용합니다",
|
||||||
|
"menu": {
|
||||||
|
"advanced": "고급 설정"
|
||||||
|
},
|
||||||
|
"name": "크로스페이드 [베타]",
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-in-duration": "페이드인 지속 시간 (밀리초)",
|
||||||
|
"fade-out-duration": "페이드아웃 지속 시간 (밀리초)",
|
||||||
|
"fade-scaling": {
|
||||||
|
"label": "페이드 스케일링",
|
||||||
|
"linear": "선형",
|
||||||
|
"logarithmic": "로그스케일"
|
||||||
|
},
|
||||||
|
"seconds-before-end": "종료되기 N초 전에 크로스페이드 적용"
|
||||||
|
},
|
||||||
|
"title": "크로스페이드 설정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"description": "노래를 '일시 정지' 모드로 시작하게 합니다",
|
||||||
|
"menu": {
|
||||||
|
"apply-once": "첫 시작 시에만 적용"
|
||||||
|
},
|
||||||
|
"name": "자동 재생 해제"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"backend": {
|
||||||
|
"already-connected": "활성화 된 연결에 연결을 시도했습니다",
|
||||||
|
"connected": "디스코드에 연결됨",
|
||||||
|
"disconnected": "디스코드에서 연결이 끊김"
|
||||||
|
},
|
||||||
|
"description": "활동 상태를 사용하여 친구들에게 내가 듣는 음악을 보여주세요",
|
||||||
|
"menu": {
|
||||||
|
"auto-reconnect": "자동 연결",
|
||||||
|
"clear-activity": "활동 제거",
|
||||||
|
"clear-activity-after-timeout": "시간 초과 시 활동 제거",
|
||||||
|
"connected": "연결 됨",
|
||||||
|
"disconnected": "연결 해제 됨",
|
||||||
|
"hide-duration-left": "남은 재생 시간 숨기기",
|
||||||
|
"hide-github-button": "GitHub 링크 버튼 숨기기",
|
||||||
|
"play-on-youtube-music": "유튜브 뮤직에서 재생",
|
||||||
|
"set-inactivity-timeout": "비활성 시간 제한 설정"
|
||||||
|
},
|
||||||
|
"name": "디스코드 활동 상태",
|
||||||
|
"prompt": {
|
||||||
|
"set-inactivity-timeout": {
|
||||||
|
"label": "비활성 시간 제한을 초 단위로 입력하세요:",
|
||||||
|
"title": "비활성 시간 제한 설정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "확인"
|
||||||
|
},
|
||||||
|
"message": "죄송합니다. 다운로드가 실패했습니다…",
|
||||||
|
"title": "다운로드 중 오류 발생!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "확인"
|
||||||
|
},
|
||||||
|
"detail": "({{playlistSize}} 곡)",
|
||||||
|
"message": "재생목록 {{playlistTitle}} 다운로드 중",
|
||||||
|
"title": "다운로드 시작됨"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"conversion-progress": "변환: {{percent}}%",
|
||||||
|
"converting": "변환 중…",
|
||||||
|
"done": "완료: {{filePath}}",
|
||||||
|
"download-info": "{{artist}} - {{title}} [{{videoId}} 다운로드 중",
|
||||||
|
"download-progress": "다운로드: {{percent}}%",
|
||||||
|
"downloading": "다운로드 중…",
|
||||||
|
"downloading-counter": "다운로드 중 {{current}}/{{total}}…",
|
||||||
|
"downloading-playlist": "재생목록 다운로드 중: \"{{playlistTitle}}\" - {{playlistSize}} 곡 ({{playlistId}})",
|
||||||
|
"error-while-downloading": "\"{{author}} - {{title}}\" 다운로드 중 오류 발생: {{error}}",
|
||||||
|
"folder-already-exists": "{{playlistFolder}} 폴더가 이미 존재합니다",
|
||||||
|
"getting-playlist-info": "재생목록 정보를 가져오는 중…",
|
||||||
|
"loading": "로딩 중…",
|
||||||
|
"playlist-has-only-one-song": "재생목록에 한 항목만 존재합니다. 직접 다운로드합니다",
|
||||||
|
"playlist-id-not-found": "재생목록 ID를 찾을 수 없습니다",
|
||||||
|
"playlist-is-empty": "재생목록이 비어있습니다",
|
||||||
|
"playlist-is-mix-or-private": "재생목록 정보 가져오는 중 오류 발생: 비공개 재생목록 또는 '유튜브 Mix' 재생목록이 아닌지 확인하세요\n\n{{error}}",
|
||||||
|
"preparing-file": "파일 준비 중…",
|
||||||
|
"saving": "저장 중…",
|
||||||
|
"trying-to-get-playlist-id": "재생목록 ID를 가져오는 중: {{playlistId}}",
|
||||||
|
"video-id-not-found": "영상을 찾을 수 없습니다",
|
||||||
|
"writing-id3": "ID3 태그 작성 중…"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "UI에서 직접 MP3/소스 오디오를 다운로드하세요",
|
||||||
|
"menu": {
|
||||||
|
"choose-download-folder": "다운로드 폴더 선택",
|
||||||
|
"download-playlist": "재생목록 다운로드",
|
||||||
|
"presets": "프리셋",
|
||||||
|
"skip-existing": "이미 존재하는 파일 넘기기"
|
||||||
|
},
|
||||||
|
"name": "다운로더",
|
||||||
|
"renderer": {
|
||||||
|
"can-not-update-progress": "진행 상황을 업데이트 할 수 없음"
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "다운로드"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"exponential-volume": {
|
||||||
|
"description": "음량 슬라이더를 지수적으로 만들어 더 낮은 음량을 쉽게 선택할 수 있도록 합니다.",
|
||||||
|
"name": "지수 음량"
|
||||||
|
},
|
||||||
|
"in-app-menu": {
|
||||||
|
"description": "메뉴 표시줄을 더 멋지게, 그리고 다크 또는 앨범의 색상으로 만듭니다",
|
||||||
|
"menu": {
|
||||||
|
"hide-dom-window-controls": "DOM 윈도우 컨트롤 숨기기"
|
||||||
|
},
|
||||||
|
"name": "인앱 메뉴"
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"description": "Last.fm에 대한 스크러블 지원을 추가합니다",
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"lumiastream": {
|
||||||
|
"description": "Lumia Stream 지원을 추가합니다",
|
||||||
|
"name": "Lumia Stream [베타]"
|
||||||
|
},
|
||||||
|
"lyrics-genius": {
|
||||||
|
"description": "더 많은 곡에 대해 가사 지원을 추가합니다",
|
||||||
|
"menu": {
|
||||||
|
"romanized-lyrics": "가사 로마자화"
|
||||||
|
},
|
||||||
|
"name": "Genius 가사",
|
||||||
|
"renderer": {
|
||||||
|
"fetched-lyrics": "Genius에서 가사 불러옴"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"description": "브라우저에서처럼, UI에 직접 통합된 앞으로/뒤로 탐색하는 화살표",
|
||||||
|
"name": "탐색"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"description": "UI에서 Google 로그인 버튼 및 링크 제거하기",
|
||||||
|
"name": "Google 로그인 제거"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"description": "노래 재생이 시작되면 알림을 표시 (Windows에서는 대화형 알림 사용 가능)",
|
||||||
|
"menu": {
|
||||||
|
"interactive": "대화형 알림",
|
||||||
|
"interactive-settings": {
|
||||||
|
"label": "대화형 알림 설정",
|
||||||
|
"submenu": {
|
||||||
|
"hide-button-text": "버튼 텍스트 숨기기",
|
||||||
|
"refresh-on-play-pause": "재생/일시정지 시 새로고침",
|
||||||
|
"tray-controls": "트레이 클릭 시 열기/닫기"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"priority": "알림 우선순위",
|
||||||
|
"toast-style": "토스트 스타일",
|
||||||
|
"unpause-notification": "일시정지 시 알림 표시"
|
||||||
|
},
|
||||||
|
"name": "알림"
|
||||||
|
},
|
||||||
|
"picture-in-picture": {
|
||||||
|
"description": "앱을 PiP 모드로 전환할 수 있게 허용합니다",
|
||||||
|
"menu": {
|
||||||
|
"always-on-top": "항상 맨 위에 표시",
|
||||||
|
"hotkey": {
|
||||||
|
"label": "단축키",
|
||||||
|
"prompt": {
|
||||||
|
"keybind-options": {
|
||||||
|
"hotkey": "단축키"
|
||||||
|
},
|
||||||
|
"label": "PiP를 전환하기 위한 단축키를 선택하세요",
|
||||||
|
"title": "PiP 단축키"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"save-window-position": "창 위치 저장",
|
||||||
|
"save-window-size": "창 크기 저장",
|
||||||
|
"use-native-pip": "브라우저 내장 PiP 사용"
|
||||||
|
},
|
||||||
|
"name": "PiP",
|
||||||
|
"templates": {
|
||||||
|
"button": "PiP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback-speed": {
|
||||||
|
"description": "빨리 듣거나, 천천히 들어보세요! 노래 속도를 제어하는 슬라이더를 추가합니다",
|
||||||
|
"name": "재생 속도",
|
||||||
|
"templates": {
|
||||||
|
"button": "배속"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"precise-volume": {
|
||||||
|
"description": "사용자 지정 HUD와 사용자 지정 음량 단계로 마우스 휠/단축키를 사용하여 음량을 정확하게 제어하세요",
|
||||||
|
"menu": {
|
||||||
|
"arrows-shortcuts": "로컬 화살표 키 컨트롤",
|
||||||
|
"custom-volume-steps": "사용자 지정 음량 단계 설정",
|
||||||
|
"global-shortcuts": "전역 단축키"
|
||||||
|
},
|
||||||
|
"name": "정확한 음량",
|
||||||
|
"prompt": {
|
||||||
|
"global-shortcuts": {
|
||||||
|
"keybind-options": {
|
||||||
|
"decrease": "음량 감소",
|
||||||
|
"increase": "음량 증가"
|
||||||
|
},
|
||||||
|
"label": "전역 음량 키를 지정하세요:",
|
||||||
|
"title": "전역 음량 키 지정"
|
||||||
|
},
|
||||||
|
"volume-steps": {
|
||||||
|
"label": "음량 증가/감소 단계를 선택하세요",
|
||||||
|
"title": "음량 단계"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality-changer": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"quality-changer": {
|
||||||
|
"detail": "현재 품질: {{quality}}",
|
||||||
|
"message": "영상 품질 선택:",
|
||||||
|
"title": "영상 품질 선택"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "영상 오버레이의 버튼으로 영상 품질을 변경할 수 있습니다",
|
||||||
|
"name": "영상 품질 체인저"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"description": "재생을 위한 전역 단축키 설정 허용 (재생/일시 정지/다음/이전) + 미디어 키를 재정의하여 미디어 OSD 비활성화 + Ctrl/CMD + F 검색 활성화 + 미디어 키에 대한 리눅스 MPRIS 지원 활성화 + 고급 사용자를 위한 사용자 지정 단축키 지원",
|
||||||
|
"menu": {
|
||||||
|
"override-media-keys": "미디어 키 재정의",
|
||||||
|
"set-keybinds": "전역 노래 제어 설정"
|
||||||
|
},
|
||||||
|
"name": "단축키 (& MPRIS)",
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "다음",
|
||||||
|
"play-pause": "재생 / 일시정지",
|
||||||
|
"previous": "이전"
|
||||||
|
},
|
||||||
|
"label": "노래 조작을 위한 전역 키를 선택하세요:",
|
||||||
|
"title": "전역 키 지정"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skip-silences": {
|
||||||
|
"description": "노래의 무음 부분을 자동으로 건너뜁니다",
|
||||||
|
"name": "무음 건너뛰기"
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"description": "인트로/아웃트로와 같은 음악이 아닌 부분이나, 노래가 재생되지 않는 뮤직 비디오의 일부를 자동으로 건너뜁니다",
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"taskbar-mediacontrol": {
|
||||||
|
"description": "Windows 작업 표시줄에서 재생을 제어하세요",
|
||||||
|
"name": "작업표시줄 미디어 컨트롤"
|
||||||
|
},
|
||||||
|
"touchbar": {
|
||||||
|
"description": "macOS 사용자를 위한 TouchBar 위젯을 추가합니다",
|
||||||
|
"name": "TouchBar"
|
||||||
|
},
|
||||||
|
"tuna-obs": {
|
||||||
|
"description": "OBS의 확장인 Tuna와의 통합을 활성화합니다",
|
||||||
|
"name": "Tuna OBS"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"description": "영상/노래 모드를 전환하는 버튼을 추가합니다. 선택적으로 전체 영상 탭을 제거할 수도 있습니다",
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"label": "정렬",
|
||||||
|
"submenu": {
|
||||||
|
"left": "왼쪽",
|
||||||
|
"middle": "가운데",
|
||||||
|
"right": "오른쪽"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"force-hide": "영상 탭 강제 제거",
|
||||||
|
"mode": {
|
||||||
|
"label": "모드",
|
||||||
|
"submenu": {
|
||||||
|
"custom": "사용자 지정 전환",
|
||||||
|
"disabled": "비활성화",
|
||||||
|
"native": "기본 토글"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "영상 전환",
|
||||||
|
"templates": {
|
||||||
|
"button": "노래"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visualizer": {
|
||||||
|
"description": "플레이어에 시각화 도구 추가",
|
||||||
|
"menu": {
|
||||||
|
"visualizer-type": "비주얼라이저 타입"
|
||||||
|
},
|
||||||
|
"name": "비주얼라이저"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/i18n/resources/nb.json
Normal file
205
src/i18n/resources/nb.json
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"code": "nb_NO",
|
||||||
|
"local-name": "Norsk bokmål",
|
||||||
|
"name": "Norwegian Bokmål"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"menu": {
|
||||||
|
"options": {
|
||||||
|
"label": "Alternativer",
|
||||||
|
"submenu": {
|
||||||
|
"hide-menu": {
|
||||||
|
"label": "Skjul meny"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"label": "Systemkurv",
|
||||||
|
"submenu": {
|
||||||
|
"disabled": "Avskrudd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"submenu": {
|
||||||
|
"theme": {
|
||||||
|
"label": "Drakt",
|
||||||
|
"submenu": {
|
||||||
|
"import-css-file": "Importer egendefinert CSS-fil",
|
||||||
|
"no-theme": "Ingen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"label": "Programtillegg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "Stenger ute reklame og sporing",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "Blokkering"
|
||||||
|
},
|
||||||
|
"name": "Reklameblokkering"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "Ifører dynamisk drakt og visuelle effekter basert på albumsfargepaletten",
|
||||||
|
"name": "Albumsfargedrakt"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"label": "Tilsløringsmengde",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} piksler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "Dekkevne",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "Kvalitet",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} piksler"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "Størrelse",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"label": "Nåværende tekstingsspråk: {{language}}",
|
||||||
|
"none": "Ingen",
|
||||||
|
"title": "Velg tekstingsspråk"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"title": "Åpne undertekstvelger"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"menu": {
|
||||||
|
"advanced": "Avansert"
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-scaling": {
|
||||||
|
"linear": "Lineær",
|
||||||
|
"logarithmic": "Logaritmisk"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"disable-autoplay": {
|
||||||
|
"name": "Skru av autospilling"
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"backend": {
|
||||||
|
"disconnected": "Frakoblet fra Discord"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"title": "Nedlasting startet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"conversion-progress": "Konvertering: {{percent}}%",
|
||||||
|
"converting": "Konverterer …",
|
||||||
|
"done": "Ferdig: {{filePath}}",
|
||||||
|
"downloading": "Laster ned …",
|
||||||
|
"loading": "Laster inn …",
|
||||||
|
"playlist-is-empty": "Tom spilleliste",
|
||||||
|
"preparing-file": "Forbereder fil …",
|
||||||
|
"saving": "Lagrer …"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"choose-download-folder": "Velg nedlastningsmappe",
|
||||||
|
"presets": "Forhåndsinnstillinger",
|
||||||
|
"skip-existing": "Hopp over eksisterende filer"
|
||||||
|
},
|
||||||
|
"name": "Nedlaster",
|
||||||
|
"templates": {
|
||||||
|
"button": "Last ned"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"name": "Merknader"
|
||||||
|
},
|
||||||
|
"picture-in-picture": {
|
||||||
|
"menu": {
|
||||||
|
"save-window-position": "Lagre vindusposisjon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playback-speed": {
|
||||||
|
"name": "Avspillingshastighet",
|
||||||
|
"templates": {
|
||||||
|
"button": "Hastighet"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"precise-volume": {
|
||||||
|
"name": "Presis lydstyrkejustering"
|
||||||
|
},
|
||||||
|
"quality-changer": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"quality-changer": {
|
||||||
|
"detail": "Nåværende kvalitet: {{quality}}",
|
||||||
|
"message": "Velg videokvalitet:",
|
||||||
|
"title": "Velg videokvalitet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"skip-silences": {
|
||||||
|
"name": "Hopp over pauser"
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"submenu": {
|
||||||
|
"left": "Venstre",
|
||||||
|
"middle": "Midten",
|
||||||
|
"right": "Høyre"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"label": "Modus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Spor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/i18n/resources/ru.json
Normal file
220
src/i18n/resources/ru.json
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
{
|
||||||
|
"language": {
|
||||||
|
"code": "ru",
|
||||||
|
"local-name": "Русский",
|
||||||
|
"name": "Russian"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"dialog": {
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"download": "Download",
|
||||||
|
"ok": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"navigation": {
|
||||||
|
"label": "Navigation"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "Options",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"submenu": {
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "Set proxy",
|
||||||
|
"prompt": {
|
||||||
|
"title": "Set proxy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auto-update": "Auto Update",
|
||||||
|
"start-at-login": "Start at login",
|
||||||
|
"tray": {
|
||||||
|
"label": "Tray"
|
||||||
|
},
|
||||||
|
"visual-tweaks": {
|
||||||
|
"submenu": {
|
||||||
|
"like-buttons": {
|
||||||
|
"default": "Default"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"label": "Theme",
|
||||||
|
"submenu": {
|
||||||
|
"no-theme": "No theme"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"label": "Plugins"
|
||||||
|
},
|
||||||
|
"view": {
|
||||||
|
"label": "View"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"adblocker": {
|
||||||
|
"description": "Блокируйте всю рекламу и трекинг сразу после установки",
|
||||||
|
"menu": {
|
||||||
|
"blocker": "Блокировщик"
|
||||||
|
},
|
||||||
|
"name": "Блокировщик рекламы"
|
||||||
|
},
|
||||||
|
"album-color-theme": {
|
||||||
|
"description": "Применяет динамическую тему и визуальные эффекты на основе цветовой палитры альбома",
|
||||||
|
"name": "Цветовая тема альбома"
|
||||||
|
},
|
||||||
|
"ambient-mode": {
|
||||||
|
"description": "Применяет световой эффект, отбрасывая мягкие цвета из видео на задний фон вашего экрана.",
|
||||||
|
"menu": {
|
||||||
|
"blur-amount": {
|
||||||
|
"label": "Степень размытия",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{blurAmount}} пикселей"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buffer": {
|
||||||
|
"label": "Буфер",
|
||||||
|
"submenu": {
|
||||||
|
"buffer": "{{buffer}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opacity": {
|
||||||
|
"label": "Прозрачность",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{opacity}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"label": "Качество",
|
||||||
|
"submenu": {
|
||||||
|
"pixels": "{{quality}} пикселей"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"label": "Размер",
|
||||||
|
"submenu": {
|
||||||
|
"percent": "{{size}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"smoothness-transition": {
|
||||||
|
"label": "Плавный переход",
|
||||||
|
"submenu": {
|
||||||
|
"during": "В течение {{interpolationTime}}s"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"use-fullscreen": {
|
||||||
|
"label": "Использовать полноэкранный режим"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Режим Ambient"
|
||||||
|
},
|
||||||
|
"audio-compressor": {
|
||||||
|
"description": "Применяет компрессию к аудио (уменьшает громкость самых громких частей сигнала и повышает громкость самых тихих частей)",
|
||||||
|
"name": "Аудио компрессор"
|
||||||
|
},
|
||||||
|
"blur-nav-bar": {
|
||||||
|
"description": "Делает панель навигации прозрачной и размытой",
|
||||||
|
"name": "Размытие панели навигации"
|
||||||
|
},
|
||||||
|
"bypass-age-restrictions": {
|
||||||
|
"description": "Обход проверки возраста на YouTube",
|
||||||
|
"name": "Обход возрастных ограничений"
|
||||||
|
},
|
||||||
|
"captions-selector": {
|
||||||
|
"description": "Выбор субтитров для аудиотреков в YouTube Music",
|
||||||
|
"name": "Выбор субтитров",
|
||||||
|
"prompt": {
|
||||||
|
"selector": {
|
||||||
|
"none": "None"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crossfade": {
|
||||||
|
"prompt": {
|
||||||
|
"options": {
|
||||||
|
"multi-input": {
|
||||||
|
"fade-scaling": {
|
||||||
|
"linear": "Linear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"downloader": {
|
||||||
|
"backend": {
|
||||||
|
"dialog": {
|
||||||
|
"error": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"title": "Error in download!"
|
||||||
|
},
|
||||||
|
"start-download-playlist": {
|
||||||
|
"buttons": {
|
||||||
|
"ok": "OK"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"download-progress": "Download: {{percent}}%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Download"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last-fm": {
|
||||||
|
"name": "Last.fm"
|
||||||
|
},
|
||||||
|
"navigation": {
|
||||||
|
"name": "Navigation"
|
||||||
|
},
|
||||||
|
"no-google-login": {
|
||||||
|
"name": "No Google Login"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"name": "Notifications"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"prompt": {
|
||||||
|
"keybind": {
|
||||||
|
"keybind-options": {
|
||||||
|
"next": "Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sponsorblock": {
|
||||||
|
"name": "SponsorBlock"
|
||||||
|
},
|
||||||
|
"touchbar": {
|
||||||
|
"name": "TouchBar"
|
||||||
|
},
|
||||||
|
"tuna-obs": {
|
||||||
|
"name": "Tuna OBS"
|
||||||
|
},
|
||||||
|
"video-toggle": {
|
||||||
|
"menu": {
|
||||||
|
"align": {
|
||||||
|
"submenu": {
|
||||||
|
"middle": "Middle",
|
||||||
|
"right": "Right"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"label": "Mode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"button": "Song"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/i18n/resources/zh-TW.json
Normal file
145
src/i18n/resources/zh-TW.json
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"console": {
|
||||||
|
"plugins": {
|
||||||
|
"execute-failed": "插件 {{pluginName}} 無法被執行::{{contextName}}",
|
||||||
|
"executed-at-ms": "插件 {{pluginName}} ::{{contextName}} 用了 {{ms}} ms 來執行",
|
||||||
|
"initialize-failed": "初始化插件 \"{{pluginName}}\" 失敗",
|
||||||
|
"load-all": "載入所有插件",
|
||||||
|
"load-failed": "載入插件 \"{{pluginName}}\" 失敗",
|
||||||
|
"loaded": "插件 \"{{pluginName}}\" 已被載入",
|
||||||
|
"unload-failed": "解除安裝插件 \"{{pluginName}}\" 失敗",
|
||||||
|
"unloaded": "插件 \"{{pluginName}}\" 已被解除安裝"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"code": "zh-TW",
|
||||||
|
"local-name": "正體字",
|
||||||
|
"name": "Traditional Chinese"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"console": {
|
||||||
|
"did-finish-load": {
|
||||||
|
"dev-tools": "載入完成。開發者工具已開啟"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"loaded": "i18n 已載入"
|
||||||
|
},
|
||||||
|
"second-instance": {
|
||||||
|
"receive-command": "使用協定來接收指令: \"{{command}}\""
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"css-file-not-found": "CSS 檔案 \"{{cssFile}}\" 不存在,已忽略"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"details": "無回應錯誤!\n{{error}}"
|
||||||
|
},
|
||||||
|
"when-ready": {
|
||||||
|
"clearing-cache-after-20s": "清理程式的快取資料"
|
||||||
|
},
|
||||||
|
"window": {
|
||||||
|
"tried-to-render-offscreen": "視窗正嘗試在螢幕外渲染,視窗大小 = {{windowSize}},螢幕大小 = {{displaySize}},位置 = {{position}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"hide-menu-enabled": {
|
||||||
|
"detail": "選單已隱藏,使用 'Alt' 鍵來重新顯示(或是使用空白鍵來使用程式內選單)",
|
||||||
|
"message": "隱藏選單已經啟用",
|
||||||
|
"title": "隱藏選單已啟用"
|
||||||
|
},
|
||||||
|
"need-to-restart": {
|
||||||
|
"buttons": {
|
||||||
|
"later": "稍後",
|
||||||
|
"restart-now": "立即重啟"
|
||||||
|
},
|
||||||
|
"detail": "插件 \"{{pluginName}}\" 需要程式重新啟動之後才會生效",
|
||||||
|
"message": "\"{{pluginName}}\" 需要重新啟動",
|
||||||
|
"title": "需要重新啟動"
|
||||||
|
},
|
||||||
|
"unresponsive": {
|
||||||
|
"buttons": {
|
||||||
|
"quit": "離開",
|
||||||
|
"relaunch": "重新啟動",
|
||||||
|
"wait": "等一下"
|
||||||
|
},
|
||||||
|
"detail": "造成不便我們深表歉意!請選擇動作:",
|
||||||
|
"message": "應用程式無回應",
|
||||||
|
"title": "視窗無回應"
|
||||||
|
},
|
||||||
|
"update-available": {
|
||||||
|
"buttons": {
|
||||||
|
"disable": "關閉更新",
|
||||||
|
"download": "下載",
|
||||||
|
"ok": "OK"
|
||||||
|
},
|
||||||
|
"detail": "新的版本已經推出,你可以到 {{downloadLink}} 下載",
|
||||||
|
"message": "有新版本可用",
|
||||||
|
"title": "有可用的更新"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"about": "關於",
|
||||||
|
"navigation": {
|
||||||
|
"label": "導覽列",
|
||||||
|
"submenu": {
|
||||||
|
"copy-current-url": "複製目前的網址",
|
||||||
|
"go-back": "回到上一頁",
|
||||||
|
"go-forward": "回到下一頁",
|
||||||
|
"quit": "退出",
|
||||||
|
"restart": "重啟程式"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"label": "選項",
|
||||||
|
"submenu": {
|
||||||
|
"advanced-options": {
|
||||||
|
"label": "進階選項",
|
||||||
|
"submenu": {
|
||||||
|
"auto-reset-app-cache": "當程式啟動時重置應用程式快取",
|
||||||
|
"disable-hardware-acceleration": "關閉硬體加速",
|
||||||
|
"edit-config-json": "編輯 config.json",
|
||||||
|
"override-user-agent": "複寫用戶代理",
|
||||||
|
"restart-on-config-changes": "重新啟動來更改配置",
|
||||||
|
"set-proxy": {
|
||||||
|
"label": "設定代理伺服器",
|
||||||
|
"prompt": {
|
||||||
|
"label": "輸入代理伺服器位置:(留空以停用本設定)",
|
||||||
|
"placeholder": "示例: socks5://127.0.0.1:9999",
|
||||||
|
"title": "設定代理伺服器"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"toggle-dev-tools": "切換開發者工具"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"always-on-top": "永遠顯示在最上層",
|
||||||
|
"auto-update": "自動更新",
|
||||||
|
"hide-menu": {
|
||||||
|
"label": "隱藏選單"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"dialog": {
|
||||||
|
"message": "語言會在下一次重啟應用時變更",
|
||||||
|
"title": "語言已變更"
|
||||||
|
},
|
||||||
|
"label": "語言",
|
||||||
|
"submenu": {
|
||||||
|
"to-help-translate": "想要協助翻譯?點擊這裡"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resume-on-start": "繼續上次關閉應用前的音樂",
|
||||||
|
"start-at-login": "開機時啟動",
|
||||||
|
"starting-page": {
|
||||||
|
"label": "啟動頁面",
|
||||||
|
"unset": "未設置"
|
||||||
|
},
|
||||||
|
"tray": {
|
||||||
|
"submenu": {
|
||||||
|
"disabled": "已停用"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/index.html
Normal file
14
src/index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!-- WARNING:
|
||||||
|
|
||||||
|
This file only exists for the build system to work properly.
|
||||||
|
Any changes done here won't be reflected in the final build.
|
||||||
|
|
||||||
|
The actual loading of `renderer.ts` is done in `src/index.ts`
|
||||||
|
within the `createMainWindow` function.
|
||||||
|
|
||||||
|
Archived link for reference:
|
||||||
|
https://github.com/th-ch/youtube-music/blob/a3104fda4b0d58b076d0c737111636a66e468acc/src/index.ts#L407-L443
|
||||||
|
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script type="module" src="./renderer.ts"></script>
|
||||||
661
src/index.ts
661
src/index.ts
@ -1,48 +1,60 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import url from 'node:url';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
import { BrowserWindow, app, screen, globalShortcut, session, shell, dialog, ipcMain } from 'electron';
|
import {
|
||||||
import enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
|
BrowserWindow,
|
||||||
|
app,
|
||||||
|
screen,
|
||||||
|
globalShortcut,
|
||||||
|
session,
|
||||||
|
shell,
|
||||||
|
dialog,
|
||||||
|
ipcMain,
|
||||||
|
} from 'electron';
|
||||||
|
import enhanceWebRequest, {
|
||||||
|
BetterSession,
|
||||||
|
} from '@jellybrick/electron-better-web-request';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import unhandled from 'electron-unhandled';
|
import unhandled from 'electron-unhandled';
|
||||||
import { autoUpdater } from 'electron-updater';
|
import { autoUpdater } from 'electron-updater';
|
||||||
import electronDebug from 'electron-debug';
|
import electronDebug from 'electron-debug';
|
||||||
|
import { parse } from 'node-html-parser';
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { deepEqual } from 'fast-equals';
|
||||||
|
|
||||||
import config from './config';
|
import { allPlugins, mainPlugins } from 'virtual:plugins';
|
||||||
import { refreshMenu, setApplicationMenu } from './menu';
|
|
||||||
import { fileExists, injectCSS, injectCSSAsFile } from './plugins/utils';
|
|
||||||
import { isTesting } from './utils/testing';
|
|
||||||
import { setUpTray } from './tray';
|
|
||||||
import { setupSongInfo } from './providers/song-info';
|
|
||||||
import { restart, setupAppControls } from './providers/app-controls';
|
|
||||||
import { APP_PROTOCOL, handleProtocol, setupProtocolHandler } from './providers/protocol-handler';
|
|
||||||
|
|
||||||
import adblocker from './plugins/adblocker/back';
|
import { languageResources } from 'virtual:i18n';
|
||||||
import albumColorTheme from './plugins/album-color-theme/back';
|
|
||||||
import ambientMode from './plugins/ambient-mode/back';
|
|
||||||
import blurNavigationBar from './plugins/blur-nav-bar/back';
|
|
||||||
import captionsSelector from './plugins/captions-selector/back';
|
|
||||||
import crossfade from './plugins/crossfade/back';
|
|
||||||
import discord from './plugins/discord/back';
|
|
||||||
import downloader from './plugins/downloader/back';
|
|
||||||
import inAppMenu from './plugins/in-app-menu/back';
|
|
||||||
import lastFm from './plugins/last-fm/back';
|
|
||||||
import lumiaStream from './plugins/lumiastream/back';
|
|
||||||
import lyricsGenius from './plugins/lyrics-genius/back';
|
|
||||||
import navigation from './plugins/navigation/back';
|
|
||||||
import noGoogleLogin from './plugins/no-google-login/back';
|
|
||||||
import notifications from './plugins/notifications/back';
|
|
||||||
import pictureInPicture, { setOptions as pipSetOptions } from './plugins/picture-in-picture/back';
|
|
||||||
import preciseVolume from './plugins/precise-volume/back';
|
|
||||||
import qualityChanger from './plugins/quality-changer/back';
|
|
||||||
import shortcuts from './plugins/shortcuts/back';
|
|
||||||
import sponsorBlock from './plugins/sponsorblock/back';
|
|
||||||
import taskbarMediaControl from './plugins/taskbar-mediacontrol/back';
|
|
||||||
import touchbar from './plugins/touchbar/back';
|
|
||||||
import tunaObs from './plugins/tuna-obs/back';
|
|
||||||
import videoToggle from './plugins/video-toggle/back';
|
|
||||||
import visualizer from './plugins/visualizer/back';
|
|
||||||
|
|
||||||
import youtubeMusicCSS from './youtube-music.css';
|
import config from '@/config';
|
||||||
|
|
||||||
|
import { refreshMenu, setApplicationMenu } from '@/menu';
|
||||||
|
import { fileExists, injectCSS, injectCSSAsFile } from '@/plugins/utils/main';
|
||||||
|
import { isTesting } from '@/utils/testing';
|
||||||
|
import { setUpTray } from '@/tray';
|
||||||
|
import { setupSongInfo } from '@/providers/song-info';
|
||||||
|
import { restart, setupAppControls } from '@/providers/app-controls';
|
||||||
|
import {
|
||||||
|
APP_PROTOCOL,
|
||||||
|
handleProtocol,
|
||||||
|
setupProtocolHandler,
|
||||||
|
} from '@/providers/protocol-handler';
|
||||||
|
|
||||||
|
import youtubeMusicCSS from '@/youtube-music.css?inline';
|
||||||
|
|
||||||
|
import {
|
||||||
|
forceLoadMainPlugin,
|
||||||
|
forceUnloadMainPlugin,
|
||||||
|
getAllLoadedMainPlugins,
|
||||||
|
loadAllMainPlugins,
|
||||||
|
} from '@/loader/main';
|
||||||
|
|
||||||
|
import { LoggerPrefix } from '@/utils';
|
||||||
|
import { loadI18n, setLanguage, t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
|
|
||||||
// Catch errors and log them
|
// Catch errors and log them
|
||||||
unhandled({
|
unhandled({
|
||||||
@ -64,7 +76,10 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
||||||
// OverlayScrollbar: Required for overlay scrollbars
|
// OverlayScrollbar: Required for overlay scrollbars
|
||||||
app.commandLine.appendSwitch('enable-features', 'OverlayScrollbar,SharedArrayBuffer');
|
app.commandLine.appendSwitch(
|
||||||
|
'enable-features',
|
||||||
|
'OverlayScrollbar,SharedArrayBuffer',
|
||||||
|
);
|
||||||
if (config.get('options.disableHardwareAcceleration')) {
|
if (config.get('options.disableHardwareAcceleration')) {
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log('Disabling hardware acceleration');
|
console.log('Disabling hardware acceleration');
|
||||||
@ -100,49 +115,116 @@ function onClosed() {
|
|||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mainPlugins = {
|
|
||||||
'adblocker': adblocker,
|
|
||||||
'album-color-theme': albumColorTheme,
|
|
||||||
'ambient-mode': ambientMode,
|
|
||||||
'blur-nav-bar': blurNavigationBar,
|
|
||||||
'captions-selector': captionsSelector,
|
|
||||||
'crossfade': crossfade,
|
|
||||||
'discord': discord,
|
|
||||||
'downloader': downloader,
|
|
||||||
'in-app-menu': inAppMenu,
|
|
||||||
'last-fm': lastFm,
|
|
||||||
'lumiastream': lumiaStream,
|
|
||||||
'lyrics-genius': lyricsGenius,
|
|
||||||
'navigation': navigation,
|
|
||||||
'no-google-login': noGoogleLogin,
|
|
||||||
'notifications': notifications,
|
|
||||||
'picture-in-picture': pictureInPicture,
|
|
||||||
'precise-volume': preciseVolume,
|
|
||||||
'quality-changer': qualityChanger,
|
|
||||||
'shortcuts': shortcuts,
|
|
||||||
'sponsorblock': sponsorBlock,
|
|
||||||
'taskbar-mediacontrol': undefined as typeof taskbarMediaControl | undefined,
|
|
||||||
'touchbar': undefined as typeof touchbar | undefined,
|
|
||||||
'tuna-obs': tunaObs,
|
|
||||||
'video-toggle': videoToggle,
|
|
||||||
'visualizer': visualizer,
|
|
||||||
};
|
|
||||||
export const mainPluginNames = Object.keys(mainPlugins);
|
|
||||||
|
|
||||||
if (is.windows()) {
|
|
||||||
mainPlugins['taskbar-mediacontrol'] = taskbarMediaControl;
|
|
||||||
delete mainPlugins['touchbar'];
|
|
||||||
} else if (is.macOS()) {
|
|
||||||
mainPlugins['touchbar'] = touchbar;
|
|
||||||
delete mainPlugins['taskbar-mediacontrol'];
|
|
||||||
} else {
|
|
||||||
delete mainPlugins['touchbar'];
|
|
||||||
delete mainPlugins['taskbar-mediacontrol'];
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
|
ipcMain.handle('get-main-plugin-names', () => Object.keys(mainPlugins));
|
||||||
|
|
||||||
async function loadPlugins(win: BrowserWindow) {
|
const initHook = (win: BrowserWindow) => {
|
||||||
|
ipcMain.handle(
|
||||||
|
'get-config',
|
||||||
|
(_, id: string) =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
);
|
||||||
|
ipcMain.handle('set-config', (_, name: string, obj: object) =>
|
||||||
|
config.setPartial(`plugins.${name}`, obj, allPlugins[name].config),
|
||||||
|
);
|
||||||
|
|
||||||
|
config.watch((newValue, oldValue) => {
|
||||||
|
const newPluginConfigList = (newValue?.plugins ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const oldPluginConfigList = (oldValue?.plugins ?? {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
Object.entries(newPluginConfigList).forEach(([id, newPluginConfig]) => {
|
||||||
|
const isEqual = deepEqual(oldPluginConfigList[id], newPluginConfig);
|
||||||
|
|
||||||
|
if (!isEqual) {
|
||||||
|
const oldConfig = oldPluginConfigList[id] as PluginConfig;
|
||||||
|
const config = deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
newPluginConfig ?? {},
|
||||||
|
) as PluginConfig;
|
||||||
|
|
||||||
|
if (config.enabled !== oldConfig?.enabled) {
|
||||||
|
if (config.enabled) {
|
||||||
|
win.webContents.send('plugin:enable', id);
|
||||||
|
ipcMain.emit('plugin:enable', id);
|
||||||
|
forceLoadMainPlugin(id, win);
|
||||||
|
} else {
|
||||||
|
win.webContents.send('plugin:unload', id);
|
||||||
|
ipcMain.emit('plugin:unload', id);
|
||||||
|
forceUnloadMainPlugin(id, win);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPlugins[id]?.restartNeeded) {
|
||||||
|
showNeedToRestartDialog(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPlugin = getAllLoadedMainPlugins()[id];
|
||||||
|
if (mainPlugin) {
|
||||||
|
if (config.enabled && typeof mainPlugin.backend !== 'function') {
|
||||||
|
mainPlugin.backend?.onConfigChange?.call(
|
||||||
|
mainPlugin.backend,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
win.webContents.send('config-changed', id, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showNeedToRestartDialog = (id: string) => {
|
||||||
|
const plugin = allPlugins[id];
|
||||||
|
|
||||||
|
const dialogOptions: Electron.MessageBoxOptions = {
|
||||||
|
type: 'info',
|
||||||
|
buttons: [
|
||||||
|
t('main.dialog.need-to-restart.buttons.restart-now'),
|
||||||
|
t('main.dialog.need-to-restart.buttons.later'),
|
||||||
|
],
|
||||||
|
title: t('main.dialog.need-to-restart.title'),
|
||||||
|
message: t('main.dialog.need-to-restart.message', {
|
||||||
|
pluginName: plugin?.name?.() ?? id,
|
||||||
|
}),
|
||||||
|
detail: t('main.dialog.need-to-restart.detail', {
|
||||||
|
pluginName: plugin?.name?.() ?? id,
|
||||||
|
}),
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dialogPromise: Promise<Electron.MessageBoxReturnValue>;
|
||||||
|
if (mainWindow) {
|
||||||
|
dialogPromise = dialog.showMessageBox(mainWindow, dialogOptions);
|
||||||
|
} else {
|
||||||
|
dialogPromise = dialog.showMessageBox(dialogOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogPromise.then((dialogOutput) => {
|
||||||
|
switch (dialogOutput.response) {
|
||||||
|
case 0: {
|
||||||
|
restart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore
|
||||||
|
default: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function initTheme(win: BrowserWindow) {
|
||||||
injectCSS(win.webContents, youtubeMusicCSS);
|
injectCSS(win.webContents, youtubeMusicCSS);
|
||||||
// Load user CSS
|
// Load user CSS
|
||||||
const themes: string[] = config.get('options.themes');
|
const themes: string[] = config.get('options.themes');
|
||||||
@ -154,7 +236,10 @@ async function loadPlugins(win: BrowserWindow) {
|
|||||||
injectCSSAsFile(win.webContents, cssFile);
|
injectCSSAsFile(win.webContents, cssFile);
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
console.warn(`CSS file "${cssFile}" does not exist, ignoring`);
|
console.warn(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('main.console.theme.css-file-not-found', { cssFile }),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -162,24 +247,10 @@ async function loadPlugins(win: BrowserWindow) {
|
|||||||
|
|
||||||
win.webContents.once('did-finish-load', () => {
|
win.webContents.once('did-finish-load', () => {
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log('did finish load');
|
console.debug(LoggerPrefix, t('main.console.did-finish-load.dev-tools'));
|
||||||
win.webContents.openDevTools();
|
win.webContents.openDevTools();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const [plugin, options] of config.plugins.getEnabled()) {
|
|
||||||
try {
|
|
||||||
if (Object.hasOwn(mainPlugins, plugin)) {
|
|
||||||
console.log('Loaded plugin - ' + plugin);
|
|
||||||
const handler = mainPlugins[plugin as keyof typeof mainPlugins];
|
|
||||||
if (handler) {
|
|
||||||
await handler(win, options as never);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Failed to load plugin "${plugin}"`, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createMainWindow() {
|
async function createMainWindow() {
|
||||||
@ -188,6 +259,12 @@ async function createMainWindow() {
|
|||||||
const windowPosition: Electron.Point = config.get('window-position');
|
const windowPosition: Electron.Point = config.get('window-position');
|
||||||
const useInlineMenu = config.plugins.isEnabled('in-app-menu');
|
const useInlineMenu = config.plugins.isEnabled('in-app-menu');
|
||||||
|
|
||||||
|
const defaultTitleBarOverlayOptions: Electron.TitleBarOverlayOptions = {
|
||||||
|
color: '#00000000',
|
||||||
|
symbolColor: '#ffffff',
|
||||||
|
height: 32,
|
||||||
|
};
|
||||||
|
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
icon,
|
icon,
|
||||||
width: windowSize.width,
|
width: windowSize.width,
|
||||||
@ -195,53 +272,62 @@ async function createMainWindow() {
|
|||||||
backgroundColor: '#000',
|
backgroundColor: '#000',
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm
|
contextIsolation: true,
|
||||||
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126
|
preload: path.join(__dirname, '..', 'preload', 'preload.js'),
|
||||||
contextIsolation: false,
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
nodeIntegrationInSubFrames: true,
|
|
||||||
...(isTesting()
|
...(isTesting()
|
||||||
? undefined
|
? undefined
|
||||||
: {
|
: {
|
||||||
// Sandbox is only enabled in tests for now
|
// Sandbox is only enabled in tests for now
|
||||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||||
sandbox: false,
|
sandbox: false,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
frame: !is.macOS() && !useInlineMenu,
|
frame: !is.macOS() && !useInlineMenu,
|
||||||
titleBarOverlay: {
|
titleBarOverlay: defaultTitleBarOverlayOptions,
|
||||||
color: '#00000000',
|
|
||||||
symbolColor: '#ffffff',
|
|
||||||
height: 36,
|
|
||||||
},
|
|
||||||
titleBarStyle: useInlineMenu
|
titleBarStyle: useInlineMenu
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: (is.macOS()
|
: is.macOS()
|
||||||
? 'hiddenInset'
|
? 'hiddenInset'
|
||||||
: 'default'),
|
: 'default',
|
||||||
autoHideMenuBar: config.get('options.hideMenu'),
|
autoHideMenuBar: config.get('options.hideMenu'),
|
||||||
});
|
});
|
||||||
await loadPlugins(win);
|
initHook(win);
|
||||||
|
initTheme(win);
|
||||||
|
|
||||||
|
await loadAllMainPlugins(win);
|
||||||
|
|
||||||
if (windowPosition) {
|
if (windowPosition) {
|
||||||
const { x: windowX, y: windowY } = windowPosition;
|
const { x: windowX, y: windowY } = windowPosition;
|
||||||
const winSize = win.getSize();
|
const winSize = win.getSize();
|
||||||
const displaySize
|
const display = screen.getDisplayNearestPoint(windowPosition);
|
||||||
= screen.getDisplayNearestPoint(windowPosition).bounds;
|
const scaleFactor = display.scaleFactor;
|
||||||
|
|
||||||
|
const scaledWidth = Math.floor(windowSize.width / scaleFactor);
|
||||||
|
const scaledHeight = Math.floor(windowSize.height / scaleFactor);
|
||||||
|
|
||||||
|
const scaledX = windowX;
|
||||||
|
const scaledY = windowY;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
windowX + winSize[0] < displaySize.x - 8
|
scaledX + scaledWidth < display.bounds.x - 8 ||
|
||||||
|| windowX - winSize[0] > displaySize.x + displaySize.width
|
scaledX - scaledWidth > display.bounds.x + display.bounds.width ||
|
||||||
|| windowY < displaySize.y - 8
|
scaledY < display.bounds.y - 8 ||
|
||||||
|| windowY > displaySize.y + displaySize.height
|
scaledY > display.bounds.y + display.bounds.height
|
||||||
) {
|
) {
|
||||||
// Window is offscreen
|
// Window is offscreen
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log(
|
console.warn(
|
||||||
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
|
LoggerPrefix,
|
||||||
|
t('main.console.window.tried-to-render-offscreen', {
|
||||||
|
winSize: String(winSize),
|
||||||
|
displaySize: String(display.bounds),
|
||||||
|
windowPosition: String(windowPosition),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
win.setPosition(windowX, windowY);
|
win.setSize(scaledWidth, scaledHeight);
|
||||||
|
win.setPosition(scaledX, scaledY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -258,40 +344,22 @@ async function createMainWindow() {
|
|||||||
: config.defaultConfig.url;
|
: config.defaultConfig.url;
|
||||||
win.on('closed', onClosed);
|
win.on('closed', onClosed);
|
||||||
|
|
||||||
type PiPOptions = typeof config.defaultConfig.plugins['picture-in-picture'];
|
|
||||||
const setPiPOptions = config.plugins.isEnabled('picture-in-picture')
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
? (key: string, value: unknown) => pipSetOptions({ [key]: value })
|
|
||||||
: () => {};
|
|
||||||
|
|
||||||
win.on('move', () => {
|
win.on('move', () => {
|
||||||
if (win.isMaximized()) {
|
if (win.isMaximized()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const position = win.getPosition();
|
const [x, y] = win.getPosition();
|
||||||
const isPiPEnabled: boolean
|
lateSave('window-position', { x, y });
|
||||||
= config.plugins.isEnabled('picture-in-picture')
|
|
||||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
|
||||||
if (!isPiPEnabled) {
|
|
||||||
|
|
||||||
lateSave('window-position', { x: position[0], y: position[1] });
|
|
||||||
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').savePosition) {
|
|
||||||
lateSave('pip-position', position, setPiPOptions);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let winWasMaximized: boolean;
|
let winWasMaximized: boolean;
|
||||||
|
|
||||||
win.on('resize', () => {
|
win.on('resize', () => {
|
||||||
const windowSize = win.getSize();
|
const [width, height] = win.getSize();
|
||||||
const isMaximized = win.isMaximized();
|
const isMaximized = win.isMaximized();
|
||||||
|
|
||||||
const isPiPEnabled
|
if (winWasMaximized !== isMaximized) {
|
||||||
= config.plugins.isEnabled('picture-in-picture')
|
|
||||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
|
||||||
|
|
||||||
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
|
|
||||||
winWasMaximized = isMaximized;
|
winWasMaximized = isMaximized;
|
||||||
config.set('window-maximized', isMaximized);
|
config.set('window-maximized', isMaximized);
|
||||||
}
|
}
|
||||||
@ -300,19 +368,19 @@ async function createMainWindow() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPiPEnabled) {
|
lateSave('window-size', {
|
||||||
lateSave('window-size', {
|
width,
|
||||||
width: windowSize[0],
|
height,
|
||||||
height: windowSize[1],
|
});
|
||||||
});
|
|
||||||
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
|
|
||||||
lateSave('pip-size', windowSize, setPiPOptions);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
const savedTimeouts: Record<string, NodeJS.Timeout | undefined> = {};
|
||||||
|
|
||||||
function lateSave(key: string, value: unknown, fn: (key: string, value: unknown) => void = config.set) {
|
function lateSave(
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
fn: (key: string, value: unknown) => void = config.set,
|
||||||
|
) {
|
||||||
if (savedTimeouts[key]) {
|
if (savedTimeouts[key]) {
|
||||||
clearTimeout(savedTimeouts[key]);
|
clearTimeout(savedTimeouts[key]);
|
||||||
}
|
}
|
||||||
@ -323,7 +391,7 @@ async function createMainWindow() {
|
|||||||
}, 600);
|
}, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('render-process-gone', (event, webContents, details) => {
|
app.on('render-process-gone', (_event, _webContents, details) => {
|
||||||
showUnresponsiveDialog(win, details);
|
showUnresponsiveDialog(win, details);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -335,32 +403,88 @@ async function createMainWindow() {
|
|||||||
|
|
||||||
removeContentSecurityPolicy();
|
removeContentSecurityPolicy();
|
||||||
|
|
||||||
|
win.webContents.on('dom-ready', async () => {
|
||||||
|
if (useInlineMenu && !is.linux()) {
|
||||||
|
win.setTitleBarOverlay({
|
||||||
|
...defaultTitleBarOverlayOptions,
|
||||||
|
height: Math.floor(
|
||||||
|
defaultTitleBarOverlayOptions.height! *
|
||||||
|
win.webContents.getZoomFactor(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject index.html file as string using insertAdjacentHTML
|
||||||
|
// In dev mode, get string from process.env.VITE_DEV_SERVER_URL, else use fs.readFileSync
|
||||||
|
if (is.dev() && process.env.ELECTRON_RENDERER_URL) {
|
||||||
|
// HACK: to make vite work with electron renderer (supports hot reload)
|
||||||
|
await win.webContents.executeJavaScript(`
|
||||||
|
console.log('Loading vite from dev server');
|
||||||
|
const viteScript = document.createElement('script');
|
||||||
|
viteScript.type = 'module';
|
||||||
|
viteScript.src = '${process.env.ELECTRON_RENDERER_URL}/@vite/client';
|
||||||
|
const rendererScript = document.createElement('script');
|
||||||
|
rendererScript.type = 'module';
|
||||||
|
rendererScript.src = '${process.env.ELECTRON_RENDERER_URL}/renderer.ts';
|
||||||
|
document.body.appendChild(viteScript);
|
||||||
|
document.body.appendChild(rendererScript);
|
||||||
|
0
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
const rendererPath = path.join(__dirname, '..', 'renderer');
|
||||||
|
const indexHTML = parse(
|
||||||
|
fs.readFileSync(path.join(rendererPath, 'index.html'), 'utf-8'),
|
||||||
|
);
|
||||||
|
const scriptSrc = indexHTML.querySelector('script')!;
|
||||||
|
const scriptPath = path.join(
|
||||||
|
rendererPath,
|
||||||
|
scriptSrc.getAttribute('src')!,
|
||||||
|
);
|
||||||
|
const scriptString = fs.readFileSync(scriptPath, 'utf-8');
|
||||||
|
await win.webContents.executeJavaScriptInIsolatedWorld(
|
||||||
|
0,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
code: scriptString + ';0',
|
||||||
|
url: url.pathToFileURL(scriptPath).toString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
win.webContents.loadURL(urlToLoad);
|
win.webContents.loadURL(urlToLoad);
|
||||||
|
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.once('browser-window-created', (event, win) => {
|
app.once('browser-window-created', (_event, win) => {
|
||||||
if (config.get('options.overrideUserAgent')) {
|
if (config.get('options.overrideUserAgent')) {
|
||||||
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
|
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
|
||||||
const originalUserAgent = win.webContents.userAgent;
|
const originalUserAgent = win.webContents.userAgent;
|
||||||
const userAgents = {
|
const userAgents = {
|
||||||
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
|
mac: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||||
windows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
windows:
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||||
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
linux: 'Mozilla/5.0 (Linux x86_64; rv:95.0) Gecko/20100101 Firefox/95.0',
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedUserAgent
|
const updatedUserAgent = is.macOS()
|
||||||
= is.macOS() ? userAgents.mac
|
? userAgents.mac
|
||||||
: (is.windows() ? userAgents.windows
|
: is.windows()
|
||||||
: userAgents.linux);
|
? userAgents.windows
|
||||||
|
: userAgents.linux;
|
||||||
|
|
||||||
win.webContents.userAgent = updatedUserAgent;
|
win.webContents.userAgent = updatedUserAgent;
|
||||||
app.userAgentFallback = updatedUserAgent;
|
app.userAgentFallback = updatedUserAgent;
|
||||||
|
|
||||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||||
// This will only happen if login failed, and "retry" was pressed
|
// This will only happen if login failed, and "retry" was pressed
|
||||||
if (win.webContents.getURL().startsWith('https://accounts.google.com') && details.url.startsWith('https://accounts.google.com')) {
|
if (
|
||||||
|
win.webContents.getURL().startsWith('https://accounts.google.com') &&
|
||||||
|
details.url.startsWith('https://accounts.google.com')
|
||||||
|
) {
|
||||||
details.requestHeaders['User-Agent'] = originalUserAgent;
|
details.requestHeaders['User-Agent'] = originalUserAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -371,33 +495,41 @@ app.once('browser-window-created', (event, win) => {
|
|||||||
setupSongInfo(win);
|
setupSongInfo(win);
|
||||||
setupAppControls();
|
setupAppControls();
|
||||||
|
|
||||||
win.webContents.on('did-fail-load', (
|
win.webContents.on(
|
||||||
_event,
|
'did-fail-load',
|
||||||
errorCode,
|
(
|
||||||
errorDescription,
|
_event,
|
||||||
validatedURL,
|
|
||||||
isMainFrame,
|
|
||||||
frameProcessId,
|
|
||||||
frameRoutingId,
|
|
||||||
) => {
|
|
||||||
const log = JSON.stringify({
|
|
||||||
error: 'did-fail-load',
|
|
||||||
errorCode,
|
errorCode,
|
||||||
errorDescription,
|
errorDescription,
|
||||||
validatedURL,
|
validatedURL,
|
||||||
isMainFrame,
|
isMainFrame,
|
||||||
frameProcessId,
|
frameProcessId,
|
||||||
frameRoutingId,
|
frameRoutingId,
|
||||||
}, null, '\t');
|
) => {
|
||||||
if (is.dev()) {
|
const log = JSON.stringify(
|
||||||
console.log(log);
|
{
|
||||||
}
|
error: 'did-fail-load',
|
||||||
|
errorCode,
|
||||||
|
errorDescription,
|
||||||
|
validatedURL,
|
||||||
|
isMainFrame,
|
||||||
|
frameProcessId,
|
||||||
|
frameRoutingId,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
'\t',
|
||||||
|
);
|
||||||
|
if (is.dev()) {
|
||||||
|
console.log(log);
|
||||||
|
}
|
||||||
|
|
||||||
if (errorCode !== -3) { // -3 is a false positive
|
if (errorCode !== -3) {
|
||||||
win.webContents.send('log', log);
|
// -3 is a false positive
|
||||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
win.webContents.send('log', log);
|
||||||
}
|
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
win.webContents.on('will-prevent-unload', (event) => {
|
win.webContents.on('will-prevent-unload', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@ -423,12 +555,30 @@ app.on('activate', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('ready', async () => {
|
const getDefaultLocale = (locale: string) =>
|
||||||
|
Object.keys(languageResources).includes(locale) ? locale : null;
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
if (!config.get('options.language')) {
|
||||||
|
const locale = getDefaultLocale(app.getLocale());
|
||||||
|
if (locale) {
|
||||||
|
config.set('options.language', locale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadI18n().then(async () => {
|
||||||
|
await setLanguage(config.get('options.language') ?? 'en');
|
||||||
|
console.log(LoggerPrefix, t('main.console.i18n.loaded'));
|
||||||
|
});
|
||||||
|
|
||||||
if (config.get('options.autoResetAppCache')) {
|
if (config.get('options.autoResetAppCache')) {
|
||||||
// Clear cache after 20s
|
// Clear cache after 20s
|
||||||
const clearCacheTimeout = setTimeout(() => {
|
const clearCacheTimeout = setTimeout(() => {
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log('Clearing app cache.');
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('main.console.when-ready.clearing-cache-after-20s'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.defaultSession.clearCache();
|
session.defaultSession.clearCache();
|
||||||
@ -443,17 +593,29 @@ app.on('ready', async () => {
|
|||||||
const appLocation = process.execPath;
|
const appLocation = process.execPath;
|
||||||
const appData = app.getPath('appData');
|
const appData = app.getPath('appData');
|
||||||
// Check shortcut validity if not in dev mode / running portable app
|
// Check shortcut validity if not in dev mode / running portable app
|
||||||
if (!is.dev() && !appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))) {
|
if (
|
||||||
const shortcutPath = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'YouTube Music.lnk');
|
!is.dev() &&
|
||||||
try { // Check if shortcut is registered and valid
|
!appLocation.startsWith(path.join(appData, '..', 'Local', 'Temp'))
|
||||||
|
) {
|
||||||
|
const shortcutPath = path.join(
|
||||||
|
appData,
|
||||||
|
'Microsoft',
|
||||||
|
'Windows',
|
||||||
|
'Start Menu',
|
||||||
|
'Programs',
|
||||||
|
'YouTube Music.lnk',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Check if shortcut is registered and valid
|
||||||
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
|
const shortcutDetails = shell.readShortcutLink(shortcutPath); // Throw error if doesn't exist yet
|
||||||
if (
|
if (
|
||||||
shortcutDetails.target !== appLocation
|
shortcutDetails.target !== appLocation ||
|
||||||
|| shortcutDetails.appUserModelId !== appID
|
shortcutDetails.appUserModelId !== appID
|
||||||
) {
|
) {
|
||||||
throw 'needUpdate';
|
throw 'needUpdate';
|
||||||
}
|
}
|
||||||
} catch (error) { // If not valid -> Register shortcut
|
} catch (error) {
|
||||||
|
// If not valid -> Register shortcut
|
||||||
shell.writeShortcutLink(
|
shell.writeShortcutLink(
|
||||||
shortcutPath,
|
shortcutPath,
|
||||||
error === 'needUpdate' ? 'update' : 'create',
|
error === 'needUpdate' ? 'update' : 'create',
|
||||||
@ -469,8 +631,8 @@ app.on('ready', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainWindow = await createMainWindow();
|
mainWindow = await createMainWindow();
|
||||||
setApplicationMenu(mainWindow);
|
await setApplicationMenu(mainWindow);
|
||||||
refreshMenu(mainWindow);
|
await refreshMenu(mainWindow);
|
||||||
setUpTray(app, mainWindow);
|
setUpTray(app, mainWindow);
|
||||||
|
|
||||||
setupProtocolHandler(mainWindow);
|
setupProtocolHandler(mainWindow);
|
||||||
@ -482,7 +644,10 @@ app.on('ready', async () => {
|
|||||||
const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined;
|
const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined;
|
||||||
const command = protocolArgv.slice(uri.length, lastIndex);
|
const command = protocolArgv.slice(uri.length, lastIndex);
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.debug(`Received command over protocol: "${command}"`);
|
console.debug(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('main.console.second-instance.receive-command', { command }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleProtocol(command);
|
handleProtocol(command);
|
||||||
@ -515,16 +680,28 @@ app.on('ready', async () => {
|
|||||||
clearTimeout(updateTimeout);
|
clearTimeout(updateTimeout);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
autoUpdater.on('update-available', () => {
|
autoUpdater.on('update-available', () => {
|
||||||
const downloadLink
|
const downloadLink =
|
||||||
= 'https://github.com/th-ch/youtube-music/releases/latest';
|
'https://github.com/th-ch/youtube-music/releases/latest';
|
||||||
const dialogOptions: Electron.MessageBoxOptions = {
|
const dialogOptions: Electron.MessageBoxOptions = {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
buttons: ['OK', 'Download', 'Disable updates'],
|
buttons: [
|
||||||
title: 'Application Update',
|
t('main.dialog.update-available.buttons.download'),
|
||||||
message: 'A new version is available',
|
t('main.dialog.update-available.buttons.ok'),
|
||||||
detail: `A new version is available and can be downloaded at ${downloadLink}`,
|
t('main.dialog.update-available.buttons.disable'),
|
||||||
|
],
|
||||||
|
title: t('main.dialog.update-available.title'),
|
||||||
|
message: t('main.dialog.update-available.message'),
|
||||||
|
detail: t('main.dialog.update-available.detail', { downloadLink }),
|
||||||
};
|
};
|
||||||
dialog.showMessageBox(dialogOptions).then((dialogOutput) => {
|
|
||||||
|
let dialogPromise: Promise<Electron.MessageBoxReturnValue>;
|
||||||
|
if (mainWindow) {
|
||||||
|
dialogPromise = dialog.showMessageBox(mainWindow, dialogOptions);
|
||||||
|
} else {
|
||||||
|
dialogPromise = dialog.showMessageBox(dialogOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogPromise.then((dialogOutput) => {
|
||||||
switch (dialogOutput.response) {
|
switch (dialogOutput.response) {
|
||||||
// Download
|
// Download
|
||||||
case 1: {
|
case 1: {
|
||||||
@ -548,8 +725,9 @@ app.on('ready', async () => {
|
|||||||
|
|
||||||
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
||||||
dialog.showMessageBox(mainWindow, {
|
dialog.showMessageBox(mainWindow, {
|
||||||
type: 'info', title: 'Hide Menu Enabled',
|
type: 'info',
|
||||||
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
title: t('main.dialog.hide-menu-enabled.title'),
|
||||||
|
message: t('main.dialog.hide-menu-enabled.message'),
|
||||||
});
|
});
|
||||||
config.set('options.hideMenuWarned', true);
|
config.set('options.hideMenuWarned', true);
|
||||||
}
|
}
|
||||||
@ -575,31 +753,45 @@ app.on('ready', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function showUnresponsiveDialog(win: BrowserWindow, details: Electron.RenderProcessGoneDetails) {
|
function showUnresponsiveDialog(
|
||||||
|
win: BrowserWindow,
|
||||||
|
details: Electron.RenderProcessGoneDetails,
|
||||||
|
) {
|
||||||
if (details) {
|
if (details) {
|
||||||
console.log('Unresponsive Error!\n' + JSON.stringify(details, null, '\t'));
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('main.console.unresponsive.details', {
|
||||||
|
error: JSON.stringify(details, null, '\t'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.showMessageBox(win, {
|
dialog
|
||||||
type: 'error',
|
.showMessageBox(win, {
|
||||||
title: 'Window Unresponsive',
|
type: 'error',
|
||||||
message: 'The Application is Unresponsive',
|
title: t('main.dialog.unresponsive.title'),
|
||||||
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
message: t('main.dialog.unresponsive.message'),
|
||||||
buttons: ['Wait', 'Relaunch', 'Quit'],
|
detail: t('main.dialog.unresponsive.detail'),
|
||||||
cancelId: 0,
|
buttons: [
|
||||||
}).then((result) => {
|
t('main.dialog.unresponsive.buttons.wait'),
|
||||||
switch (result.response) {
|
t('main.dialog.unresponsive.buttons.relaunch'),
|
||||||
case 1: {
|
t('main.dialog.unresponsive.buttons.quit'),
|
||||||
restart();
|
],
|
||||||
break;
|
cancelId: 0,
|
||||||
}
|
})
|
||||||
|
.then((result) => {
|
||||||
|
switch (result.response) {
|
||||||
|
case 1: {
|
||||||
|
restart();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 2: {
|
case 2: {
|
||||||
app.quit();
|
app.quit();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeContentSecurityPolicy(
|
function removeContentSecurityPolicy(
|
||||||
@ -622,18 +814,21 @@ function removeContentSecurityPolicy(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When multiple listeners are defined, apply them all
|
// When multiple listeners are defined, apply them all
|
||||||
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => {
|
betterSession.webRequest.setResolver(
|
||||||
return listeners.reduce(
|
'onHeadersReceived',
|
||||||
async (accumulator, listener) => {
|
async (listeners) => {
|
||||||
const acc = await accumulator;
|
return listeners.reduce(
|
||||||
if (acc.cancel) {
|
async (accumulator, listener) => {
|
||||||
return acc;
|
const acc = await accumulator;
|
||||||
}
|
if (acc.cancel) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await listener.apply();
|
const result = await listener.apply();
|
||||||
return { ...accumulator, ...result };
|
return { ...accumulator, ...result };
|
||||||
},
|
},
|
||||||
Promise.resolve({ cancel: false }),
|
Promise.resolve({ cancel: false }),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
161
src/loader/main.ts
Normal file
161
src/loader/main.ts
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { BrowserWindow, ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins, mainPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||||
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
const loadedPluginMap: Record<
|
||||||
|
string,
|
||||||
|
PluginDef<unknown, unknown, unknown>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
const createContext = (
|
||||||
|
id: string,
|
||||||
|
win: BrowserWindow,
|
||||||
|
): BackendContext<PluginConfig> => ({
|
||||||
|
getConfig: () =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
setConfig: (newConfig) => {
|
||||||
|
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
|
||||||
|
},
|
||||||
|
|
||||||
|
ipc: {
|
||||||
|
send: (event: string, ...args: unknown[]) => {
|
||||||
|
win.webContents.send(event, ...args);
|
||||||
|
},
|
||||||
|
handle: (event: string, listener: CallableFunction) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
ipcMain.handle(event, (_, ...args: unknown[]) => listener(...args));
|
||||||
|
},
|
||||||
|
on: (event: string, listener: CallableFunction) => {
|
||||||
|
ipcMain.on(event, (_, ...args: unknown[]) => {
|
||||||
|
listener(...args);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeHandler: (event: string) => {
|
||||||
|
ipcMain.removeHandler(event);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
window: win,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceUnloadMainPlugin = async (
|
||||||
|
id: string,
|
||||||
|
win: BrowserWindow,
|
||||||
|
): Promise<void> => {
|
||||||
|
const plugin = loadedPluginMap[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasStopped = await stopPlugin(id, plugin, {
|
||||||
|
ctx: 'backend',
|
||||||
|
context: createContext(id, win),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasStopped ||
|
||||||
|
(hasStopped === null &&
|
||||||
|
typeof plugin.backend !== 'function' &&
|
||||||
|
plugin.backend)
|
||||||
|
) {
|
||||||
|
delete loadedPluginMap[id];
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unloaded', { pluginName: id }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unload-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unload-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
console.trace(err);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLoadMainPlugin = async (
|
||||||
|
id: string,
|
||||||
|
win: BrowserWindow,
|
||||||
|
): Promise<void> => {
|
||||||
|
const plugin = mainPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasStarted = await startPlugin(id, plugin, {
|
||||||
|
ctx: 'backend',
|
||||||
|
context: createContext(id, win),
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
hasStarted ||
|
||||||
|
(hasStarted === null &&
|
||||||
|
typeof plugin.backend !== 'function' &&
|
||||||
|
plugin.backend)
|
||||||
|
) {
|
||||||
|
loadedPluginMap[id] = plugin;
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.load-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.initialize-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
console.trace(err);
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllMainPlugins = async (win: BrowserWindow) => {
|
||||||
|
console.log(LoggerPrefix, t('common.console.plugins.load-all'));
|
||||||
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
const queue: Promise<void>[] = [];
|
||||||
|
|
||||||
|
for (const [plugin, pluginDef] of Object.entries(mainPlugins)) {
|
||||||
|
const config = deepmerge(pluginDef.config, pluginConfigs[plugin] ?? {});
|
||||||
|
if (config.enabled) {
|
||||||
|
queue.push(forceLoadMainPlugin(plugin, win));
|
||||||
|
} else if (loadedPluginMap[plugin]) {
|
||||||
|
queue.push(forceUnloadMainPlugin(plugin, win));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(queue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unloadAllMainPlugins = async (win: BrowserWindow) => {
|
||||||
|
for (const id of Object.keys(loadedPluginMap)) {
|
||||||
|
await forceUnloadMainPlugin(id, win);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoadedMainPlugin = (
|
||||||
|
id: string,
|
||||||
|
): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
|
return loadedPluginMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoadedMainPlugins = () => {
|
||||||
|
return loadedPluginMap;
|
||||||
|
};
|
||||||
91
src/loader/menu.ts
Normal file
91
src/loader/menu.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
import { setApplicationMenu } from '@/menu';
|
||||||
|
|
||||||
|
import { LoggerPrefix } from '@/utils';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
|
||||||
|
import type { PluginConfig } from '@/types/plugins';
|
||||||
|
|
||||||
|
const menuTemplateMap: Record<string, MenuItemConstructorOptions[]> = {};
|
||||||
|
const createContext = (
|
||||||
|
id: string,
|
||||||
|
win: BrowserWindow,
|
||||||
|
): MenuContext<PluginConfig> => ({
|
||||||
|
getConfig: () =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
setConfig: (newConfig) => {
|
||||||
|
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
|
||||||
|
},
|
||||||
|
window: win,
|
||||||
|
refresh: async () => {
|
||||||
|
await setApplicationMenu(win);
|
||||||
|
|
||||||
|
if (config.plugins.isEnabled('in-app-menu')) {
|
||||||
|
win.webContents.send('refresh-in-app-menu');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceLoadMenuPlugin = async (id: string, win: BrowserWindow) => {
|
||||||
|
try {
|
||||||
|
const plugin = allPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const menu = plugin.menu?.(createContext(id, win));
|
||||||
|
if (menu) {
|
||||||
|
const result = await menu;
|
||||||
|
if (result.length > 0) {
|
||||||
|
menuTemplateMap[id] = result;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.loaded', { pluginName: `${id}::menu` }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.initialize-failed', {
|
||||||
|
pluginName: `${id}::menu`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.trace(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllMenuPlugins = async (win: BrowserWindow) => {
|
||||||
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
|
||||||
|
for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
|
||||||
|
const config = deepmerge(
|
||||||
|
pluginDef.config ?? { enabled: false },
|
||||||
|
pluginConfigs[pluginId] ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
await forceLoadMenuPlugin(pluginId, win);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMenuTemplate = (
|
||||||
|
id: string,
|
||||||
|
): MenuItemConstructorOptions[] | undefined => {
|
||||||
|
return menuTemplateMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllMenuTemplate = () => {
|
||||||
|
return menuTemplateMap;
|
||||||
|
};
|
||||||
114
src/loader/preload.ts
Normal file
114
src/loader/preload.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
import { allPlugins, preloadPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import config from '@/config';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { PreloadContext } from '@/types/contexts';
|
||||||
|
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||||
|
|
||||||
|
const loadedPluginMap: Record<
|
||||||
|
string,
|
||||||
|
PluginDef<unknown, unknown, unknown>
|
||||||
|
> = {};
|
||||||
|
const createContext = (id: string): PreloadContext<PluginConfig> => ({
|
||||||
|
getConfig: () =>
|
||||||
|
deepmerge(
|
||||||
|
allPlugins[id].config ?? { enabled: false },
|
||||||
|
config.get(`plugins.${id}`) ?? {},
|
||||||
|
) as PluginConfig,
|
||||||
|
setConfig: (newConfig) => {
|
||||||
|
config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceUnloadPreloadPlugin = async (id: string) => {
|
||||||
|
if (!loadedPluginMap[id]) return;
|
||||||
|
|
||||||
|
const hasStopped = await stopPlugin(id, loadedPluginMap[id], {
|
||||||
|
ctx: 'preload',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
if (hasStopped || (hasStopped === null && loadedPluginMap[id].preload)) {
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unloaded', { pluginName: id }),
|
||||||
|
);
|
||||||
|
delete loadedPluginMap[id];
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unload-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLoadPreloadPlugin = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const plugin = preloadPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const hasStarted = await startPlugin(id, plugin, {
|
||||||
|
ctx: 'preload',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasStarted ||
|
||||||
|
(hasStarted === null &&
|
||||||
|
typeof plugin.preload !== 'function' &&
|
||||||
|
plugin.preload)
|
||||||
|
) {
|
||||||
|
loadedPluginMap[id] = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.loaded', { pluginName: id }),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.initialize-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
console.trace(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllPreloadPlugins = () => {
|
||||||
|
const pluginConfigs = config.plugins.getPlugins();
|
||||||
|
|
||||||
|
for (const [pluginId, pluginDef] of Object.entries(preloadPlugins)) {
|
||||||
|
const config = deepmerge(
|
||||||
|
pluginDef.config ?? { enable: false },
|
||||||
|
pluginConfigs[pluginId] ?? {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
forceLoadPreloadPlugin(pluginId);
|
||||||
|
} else {
|
||||||
|
if (loadedPluginMap[pluginId]) {
|
||||||
|
forceUnloadPreloadPlugin(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unloadAllPreloadPlugins = async () => {
|
||||||
|
for (const id of Object.keys(loadedPluginMap)) {
|
||||||
|
await forceUnloadPreloadPlugin(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoadedPreloadPlugin = (
|
||||||
|
id: string,
|
||||||
|
): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
|
return loadedPluginMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoadedPreloadPlugins = () => {
|
||||||
|
return loadedPluginMap;
|
||||||
|
};
|
||||||
145
src/loader/renderer.ts
Normal file
145
src/loader/renderer.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { deepmerge } from 'deepmerge-ts';
|
||||||
|
|
||||||
|
import { rendererPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import { LoggerPrefix, startPlugin, stopPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
import type { PluginConfig, PluginDef } from '@/types/plugins';
|
||||||
|
|
||||||
|
const unregisterStyleMap: Record<string, (() => void)[]> = {};
|
||||||
|
const loadedPluginMap: Record<
|
||||||
|
string,
|
||||||
|
PluginDef<unknown, unknown, unknown>
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
export const createContext = <Config extends PluginConfig>(
|
||||||
|
id: string,
|
||||||
|
): RendererContext<Config> => ({
|
||||||
|
getConfig: async () => window.ipcRenderer.invoke('get-config', id),
|
||||||
|
setConfig: async (newConfig) => {
|
||||||
|
await window.ipcRenderer.invoke('set-config', id, newConfig);
|
||||||
|
},
|
||||||
|
ipc: {
|
||||||
|
send: (event: string, ...args: unknown[]) => {
|
||||||
|
window.ipcRenderer.send(event, ...args);
|
||||||
|
},
|
||||||
|
invoke: (event: string, ...args: unknown[]) =>
|
||||||
|
window.ipcRenderer.invoke(event, ...args),
|
||||||
|
on: (event: string, listener: CallableFunction) => {
|
||||||
|
window.ipcRenderer.on(event, (_, ...args: unknown[]) => {
|
||||||
|
listener(...args);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeAllListeners: (event: string) => {
|
||||||
|
window.ipcRenderer.removeAllListeners(event);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const forceUnloadRendererPlugin = async (id: string) => {
|
||||||
|
unregisterStyleMap[id]?.forEach((unregister) => unregister());
|
||||||
|
|
||||||
|
delete unregisterStyleMap[id];
|
||||||
|
delete loadedPluginMap[id];
|
||||||
|
|
||||||
|
const plugin = rendererPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const hasStopped = await stopPlugin(id, plugin, {
|
||||||
|
ctx: 'renderer',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
if (plugin?.stylesheets) {
|
||||||
|
document.querySelector(`style#plugin-${id}`)?.remove();
|
||||||
|
}
|
||||||
|
if (hasStopped || (hasStopped === null && plugin?.renderer)) {
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unloaded', { pluginName: id }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.unload-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const forceLoadRendererPlugin = async (id: string) => {
|
||||||
|
const plugin = rendererPlugins[id];
|
||||||
|
if (!plugin) return;
|
||||||
|
|
||||||
|
const hasEvaled = await startPlugin(id, plugin, {
|
||||||
|
ctx: 'renderer',
|
||||||
|
context: createContext(id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
hasEvaled ||
|
||||||
|
plugin?.stylesheets ||
|
||||||
|
(hasEvaled === null &&
|
||||||
|
typeof plugin?.renderer !== 'function' &&
|
||||||
|
plugin?.renderer)
|
||||||
|
) {
|
||||||
|
loadedPluginMap[id] = plugin;
|
||||||
|
|
||||||
|
if (plugin?.stylesheets) {
|
||||||
|
const styleSheetList = plugin.stylesheets.map((style) => {
|
||||||
|
const styleSheet = new CSSStyleSheet();
|
||||||
|
styleSheet.replaceSync(style);
|
||||||
|
|
||||||
|
return styleSheet;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.adoptedStyleSheets = [
|
||||||
|
...document.adoptedStyleSheets,
|
||||||
|
...styleSheetList,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.loaded', { pluginName: id }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('common.console.plugins.initialize-failed', { pluginName: id }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadAllRendererPlugins = async () => {
|
||||||
|
const pluginConfigs = window.mainConfig.plugins.getPlugins();
|
||||||
|
|
||||||
|
for (const [pluginId, pluginDef] of Object.entries(rendererPlugins)) {
|
||||||
|
const config = deepmerge(pluginDef.config, pluginConfigs[pluginId] ?? {});
|
||||||
|
|
||||||
|
if (config.enabled) {
|
||||||
|
await forceLoadRendererPlugin(pluginId);
|
||||||
|
} else {
|
||||||
|
if (loadedPluginMap[pluginId]) {
|
||||||
|
await forceUnloadRendererPlugin(pluginId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unloadAllRendererPlugins = async () => {
|
||||||
|
for (const id of Object.keys(loadedPluginMap)) {
|
||||||
|
await forceUnloadRendererPlugin(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLoadedRendererPlugin = (
|
||||||
|
id: string,
|
||||||
|
): PluginDef<unknown, unknown, unknown> | undefined => {
|
||||||
|
return loadedPluginMap[id];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllLoadedRendererPlugins = () => {
|
||||||
|
return loadedPluginMap;
|
||||||
|
};
|
||||||
546
src/menu.ts
546
src/menu.ts
@ -1,55 +1,41 @@
|
|||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
|
import {
|
||||||
|
app,
|
||||||
|
BrowserWindow,
|
||||||
|
clipboard,
|
||||||
|
dialog,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
shell,
|
||||||
|
} from 'electron';
|
||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import { restart } from './providers/app-controls';
|
import { allPlugins } from 'virtual:plugins';
|
||||||
|
|
||||||
|
import { languageResources } from 'virtual:i18n';
|
||||||
|
|
||||||
import config from './config';
|
import config from './config';
|
||||||
|
|
||||||
|
import { restart } from './providers/app-controls';
|
||||||
import { startingPages } from './providers/extracted-data';
|
import { startingPages } from './providers/extracted-data';
|
||||||
import promptOptions from './providers/prompt-options';
|
import promptOptions from './providers/prompt-options';
|
||||||
|
|
||||||
import adblockerMenu from './plugins/adblocker/menu';
|
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
|
||||||
import ambientModeMenu from './plugins/ambient-mode/menu';
|
import { setLanguage, t } from '@/i18n';
|
||||||
import captionsSelectorMenu from './plugins/captions-selector/menu';
|
|
||||||
import crossfadeMenu from './plugins/crossfade/menu';
|
|
||||||
import disableAutoplayMenu from './plugins/disable-autoplay/menu';
|
|
||||||
import discordMenu from './plugins/discord/menu';
|
|
||||||
import downloaderMenu from './plugins/downloader/menu';
|
|
||||||
import inAppMenuTitlebarMenu from './plugins/in-app-menu/menu';
|
|
||||||
import lyricsGeniusMenu from './plugins/lyrics-genius/menu';
|
|
||||||
import notificationsMenu from './plugins/notifications/menu';
|
|
||||||
import pictureInPictureMenu from './plugins/picture-in-picture/menu';
|
|
||||||
import preciseVolumeMenu from './plugins/precise-volume/menu';
|
|
||||||
import shortcutsMenu from './plugins/shortcuts/menu';
|
|
||||||
import videoToggleMenu from './plugins/video-toggle/menu';
|
|
||||||
import visualizerMenu from './plugins/visualizer/menu';
|
|
||||||
import { getAvailablePluginNames } from './plugins/utils';
|
|
||||||
|
|
||||||
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
||||||
|
|
||||||
// True only if in-app-menu was loaded on launch
|
// True only if in-app-menu was loaded on launch
|
||||||
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
||||||
|
|
||||||
const betaPlugins = ['crossfade', 'lumiastream'];
|
const pluginEnabledMenu = (
|
||||||
|
plugin: string,
|
||||||
const pluginMenus = {
|
label = '',
|
||||||
'adblocker': adblockerMenu,
|
hasSubmenu = false,
|
||||||
'ambient-mode': ambientModeMenu,
|
refreshMenu: (() => void) | undefined = undefined,
|
||||||
'disable-autoplay': disableAutoplayMenu,
|
): Electron.MenuItemConstructorOptions => ({
|
||||||
'captions-selector': captionsSelectorMenu,
|
|
||||||
'crossfade': crossfadeMenu,
|
|
||||||
'discord': discordMenu,
|
|
||||||
'downloader': downloaderMenu,
|
|
||||||
'in-app-menu': inAppMenuTitlebarMenu,
|
|
||||||
'lyrics-genius': lyricsGeniusMenu,
|
|
||||||
'notifications': notificationsMenu,
|
|
||||||
'picture-in-picture': pictureInPictureMenu,
|
|
||||||
'precise-volume': preciseVolumeMenu,
|
|
||||||
'shortcuts': shortcutsMenu,
|
|
||||||
'video-toggle': videoToggleMenu,
|
|
||||||
'visualizer': visualizerMenu,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refreshMenu: (() => void ) | undefined = undefined): Electron.MenuItemConstructorOptions => ({
|
|
||||||
label: label || plugin,
|
label: label || plugin,
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.plugins.isEnabled(plugin),
|
checked: config.plugins.isEnabled(plugin),
|
||||||
@ -66,78 +52,107 @@ const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refre
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const refreshMenu = (win: BrowserWindow) => {
|
export const refreshMenu = async (win: BrowserWindow) => {
|
||||||
setApplicationMenu(win);
|
await setApplicationMenu(win);
|
||||||
if (inAppMenuActive) {
|
if (inAppMenuActive) {
|
||||||
win.webContents.send('refreshMenu');
|
win.webContents.send('refresh-in-app-menu');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
export const mainMenuTemplate = async (
|
||||||
|
win: BrowserWindow,
|
||||||
|
): Promise<MenuTemplate> => {
|
||||||
const innerRefreshMenu = () => refreshMenu(win);
|
const innerRefreshMenu = () => refreshMenu(win);
|
||||||
|
|
||||||
|
await loadAllMenuPlugins(win);
|
||||||
|
|
||||||
|
const menuResult = Object.entries(getAllMenuTemplate()).map(
|
||||||
|
([id, template]) => {
|
||||||
|
const pluginLabel = allPlugins[id]?.name?.() ?? id;
|
||||||
|
|
||||||
|
if (!config.plugins.isEnabled(id)) {
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu),
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
label: pluginLabel,
|
||||||
|
submenu: [
|
||||||
|
pluginEnabledMenu(
|
||||||
|
id,
|
||||||
|
t('main.menu.plugins.enabled'),
|
||||||
|
true,
|
||||||
|
innerRefreshMenu,
|
||||||
|
),
|
||||||
|
{ type: 'separator' },
|
||||||
|
...template,
|
||||||
|
],
|
||||||
|
} satisfies Electron.MenuItemConstructorOptions,
|
||||||
|
] as const;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const availablePlugins = Object.keys(allPlugins);
|
||||||
|
const pluginMenus = availablePlugins
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aPluginLabel = allPlugins[a]?.name?.() ?? a;
|
||||||
|
const bPluginLabel = allPlugins[b]?.name?.() ?? b;
|
||||||
|
|
||||||
|
return aPluginLabel.localeCompare(bPluginLabel);
|
||||||
|
})
|
||||||
|
.map((id) => {
|
||||||
|
const predefinedTemplate = menuResult.find((it) => it[0] === id);
|
||||||
|
if (predefinedTemplate) return predefinedTemplate[1];
|
||||||
|
|
||||||
|
const pluginLabel = allPlugins[id]?.name?.() ?? id;
|
||||||
|
|
||||||
|
return pluginEnabledMenu(id, pluginLabel, true, innerRefreshMenu);
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableLanguages = Object.keys(languageResources);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: 'Plugins',
|
label: t('main.menu.plugins.label'),
|
||||||
submenu:
|
submenu: pluginMenus,
|
||||||
getAvailablePluginNames().map((pluginName) => {
|
|
||||||
let pluginLabel = pluginName;
|
|
||||||
if (betaPlugins.includes(pluginLabel)) {
|
|
||||||
pluginLabel += ' [beta]';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.hasOwn(pluginMenus, pluginName)) {
|
|
||||||
const getPluginMenu = pluginMenus[pluginName as keyof typeof pluginMenus];
|
|
||||||
|
|
||||||
if (!config.plugins.isEnabled(pluginName)) {
|
|
||||||
return pluginEnabledMenu(pluginName, pluginLabel, true, innerRefreshMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label: pluginLabel,
|
|
||||||
submenu: [
|
|
||||||
pluginEnabledMenu(pluginName, 'Enabled', true, innerRefreshMenu),
|
|
||||||
{ type: 'separator' },
|
|
||||||
...getPluginMenu(win, config.plugins.getOptions(pluginName), innerRefreshMenu),
|
|
||||||
],
|
|
||||||
} satisfies Electron.MenuItemConstructorOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pluginEnabledMenu(pluginName, pluginLabel);
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Options',
|
label: t('main.menu.options.label'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Auto-update',
|
label: t('main.menu.options.submenu.auto-update'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.autoUpdates'),
|
checked: config.get('options.autoUpdates'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.autoUpdates', item.checked);
|
config.setMenuOption('options.autoUpdates', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Resume last song when app starts',
|
label: t('main.menu.options.submenu.resume-on-start'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.resumeOnStart'),
|
checked: config.get('options.resumeOnStart'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.resumeOnStart', item.checked);
|
config.setMenuOption('options.resumeOnStart', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Starting page',
|
label: t('main.menu.options.submenu.starting-page.label'),
|
||||||
submenu: (() => {
|
submenu: (() => {
|
||||||
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
|
const subMenuArray: Electron.MenuItemConstructorOptions[] =
|
||||||
label: name,
|
Object.keys(startingPages).map((name) => ({
|
||||||
type: 'radio',
|
label: name,
|
||||||
checked: config.get('options.startingPage') === name,
|
type: 'radio',
|
||||||
click() {
|
checked: config.get('options.startingPage') === name,
|
||||||
config.set('options.startingPage', name);
|
click() {
|
||||||
},
|
config.set('options.startingPage', name);
|
||||||
}));
|
},
|
||||||
|
}));
|
||||||
subMenuArray.unshift({
|
subMenuArray.unshift({
|
||||||
label: 'Unset',
|
label: t('main.menu.options.submenu.starting-page.unset'),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.startingPage') === '',
|
checked: config.get('options.startingPage') === '',
|
||||||
click() {
|
click() {
|
||||||
@ -148,21 +163,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
})(),
|
})(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Visual Tweaks',
|
label: t('main.menu.options.submenu.visual-tweaks.label'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Remove upgrade button',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.remove-upgrade-button',
|
||||||
|
),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.removeUpgradeButton'),
|
checked: config.get('options.removeUpgradeButton'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.removeUpgradeButton', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.removeUpgradeButton',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Like buttons',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.label',
|
||||||
|
),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Default',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.default',
|
||||||
|
),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: !config.get('options.likeButtons'),
|
checked: !config.get('options.likeButtons'),
|
||||||
click() {
|
click() {
|
||||||
@ -170,7 +194,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Force show',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.force-show',
|
||||||
|
),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.likeButtons') === 'force',
|
checked: config.get('options.likeButtons') === 'force',
|
||||||
click() {
|
click() {
|
||||||
@ -178,7 +204,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hide',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.hide',
|
||||||
|
),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.likeButtons') === 'hide',
|
checked: config.get('options.likeButtons') === 'hide',
|
||||||
click() {
|
click() {
|
||||||
@ -188,10 +216,14 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Theme',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.theme.label',
|
||||||
|
),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'No theme',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
|
||||||
|
),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
|
checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
|
||||||
click() {
|
click() {
|
||||||
@ -200,7 +232,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Import custom CSS file',
|
label: t(
|
||||||
|
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.import-css-file',
|
||||||
|
),
|
||||||
type: 'normal',
|
type: 'normal',
|
||||||
async click() {
|
async click() {
|
||||||
const { filePaths } = await dialog.showOpenDialog({
|
const { filePaths } = await dialog.showOpenDialog({
|
||||||
@ -217,10 +251,10 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Single instance lock',
|
label: t('main.menu.options.submenu.single-instance-lock'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: true,
|
checked: true,
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
if (!item.checked && app.hasSingleInstanceLock()) {
|
if (!item.checked && app.hasSingleInstanceLock()) {
|
||||||
app.releaseSingleInstanceLock();
|
app.releaseSingleInstanceLock();
|
||||||
} else if (item.checked && !app.hasSingleInstanceLock()) {
|
} else if (item.checked && !app.hasSingleInstanceLock()) {
|
||||||
@ -229,51 +263,56 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Always on top',
|
label: t('main.menu.options.submenu.always-on-top'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.alwaysOnTop'),
|
checked: config.get('options.alwaysOnTop'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.alwaysOnTop', item.checked);
|
config.setMenuOption('options.alwaysOnTop', item.checked);
|
||||||
win.setAlwaysOnTop(item.checked);
|
win.setAlwaysOnTop(item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(is.windows() || is.linux()
|
...((is.windows() || is.linux()
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: 'Hide menu',
|
label: t('main.menu.options.submenu.hide-menu.label'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.hideMenu'),
|
checked: config.get('options.hideMenu'),
|
||||||
click(item) {
|
click(item) {
|
||||||
config.setMenuOption('options.hideMenu', item.checked);
|
config.setMenuOption('options.hideMenu', item.checked);
|
||||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
if (item.checked && !config.get('options.hideMenuWarned')) {
|
||||||
dialog.showMessageBox(win, {
|
dialog.showMessageBox(win, {
|
||||||
type: 'info', title: 'Hide Menu Enabled',
|
type: 'info',
|
||||||
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
|
title: t(
|
||||||
});
|
'main.menu.options.submenu.hide-menu.dialog.title',
|
||||||
}
|
),
|
||||||
|
message: t(
|
||||||
|
'main.menu.options.submenu.hide-menu.dialog.message',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]
|
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
...((is.windows() || is.macOS()
|
||||||
...(is.windows() || is.macOS()
|
|
||||||
? // Only works on Win/Mac
|
? // Only works on Win/Mac
|
||||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
label: 'Start at login',
|
label: t('main.menu.options.submenu.start-at-login'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.startAtLogin'),
|
checked: config.get('options.startAtLogin'),
|
||||||
click(item) {
|
click(item) {
|
||||||
config.setMenuOption('options.startAtLogin', item.checked);
|
config.setMenuOption('options.startAtLogin', item.checked);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]
|
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
|
||||||
{
|
{
|
||||||
label: 'Tray',
|
label: t('main.menu.options.submenu.tray.label'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Disabled',
|
label: t('main.menu.options.submenu.tray.submenu.disabled'),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: !config.get('options.tray'),
|
checked: !config.get('options.tray'),
|
||||||
click() {
|
click() {
|
||||||
@ -282,18 +321,24 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Enabled + app visible',
|
label: t(
|
||||||
|
'main.menu.options.submenu.tray.submenu.enabled-and-show-app',
|
||||||
|
),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.tray') && config.get('options.appVisible'),
|
checked:
|
||||||
|
config.get('options.tray') && config.get('options.appVisible'),
|
||||||
click() {
|
click() {
|
||||||
config.setMenuOption('options.tray', true);
|
config.setMenuOption('options.tray', true);
|
||||||
config.setMenuOption('options.appVisible', true);
|
config.setMenuOption('options.appVisible', true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Enabled + app hidden',
|
label: t(
|
||||||
|
'main.menu.options.submenu.tray.submenu.enabled-and-hide-app',
|
||||||
|
),
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
checked: config.get('options.tray') && !config.get('options.appVisible'),
|
checked:
|
||||||
|
config.get('options.tray') && !config.get('options.appVisible'),
|
||||||
click() {
|
click() {
|
||||||
config.setMenuOption('options.tray', true);
|
config.setMenuOption('options.tray', true);
|
||||||
config.setMenuOption('options.appVisible', false);
|
config.setMenuOption('options.appVisible', false);
|
||||||
@ -301,75 +346,143 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Play/Pause on click',
|
label: t(
|
||||||
|
'main.menu.options.submenu.tray.submenu.play-pause-on-click',
|
||||||
|
),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.trayClickPlayPause'),
|
checked: config.get('options.trayClickPlayPause'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.trayClickPlayPause', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.trayClickPlayPause',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
|
||||||
{
|
{
|
||||||
label: 'Advanced options',
|
label: t('main.menu.options.submenu.language.label') + ' (Language)',
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Set Proxy',
|
label: t(
|
||||||
|
'main.menu.options.submenu.language.submenu.to-help-translate',
|
||||||
|
),
|
||||||
type: 'normal',
|
type: 'normal',
|
||||||
async click(item) {
|
click() {
|
||||||
|
const url = 'https://hosted.weblate.org/engage/youtube-music/';
|
||||||
|
shell.openExternal(url);
|
||||||
|
},
|
||||||
|
} as Electron.MenuItemConstructorOptions,
|
||||||
|
].concat(
|
||||||
|
availableLanguages
|
||||||
|
.map(
|
||||||
|
(lang): Electron.MenuItemConstructorOptions => ({
|
||||||
|
label: `${languageResources[lang].translation.language?.name ?? 'Unknown'} (${languageResources[lang].translation.language?.['local-name'] ?? 'Unknown'})`,
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: (config.get('options.language') ?? 'en') === lang,
|
||||||
|
click() {
|
||||||
|
config.setMenuOption('options.language', lang);
|
||||||
|
refreshMenu(win);
|
||||||
|
setLanguage(lang);
|
||||||
|
dialog.showMessageBox(win, {
|
||||||
|
title: t(
|
||||||
|
'main.menu.options.submenu.language.dialog.title',
|
||||||
|
),
|
||||||
|
message: t(
|
||||||
|
'main.menu.options.submenu.language.dialog.message',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.label!.localeCompare(b.label!)),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: t('main.menu.options.submenu.advanced-options.label'),
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.set-proxy.label',
|
||||||
|
),
|
||||||
|
type: 'normal',
|
||||||
|
async click(item: MenuItem) {
|
||||||
await setProxy(item, win);
|
await setProxy(item, win);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Override useragent',
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.override-user-agent',
|
||||||
|
),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.overrideUserAgent'),
|
checked: config.get('options.overrideUserAgent'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.overrideUserAgent', item.checked);
|
config.setMenuOption('options.overrideUserAgent', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Disable hardware acceleration',
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.disable-hardware-acceleration',
|
||||||
|
),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.disableHardwareAcceleration'),
|
checked: config.get('options.disableHardwareAcceleration'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.disableHardwareAcceleration',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Restart on config changes',
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.restart-on-config-changes',
|
||||||
|
),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.restartOnConfigChanges'),
|
checked: config.get('options.restartOnConfigChanges'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.restartOnConfigChanges', item.checked);
|
config.setMenuOption(
|
||||||
|
'options.restartOnConfigChanges',
|
||||||
|
item.checked,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reset App cache when app starts',
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.auto-reset-app-cache',
|
||||||
|
),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: config.get('options.autoResetAppCache'),
|
checked: config.get('options.autoResetAppCache'),
|
||||||
click(item) {
|
click(item: MenuItem) {
|
||||||
config.setMenuOption('options.autoResetAppCache', item.checked);
|
config.setMenuOption('options.autoResetAppCache', item.checked);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
is.macOS()
|
is.macOS()
|
||||||
? {
|
? {
|
||||||
label: 'Toggle DevTools',
|
label: t(
|
||||||
// Cannot use "toggleDevTools" role in macOS
|
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||||
click() {
|
),
|
||||||
const { webContents } = win;
|
// Cannot use "toggleDevTools" role in macOS
|
||||||
if (webContents.isDevToolsOpened()) {
|
click() {
|
||||||
webContents.closeDevTools();
|
const { webContents } = win;
|
||||||
} else {
|
if (webContents.isDevToolsOpened()) {
|
||||||
webContents.openDevTools();
|
webContents.closeDevTools();
|
||||||
}
|
} else {
|
||||||
|
webContents.openDevTools();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||||
|
),
|
||||||
|
role: 'toggleDevTools',
|
||||||
},
|
},
|
||||||
}
|
|
||||||
: { role: 'toggleDevTools' },
|
|
||||||
{
|
{
|
||||||
label: 'Edit config.json',
|
label: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.edit-config-json',
|
||||||
|
),
|
||||||
click() {
|
click() {
|
||||||
config.edit();
|
config.edit();
|
||||||
},
|
},
|
||||||
@ -379,23 +492,55 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'View',
|
label: t('main.menu.view.label'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{ role: 'reload' },
|
{
|
||||||
{ role: 'forceReload' },
|
label: t('main.menu.view.submenu.reload'),
|
||||||
|
role: 'reload',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('main.menu.view.submenu.force-reload'),
|
||||||
|
role: 'forceReload',
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' },
|
{
|
||||||
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' },
|
label: t('main.menu.view.submenu.zoom-in'),
|
||||||
{ role: 'resetZoom' },
|
role: 'zoomIn',
|
||||||
|
accelerator: 'CmdOrCtrl+=',
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('main.menu.view.submenu.zoom-in'),
|
||||||
|
role: 'zoomIn',
|
||||||
|
accelerator: 'CmdOrCtrl+Plus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('main.menu.view.submenu.zoom-out'),
|
||||||
|
role: 'zoomOut',
|
||||||
|
accelerator: 'CmdOrCtrl+-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('main.menu.view.submenu.zoom-out'),
|
||||||
|
role: 'zoomOut',
|
||||||
|
accelerator: 'CmdOrCtrl+Shift+-',
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('main.menu.view.submenu.reset-zoom'),
|
||||||
|
role: 'resetZoom',
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ role: 'togglefullscreen' },
|
{
|
||||||
|
label: t('main.menu.view.submenu.toggle-fullscreen'),
|
||||||
|
role: 'togglefullscreen',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Navigation',
|
label: t('main.menu.navigation.label'),
|
||||||
submenu: [
|
submenu: [
|
||||||
{
|
{
|
||||||
label: 'Go back',
|
label: t('main.menu.navigation.submenu.go-back'),
|
||||||
click() {
|
click() {
|
||||||
if (win.webContents.canGoBack()) {
|
if (win.webContents.canGoBack()) {
|
||||||
win.webContents.goBack();
|
win.webContents.goBack();
|
||||||
@ -403,7 +548,7 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Go forward',
|
label: t('main.menu.navigation.submenu.go-forward'),
|
||||||
click() {
|
click() {
|
||||||
if (win.webContents.canGoForward()) {
|
if (win.webContents.canGoForward()) {
|
||||||
win.webContents.goForward();
|
win.webContents.goForward();
|
||||||
@ -411,29 +556,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Copy current URL',
|
label: t('main.menu.navigation.submenu.copy-current-url'),
|
||||||
click() {
|
click() {
|
||||||
const currentURL = win.webContents.getURL();
|
const currentURL = win.webContents.getURL();
|
||||||
clipboard.writeText(currentURL);
|
clipboard.writeText(currentURL);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Restart App',
|
label: t('main.menu.navigation.submenu.restart'),
|
||||||
click: restart,
|
click: restart,
|
||||||
},
|
},
|
||||||
{ role: 'quit' },
|
{
|
||||||
|
label: t('main.menu.navigation.submenu.quit'),
|
||||||
|
role: 'quit',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'About',
|
label: t('main.menu.about'),
|
||||||
submenu: [
|
submenu: [{ role: 'about' }],
|
||||||
{ role: 'about' },
|
},
|
||||||
],
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
|
||||||
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
|
const menuTemplate: MenuTemplate = [...(await mainMenuTemplate(win))];
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
const { name } = app;
|
const { name } = app;
|
||||||
menuTemplate.unshift({
|
menuTemplate.unshift({
|
||||||
@ -462,23 +608,33 @@ export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
|
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
|
||||||
const output = await prompt({
|
const output = await prompt(
|
||||||
title: 'Set Proxy',
|
{
|
||||||
label: 'Enter Proxy Address: (leave empty to disable)',
|
title: t(
|
||||||
value: config.get('options.proxy'),
|
'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.title',
|
||||||
type: 'input',
|
),
|
||||||
inputAttrs: {
|
label: t(
|
||||||
type: 'url',
|
'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.label',
|
||||||
placeholder: "Example: 'socks5://127.0.0.1:9999",
|
),
|
||||||
|
value: config.get('options.proxy'),
|
||||||
|
type: 'input',
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'url',
|
||||||
|
placeholder: t(
|
||||||
|
'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.placeholder',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
width: 450,
|
||||||
|
...promptOptions(),
|
||||||
},
|
},
|
||||||
width: 450,
|
win,
|
||||||
...promptOptions(),
|
);
|
||||||
}, win);
|
|
||||||
|
|
||||||
if (typeof output === 'string') {
|
if (typeof output === 'string') {
|
||||||
config.setMenuOption('options.proxy', output);
|
config.setMenuOption('options.proxy', output);
|
||||||
item.checked = output !== '';
|
item.checked = output !== '';
|
||||||
} else { // User pressed cancel
|
} else {
|
||||||
|
// User pressed cancel
|
||||||
item.checked = !item.checked; // Reset checkbox
|
item.checked = !item.checked; // Reset checkbox
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/navigation.d.ts
vendored
7
src/navigation.d.ts
vendored
@ -62,7 +62,10 @@ interface Navigation extends EventTarget {
|
|||||||
onnavigateerror: ((this: Navigation, ev: Event) => any) | null;
|
onnavigateerror: ((this: Navigation, ev: Event) => any) | null;
|
||||||
oncurrententrychange: ((this: Navigation, ev: Event) => any) | null;
|
oncurrententrychange: ((this: Navigation, ev: Event) => any) | null;
|
||||||
|
|
||||||
addEventListener<K extends keyof NavigationEventsMap>(name: K, listener: (event: NavigationEventsMap[K]) => void);
|
addEventListener<K extends keyof NavigationEventsMap>(
|
||||||
|
name: K,
|
||||||
|
listener: (event: NavigationEventsMap[K]) => void,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
declare class NavigateEvent extends Event {
|
declare class NavigateEvent extends Event {
|
||||||
@ -84,5 +87,5 @@ type NavigationHistoryBehavior = 'auto' | 'push' | 'replace';
|
|||||||
|
|
||||||
declare const Navigation: {
|
declare const Navigation: {
|
||||||
prototype: Navigation;
|
prototype: Navigation;
|
||||||
new(): Navigation;
|
new (): Navigation;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { loadAdBlockerEngine } from './blocker';
|
|
||||||
import { shouldUseBlocklists } from './config';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
type AdBlockOptions = ConfigType<'adblocker'>;
|
|
||||||
|
|
||||||
export default async (win: BrowserWindow, options: AdBlockOptions) => {
|
|
||||||
if (shouldUseBlocklists()) {
|
|
||||||
await loadAdBlockerEngine(
|
|
||||||
win.webContents.session,
|
|
||||||
options.cache,
|
|
||||||
options.additionalBlockLists,
|
|
||||||
options.disableDefaultLists,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -17,10 +17,12 @@ const SOURCES = [
|
|||||||
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
|
'https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let blocker: ElectronBlocker | undefined;
|
||||||
|
|
||||||
export const loadAdBlockerEngine = async (
|
export const loadAdBlockerEngine = async (
|
||||||
session: Electron.Session | undefined = undefined,
|
session: Electron.Session | undefined = undefined,
|
||||||
cache = true,
|
cache: boolean = true,
|
||||||
additionalBlockLists = [],
|
additionalBlockLists: string[] = [],
|
||||||
disableDefaultLists: boolean | unknown[] = false,
|
disableDefaultLists: boolean | unknown[] = false,
|
||||||
) => {
|
) => {
|
||||||
// Only use cache if no additional blocklists are passed
|
// Only use cache if no additional blocklists are passed
|
||||||
@ -28,24 +30,24 @@ export const loadAdBlockerEngine = async (
|
|||||||
if (!fs.existsSync(cacheDirectory)) {
|
if (!fs.existsSync(cacheDirectory)) {
|
||||||
fs.mkdirSync(cacheDirectory);
|
fs.mkdirSync(cacheDirectory);
|
||||||
}
|
}
|
||||||
const cachingOptions
|
const cachingOptions =
|
||||||
= cache && additionalBlockLists.length === 0
|
cache && additionalBlockLists.length === 0
|
||||||
? {
|
? {
|
||||||
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
|
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
|
||||||
read: promises.readFile,
|
read: promises.readFile,
|
||||||
write: promises.writeFile,
|
write: promises.writeFile,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const lists = [
|
const lists = [
|
||||||
...(
|
...((disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
|
||||||
(disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
|
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0)
|
||||||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES
|
? []
|
||||||
),
|
: SOURCES),
|
||||||
...additionalBlockLists,
|
...additionalBlockLists,
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blocker = await ElectronBlocker.fromLists(
|
blocker = await ElectronBlocker.fromLists(
|
||||||
(url: string) => net.fetch(url),
|
(url: string) => net.fetch(url),
|
||||||
lists,
|
lists,
|
||||||
{
|
{
|
||||||
@ -64,4 +66,11 @@ export const loadAdBlockerEngine = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { loadAdBlockerEngine };
|
export const unloadAdBlockerEngine = (session: Electron.Session) => {
|
||||||
|
if (blocker) {
|
||||||
|
blocker.disableBlockingInSession(session);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isBlockerEnabled = (session: Electron.Session) =>
|
||||||
|
blocker !== undefined && blocker.isBlockingEnabled(session);
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/await-thenable */
|
|
||||||
/* renderer */
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('adblocker', { enableFront: true });
|
|
||||||
|
|
||||||
export const shouldUseBlocklists = () => config.get('blocker') !== blockers.InPlayer;
|
|
||||||
|
|
||||||
export default Object.assign(config, {
|
|
||||||
shouldUseBlocklists,
|
|
||||||
blockers,
|
|
||||||
});
|
|
||||||
137
src/plugins/adblocker/index.ts
Normal file
137
src/plugins/adblocker/index.ts
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { blockers } from './types';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import {
|
||||||
|
isBlockerEnabled,
|
||||||
|
loadAdBlockerEngine,
|
||||||
|
unloadAdBlockerEngine,
|
||||||
|
} from './blocker';
|
||||||
|
|
||||||
|
import injectCliqzPreload from './injectors/inject-cliqz-preload';
|
||||||
|
import { inject, isInjected } from './injectors/inject';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
interface AdblockerConfig {
|
||||||
|
/**
|
||||||
|
* Whether to enable the adblocker.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* When enabled, the adblocker will cache the blocklists.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
cache: boolean;
|
||||||
|
/**
|
||||||
|
* Which adblocker to use.
|
||||||
|
* @default blockers.InPlayer
|
||||||
|
*/
|
||||||
|
blocker: (typeof blockers)[keyof typeof blockers];
|
||||||
|
/**
|
||||||
|
* Additional list of filters to use.
|
||||||
|
* @example ["https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt"]
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
additionalBlockLists: string[];
|
||||||
|
/**
|
||||||
|
* Disable the default blocklists.
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
disableDefaultLists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.adblocker.name'),
|
||||||
|
description: () => t('plugins.adblocker.description'),
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
cache: true,
|
||||||
|
blocker: blockers.InPlayer,
|
||||||
|
additionalBlockLists: [],
|
||||||
|
disableDefaultLists: false,
|
||||||
|
} as AdblockerConfig,
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('plugins.adblocker.menu.blocker'),
|
||||||
|
submenu: Object.values(blockers).map((blocker) => ({
|
||||||
|
label: blocker,
|
||||||
|
type: 'radio',
|
||||||
|
checked: (config.blocker || blockers.WithBlocklists) === blocker,
|
||||||
|
click() {
|
||||||
|
setConfig({ blocker });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
backend: {
|
||||||
|
mainWindow: null as BrowserWindow | null,
|
||||||
|
async start({ getConfig, window }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
this.mainWindow = window;
|
||||||
|
|
||||||
|
if (config.blocker === blockers.WithBlocklists) {
|
||||||
|
await loadAdBlockerEngine(
|
||||||
|
window.webContents.session,
|
||||||
|
config.cache,
|
||||||
|
config.additionalBlockLists,
|
||||||
|
config.disableDefaultLists,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop({ window }) {
|
||||||
|
if (isBlockerEnabled(window.webContents.session)) {
|
||||||
|
unloadAdBlockerEngine(window.webContents.session);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onConfigChange(newConfig) {
|
||||||
|
if (this.mainWindow) {
|
||||||
|
if (
|
||||||
|
newConfig.blocker === blockers.WithBlocklists &&
|
||||||
|
!isBlockerEnabled(this.mainWindow.webContents.session)
|
||||||
|
) {
|
||||||
|
await loadAdBlockerEngine(
|
||||||
|
this.mainWindow.webContents.session,
|
||||||
|
newConfig.cache,
|
||||||
|
newConfig.additionalBlockLists,
|
||||||
|
newConfig.disableDefaultLists,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
async start({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
if (config.blocker === blockers.WithBlocklists) {
|
||||||
|
// Preload adblocker to inject scripts/styles
|
||||||
|
await injectCliqzPreload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onConfigChange(newConfig) {
|
||||||
|
if (newConfig.blocker === blockers.WithBlocklists) {
|
||||||
|
await injectCliqzPreload();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
async start({ getConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
if (config.blocker === blockers.InPlayer && !isInjected()) {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
if (newConfig.blocker === blockers.InPlayer && !isInjected()) {
|
||||||
|
inject();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
});
|
||||||
3
src/plugins/adblocker/inject.d.ts
vendored
3
src/plugins/adblocker/inject.d.ts
vendored
@ -1,3 +0,0 @@
|
|||||||
const inject: () => void;
|
|
||||||
|
|
||||||
export default inject;
|
|
||||||
3
src/plugins/adblocker/injectors/inject.d.ts
vendored
Normal file
3
src/plugins/adblocker/injectors/inject.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const inject: () => void;
|
||||||
|
|
||||||
|
export const isInjected: () => boolean;
|
||||||
@ -7,7 +7,13 @@
|
|||||||
Parts of this code is derived from set-constant.js:
|
Parts of this code is derived from set-constant.js:
|
||||||
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
|
https://github.com/gorhill/uBlock/blob/5de0ce975753b7565759ac40983d31978d1f84ca/assets/resources/scriptlets.js#L704
|
||||||
*/
|
*/
|
||||||
module.exports = () => {
|
|
||||||
|
let injected = false;
|
||||||
|
|
||||||
|
export const isInjected = () => injected;
|
||||||
|
|
||||||
|
export const inject = () => {
|
||||||
|
injected = true;
|
||||||
{
|
{
|
||||||
const pruner = function (o) {
|
const pruner = function (o) {
|
||||||
delete o.playerAds;
|
delete o.playerAds;
|
||||||
@ -67,8 +73,7 @@ module.exports = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'noopFunc': {
|
case 'noopFunc': {
|
||||||
cValue = function () {
|
cValue = function () {};
|
||||||
};
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -97,7 +102,7 @@ module.exports = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(cValue) > 0x7F_FF) {
|
if (Math.abs(cValue) > 0x7f_ff) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -113,12 +118,12 @@ module.exports = () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
aborted
|
aborted =
|
||||||
= v !== undefined
|
v !== undefined &&
|
||||||
&& v !== null
|
v !== null &&
|
||||||
&& cValue !== undefined
|
cValue !== undefined &&
|
||||||
&& cValue !== null
|
cValue !== null &&
|
||||||
&& typeof v !== typeof cValue;
|
typeof v !== typeof cValue;
|
||||||
return aborted;
|
return aborted;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -266,8 +271,7 @@ module.exports = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'noopFunc': {
|
case 'noopFunc': {
|
||||||
cValue = function () {
|
cValue = function () {};
|
||||||
};
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -296,7 +300,7 @@ module.exports = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(cValue) > 0x7F_FF) {
|
if (Math.abs(cValue) > 0x7f_ff) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -312,12 +316,12 @@ module.exports = () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
aborted
|
aborted =
|
||||||
= v !== undefined
|
v !== undefined &&
|
||||||
&& v !== null
|
v !== null &&
|
||||||
&& cValue !== undefined
|
cValue !== undefined &&
|
||||||
&& cValue !== null
|
cValue !== null &&
|
||||||
&& typeof v !== typeof cValue;
|
typeof v !== typeof cValue;
|
||||||
return aborted;
|
return aborted;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (): MenuTemplate => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'Blocker',
|
|
||||||
submenu: Object.values(blockers).map((blocker: string) => ({
|
|
||||||
label: blocker,
|
|
||||||
type: 'radio',
|
|
||||||
checked: (config.get('blocker') || blockers.WithBlocklists) === blocker,
|
|
||||||
click() {
|
|
||||||
config.set('blocker', blocker);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import config, { shouldUseBlocklists } from './config';
|
|
||||||
import inject from './inject';
|
|
||||||
import injectCliqzPreload from './inject-cliqz-preload';
|
|
||||||
|
|
||||||
import { blockers } from './blocker-types';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
if (shouldUseBlocklists()) {
|
|
||||||
// Preload adblocker to inject scripts/styles
|
|
||||||
await injectCliqzPreload();
|
|
||||||
// eslint-disable-next-line @typescript-eslint/await-thenable
|
|
||||||
} else if ((config.get('blocker')) === blockers.InPlayer) {
|
|
||||||
inject();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import { FastAverageColor } from 'fast-average-color';
|
|
||||||
|
|
||||||
import { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
function hexToHSL(H: string) {
|
|
||||||
// Convert hex to RGB first
|
|
||||||
let r = 0;
|
|
||||||
let g = 0;
|
|
||||||
let b = 0;
|
|
||||||
if (H.length == 4) {
|
|
||||||
r = Number('0x' + H[1] + H[1]);
|
|
||||||
g = Number('0x' + H[2] + H[2]);
|
|
||||||
b = Number('0x' + H[3] + H[3]);
|
|
||||||
} else if (H.length == 7) {
|
|
||||||
r = Number('0x' + H[1] + H[2]);
|
|
||||||
g = Number('0x' + H[3] + H[4]);
|
|
||||||
b = Number('0x' + H[5] + H[6]);
|
|
||||||
}
|
|
||||||
// Then to HSL
|
|
||||||
r /= 255;
|
|
||||||
g /= 255;
|
|
||||||
b /= 255;
|
|
||||||
const cmin = Math.min(r, g, b);
|
|
||||||
const cmax = Math.max(r, g, b);
|
|
||||||
const delta = cmax - cmin;
|
|
||||||
let h: number;
|
|
||||||
let s: number;
|
|
||||||
let l: number;
|
|
||||||
|
|
||||||
if (delta == 0) {
|
|
||||||
h = 0;
|
|
||||||
} else if (cmax == r) {
|
|
||||||
h = ((g - b) / delta) % 6;
|
|
||||||
} else if (cmax == g) {
|
|
||||||
h = ((b - r) / delta) + 2;
|
|
||||||
} else {
|
|
||||||
h = ((r - g) / delta) + 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
h = Math.round(h * 60);
|
|
||||||
|
|
||||||
if (h < 0) {
|
|
||||||
h += 360;
|
|
||||||
}
|
|
||||||
|
|
||||||
l = (cmax + cmin) / 2;
|
|
||||||
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
|
|
||||||
s = +(s * 100).toFixed(1);
|
|
||||||
l = +(l * 100).toFixed(1);
|
|
||||||
|
|
||||||
//return "hsl(" + h + "," + s + "%," + l + "%)";
|
|
||||||
return [h,s,l];
|
|
||||||
}
|
|
||||||
|
|
||||||
let hue = 0;
|
|
||||||
let saturation = 0;
|
|
||||||
let lightness = 0;
|
|
||||||
|
|
||||||
function changeElementColor(element: HTMLElement | null, hue: number, saturation: number, lightness: number){
|
|
||||||
if (element) {
|
|
||||||
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (_: ConfigType<'album-color-theme'>) => {
|
|
||||||
// updated elements
|
|
||||||
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
|
||||||
const navBarBackground = document.querySelector<HTMLElement>('#nav-bar-background');
|
|
||||||
const ytmusicPlayerBar = document.querySelector<HTMLElement>('ytmusic-player-bar');
|
|
||||||
const playerBarBackground = document.querySelector<HTMLElement>('#player-bar-background');
|
|
||||||
const sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
|
|
||||||
const sidebarSmall = document.querySelector<HTMLElement>('#mini-guide-background');
|
|
||||||
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutationsList) => {
|
|
||||||
for (const mutation of mutationsList) {
|
|
||||||
if (mutation.type === 'attributes') {
|
|
||||||
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
|
||||||
if (isPageOpen) {
|
|
||||||
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
|
|
||||||
} else {
|
|
||||||
if (sidebarSmall) {
|
|
||||||
sidebarSmall.style.backgroundColor = 'black';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (playerPage) {
|
|
||||||
observer.observe(playerPage, { attributes: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', (apiEvent) => {
|
|
||||||
const fastAverageColor = new FastAverageColor();
|
|
||||||
|
|
||||||
apiEvent.detail.addEventListener('videodatachange', (name: string) => {
|
|
||||||
if (name === 'dataloaded') {
|
|
||||||
const playerResponse = apiEvent.detail.getPlayerResponse();
|
|
||||||
const thumbnail = playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
|
||||||
if (thumbnail) {
|
|
||||||
fastAverageColor.getColorAsync(thumbnail.url)
|
|
||||||
.then((albumColor) => {
|
|
||||||
if (albumColor) {
|
|
||||||
[hue, saturation, lightness] = hexToHSL(albumColor.hex);
|
|
||||||
changeElementColor(playerPage, hue, saturation, lightness - 30);
|
|
||||||
changeElementColor(navBarBackground, hue, saturation, lightness - 15);
|
|
||||||
changeElementColor(ytmusicPlayerBar, hue, saturation, lightness - 15);
|
|
||||||
changeElementColor(playerBarBackground, hue, saturation, lightness - 15);
|
|
||||||
changeElementColor(sidebarBig, hue, saturation, lightness - 15);
|
|
||||||
if (ytmusicAppLayout?.hasAttribute('player-page-open')) {
|
|
||||||
changeElementColor(sidebarSmall, hue, saturation, lightness - 30);
|
|
||||||
}
|
|
||||||
const ytRightClickList = document.querySelector<HTMLElement>('tp-yt-paper-listbox');
|
|
||||||
changeElementColor(ytRightClickList, hue, saturation, lightness - 15);
|
|
||||||
} else {
|
|
||||||
if (playerPage) {
|
|
||||||
playerPage.style.backgroundColor = '#000000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => console.error(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
215
src/plugins/album-color-theme/index.ts
Normal file
215
src/plugins/album-color-theme/index.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { FastAverageColor } from 'fast-average-color';
|
||||||
|
|
||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.album-color-theme.name'),
|
||||||
|
description: () => t('plugins.album-color-theme.description'),
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
stylesheets: [style],
|
||||||
|
renderer: {
|
||||||
|
hexToHSL: (H: string) => {
|
||||||
|
// Convert hex to RGB first
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (H.length == 4) {
|
||||||
|
r = Number('0x' + H[1] + H[1]);
|
||||||
|
g = Number('0x' + H[2] + H[2]);
|
||||||
|
b = Number('0x' + H[3] + H[3]);
|
||||||
|
} else if (H.length == 7) {
|
||||||
|
r = Number('0x' + H[1] + H[2]);
|
||||||
|
g = Number('0x' + H[3] + H[4]);
|
||||||
|
b = Number('0x' + H[5] + H[6]);
|
||||||
|
}
|
||||||
|
// Then to HSL
|
||||||
|
r /= 255;
|
||||||
|
g /= 255;
|
||||||
|
b /= 255;
|
||||||
|
const cmin = Math.min(r, g, b);
|
||||||
|
const cmax = Math.max(r, g, b);
|
||||||
|
const delta = cmax - cmin;
|
||||||
|
let h: number;
|
||||||
|
let s: number;
|
||||||
|
let l: number;
|
||||||
|
|
||||||
|
if (delta == 0) {
|
||||||
|
h = 0;
|
||||||
|
} else if (cmax == r) {
|
||||||
|
h = ((g - b) / delta) % 6;
|
||||||
|
} else if (cmax == g) {
|
||||||
|
h = ((b - r) / delta) + 2;
|
||||||
|
} else {
|
||||||
|
h = ((r - g) / delta) + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
h = Math.round(h * 60);
|
||||||
|
|
||||||
|
if (h < 0) {
|
||||||
|
h += 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
l = (cmax + cmin) / 2;
|
||||||
|
s = delta == 0 ? 0 : delta / (1 - Math.abs((2 * l) - 1));
|
||||||
|
s = +(s * 100).toFixed(1);
|
||||||
|
l = +(l * 100).toFixed(1);
|
||||||
|
|
||||||
|
//return "hsl(" + h + "," + s + "%," + l + "%)";
|
||||||
|
return [h, s, l];
|
||||||
|
},
|
||||||
|
hue: 0,
|
||||||
|
saturation: 0,
|
||||||
|
lightness: 0,
|
||||||
|
|
||||||
|
changeElementColor: (
|
||||||
|
element: HTMLElement | null,
|
||||||
|
hue: number,
|
||||||
|
saturation: number,
|
||||||
|
lightness: number,
|
||||||
|
) => {
|
||||||
|
if (element) {
|
||||||
|
element.style.backgroundColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
playerPage: null as HTMLElement | null,
|
||||||
|
navBarBackground: null as HTMLElement | null,
|
||||||
|
ytmusicPlayerBar: null as HTMLElement | null,
|
||||||
|
playerBarBackground: null as HTMLElement | null,
|
||||||
|
sidebarBig: null as HTMLElement | null,
|
||||||
|
sidebarSmall: null as HTMLElement | null,
|
||||||
|
ytmusicAppLayout: null as HTMLElement | null,
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||||
|
this.navBarBackground = document.querySelector<HTMLElement>(
|
||||||
|
'#nav-bar-background',
|
||||||
|
);
|
||||||
|
this.ytmusicPlayerBar =
|
||||||
|
document.querySelector<HTMLElement>('ytmusic-player-bar');
|
||||||
|
this.playerBarBackground = document.querySelector<HTMLElement>(
|
||||||
|
'#player-bar-background',
|
||||||
|
);
|
||||||
|
this.sidebarBig = document.querySelector<HTMLElement>('#guide-wrapper');
|
||||||
|
this.sidebarSmall = document.querySelector<HTMLElement>(
|
||||||
|
'#mini-guide-background',
|
||||||
|
);
|
||||||
|
this.ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
for (const mutation of mutationsList) {
|
||||||
|
if (mutation.type === 'attributes') {
|
||||||
|
const isPageOpen =
|
||||||
|
this.ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||||
|
if (isPageOpen) {
|
||||||
|
this.changeElementColor(
|
||||||
|
this.sidebarSmall,
|
||||||
|
this.hue,
|
||||||
|
this.saturation,
|
||||||
|
this.lightness - 30,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (this.sidebarSmall) {
|
||||||
|
this.sidebarSmall.style.backgroundColor = 'black';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.playerPage) {
|
||||||
|
observer.observe(this.playerPage, { attributes: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPlayerApiReady(playerApi) {
|
||||||
|
const fastAverageColor = new FastAverageColor();
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'videodatachange',
|
||||||
|
(event: CustomEvent<VideoDataChanged>) => {
|
||||||
|
if (event.detail.name === 'dataloaded') {
|
||||||
|
const playerResponse = playerApi.getPlayerResponse();
|
||||||
|
const thumbnail =
|
||||||
|
playerResponse?.videoDetails?.thumbnail?.thumbnails?.at(0);
|
||||||
|
if (thumbnail) {
|
||||||
|
fastAverageColor
|
||||||
|
.getColorAsync(thumbnail.url)
|
||||||
|
.then((albumColor) => {
|
||||||
|
if (albumColor) {
|
||||||
|
const [hue, saturation, lightness] = ([
|
||||||
|
this.hue,
|
||||||
|
this.saturation,
|
||||||
|
this.lightness,
|
||||||
|
] = this.hexToHSL(albumColor.hex));
|
||||||
|
this.changeElementColor(
|
||||||
|
this.playerPage,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 30,
|
||||||
|
);
|
||||||
|
this.changeElementColor(
|
||||||
|
this.navBarBackground,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 15,
|
||||||
|
);
|
||||||
|
this.changeElementColor(
|
||||||
|
this.ytmusicPlayerBar,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 15,
|
||||||
|
);
|
||||||
|
this.changeElementColor(
|
||||||
|
this.playerBarBackground,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 15,
|
||||||
|
);
|
||||||
|
this.changeElementColor(
|
||||||
|
this.sidebarBig,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 15,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
this.ytmusicAppLayout?.hasAttribute('player-page-open')
|
||||||
|
) {
|
||||||
|
this.changeElementColor(
|
||||||
|
this.sidebarSmall,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 30,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const ytRightClickList =
|
||||||
|
document.querySelector<HTMLElement>(
|
||||||
|
'tp-yt-paper-listbox',
|
||||||
|
);
|
||||||
|
this.changeElementColor(
|
||||||
|
ytRightClickList,
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness - 15,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (this.playerPage) {
|
||||||
|
this.playerPage.style.backgroundColor = '#000000';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => console.error(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -4,23 +4,33 @@ yt-page-navigation-progress {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#player-page {
|
#player-page {
|
||||||
transition: transform 300ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
transition:
|
||||||
|
transform 300ms,
|
||||||
|
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-bar-background {
|
#nav-bar-background {
|
||||||
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
transition:
|
||||||
|
opacity 200ms,
|
||||||
|
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mini-guide-background {
|
#mini-guide-background {
|
||||||
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
transition:
|
||||||
|
opacity 200ms,
|
||||||
|
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
|
||||||
border-right: 0px !important;
|
border-right: 0px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#guide-wrapper {
|
#guide-wrapper {
|
||||||
transition: opacity 200ms,background-color 300ms cubic-bezier(0.2,0,0.6,1) !important;
|
transition:
|
||||||
|
opacity 200ms,
|
||||||
|
background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#img, #player, .song-media-controls.style-scope.ytmusic-player {
|
#img,
|
||||||
|
#player,
|
||||||
|
.song-media-controls.style-scope.ytmusic-player {
|
||||||
border-radius: 2% !important;
|
border-radius: 2% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
config.subscribeAll((newConfig) => {
|
|
||||||
win.webContents.send('ambient-mode:config-change', newConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('ambient-mode');
|
|
||||||
export default config;
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (config: ConfigType<'ambient-mode'>) => {
|
|
||||||
let interpolationTime = config.interpolationTime; // interpolation time (ms)
|
|
||||||
let buffer = config.buffer; // frame
|
|
||||||
let qualityRatio = config.quality; // width size (pixel)
|
|
||||||
let sizeRatio = config.size / 100; // size ratio (percent)
|
|
||||||
let blur = config.blur; // blur (pixel)
|
|
||||||
let opacity = config.opacity; // opacity (percent)
|
|
||||||
let isFullscreen = config.fullscreen; // fullscreen (boolean)
|
|
||||||
|
|
||||||
let unregister: (() => void) | null = null;
|
|
||||||
|
|
||||||
const injectBlurVideo = (): (() => void) | null => {
|
|
||||||
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
|
|
||||||
const video = document.querySelector<HTMLVideoElement>('#song-video .html5-video-container > video');
|
|
||||||
const wrapper = document.querySelector('#song-video > .player-wrapper');
|
|
||||||
|
|
||||||
if (!songVideo) return null;
|
|
||||||
if (!video) return null;
|
|
||||||
if (!wrapper) return null;
|
|
||||||
|
|
||||||
const blurCanvas = document.createElement('canvas');
|
|
||||||
blurCanvas.classList.add('html5-blur-canvas');
|
|
||||||
|
|
||||||
const context = blurCanvas.getContext('2d', { willReadFrequently: true });
|
|
||||||
|
|
||||||
/* effect */
|
|
||||||
let lastEffectWorkId: number | null = null;
|
|
||||||
let lastImageData: ImageData | null = null;
|
|
||||||
|
|
||||||
const onSync = () => {
|
|
||||||
if (typeof lastEffectWorkId === 'number') cancelAnimationFrame(lastEffectWorkId);
|
|
||||||
|
|
||||||
lastEffectWorkId = requestAnimationFrame(() => {
|
|
||||||
if (!context) return;
|
|
||||||
|
|
||||||
const width = qualityRatio;
|
|
||||||
let height = Math.max(Math.floor(blurCanvas.height / blurCanvas.width * width), 1);
|
|
||||||
if (!Number.isFinite(height)) height = width;
|
|
||||||
|
|
||||||
context.globalAlpha = 1;
|
|
||||||
if (lastImageData) {
|
|
||||||
const frameOffset = (1 / buffer) * (1000 / interpolationTime);
|
|
||||||
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
|
|
||||||
context.putImageData(lastImageData, 0, 0);
|
|
||||||
context.globalAlpha = frameOffset;
|
|
||||||
}
|
|
||||||
context.drawImage(video, 0, 0, width, height);
|
|
||||||
|
|
||||||
const nowImageData = context.getImageData(0, 0, width, height);
|
|
||||||
lastImageData = nowImageData;
|
|
||||||
|
|
||||||
lastEffectWorkId = null;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyVideoAttributes = () => {
|
|
||||||
const rect = video.getBoundingClientRect();
|
|
||||||
|
|
||||||
const newWidth = Math.floor(video.width || rect.width);
|
|
||||||
const newHeight = Math.floor(video.height || rect.height);
|
|
||||||
|
|
||||||
if (newWidth === 0 || newHeight === 0) return;
|
|
||||||
|
|
||||||
blurCanvas.width = qualityRatio;
|
|
||||||
blurCanvas.height = Math.floor(newHeight / newWidth * qualityRatio);
|
|
||||||
blurCanvas.style.width = `${newWidth * sizeRatio}px`;
|
|
||||||
blurCanvas.style.height = `${newHeight * sizeRatio}px`;
|
|
||||||
|
|
||||||
if (isFullscreen) blurCanvas.classList.add('fullscreen');
|
|
||||||
else blurCanvas.classList.remove('fullscreen');
|
|
||||||
|
|
||||||
const leftOffset = newWidth * (sizeRatio - 1) / 2;
|
|
||||||
const topOffset = newHeight * (sizeRatio - 1) / 2;
|
|
||||||
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
|
||||||
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
|
|
||||||
blurCanvas.style.setProperty('--blur', `${blur}px`);
|
|
||||||
blurCanvas.style.setProperty('--opacity', `${opacity}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
mutations.forEach((mutation) => {
|
|
||||||
if (mutation.type === 'attributes') {
|
|
||||||
applyVideoAttributes();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
applyVideoAttributes();
|
|
||||||
});
|
|
||||||
const onConfigSync = (_: Electron.IpcRendererEvent, newConfig: ConfigType<'ambient-mode'>) => {
|
|
||||||
if (typeof newConfig.interpolationTime === 'number') interpolationTime = newConfig.interpolationTime;
|
|
||||||
if (typeof newConfig.buffer === 'number') buffer = newConfig.buffer;
|
|
||||||
if (typeof newConfig.quality === 'number') qualityRatio = newConfig.quality;
|
|
||||||
if (typeof newConfig.size === 'number') sizeRatio = newConfig.size / 100;
|
|
||||||
if (typeof newConfig.blur === 'number') blur = newConfig.blur;
|
|
||||||
if (typeof newConfig.opacity === 'number') opacity = newConfig.opacity;
|
|
||||||
if (typeof newConfig.fullscreen === 'boolean') isFullscreen = newConfig.fullscreen;
|
|
||||||
|
|
||||||
applyVideoAttributes();
|
|
||||||
};
|
|
||||||
ipcRenderer.on('ambient-mode:config-change', onConfigSync);
|
|
||||||
|
|
||||||
/* hooking */
|
|
||||||
let canvasInterval: NodeJS.Timeout | null = null;
|
|
||||||
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
|
|
||||||
applyVideoAttributes();
|
|
||||||
observer.observe(songVideo, { attributes: true });
|
|
||||||
resizeObserver.observe(songVideo);
|
|
||||||
window.addEventListener('resize', applyVideoAttributes);
|
|
||||||
|
|
||||||
const onPause = () => {
|
|
||||||
if (canvasInterval) clearInterval(canvasInterval);
|
|
||||||
canvasInterval = null;
|
|
||||||
};
|
|
||||||
const onPlay = () => {
|
|
||||||
if (canvasInterval) clearInterval(canvasInterval);
|
|
||||||
canvasInterval = setInterval(onSync, Math.max(1, Math.ceil(1000 / buffer)));
|
|
||||||
};
|
|
||||||
songVideo.addEventListener('pause', onPause);
|
|
||||||
songVideo.addEventListener('play', onPlay);
|
|
||||||
|
|
||||||
/* injecting */
|
|
||||||
wrapper.prepend(blurCanvas);
|
|
||||||
|
|
||||||
/* cleanup */
|
|
||||||
return () => {
|
|
||||||
if (canvasInterval) clearInterval(canvasInterval);
|
|
||||||
|
|
||||||
songVideo.removeEventListener('pause', onPause);
|
|
||||||
songVideo.removeEventListener('play', onPlay);
|
|
||||||
|
|
||||||
observer.disconnect();
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
ipcRenderer.off('ambient-mode:config-change', onConfigSync);
|
|
||||||
window.removeEventListener('resize', applyVideoAttributes);
|
|
||||||
|
|
||||||
wrapper.removeChild(blurCanvas);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
|
||||||
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
|
||||||
|
|
||||||
const observer = new MutationObserver((mutationsList) => {
|
|
||||||
for (const mutation of mutationsList) {
|
|
||||||
if (mutation.type === 'attributes') {
|
|
||||||
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
|
||||||
if (isPageOpen) {
|
|
||||||
unregister?.();
|
|
||||||
unregister = injectBlurVideo() ?? null;
|
|
||||||
} else {
|
|
||||||
unregister?.();
|
|
||||||
unregister = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (playerPage) {
|
|
||||||
observer.observe(playerPage, { attributes: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
329
src/plugins/ambient-mode/index.ts
Normal file
329
src/plugins/ambient-mode/index.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export type AmbientModePluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
quality: number;
|
||||||
|
buffer: number;
|
||||||
|
interpolationTime: number;
|
||||||
|
blur: number;
|
||||||
|
size: number;
|
||||||
|
opacity: number;
|
||||||
|
fullscreen: boolean;
|
||||||
|
};
|
||||||
|
const defaultConfig: AmbientModePluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
quality: 50,
|
||||||
|
buffer: 30,
|
||||||
|
interpolationTime: 1500,
|
||||||
|
blur: 100,
|
||||||
|
size: 100,
|
||||||
|
opacity: 1,
|
||||||
|
fullscreen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.ambient-mode.name'),
|
||||||
|
description: () => t('plugins.ambient-mode.description'),
|
||||||
|
restartNeeded: false,
|
||||||
|
config: defaultConfig,
|
||||||
|
stylesheets: [style],
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
||||||
|
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
||||||
|
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
||||||
|
const bufferList = [1, 5, 10, 20, 30];
|
||||||
|
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
|
||||||
|
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.smoothness-transition.label'),
|
||||||
|
submenu: interpolationTimeList.map((interpolationTime) => ({
|
||||||
|
label: t(
|
||||||
|
'plugins.ambient-mode.menu.smoothness-transition.submenu.during',
|
||||||
|
{
|
||||||
|
interpolationTime: interpolationTime / 1000,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.interpolationTime === interpolationTime,
|
||||||
|
click() {
|
||||||
|
setConfig({ interpolationTime });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.quality.label'),
|
||||||
|
submenu: qualityList.map((quality) => ({
|
||||||
|
label: t('plugins.ambient-mode.menu.quality.submenu.pixels', {
|
||||||
|
quality,
|
||||||
|
}),
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.quality === quality,
|
||||||
|
click() {
|
||||||
|
setConfig({ quality });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.size.label'),
|
||||||
|
submenu: sizeList.map((size) => ({
|
||||||
|
label: t('plugins.ambient-mode.menu.size.submenu.percent', { size }),
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.size === size,
|
||||||
|
click() {
|
||||||
|
setConfig({ size });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.buffer.label'),
|
||||||
|
submenu: bufferList.map((buffer) => ({
|
||||||
|
label: t('plugins.ambient-mode.menu.buffer.submenu.buffer', {
|
||||||
|
buffer,
|
||||||
|
}),
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.buffer === buffer,
|
||||||
|
click() {
|
||||||
|
setConfig({ buffer });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.opacity.label'),
|
||||||
|
submenu: opacityList.map((opacity) => ({
|
||||||
|
label: t('plugins.ambient-mode.menu.opacity.submenu.percent', {
|
||||||
|
opacity: opacity * 100,
|
||||||
|
}),
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.opacity === opacity,
|
||||||
|
click() {
|
||||||
|
setConfig({ opacity });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.blur-amount.label'),
|
||||||
|
submenu: blurAmountList.map((blur) => ({
|
||||||
|
label: t('plugins.ambient-mode.menu.blur-amount.submenu.pixels', {
|
||||||
|
blurAmount: blur,
|
||||||
|
}),
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.blur === blur,
|
||||||
|
click() {
|
||||||
|
setConfig({ blur });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.ambient-mode.menu.use-fullscreen.label'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.fullscreen,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ fullscreen: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
interpolationTime: defaultConfig.interpolationTime,
|
||||||
|
buffer: defaultConfig.buffer,
|
||||||
|
qualityRatio: defaultConfig.quality,
|
||||||
|
sizeRatio: defaultConfig.size / 100,
|
||||||
|
blur: defaultConfig.blur,
|
||||||
|
opacity: defaultConfig.opacity,
|
||||||
|
isFullscreen: defaultConfig.fullscreen,
|
||||||
|
|
||||||
|
unregister: null as (() => void) | null,
|
||||||
|
update: null as (() => void) | null,
|
||||||
|
observer: null as MutationObserver | null,
|
||||||
|
|
||||||
|
start() {
|
||||||
|
const injectBlurVideo = (): (() => void) | null => {
|
||||||
|
const songVideo = document.querySelector<HTMLDivElement>('#song-video');
|
||||||
|
const video = document.querySelector<HTMLVideoElement>(
|
||||||
|
'#song-video .html5-video-container > video',
|
||||||
|
);
|
||||||
|
const wrapper = document.querySelector('#song-video > .player-wrapper');
|
||||||
|
|
||||||
|
if (!songVideo) return null;
|
||||||
|
if (!video) return null;
|
||||||
|
if (!wrapper) return null;
|
||||||
|
|
||||||
|
const blurCanvas = document.createElement('canvas');
|
||||||
|
blurCanvas.classList.add('html5-blur-canvas');
|
||||||
|
|
||||||
|
const context = blurCanvas.getContext('2d', {
|
||||||
|
willReadFrequently: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* effect */
|
||||||
|
let lastEffectWorkId: number | null = null;
|
||||||
|
let lastImageData: ImageData | null = null;
|
||||||
|
|
||||||
|
const onSync = () => {
|
||||||
|
if (typeof lastEffectWorkId === 'number')
|
||||||
|
cancelAnimationFrame(lastEffectWorkId);
|
||||||
|
|
||||||
|
lastEffectWorkId = requestAnimationFrame(() => {
|
||||||
|
// console.log('context', context);
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
const width = this.qualityRatio;
|
||||||
|
let height = Math.max(
|
||||||
|
Math.floor((blurCanvas.height / blurCanvas.width) * width),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(height)) height = width;
|
||||||
|
if (!height) return;
|
||||||
|
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
if (lastImageData) {
|
||||||
|
const frameOffset =
|
||||||
|
(1 / this.buffer) * (1000 / this.interpolationTime);
|
||||||
|
context.globalAlpha = 1 - (frameOffset * 2); // because of alpha value must be < 1
|
||||||
|
context.putImageData(lastImageData, 0, 0);
|
||||||
|
context.globalAlpha = frameOffset;
|
||||||
|
}
|
||||||
|
context.drawImage(video, 0, 0, width, height);
|
||||||
|
|
||||||
|
lastImageData = context.getImageData(0, 0, width, height); // current image data
|
||||||
|
|
||||||
|
lastEffectWorkId = null;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyVideoAttributes = () => {
|
||||||
|
const rect = video.getBoundingClientRect();
|
||||||
|
|
||||||
|
const newWidth = Math.floor(video.width || rect.width);
|
||||||
|
const newHeight = Math.floor(video.height || rect.height);
|
||||||
|
|
||||||
|
if (newWidth === 0 || newHeight === 0) return;
|
||||||
|
|
||||||
|
blurCanvas.width = this.qualityRatio;
|
||||||
|
blurCanvas.height = Math.floor(
|
||||||
|
(newHeight / newWidth) * this.qualityRatio,
|
||||||
|
);
|
||||||
|
blurCanvas.style.width = `${newWidth * this.sizeRatio}px`;
|
||||||
|
blurCanvas.style.height = `${newHeight * this.sizeRatio}px`;
|
||||||
|
|
||||||
|
if (this.isFullscreen) blurCanvas.classList.add('fullscreen');
|
||||||
|
else blurCanvas.classList.remove('fullscreen');
|
||||||
|
|
||||||
|
const leftOffset = (newWidth * (this.sizeRatio - 1)) / 2;
|
||||||
|
const topOffset = (newHeight * (this.sizeRatio - 1)) / 2;
|
||||||
|
blurCanvas.style.setProperty('--left', `${-1 * leftOffset}px`);
|
||||||
|
blurCanvas.style.setProperty('--top', `${-1 * topOffset}px`);
|
||||||
|
blurCanvas.style.setProperty('--blur', `${this.blur}px`);
|
||||||
|
blurCanvas.style.setProperty('--opacity', `${this.opacity}`);
|
||||||
|
};
|
||||||
|
this.update = applyVideoAttributes;
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'attributes') {
|
||||||
|
applyVideoAttributes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
applyVideoAttributes();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* hooking */
|
||||||
|
let canvasInterval: NodeJS.Timeout | null = null;
|
||||||
|
canvasInterval = setInterval(
|
||||||
|
onSync,
|
||||||
|
Math.max(1, Math.ceil(1000 / this.buffer)),
|
||||||
|
);
|
||||||
|
applyVideoAttributes();
|
||||||
|
observer.observe(songVideo, { attributes: true });
|
||||||
|
resizeObserver.observe(songVideo);
|
||||||
|
window.addEventListener('resize', applyVideoAttributes);
|
||||||
|
|
||||||
|
const onPause = () => {
|
||||||
|
if (canvasInterval) clearInterval(canvasInterval);
|
||||||
|
canvasInterval = null;
|
||||||
|
};
|
||||||
|
const onPlay = () => {
|
||||||
|
if (canvasInterval) clearInterval(canvasInterval);
|
||||||
|
canvasInterval = setInterval(
|
||||||
|
onSync,
|
||||||
|
Math.max(1, Math.ceil(1000 / this.buffer)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
songVideo.addEventListener('pause', onPause);
|
||||||
|
songVideo.addEventListener('play', onPlay);
|
||||||
|
|
||||||
|
/* injecting */
|
||||||
|
wrapper.prepend(blurCanvas);
|
||||||
|
|
||||||
|
/* cleanup */
|
||||||
|
return () => {
|
||||||
|
if (canvasInterval) clearInterval(canvasInterval);
|
||||||
|
|
||||||
|
songVideo.removeEventListener('pause', onPause);
|
||||||
|
songVideo.removeEventListener('play', onPlay);
|
||||||
|
|
||||||
|
observer.disconnect();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
window.removeEventListener('resize', applyVideoAttributes);
|
||||||
|
|
||||||
|
if (blurCanvas.isConnected) blurCanvas.remove();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const playerPage = document.querySelector<HTMLElement>('#player-page');
|
||||||
|
const ytmusicAppLayout = document.querySelector<HTMLElement>('#layout');
|
||||||
|
|
||||||
|
const isPageOpen = ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||||
|
if (isPageOpen) {
|
||||||
|
this.unregister?.();
|
||||||
|
this.unregister = injectBlurVideo() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new MutationObserver((mutationsList) => {
|
||||||
|
for (const mutation of mutationsList) {
|
||||||
|
if (mutation.type === 'attributes') {
|
||||||
|
const isPageOpen =
|
||||||
|
ytmusicAppLayout?.hasAttribute('player-page-open');
|
||||||
|
if (isPageOpen) {
|
||||||
|
this.unregister?.();
|
||||||
|
this.unregister = injectBlurVideo() ?? null;
|
||||||
|
} else {
|
||||||
|
this.unregister?.();
|
||||||
|
this.unregister = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (playerPage) {
|
||||||
|
observer.observe(playerPage, { attributes: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.interpolationTime = newConfig.interpolationTime;
|
||||||
|
this.buffer = newConfig.buffer;
|
||||||
|
this.qualityRatio = newConfig.quality;
|
||||||
|
this.sizeRatio = newConfig.size / 100;
|
||||||
|
this.blur = newConfig.blur;
|
||||||
|
this.opacity = newConfig.opacity;
|
||||||
|
this.isFullscreen = newConfig.fullscreen;
|
||||||
|
|
||||||
|
this.update?.();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
this.update = null;
|
||||||
|
this.unregister?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
const interpolationTimeList = [0, 500, 1000, 1500, 2000, 3000, 4000, 5000];
|
|
||||||
const qualityList = [10, 25, 50, 100, 200, 500, 1000];
|
|
||||||
const sizeList = [100, 110, 125, 150, 175, 200, 300];
|
|
||||||
const bufferList = [1, 5, 10, 20, 30];
|
|
||||||
const blurAmountList = [0, 5, 10, 25, 50, 100, 150, 200, 500];
|
|
||||||
const opacityList = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1];
|
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Smoothness transition',
|
|
||||||
submenu: interpolationTimeList.map((interpolationTime) => ({
|
|
||||||
label: `During ${interpolationTime / 1000}s`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('interpolationTime') === interpolationTime,
|
|
||||||
click() {
|
|
||||||
config.set('interpolationTime', interpolationTime);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Quality',
|
|
||||||
submenu: qualityList.map((quality) => ({
|
|
||||||
label: `${quality} pixels`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('quality') === quality,
|
|
||||||
click() {
|
|
||||||
config.set('quality', quality);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Size',
|
|
||||||
submenu: sizeList.map((size) => ({
|
|
||||||
label: `${size}%`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('size') === size,
|
|
||||||
click() {
|
|
||||||
config.set('size', size);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Buffer',
|
|
||||||
submenu: bufferList.map((buffer) => ({
|
|
||||||
label: `${buffer}`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('buffer') === buffer,
|
|
||||||
click() {
|
|
||||||
config.set('buffer', buffer);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Opacity',
|
|
||||||
submenu: opacityList.map((opacity) => ({
|
|
||||||
label: `${opacity * 100}%`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('opacity') === opacity,
|
|
||||||
click() {
|
|
||||||
config.set('opacity', opacity);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Blur amount',
|
|
||||||
submenu: blurAmountList.map((blur) => ({
|
|
||||||
label: `${blur} pixels`,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('blur') === blur,
|
|
||||||
click() {
|
|
||||||
config.set('blur', blur);
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Using fullscreen',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('fullscreen'),
|
|
||||||
click(item) {
|
|
||||||
config.set('fullscreen', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
26
src/plugins/audio-compressor.ts
Normal file
26
src/plugins/audio-compressor.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.audio-compressor.name'),
|
||||||
|
description: () => t('plugins.audio-compressor.description'),
|
||||||
|
|
||||||
|
renderer() {
|
||||||
|
document.addEventListener(
|
||||||
|
'audioCanPlay',
|
||||||
|
({ detail: { audioSource, audioContext } }) => {
|
||||||
|
const compressor = audioContext.createDynamicsCompressor();
|
||||||
|
|
||||||
|
compressor.threshold.value = -50;
|
||||||
|
compressor.ratio.value = 12;
|
||||||
|
compressor.knee.value = 40;
|
||||||
|
compressor.attack.value = 0;
|
||||||
|
compressor.release.value = 0.25;
|
||||||
|
|
||||||
|
audioSource.connect(compressor);
|
||||||
|
compressor.connect(audioContext.destination);
|
||||||
|
},
|
||||||
|
{ once: true, passive: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,17 +0,0 @@
|
|||||||
export default () =>
|
|
||||||
document.addEventListener('audioCanPlay', (e) => {
|
|
||||||
const { audioContext } = e.detail;
|
|
||||||
|
|
||||||
const compressor = audioContext.createDynamicsCompressor();
|
|
||||||
compressor.threshold.value = -50;
|
|
||||||
compressor.ratio.value = 12;
|
|
||||||
compressor.knee.value = 40;
|
|
||||||
compressor.attack.value = 0;
|
|
||||||
compressor.release.value = 0.25;
|
|
||||||
|
|
||||||
e.detail.audioSource.connect(compressor);
|
|
||||||
compressor.connect(audioContext.destination);
|
|
||||||
}, {
|
|
||||||
once: true, // Only create the audio compressor once, not on each video
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import style from './style.css';
|
|
||||||
|
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
|
||||||
injectCSS(win.webContents, style);
|
|
||||||
};
|
|
||||||
11
src/plugins/blur-nav-bar/index.ts
Normal file
11
src/plugins/blur-nav-bar/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import style from './style.css?inline';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.blur-nav-bar.name'),
|
||||||
|
description: () => t('plugins.blur-nav-bar.description'),
|
||||||
|
restartNeeded: true,
|
||||||
|
stylesheets: [style],
|
||||||
|
renderer() {},
|
||||||
|
});
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export default async () => {
|
|
||||||
// See https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass#userscript
|
|
||||||
await import('simple-youtube-age-restriction-bypass');
|
|
||||||
};
|
|
||||||
13
src/plugins/bypass-age-restrictions/index.ts
Normal file
13
src/plugins/bypass-age-restrictions/index.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { inject } from 'simple-youtube-age-restriction-bypass';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.bypass-age-restrictions.name'),
|
||||||
|
description: () => t('plugins.bypass-age-restrictions.description'),
|
||||||
|
restartNeeded: true,
|
||||||
|
|
||||||
|
// See https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass#userscript
|
||||||
|
renderer: () => inject(),
|
||||||
|
});
|
||||||
@ -1,4 +1,3 @@
|
|||||||
declare module 'simple-youtube-age-restriction-bypass' {
|
declare module 'simple-youtube-age-restriction-bypass' {
|
||||||
const nothing: never;
|
export const inject: () => void;
|
||||||
export default nothing;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,33 @@
|
|||||||
import { BrowserWindow, ipcMain } from 'electron';
|
|
||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
import { createBackend } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
export default (win: BrowserWindow) => {
|
export default createBackend({
|
||||||
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
|
start({ ipc: { handle }, window }) {
|
||||||
{
|
handle(
|
||||||
title: 'Choose Caption',
|
'captionsSelector',
|
||||||
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
async (captionLabels: Record<string, string>, currentIndex: string) =>
|
||||||
type: 'select',
|
await prompt(
|
||||||
value: currentIndex,
|
{
|
||||||
selectOptions: captionLabels,
|
title: t('plugins.captions-selector.prompt.selector.title'),
|
||||||
resizable: true,
|
label: t('plugins.captions-selector.prompt.selector.label', {
|
||||||
...promptOptions(),
|
language:
|
||||||
},
|
captionLabels[currentIndex] ||
|
||||||
win,
|
t('plugins.captions-selector.prompt.selector.none'),
|
||||||
));
|
}),
|
||||||
};
|
type: 'select',
|
||||||
|
value: currentIndex,
|
||||||
|
selectOptions: captionLabels,
|
||||||
|
resizable: true,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
window,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
stop({ ipc: { removeHandler } }) {
|
||||||
|
removeHandler('captionsSelector');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('captions-selector', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/await-thenable */
|
|
||||||
/* renderer */
|
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import configProvider from './config';
|
|
||||||
|
|
||||||
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html';
|
|
||||||
|
|
||||||
import { ElementFromHtml } from '../utils';
|
|
||||||
import { YoutubePlayer } from '../../types/youtube-player';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
interface LanguageOptions {
|
|
||||||
displayName: string;
|
|
||||||
id: string | null;
|
|
||||||
is_default: boolean;
|
|
||||||
is_servable: boolean;
|
|
||||||
is_translateable: boolean;
|
|
||||||
kind: string;
|
|
||||||
languageCode: string; // 2 length
|
|
||||||
languageName: string;
|
|
||||||
name: string | null;
|
|
||||||
vss_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: ConfigType<'captions-selector'>;
|
|
||||||
|
|
||||||
const $ = <Element extends HTMLElement>(selector: string): Element => document.querySelector(selector)!;
|
|
||||||
|
|
||||||
const captionsSettingsButton = ElementFromHtml(CaptionsSettingsButtonHTML);
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
// RENDERER
|
|
||||||
config = await configProvider.getAll();
|
|
||||||
|
|
||||||
configProvider.subscribeAll((newConfig) => {
|
|
||||||
config = newConfig;
|
|
||||||
});
|
|
||||||
document.addEventListener('apiLoaded', (event) => setup(event.detail), { once: true, passive: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup(api: YoutubePlayer) {
|
|
||||||
$('.right-controls-buttons').append(captionsSettingsButton);
|
|
||||||
|
|
||||||
let captionTrackList = api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
|
||||||
|
|
||||||
$('video').addEventListener('srcChanged', () => {
|
|
||||||
if (config.disableCaptions) {
|
|
||||||
setTimeout(() => api.unloadModule('captions'), 100);
|
|
||||||
captionsSettingsButton.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
api.loadModule('captions');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
captionTrackList = api.getOption('captions', 'tracklist') ?? [];
|
|
||||||
|
|
||||||
if (config.autoload && config.lastCaptionsCode) {
|
|
||||||
api.setOption('captions', 'track', {
|
|
||||||
languageCode: config.lastCaptionsCode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
captionsSettingsButton.style.display = captionTrackList?.length
|
|
||||||
? 'inline-block'
|
|
||||||
: 'none';
|
|
||||||
}, 250);
|
|
||||||
});
|
|
||||||
|
|
||||||
captionsSettingsButton.addEventListener('click', async () => {
|
|
||||||
if (captionTrackList?.length) {
|
|
||||||
const currentCaptionTrack = api.getOption<LanguageOptions>('captions', 'track')!;
|
|
||||||
let currentIndex = currentCaptionTrack
|
|
||||||
? captionTrackList.indexOf(captionTrackList.find((track) => track.languageCode === currentCaptionTrack.languageCode)!)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const captionLabels = [
|
|
||||||
...captionTrackList.map((track) => track.displayName),
|
|
||||||
'None',
|
|
||||||
];
|
|
||||||
|
|
||||||
currentIndex = await ipcRenderer.invoke('captionsSelector', captionLabels, currentIndex) as number;
|
|
||||||
if (currentIndex === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newCaptions = captionTrackList[currentIndex];
|
|
||||||
configProvider.set('lastCaptionsCode', newCaptions?.languageCode);
|
|
||||||
if (newCaptions) {
|
|
||||||
api.setOption('captions', 'track', { languageCode: newCaptions.languageCode });
|
|
||||||
} else {
|
|
||||||
api.setOption('captions', 'track', {});
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => api.playVideo());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
55
src/plugins/captions-selector/index.ts
Normal file
55
src/plugins/captions-selector/index.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
import backend from './back';
|
||||||
|
import renderer, { CaptionsSelectorConfig, LanguageOptions } from './renderer';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
captionsSettingsButton: HTMLElement;
|
||||||
|
captionTrackList: LanguageOptions[] | null;
|
||||||
|
api: YoutubePlayer | null;
|
||||||
|
config: CaptionsSelectorConfig | null;
|
||||||
|
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
|
||||||
|
videoChangeListener: () => void;
|
||||||
|
captionsButtonClickListener: () => void;
|
||||||
|
},
|
||||||
|
CaptionsSelectorConfig
|
||||||
|
>({
|
||||||
|
name: () => t('plugins.captions-selector.name'),
|
||||||
|
description: () => t('plugins.captions-selector.description'),
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
disableCaptions: false,
|
||||||
|
autoload: false,
|
||||||
|
lastCaptionsCode: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
async menu({ getConfig, setConfig }) {
|
||||||
|
const config = await getConfig();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('plugins.captions-selector.menu.autoload'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.autoload as boolean,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ autoload: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.captions-selector.menu.disable-captions'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.disableCaptions as boolean,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ disableCaptions: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
backend,
|
||||||
|
renderer,
|
||||||
|
});
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Automatically select last used caption',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('autoload'),
|
|
||||||
click(item) {
|
|
||||||
config.set('autoload', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'No captions by default',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('disableCaptions'),
|
|
||||||
click(item) {
|
|
||||||
config.set('disableCaptions', item.checked);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
151
src/plugins/captions-selector/renderer.ts
Normal file
151
src/plugins/captions-selector/renderer.ts
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { ElementFromHtml } from '@/plugins/utils/renderer';
|
||||||
|
import { createRenderer } from '@/utils';
|
||||||
|
|
||||||
|
import CaptionsSettingsButtonHTML from './templates/captions-settings-template.html?raw';
|
||||||
|
|
||||||
|
import { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
export interface LanguageOptions {
|
||||||
|
displayName: string;
|
||||||
|
id: string | null;
|
||||||
|
is_default: boolean;
|
||||||
|
is_servable: boolean;
|
||||||
|
is_translateable: boolean;
|
||||||
|
kind: string;
|
||||||
|
languageCode: string; // 2 length
|
||||||
|
languageName: string;
|
||||||
|
name: string | null;
|
||||||
|
vss_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaptionsSelectorConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
disableCaptions: boolean;
|
||||||
|
autoload: boolean;
|
||||||
|
lastCaptionsCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createRenderer<
|
||||||
|
{
|
||||||
|
captionsSettingsButton: HTMLElement;
|
||||||
|
captionTrackList: LanguageOptions[] | null;
|
||||||
|
api: YoutubePlayer | null;
|
||||||
|
config: CaptionsSelectorConfig | null;
|
||||||
|
setConfig: (config: Partial<CaptionsSelectorConfig>) => void;
|
||||||
|
videoChangeListener: () => void;
|
||||||
|
captionsButtonClickListener: () => void;
|
||||||
|
},
|
||||||
|
CaptionsSelectorConfig
|
||||||
|
>({
|
||||||
|
captionsSettingsButton: ElementFromHtml(CaptionsSettingsButtonHTML),
|
||||||
|
captionTrackList: null,
|
||||||
|
api: null,
|
||||||
|
config: null,
|
||||||
|
setConfig: () => {},
|
||||||
|
async captionsButtonClickListener() {
|
||||||
|
if (this.captionTrackList?.length) {
|
||||||
|
const currentCaptionTrack = this.api!.getOption<LanguageOptions>(
|
||||||
|
'captions',
|
||||||
|
'track',
|
||||||
|
);
|
||||||
|
let currentIndex = currentCaptionTrack
|
||||||
|
? this.captionTrackList.indexOf(
|
||||||
|
this.captionTrackList.find(
|
||||||
|
(track) =>
|
||||||
|
track.languageCode === currentCaptionTrack.languageCode,
|
||||||
|
)!,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const captionLabels = [
|
||||||
|
...this.captionTrackList.map((track) => track.displayName),
|
||||||
|
'None',
|
||||||
|
];
|
||||||
|
|
||||||
|
currentIndex = (await window.ipcRenderer.invoke(
|
||||||
|
'captionsSelector',
|
||||||
|
captionLabels,
|
||||||
|
currentIndex,
|
||||||
|
)) as number;
|
||||||
|
if (currentIndex === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCaptions = this.captionTrackList[currentIndex];
|
||||||
|
this.setConfig({ lastCaptionsCode: newCaptions?.languageCode });
|
||||||
|
if (newCaptions) {
|
||||||
|
this.api?.setOption('captions', 'track', {
|
||||||
|
languageCode: newCaptions.languageCode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.api?.setOption('captions', 'track', {});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => this.api?.playVideo());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
videoChangeListener() {
|
||||||
|
if (this.config?.disableCaptions) {
|
||||||
|
setTimeout(() => this.api!.unloadModule('captions'), 100);
|
||||||
|
this.captionsSettingsButton.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.api!.loadModule('captions');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.captionTrackList =
|
||||||
|
this.api!.getOption('captions', 'tracklist') ?? [];
|
||||||
|
|
||||||
|
if (this.config!.autoload && this.config!.lastCaptionsCode) {
|
||||||
|
this.api?.setOption('captions', 'track', {
|
||||||
|
languageCode: this.config!.lastCaptionsCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.captionsSettingsButton.style.display = this.captionTrackList?.length
|
||||||
|
? 'inline-block'
|
||||||
|
: 'none';
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
async start({ getConfig, setConfig }) {
|
||||||
|
this.config = await getConfig();
|
||||||
|
this.setConfig = setConfig;
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
document
|
||||||
|
.querySelector('.right-controls-buttons')
|
||||||
|
?.removeChild(this.captionsSettingsButton);
|
||||||
|
document
|
||||||
|
.querySelector<YoutubePlayer & HTMLElement>('#movie_player')
|
||||||
|
?.unloadModule('captions');
|
||||||
|
document
|
||||||
|
.querySelector('video')
|
||||||
|
?.removeEventListener('srcChanged', this.videoChangeListener);
|
||||||
|
this.captionsSettingsButton.removeEventListener(
|
||||||
|
'click',
|
||||||
|
this.captionsButtonClickListener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onPlayerApiReady(playerApi) {
|
||||||
|
this.api = playerApi;
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('.right-controls-buttons')
|
||||||
|
?.append(this.captionsSettingsButton);
|
||||||
|
|
||||||
|
this.captionTrackList =
|
||||||
|
this.api.getOption<LanguageOptions[]>('captions', 'tracklist') ?? [];
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('video')
|
||||||
|
?.addEventListener('srcChanged', this.videoChangeListener);
|
||||||
|
this.captionsSettingsButton.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.captionsButtonClickListener,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,16 +1,25 @@
|
|||||||
<tp-yt-paper-icon-button aria-disabled="false" aria-label="Open captions selector"
|
<tp-yt-paper-icon-button
|
||||||
class="player-captions-button style-scope ytmusic-player" icon="yt-icons:subtitles"
|
aria-disabled="false"
|
||||||
role="button" tabindex="0"
|
aria-label="Open captions selector"
|
||||||
title="Open captions selector">
|
class="player-captions-button style-scope ytmusic-player"
|
||||||
|
icon="yt-icons:subtitles"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
title="Open captions selector"
|
||||||
|
>
|
||||||
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
|
<tp-yt-iron-icon class="style-scope tp-yt-paper-icon-button" id="icon">
|
||||||
<svg class="style-scope yt-icon"
|
<svg
|
||||||
focusable="false" preserveAspectRatio="xMidYMid meet"
|
class="style-scope yt-icon"
|
||||||
style="pointer-events: none; display: block; width: 100%; height: 100%;"
|
focusable="false"
|
||||||
viewBox="0 0 24 24">
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
style="pointer-events: none; display: block; width: 100%; height: 100%"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
<g class="style-scope yt-icon">
|
<g class="style-scope yt-icon">
|
||||||
<path
|
<path
|
||||||
class="style-scope tp-yt-iron-icon"
|
class="style-scope tp-yt-iron-icon"
|
||||||
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"></path>
|
d="M20 4H4c-1.103 0-2 .897-2 2v12c0 1.103.897 2 2 2h16c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2zm-9 6H8v4h3v2H8c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2zm7 0h-3v4h3v2h-3c-1.103 0-2-.897-2-2v-4c0-1.103.897-2 2-2h3v2z"
|
||||||
|
></path>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</tp-yt-iron-icon>
|
</tp-yt-iron-icon>
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
export default () => {
|
|
||||||
const compactSidebar = document.querySelector('#mini-guide');
|
|
||||||
const isCompactSidebarDisabled
|
|
||||||
= compactSidebar === null
|
|
||||||
|| window.getComputedStyle(compactSidebar).display === 'none';
|
|
||||||
|
|
||||||
if (isCompactSidebarDisabled) {
|
|
||||||
document.querySelector<HTMLButtonElement>('#button')?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
43
src/plugins/compact-sidebar/index.ts
Normal file
43
src/plugins/compact-sidebar/index.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
getCompactSidebar: () => HTMLElement | null;
|
||||||
|
isCompactSidebarDisabled: () => boolean;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
name: () => t('plugins.compact-sidebar.name'),
|
||||||
|
description: () => t('plugins.compact-sidebar.description'),
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
getCompactSidebar: () => document.querySelector('#mini-guide'),
|
||||||
|
isCompactSidebarDisabled() {
|
||||||
|
const compactSidebar = this.getCompactSidebar();
|
||||||
|
return (
|
||||||
|
compactSidebar === null ||
|
||||||
|
window.getComputedStyle(compactSidebar).display === 'none'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
start() {
|
||||||
|
if (this.isCompactSidebarDisabled()) {
|
||||||
|
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (this.isCompactSidebarDisabled()) {
|
||||||
|
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfigChange() {
|
||||||
|
if (this.isCompactSidebarDisabled()) {
|
||||||
|
document.querySelector<HTMLButtonElement>('#button')?.click();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { ipcMain } from 'electron';
|
|
||||||
import { Innertube } from 'youtubei.js';
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
const yt = await Innertube.create();
|
|
||||||
|
|
||||||
ipcMain.handle('audio-url', async (_, videoID: string) => {
|
|
||||||
const info = await yt.getBasicInfo(videoID);
|
|
||||||
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('crossfade', { enableFront: true });
|
|
||||||
export default config;
|
|
||||||
@ -15,21 +15,21 @@
|
|||||||
* v0.2.0, 07/2016
|
* v0.2.0, 07/2016
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// Internal utility: check if value is a valid volume level and throw if not
|
// Internal utility: check if value is a valid volume level and throw if not
|
||||||
const validateVolumeLevel = (value: number) => {
|
const validateVolumeLevel = (value: number) => {
|
||||||
// Number between 0 and 1?
|
// Number between 0 and 1?
|
||||||
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
|
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
|
||||||
// Yup, that's fine
|
// Yup, that's fine
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Abort and throw an exception
|
// Abort and throw an exception
|
||||||
throw new TypeError('Number between 0 and 1 expected as volume!');
|
throw new TypeError('Number between 0 and 1 expected as volume!');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type VolumeLogger = <Params extends unknown[]>(message: string, ...args: Params) => void;
|
type VolumeLogger = <Params extends unknown[]>(
|
||||||
|
message: string,
|
||||||
|
...args: Params
|
||||||
|
) => void;
|
||||||
interface VolumeFaderOptions {
|
interface VolumeFaderOptions {
|
||||||
/**
|
/**
|
||||||
* logging `function(stuff, …)` for execution information (default: no logging)
|
* logging `function(stuff, …)` for execution information (default: no logging)
|
||||||
@ -73,7 +73,6 @@ export class VolumeFader {
|
|||||||
private active: boolean = false;
|
private active: boolean = false;
|
||||||
private fade: VolumeFade | undefined;
|
private fade: VolumeFade | undefined;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VolumeFader Constructor
|
* VolumeFader Constructor
|
||||||
*
|
*
|
||||||
@ -121,17 +120,17 @@ export class VolumeFader {
|
|||||||
|
|
||||||
// Default dynamic range?
|
// Default dynamic range?
|
||||||
if (
|
if (
|
||||||
options.fadeScaling === undefined
|
options.fadeScaling === undefined ||
|
||||||
|| options.fadeScaling === 'logarithmic'
|
options.fadeScaling === 'logarithmic'
|
||||||
) {
|
) {
|
||||||
// Set default of 60 dB
|
// Set default of 60 dB
|
||||||
dynamicRange = 3;
|
dynamicRange = 3;
|
||||||
}
|
}
|
||||||
// Custom dynamic range?
|
// Custom dynamic range?
|
||||||
else if (
|
else if (
|
||||||
typeof options.fadeScaling === 'number'
|
typeof options.fadeScaling === 'number' &&
|
||||||
&& !Number.isNaN(options.fadeScaling)
|
!Number.isNaN(options.fadeScaling) &&
|
||||||
&& options.fadeScaling > 0
|
options.fadeScaling > 0
|
||||||
) {
|
) {
|
||||||
// Turn amplitude dB into a multiple of 10 power dB
|
// Turn amplitude dB into a multiple of 10 power dB
|
||||||
dynamicRange = options.fadeScaling / 2 / 10;
|
dynamicRange = options.fadeScaling / 2 / 10;
|
||||||
@ -153,13 +152,13 @@ export class VolumeFader {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Log setting if not default
|
// Log setting if not default
|
||||||
options.fadeScaling
|
options.fadeScaling &&
|
||||||
&& this.logger
|
this.logger &&
|
||||||
&& this.logger(
|
this.logger(
|
||||||
'Using logarithmic fading with '
|
'Using logarithmic fading with ' +
|
||||||
+ String(10 * dynamicRange)
|
String(10 * dynamicRange) +
|
||||||
+ ' dB dynamic range.',
|
' dB dynamic range.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set initial volume?
|
// Set initial volume?
|
||||||
@ -171,10 +170,8 @@ export class VolumeFader {
|
|||||||
this.media.volume = options.initialVolume;
|
this.media.volume = options.initialVolume;
|
||||||
|
|
||||||
// Log setting
|
// Log setting
|
||||||
this.logger
|
this.logger &&
|
||||||
&& this.logger(
|
this.logger('Set initial volume to ' + String(this.media.volume) + '.');
|
||||||
'Set initial volume to ' + String(this.media.volume) + '.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fade duration given?
|
// Fade duration given?
|
||||||
@ -239,8 +236,8 @@ export class VolumeFader {
|
|||||||
this.fadeDuration = fadeDuration;
|
this.fadeDuration = fadeDuration;
|
||||||
|
|
||||||
// Log setting
|
// Log setting
|
||||||
this.logger
|
this.logger &&
|
||||||
&& this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
||||||
} else {
|
} else {
|
||||||
// Abort and throw an exception
|
// Abort and throw an exception
|
||||||
throw new TypeError('Positive number expected as fade duration!');
|
throw new TypeError('Positive number expected as fade duration!');
|
||||||
@ -310,13 +307,14 @@ export class VolumeFader {
|
|||||||
// Time left for fading?
|
// Time left for fading?
|
||||||
if (now < this.fade.time.end) {
|
if (now < this.fade.time.end) {
|
||||||
// Compute current fade progress
|
// Compute current fade progress
|
||||||
const progress
|
const progress =
|
||||||
= (now - this.fade.time.start)
|
(now - this.fade.time.start) /
|
||||||
/ (this.fade.time.end - this.fade.time.start);
|
(this.fade.time.end - this.fade.time.start);
|
||||||
|
|
||||||
// Compute current level on internal scale
|
// Compute current level on internal scale
|
||||||
const level
|
const level =
|
||||||
= (progress * (this.fade.volume.end - this.fade.volume.start)) + this.fade.volume.start;
|
(progress * (this.fade.volume.end - this.fade.volume.start)) +
|
||||||
|
this.fade.volume.start;
|
||||||
|
|
||||||
// Map fade level to volume level and apply it to media element
|
// Map fade level to volume level and apply it to media element
|
||||||
this.media.volume = this.scale.internalToVolume(level);
|
this.media.volume = this.scale.internalToVolume(level);
|
||||||
@ -325,10 +323,8 @@ export class VolumeFader {
|
|||||||
window.requestAnimationFrame(this.updateVolume.bind(this));
|
window.requestAnimationFrame(this.updateVolume.bind(this));
|
||||||
} else {
|
} else {
|
||||||
// Log end of fade
|
// Log end of fade
|
||||||
this.logger
|
this.logger &&
|
||||||
&& this.logger(
|
this.logger('Fade to ' + String(this.fade.volume.end) + ' complete.');
|
||||||
'Fade to ' + String(this.fade.volume.end) + ' complete.',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Time is up, jump to target volume
|
// Time is up, jump to target volume
|
||||||
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
|
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
|
||||||
@ -391,5 +387,5 @@ export class VolumeFader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
VolumeFader
|
VolumeFader,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/await-thenable */
|
|
||||||
/* renderer */
|
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
|
||||||
import { Howl } from 'howler';
|
|
||||||
|
|
||||||
// Extracted from https://github.com/bitfasching/VolumeFader
|
|
||||||
import { VolumeFader } from './fader';
|
|
||||||
|
|
||||||
import configProvider from './config';
|
|
||||||
|
|
||||||
import defaultConfigs from '../../config/defaults';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
|
||||||
let firstVideo = true;
|
|
||||||
let waitForTransition: Promise<unknown>;
|
|
||||||
|
|
||||||
const defaultConfig = defaultConfigs.plugins.crossfade;
|
|
||||||
|
|
||||||
let config: ConfigType<'crossfade'>;
|
|
||||||
|
|
||||||
const configGetNumber = (key: keyof ConfigType<'crossfade'>): number => Number(config[key]) || (defaultConfig[key] as number);
|
|
||||||
|
|
||||||
const getStreamURL = async (videoID: string) => ipcRenderer.invoke('audio-url', videoID) as Promise<string>;
|
|
||||||
|
|
||||||
const getVideoIDFromURL = (url: string) => new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
|
||||||
|
|
||||||
const isReadyToCrossfade = () => transitionAudio && transitionAudio.state() === 'loaded';
|
|
||||||
|
|
||||||
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
|
||||||
window.navigation.addEventListener('navigate', (event) => {
|
|
||||||
const currentVideoID = getVideoIDFromURL(
|
|
||||||
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
|
||||||
);
|
|
||||||
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextVideoID
|
|
||||||
&& currentVideoID
|
|
||||||
&& (firstVideo || nextVideoID !== currentVideoID)
|
|
||||||
) {
|
|
||||||
if (isReadyToCrossfade()) {
|
|
||||||
crossfade(() => {
|
|
||||||
cb(nextVideoID);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
cb(nextVideoID);
|
|
||||||
firstVideo = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAudioForCrossfade = (url: string) => {
|
|
||||||
if (transitionAudio) {
|
|
||||||
transitionAudio.unload();
|
|
||||||
}
|
|
||||||
|
|
||||||
transitionAudio = new Howl({
|
|
||||||
src: url,
|
|
||||||
html5: true,
|
|
||||||
volume: 0,
|
|
||||||
});
|
|
||||||
syncVideoWithTransitionAudio();
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncVideoWithTransitionAudio = () => {
|
|
||||||
const video = document.querySelector('video')!;
|
|
||||||
|
|
||||||
const videoFader = new VolumeFader(video, {
|
|
||||||
fadeScaling: configGetNumber('fadeScaling'),
|
|
||||||
fadeDuration: configGetNumber('fadeInDuration'),
|
|
||||||
});
|
|
||||||
|
|
||||||
transitionAudio.play();
|
|
||||||
transitionAudio.seek(video.currentTime);
|
|
||||||
|
|
||||||
video.addEventListener('seeking', () => {
|
|
||||||
transitionAudio.seek(video.currentTime);
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('pause', () => {
|
|
||||||
transitionAudio.pause();
|
|
||||||
});
|
|
||||||
|
|
||||||
video.addEventListener('play', () => {
|
|
||||||
transitionAudio.play();
|
|
||||||
transitionAudio.seek(video.currentTime);
|
|
||||||
|
|
||||||
// Fade in
|
|
||||||
const videoVolume = video.volume;
|
|
||||||
video.volume = 0;
|
|
||||||
videoFader.fadeTo(videoVolume);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Exit just before the end for the transition
|
|
||||||
const transitionBeforeEnd = () => {
|
|
||||||
if (
|
|
||||||
video.currentTime >= video.duration - configGetNumber('secondsBeforeEnd')
|
|
||||||
&& isReadyToCrossfade()
|
|
||||||
) {
|
|
||||||
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
|
||||||
|
|
||||||
// Go to next video - XXX: does not support "repeat 1" mode
|
|
||||||
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('timeupdate', transitionBeforeEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onApiLoaded = () => {
|
|
||||||
watchVideoIDChanges(async (videoID) => {
|
|
||||||
await waitForTransition;
|
|
||||||
const url = await getStreamURL(videoID);
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await createAudioForCrossfade(url);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const crossfade = (cb: () => void) => {
|
|
||||||
if (!isReadyToCrossfade()) {
|
|
||||||
cb();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolveTransition: () => void;
|
|
||||||
waitForTransition = new Promise<void>((resolve) => {
|
|
||||||
resolveTransition = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const video = document.querySelector('video')!;
|
|
||||||
|
|
||||||
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
|
||||||
initialVolume: video.volume,
|
|
||||||
fadeScaling: configGetNumber('fadeScaling'),
|
|
||||||
fadeDuration: configGetNumber('fadeOutDuration'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fade out the music
|
|
||||||
video.volume = 0;
|
|
||||||
fader.fadeOut(() => {
|
|
||||||
resolveTransition();
|
|
||||||
cb();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async () => {
|
|
||||||
config = await configProvider.getAll();
|
|
||||||
|
|
||||||
configProvider.subscribeAll((newConfig) => {
|
|
||||||
config = newConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', onApiLoaded, {
|
|
||||||
once: true,
|
|
||||||
passive: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
325
src/plugins/crossfade/index.ts
Normal file
325
src/plugins/crossfade/index.ts
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import { Innertube } from 'youtubei.js';
|
||||||
|
|
||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
|
import { Howl } from 'howler';
|
||||||
|
|
||||||
|
import promptOptions from '@/providers/prompt-options';
|
||||||
|
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { VolumeFader } from './fader';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
export type CrossfadePluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
fadeInDuration: number;
|
||||||
|
fadeOutDuration: number;
|
||||||
|
secondsBeforeEnd: number;
|
||||||
|
fadeScaling: 'linear' | 'logarithmic' | number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
config: CrossfadePluginConfig | null;
|
||||||
|
ipc: RendererContext<CrossfadePluginConfig>['ipc'] | null;
|
||||||
|
},
|
||||||
|
CrossfadePluginConfig
|
||||||
|
>({
|
||||||
|
name: () => t('plugins.crossfade.name'),
|
||||||
|
description: () => t('plugins.crossfade.description'),
|
||||||
|
restartNeeded: true,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
/**
|
||||||
|
* The duration of the fade in and fade out in milliseconds.
|
||||||
|
*
|
||||||
|
* @default 1500ms
|
||||||
|
*/
|
||||||
|
fadeInDuration: 1500,
|
||||||
|
/**
|
||||||
|
* The duration of the fade in and fade out in milliseconds.
|
||||||
|
*
|
||||||
|
* @default 5000ms
|
||||||
|
*/
|
||||||
|
fadeOutDuration: 5000,
|
||||||
|
/**
|
||||||
|
* The duration of the fade in and fade out in seconds.
|
||||||
|
*
|
||||||
|
* @default 10s
|
||||||
|
*/
|
||||||
|
secondsBeforeEnd: 10,
|
||||||
|
/**
|
||||||
|
* The scaling algorithm to use for the fade.
|
||||||
|
* (or a positive number in dB)
|
||||||
|
*
|
||||||
|
* @default 'linear'
|
||||||
|
*/
|
||||||
|
fadeScaling: 'linear',
|
||||||
|
},
|
||||||
|
menu({ window, getConfig, setConfig }) {
|
||||||
|
const promptCrossfadeValues = async (
|
||||||
|
win: BrowserWindow,
|
||||||
|
options: CrossfadePluginConfig,
|
||||||
|
): Promise<Omit<CrossfadePluginConfig, 'enabled'> | undefined> => {
|
||||||
|
const res = await prompt(
|
||||||
|
{
|
||||||
|
title: t('plugins.crossfade.prompt.options'),
|
||||||
|
type: 'multiInput',
|
||||||
|
multiInputOptions: [
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'plugins.crossfade.prompt.options.multi-input.fade-in-duration',
|
||||||
|
),
|
||||||
|
value: options.fadeInDuration,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
min: '0',
|
||||||
|
step: '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'plugins.crossfade.prompt.options.multi-input.fade-out-duration',
|
||||||
|
),
|
||||||
|
value: options.fadeOutDuration,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
min: '0',
|
||||||
|
step: '100',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'plugins.crossfade.prompt.options.multi-input.seconds-before-end',
|
||||||
|
),
|
||||||
|
value: options.secondsBeforeEnd,
|
||||||
|
inputAttrs: {
|
||||||
|
type: 'number',
|
||||||
|
required: true,
|
||||||
|
min: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t(
|
||||||
|
'plugins.crossfade.prompt.options.multi-input.fade-scaling.label',
|
||||||
|
),
|
||||||
|
selectOptions: {
|
||||||
|
linear: t(
|
||||||
|
'plugins.crossfade.prompt.options.multi-input.fade-scaling.linear',
|
||||||
|
),
|
||||||
|
logarithmic: t(
|
||||||
|
'plugins.crossfade.prompt.options.multi-input.fade-scaling.logarithmic',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
value: options.fadeScaling,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resizable: true,
|
||||||
|
height: 360,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
win,
|
||||||
|
).catch(console.error);
|
||||||
|
|
||||||
|
if (!res) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fadeScaling: 'linear' | 'logarithmic' | number;
|
||||||
|
if (res[3] === 'linear' || res[3] === 'logarithmic') {
|
||||||
|
fadeScaling = res[3];
|
||||||
|
} else if (isFinite(Number(res[3]))) {
|
||||||
|
fadeScaling = Number(res[3]);
|
||||||
|
} else {
|
||||||
|
fadeScaling = options.fadeScaling;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fadeInDuration: Number(res[0]),
|
||||||
|
fadeOutDuration: Number(res[1]),
|
||||||
|
secondsBeforeEnd: Number(res[2]),
|
||||||
|
fadeScaling,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('plugins.crossfade.menu.advanced'),
|
||||||
|
async click() {
|
||||||
|
const newOptions = await promptCrossfadeValues(
|
||||||
|
window,
|
||||||
|
await getConfig(),
|
||||||
|
);
|
||||||
|
if (newOptions) {
|
||||||
|
setConfig(newOptions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
async backend({ ipc }) {
|
||||||
|
const yt = await Innertube.create({
|
||||||
|
fetch: getNetFetchAsFetch(),
|
||||||
|
});
|
||||||
|
|
||||||
|
ipc.handle('audio-url', async (videoID: string) => {
|
||||||
|
const info = await yt.getBasicInfo(videoID);
|
||||||
|
return info.streaming_data?.formats[0].decipher(yt.session.player);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderer: {
|
||||||
|
config: null,
|
||||||
|
ipc: null,
|
||||||
|
|
||||||
|
start({ ipc }) {
|
||||||
|
this.ipc = ipc;
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
},
|
||||||
|
onPlayerApiReady() {
|
||||||
|
let transitionAudio: Howl; // Howler audio used to fade out the current music
|
||||||
|
let firstVideo = true;
|
||||||
|
let waitForTransition: Promise<unknown>;
|
||||||
|
|
||||||
|
const getStreamURL = async (videoID: string): Promise<string> =>
|
||||||
|
this.ipc?.invoke('audio-url', videoID);
|
||||||
|
|
||||||
|
const getVideoIDFromURL = (url: string) =>
|
||||||
|
new URLSearchParams(url.split('?')?.at(-1)).get('v');
|
||||||
|
|
||||||
|
const isReadyToCrossfade = () =>
|
||||||
|
transitionAudio && transitionAudio.state() === 'loaded';
|
||||||
|
|
||||||
|
const watchVideoIDChanges = (cb: (id: string) => void) => {
|
||||||
|
window.navigation.addEventListener('navigate', (event) => {
|
||||||
|
const currentVideoID = getVideoIDFromURL(
|
||||||
|
(event.currentTarget as Navigation).currentEntry?.url ?? '',
|
||||||
|
);
|
||||||
|
const nextVideoID = getVideoIDFromURL(event.destination.url ?? '');
|
||||||
|
|
||||||
|
if (
|
||||||
|
nextVideoID &&
|
||||||
|
currentVideoID &&
|
||||||
|
(firstVideo || nextVideoID !== currentVideoID)
|
||||||
|
) {
|
||||||
|
if (isReadyToCrossfade()) {
|
||||||
|
crossfade(() => {
|
||||||
|
cb(nextVideoID);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
cb(nextVideoID);
|
||||||
|
firstVideo = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAudioForCrossfade = (url: string) => {
|
||||||
|
if (transitionAudio) {
|
||||||
|
transitionAudio.unload();
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionAudio = new Howl({
|
||||||
|
src: url,
|
||||||
|
html5: true,
|
||||||
|
volume: 0,
|
||||||
|
});
|
||||||
|
syncVideoWithTransitionAudio();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncVideoWithTransitionAudio = () => {
|
||||||
|
const video = document.querySelector('video')!;
|
||||||
|
|
||||||
|
const videoFader = new VolumeFader(video, {
|
||||||
|
fadeScaling: this.config?.fadeScaling,
|
||||||
|
fadeDuration: this.config?.fadeInDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
transitionAudio.play();
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
|
||||||
|
video.addEventListener('seeking', () => {
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('pause', () => {
|
||||||
|
transitionAudio.pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('play', () => {
|
||||||
|
transitionAudio.play();
|
||||||
|
transitionAudio.seek(video.currentTime);
|
||||||
|
|
||||||
|
// Fade in
|
||||||
|
const videoVolume = video.volume;
|
||||||
|
video.volume = 0;
|
||||||
|
videoFader.fadeTo(videoVolume);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exit just before the end for the transition
|
||||||
|
const transitionBeforeEnd = () => {
|
||||||
|
if (
|
||||||
|
video.currentTime >=
|
||||||
|
video.duration - this.config!.secondsBeforeEnd &&
|
||||||
|
isReadyToCrossfade()
|
||||||
|
) {
|
||||||
|
video.removeEventListener('timeupdate', transitionBeforeEnd);
|
||||||
|
|
||||||
|
// Go to next video - XXX: does not support "repeat 1" mode
|
||||||
|
document.querySelector<HTMLButtonElement>('.next-button')?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('timeupdate', transitionBeforeEnd);
|
||||||
|
};
|
||||||
|
|
||||||
|
const crossfade = (cb: () => void) => {
|
||||||
|
if (!isReadyToCrossfade()) {
|
||||||
|
cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolveTransition: () => void;
|
||||||
|
waitForTransition = new Promise<void>((resolve) => {
|
||||||
|
resolveTransition = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const video = document.querySelector('video')!;
|
||||||
|
|
||||||
|
const fader = new VolumeFader(transitionAudio._sounds[0]._node, {
|
||||||
|
initialVolume: video.volume,
|
||||||
|
fadeScaling: this.config?.fadeScaling,
|
||||||
|
fadeDuration: this.config?.fadeOutDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade out the music
|
||||||
|
video.volume = 0;
|
||||||
|
fader.fadeOut(() => {
|
||||||
|
resolveTransition();
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watchVideoIDChanges(async (videoID) => {
|
||||||
|
await waitForTransition;
|
||||||
|
const url = await getStreamURL(videoID);
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createAudioForCrossfade(url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
|
||||||
|
|
||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import promptOptions from '../../providers/prompt-options';
|
|
||||||
import configOptions from '../../config/defaults';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const defaultOptions = configOptions.plugins.crossfade;
|
|
||||||
|
|
||||||
export default (win: BrowserWindow): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Advanced',
|
|
||||||
async click() {
|
|
||||||
const newOptions = await promptCrossfadeValues(win, config.getAll());
|
|
||||||
if (newOptions) {
|
|
||||||
config.setAll(newOptions);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
async function promptCrossfadeValues(win: BrowserWindow, options: ConfigType<'crossfade'>): Promise<Partial<ConfigType<'crossfade'>> | undefined> {
|
|
||||||
const res = await prompt(
|
|
||||||
{
|
|
||||||
title: 'Crossfade Options',
|
|
||||||
type: 'multiInput',
|
|
||||||
multiInputOptions: [
|
|
||||||
{
|
|
||||||
label: 'Fade in duration (ms)',
|
|
||||||
value: options.fadeInDuration || defaultOptions.fadeInDuration,
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
min: '0',
|
|
||||||
step: '100',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fade out duration (ms)',
|
|
||||||
value: options.fadeOutDuration || defaultOptions.fadeOutDuration,
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
min: '0',
|
|
||||||
step: '100',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Crossfade x seconds before end',
|
|
||||||
value:
|
|
||||||
options.secondsBeforeEnd || defaultOptions.secondsBeforeEnd,
|
|
||||||
inputAttrs: {
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
min: '0',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Fade scaling',
|
|
||||||
selectOptions: { linear: 'Linear', logarithmic: 'Logarithmic' },
|
|
||||||
value: options.fadeScaling || defaultOptions.fadeScaling,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
resizable: true,
|
|
||||||
height: 360,
|
|
||||||
...promptOptions(),
|
|
||||||
},
|
|
||||||
win,
|
|
||||||
).catch(console.error);
|
|
||||||
if (!res) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fadeInDuration: Number(res[0]),
|
|
||||||
fadeOutDuration: Number(res[1]),
|
|
||||||
secondsBeforeEnd: Number(res[2]),
|
|
||||||
fadeScaling: res[3],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (options: ConfigType<'disable-autoplay'>) => {
|
|
||||||
const timeUpdateListener = (e: Event) => {
|
|
||||||
if (e.target instanceof HTMLVideoElement) {
|
|
||||||
e.target.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('apiLoaded', (apiEvent) => {
|
|
||||||
const eventListener = (name: string) => {
|
|
||||||
if (options.applyOnce) {
|
|
||||||
apiEvent.detail.removeEventListener('videodatachange', eventListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name === 'dataloaded') {
|
|
||||||
apiEvent.detail.pauseVideo();
|
|
||||||
document.querySelector<HTMLVideoElement>('video')?.addEventListener('timeupdate', timeUpdateListener, { once: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
apiEvent.detail.addEventListener('videodatachange', eventListener);
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
};
|
|
||||||
85
src/plugins/disable-autoplay/index.ts
Normal file
85
src/plugins/disable-autoplay/index.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { VideoDataChanged } from '@/types/video-data-changed';
|
||||||
|
import type { YoutubePlayer } from '@/types/youtube-player';
|
||||||
|
|
||||||
|
export type DisableAutoPlayPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
applyOnce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin<
|
||||||
|
unknown,
|
||||||
|
unknown,
|
||||||
|
{
|
||||||
|
config: DisableAutoPlayPluginConfig | null;
|
||||||
|
api: YoutubePlayer | null;
|
||||||
|
eventListener: (event: CustomEvent<VideoDataChanged>) => void;
|
||||||
|
timeUpdateListener: (e: Event) => void;
|
||||||
|
},
|
||||||
|
DisableAutoPlayPluginConfig
|
||||||
|
>({
|
||||||
|
name: () => t('plugins.disable-autoplay.name'),
|
||||||
|
description: () => t('plugins.disable-autoplay.description'),
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
applyOnce: false,
|
||||||
|
},
|
||||||
|
menu: async ({ getConfig, setConfig }) => {
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('plugins.disable-autoplay.menu.apply-once'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.applyOnce,
|
||||||
|
async click() {
|
||||||
|
const nowConfig = await getConfig();
|
||||||
|
setConfig({
|
||||||
|
applyOnce: !nowConfig.applyOnce,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
config: null,
|
||||||
|
api: null,
|
||||||
|
eventListener(event: CustomEvent<VideoDataChanged>) {
|
||||||
|
if (this.config?.applyOnce) {
|
||||||
|
document.removeEventListener('videodatachange', this.eventListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.detail.name === 'dataloaded') {
|
||||||
|
this.api?.pauseVideo();
|
||||||
|
document
|
||||||
|
.querySelector<HTMLVideoElement>('video')
|
||||||
|
?.addEventListener('timeupdate', this.timeUpdateListener, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeUpdateListener(e: Event) {
|
||||||
|
if (e.target instanceof HTMLVideoElement) {
|
||||||
|
e.target.pause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async start({ getConfig }) {
|
||||||
|
this.config = await getConfig();
|
||||||
|
},
|
||||||
|
onPlayerApiReady(api) {
|
||||||
|
this.api = api;
|
||||||
|
|
||||||
|
document.addEventListener('videodatachange', this.eventListener);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
document.removeEventListener('videodatachange', this.eventListener);
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { BrowserWindow } from 'electron';
|
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
export default (_: BrowserWindow, options: ConfigType<'disable-autoplay'>): MenuTemplate => [
|
|
||||||
{
|
|
||||||
label: 'Applies only on startup',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: options.applyOnce,
|
|
||||||
click() {
|
|
||||||
setMenuOptions('disable-autoplay', {
|
|
||||||
applyOnce: !options.applyOnce,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@ -1,228 +0,0 @@
|
|||||||
import { app, dialog, ipcMain } from 'electron';
|
|
||||||
import { Client as DiscordClient } from '@xhayper/discord-rpc';
|
|
||||||
import { dev } from 'electron-is';
|
|
||||||
|
|
||||||
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
|
||||||
|
|
||||||
import registerCallback, { type SongInfoCallback, type SongInfo } from '../../providers/song-info';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
|
||||||
|
|
||||||
// Application ID registered by @Zo-Bro-23
|
|
||||||
const clientId = '1043858434585526382';
|
|
||||||
|
|
||||||
export interface Info {
|
|
||||||
rpc: DiscordClient;
|
|
||||||
ready: boolean;
|
|
||||||
autoReconnect: boolean;
|
|
||||||
lastSongInfo?: SongInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
const info: Info = {
|
|
||||||
rpc: new DiscordClient({
|
|
||||||
clientId,
|
|
||||||
}),
|
|
||||||
ready: false,
|
|
||||||
autoReconnect: true,
|
|
||||||
lastSongInfo: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @type {(() => void)[]}
|
|
||||||
*/
|
|
||||||
const refreshCallbacks: (() => void)[] = [];
|
|
||||||
|
|
||||||
const resetInfo = () => {
|
|
||||||
info.ready = false;
|
|
||||||
clearTimeout(clearActivity);
|
|
||||||
if (dev()) {
|
|
||||||
console.log('discord disconnected');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const cb of refreshCallbacks) {
|
|
||||||
cb();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectTimeout = () => new Promise((resolve, reject) => setTimeout(() => {
|
|
||||||
if (!info.autoReconnect || info.rpc.isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info.rpc.login().then(resolve).catch(reject);
|
|
||||||
}, 5000));
|
|
||||||
|
|
||||||
const connectRecursive = () => {
|
|
||||||
if (!info.autoReconnect || info.rpc.isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectTimeout().catch(connectRecursive);
|
|
||||||
};
|
|
||||||
|
|
||||||
let window: Electron.BrowserWindow;
|
|
||||||
export const connect = (showError = false) => {
|
|
||||||
if (info.rpc.isConnected) {
|
|
||||||
if (dev()) {
|
|
||||||
console.log('Attempted to connect with active connection');
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info.ready = false;
|
|
||||||
|
|
||||||
// Startup the rpc client
|
|
||||||
info.rpc.login().catch((error: Error) => {
|
|
||||||
resetInfo();
|
|
||||||
if (dev()) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.autoReconnect) {
|
|
||||||
connectRecursive();
|
|
||||||
} else if (showError) {
|
|
||||||
dialog.showMessageBox(window, {
|
|
||||||
title: 'Connection failed',
|
|
||||||
message: error.message || String(error),
|
|
||||||
type: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
let clearActivity: NodeJS.Timeout | undefined;
|
|
||||||
let updateActivity: SongInfoCallback;
|
|
||||||
|
|
||||||
type DiscordOptions = ConfigType<'discord'>;
|
|
||||||
|
|
||||||
export default (
|
|
||||||
win: Electron.BrowserWindow,
|
|
||||||
options: DiscordOptions,
|
|
||||||
) => {
|
|
||||||
info.rpc.on('connected', () => {
|
|
||||||
if (dev()) {
|
|
||||||
console.log('discord connected');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const cb of refreshCallbacks) {
|
|
||||||
cb();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
info.rpc.on('ready', () => {
|
|
||||||
info.ready = true;
|
|
||||||
if (info.lastSongInfo) {
|
|
||||||
updateActivity(info.lastSongInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
info.rpc.on('disconnected', () => {
|
|
||||||
resetInfo();
|
|
||||||
|
|
||||||
if (info.autoReconnect) {
|
|
||||||
connectTimeout();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
info.autoReconnect = options.autoReconnect;
|
|
||||||
|
|
||||||
window = win;
|
|
||||||
// We get multiple events
|
|
||||||
// Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
|
|
||||||
// Skip time: PAUSE(N), PLAY(N)
|
|
||||||
updateActivity = (songInfo) => {
|
|
||||||
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info.lastSongInfo = songInfo;
|
|
||||||
|
|
||||||
// Stop the clear activity timout
|
|
||||||
clearTimeout(clearActivity);
|
|
||||||
|
|
||||||
// Stop early if discord connection is not ready
|
|
||||||
// do this after clearTimeout to avoid unexpected clears
|
|
||||||
if (!info.rpc || !info.ready) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear directly if timeout is 0
|
|
||||||
if (songInfo.isPaused && options.activityTimoutEnabled && options.activityTimoutTime === 0) {
|
|
||||||
info.rpc.user?.clearActivity().catch(console.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Song information changed, so lets update the rich presence
|
|
||||||
// @see https://discord.com/developers/docs/topics/gateway#activity-object
|
|
||||||
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
|
|
||||||
const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
|
|
||||||
if (songInfo.title.length < 2) {
|
|
||||||
songInfo.title += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
|
|
||||||
}
|
|
||||||
if (songInfo.artist.length < 2) {
|
|
||||||
songInfo.artist += hangulFillerUnicodeCharacter.repeat(2 - songInfo.title.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activityInfo: SetActivity = {
|
|
||||||
details: songInfo.title,
|
|
||||||
state: songInfo.artist,
|
|
||||||
largeImageKey: songInfo.imageSrc ?? '',
|
|
||||||
largeImageText: songInfo.album ?? '',
|
|
||||||
buttons: [
|
|
||||||
...(options.playOnYouTubeMusic ? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }] : []),
|
|
||||||
...(options.hideGitHubButton ? [] : [{ label: 'View App On GitHub', url: 'https://github.com/th-ch/youtube-music' }]),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (songInfo.isPaused) {
|
|
||||||
// Add a paused icon to show that the song is paused
|
|
||||||
activityInfo.smallImageKey = 'paused';
|
|
||||||
activityInfo.smallImageText = 'Paused';
|
|
||||||
// Set start the timer so the activity gets cleared after a while if enabled
|
|
||||||
if (options.activityTimoutEnabled) {
|
|
||||||
clearActivity = setTimeout(() => info.rpc.user?.clearActivity().catch(console.error), options.activityTimoutTime ?? 10_000);
|
|
||||||
}
|
|
||||||
} else if (!options.hideDurationLeft) {
|
|
||||||
// Add the start and end time of the song
|
|
||||||
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
|
|
||||||
activityInfo.startTimestamp = songStartTime;
|
|
||||||
activityInfo.endTimestamp
|
|
||||||
= songStartTime + (songInfo.songDuration * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
// If the page is ready, register the callback
|
|
||||||
win.once('ready-to-show', () => {
|
|
||||||
let lastSongInfo: SongInfo;
|
|
||||||
registerCallback((songInfo) => {
|
|
||||||
lastSongInfo = songInfo;
|
|
||||||
updateActivity(songInfo);
|
|
||||||
});
|
|
||||||
connect();
|
|
||||||
let lastSent = Date.now();
|
|
||||||
ipcMain.on('timeChanged', (_, t: number) => {
|
|
||||||
const currentTime = Date.now();
|
|
||||||
// if lastSent is more than 5 seconds ago, send the new time
|
|
||||||
if (currentTime - lastSent > 5000) {
|
|
||||||
lastSent = currentTime;
|
|
||||||
lastSongInfo.elapsedSeconds = t;
|
|
||||||
updateActivity(lastSongInfo);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
app.on('window-all-closed', clear);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clear = () => {
|
|
||||||
if (info.rpc) {
|
|
||||||
info.rpc.user?.clearActivity();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(clearActivity);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
|
|
||||||
export const isConnected = () => info.rpc !== null;
|
|
||||||
53
src/plugins/discord/index.ts
Normal file
53
src/plugins/discord/index.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { backend } from './main';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export type DiscordPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
/**
|
||||||
|
* If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
|
||||||
|
*
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
autoReconnect: boolean;
|
||||||
|
/**
|
||||||
|
* If enabled, the discord rich presence gets cleared when music paused after the time specified below
|
||||||
|
*/
|
||||||
|
activityTimeoutEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* The time in milliseconds after which the discord rich presence gets cleared when music paused
|
||||||
|
*
|
||||||
|
* @default 10 * 60 * 1000 (10 minutes)
|
||||||
|
*/
|
||||||
|
activityTimeoutTime: number;
|
||||||
|
/**
|
||||||
|
* Add a "Play on YouTube Music" button to rich presence
|
||||||
|
*/
|
||||||
|
playOnYouTubeMusic: boolean;
|
||||||
|
/**
|
||||||
|
* Hide the "View App On GitHub" button in the rich presence
|
||||||
|
*/
|
||||||
|
hideGitHubButton: boolean;
|
||||||
|
/**
|
||||||
|
* Hide the "duration left" in the rich presence
|
||||||
|
*/
|
||||||
|
hideDurationLeft: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.discord.name'),
|
||||||
|
description: () => t('plugins.discord.description'),
|
||||||
|
restartNeeded: false,
|
||||||
|
config: {
|
||||||
|
enabled: false,
|
||||||
|
autoReconnect: true,
|
||||||
|
activityTimeoutEnabled: true,
|
||||||
|
activityTimeoutTime: 10 * 60 * 1000,
|
||||||
|
playOnYouTubeMusic: true,
|
||||||
|
hideGitHubButton: false,
|
||||||
|
hideDurationLeft: false,
|
||||||
|
} as DiscordPluginConfig,
|
||||||
|
menu: onMenu,
|
||||||
|
backend,
|
||||||
|
});
|
||||||
271
src/plugins/discord/main.ts
Normal file
271
src/plugins/discord/main.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import { app, dialog, ipcMain } from 'electron';
|
||||||
|
import { Client as DiscordClient } from '@xhayper/discord-rpc';
|
||||||
|
import { dev } from 'electron-is';
|
||||||
|
|
||||||
|
import { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
||||||
|
|
||||||
|
import registerCallback, { type SongInfo } from '@/providers/song-info';
|
||||||
|
|
||||||
|
import { createBackend, LoggerPrefix } from '@/utils';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
|
// Application ID registered by @th-ch/youtube-music dev team
|
||||||
|
const clientId = '1177081335727267940';
|
||||||
|
|
||||||
|
export interface Info {
|
||||||
|
rpc: DiscordClient;
|
||||||
|
ready: boolean;
|
||||||
|
autoReconnect: boolean;
|
||||||
|
lastSongInfo?: SongInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: Info = {
|
||||||
|
rpc: new DiscordClient({
|
||||||
|
clientId,
|
||||||
|
}),
|
||||||
|
ready: false,
|
||||||
|
autoReconnect: true,
|
||||||
|
lastSongInfo: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {(() => void)[]}
|
||||||
|
*/
|
||||||
|
const refreshCallbacks: (() => void)[] = [];
|
||||||
|
|
||||||
|
const resetInfo = () => {
|
||||||
|
info.ready = false;
|
||||||
|
clearTimeout(clearActivity);
|
||||||
|
if (dev()) {
|
||||||
|
console.log(LoggerPrefix, t('plugins.discord.backend.disconnected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cb of refreshCallbacks) {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectTimeout = () =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!info.autoReconnect || info.rpc.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.rpc.login().then(resolve).catch(reject);
|
||||||
|
}, 5000),
|
||||||
|
);
|
||||||
|
const connectRecursive = () => {
|
||||||
|
if (!info.autoReconnect || info.rpc.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectTimeout().catch(connectRecursive);
|
||||||
|
};
|
||||||
|
|
||||||
|
let window: Electron.BrowserWindow;
|
||||||
|
export const connect = (showError = false) => {
|
||||||
|
if (info.rpc.isConnected) {
|
||||||
|
if (dev()) {
|
||||||
|
console.log(LoggerPrefix, t('plugins.discord.backend.already-connected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.ready = false;
|
||||||
|
|
||||||
|
// Startup the rpc client
|
||||||
|
info.rpc.login().catch((error: Error) => {
|
||||||
|
resetInfo();
|
||||||
|
if (dev()) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.autoReconnect) {
|
||||||
|
connectRecursive();
|
||||||
|
} else if (showError) {
|
||||||
|
dialog.showMessageBox(window, {
|
||||||
|
title: 'Connection failed',
|
||||||
|
message: error.message || String(error),
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let clearActivity: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
|
export const clear = () => {
|
||||||
|
if (info.rpc) {
|
||||||
|
info.rpc.user?.clearActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(clearActivity);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
|
||||||
|
export const isConnected = () => info.rpc !== null;
|
||||||
|
|
||||||
|
export const backend = createBackend<
|
||||||
|
{
|
||||||
|
config?: DiscordPluginConfig;
|
||||||
|
updateActivity: (songInfo: SongInfo, config: DiscordPluginConfig) => void;
|
||||||
|
},
|
||||||
|
DiscordPluginConfig
|
||||||
|
>({
|
||||||
|
/**
|
||||||
|
* We get multiple events
|
||||||
|
* Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
|
||||||
|
* Skip time: PAUSE(N), PLAY(N)
|
||||||
|
*/
|
||||||
|
updateActivity: (songInfo, config) => {
|
||||||
|
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.lastSongInfo = songInfo;
|
||||||
|
|
||||||
|
// Stop the clear activity timeout
|
||||||
|
clearTimeout(clearActivity);
|
||||||
|
|
||||||
|
// Stop early if discord connection is not ready
|
||||||
|
// do this after clearTimeout to avoid unexpected clears
|
||||||
|
if (!info.rpc || !info.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear directly if timeout is 0
|
||||||
|
if (
|
||||||
|
songInfo.isPaused &&
|
||||||
|
config.activityTimeoutEnabled &&
|
||||||
|
config.activityTimeoutTime === 0
|
||||||
|
) {
|
||||||
|
info.rpc.user?.clearActivity().catch(console.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Song information changed, so lets update the rich presence
|
||||||
|
// @see https://discord.com/developers/docs/topics/gateway#activity-object
|
||||||
|
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
|
||||||
|
const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
|
||||||
|
if (songInfo.title.length < 2) {
|
||||||
|
songInfo.title += hangulFillerUnicodeCharacter.repeat(
|
||||||
|
2 - songInfo.title.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (songInfo.artist.length < 2) {
|
||||||
|
songInfo.artist += hangulFillerUnicodeCharacter.repeat(
|
||||||
|
2 - songInfo.title.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityInfo: SetActivity = {
|
||||||
|
details: songInfo.title,
|
||||||
|
state: songInfo.artist,
|
||||||
|
largeImageKey: songInfo.imageSrc ?? '',
|
||||||
|
largeImageText: songInfo.album ?? '',
|
||||||
|
buttons: [
|
||||||
|
...(config.playOnYouTubeMusic
|
||||||
|
? [{ label: 'Play on YouTube Music', url: songInfo.url ?? '' }]
|
||||||
|
: []),
|
||||||
|
...(config.hideGitHubButton
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: 'View App On GitHub',
|
||||||
|
url: 'https://github.com/th-ch/youtube-music',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (songInfo.isPaused) {
|
||||||
|
// Add a paused icon to show that the song is paused
|
||||||
|
activityInfo.smallImageKey = 'paused';
|
||||||
|
activityInfo.smallImageText = 'Paused';
|
||||||
|
// Set start the timer so the activity gets cleared after a while if enabled
|
||||||
|
if (config.activityTimeoutEnabled) {
|
||||||
|
clearActivity = setTimeout(
|
||||||
|
() => info.rpc.user?.clearActivity().catch(console.error),
|
||||||
|
config.activityTimeoutTime ?? 10_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!config.hideDurationLeft) {
|
||||||
|
// Add the start and end time of the song
|
||||||
|
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
|
||||||
|
activityInfo.startTimestamp = songStartTime;
|
||||||
|
activityInfo.endTimestamp = songStartTime + (songInfo.songDuration * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
info.rpc.user?.setActivity(activityInfo).catch(console.error);
|
||||||
|
},
|
||||||
|
async start({ window: win, getConfig }) {
|
||||||
|
this.config = await getConfig();
|
||||||
|
|
||||||
|
info.rpc.on('connected', () => {
|
||||||
|
if (dev()) {
|
||||||
|
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cb of refreshCallbacks) {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info.rpc.on('ready', () => {
|
||||||
|
info.ready = true;
|
||||||
|
if (info.lastSongInfo && this.config) {
|
||||||
|
this.updateActivity(info.lastSongInfo, this.config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info.rpc.on('disconnected', () => {
|
||||||
|
resetInfo();
|
||||||
|
|
||||||
|
if (info.autoReconnect) {
|
||||||
|
connectTimeout();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
info.autoReconnect = this.config.autoReconnect;
|
||||||
|
|
||||||
|
window = win;
|
||||||
|
|
||||||
|
// If the page is ready, register the callback
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
let lastSongInfo: SongInfo;
|
||||||
|
registerCallback((songInfo) => {
|
||||||
|
lastSongInfo = songInfo;
|
||||||
|
if (this.config) this.updateActivity(songInfo, this.config);
|
||||||
|
});
|
||||||
|
connect();
|
||||||
|
let lastSent = Date.now();
|
||||||
|
ipcMain.on('timeChanged', (_, t: number) => {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
// if lastSent is more than 5 seconds ago, send the new time
|
||||||
|
if (currentTime - lastSent > 5000) {
|
||||||
|
lastSent = currentTime;
|
||||||
|
if (lastSongInfo) {
|
||||||
|
lastSongInfo.elapsedSeconds = t;
|
||||||
|
if (this.config) this.updateActivity(lastSongInfo, this.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.on('window-all-closed', clear);
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
resetInfo();
|
||||||
|
},
|
||||||
|
onConfigChange(newConfig) {
|
||||||
|
this.config = newConfig;
|
||||||
|
info.autoReconnect = newConfig.autoReconnect;
|
||||||
|
if (info.lastSongInfo) {
|
||||||
|
this.updateActivity(info.lastSongInfo, newConfig);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -1,98 +1,119 @@
|
|||||||
import prompt from 'custom-electron-prompt';
|
import prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import { clear, connect, isConnected, registerRefresh } from './back';
|
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||||
|
|
||||||
import { setMenuOptions } from '../../config/plugins';
|
import { singleton } from '@/providers/decorators';
|
||||||
import promptOptions from '../../providers/prompt-options';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
import { singleton } from '../../providers/decorators';
|
import { setMenuOptions } from '@/config/plugins';
|
||||||
import { MenuTemplate } from '../../menu';
|
|
||||||
|
|
||||||
import type { ConfigType } from '../../config/dynamic';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import type { MenuContext } from '@/types/contexts';
|
||||||
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||||
registerRefresh(refreshMenu);
|
registerRefresh(refreshMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
type DiscordOptions = ConfigType<'discord'>;
|
export const onMenu = async ({
|
||||||
|
window,
|
||||||
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
|
getConfig,
|
||||||
registerRefreshOnce(refreshMenu);
|
setConfig,
|
||||||
|
refresh,
|
||||||
|
}: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
|
||||||
|
const config = await getConfig();
|
||||||
|
registerRefreshOnce(refresh);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: isConnected() ? 'Connected' : 'Reconnect',
|
label: isConnected()
|
||||||
|
? t('plugins.discord.menu.connected')
|
||||||
|
: t('plugins.discord.menu.disconnected'),
|
||||||
enabled: !isConnected(),
|
enabled: !isConnected(),
|
||||||
click: () => connect(),
|
click: () => connect(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Auto reconnect',
|
label: t('plugins.discord.menu.auto-reconnect'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.autoReconnect,
|
checked: config.autoReconnect,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.autoReconnect = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
autoReconnect: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Clear activity',
|
label: t('plugins.discord.menu.clear-activity'),
|
||||||
click: clear,
|
click: clear,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Clear activity after timeout',
|
label: t('plugins.discord.menu.clear-activity-after-timeout'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.activityTimoutEnabled,
|
checked: config.activityTimeoutEnabled,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.activityTimoutEnabled = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
activityTimeoutEnabled: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Play on YouTube Music',
|
label: t('plugins.discord.menu.play-on-youtube-music'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.playOnYouTubeMusic,
|
checked: config.playOnYouTubeMusic,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.playOnYouTubeMusic = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
playOnYouTubeMusic: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hide GitHub link Button',
|
label: t('plugins.discord.menu.hide-github-button'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.hideGitHubButton,
|
checked: config.hideGitHubButton,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.hideGitHubButton = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
hideGitHubButton: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hide duration left',
|
label: t('plugins.discord.menu.hide-duration-left'),
|
||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
checked: options.hideDurationLeft,
|
checked: config.hideDurationLeft,
|
||||||
click(item: Electron.MenuItem) {
|
click(item: Electron.MenuItem) {
|
||||||
options.hideDurationLeft = item.checked;
|
setConfig({
|
||||||
setMenuOptions('discord', options);
|
hideGitHubButton: item.checked,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Set inactivity timeout',
|
label: t('plugins.discord.menu.set-inactivity-timeout'),
|
||||||
click: () => setInactivityTimeout(win, options),
|
click: () => setInactivityTimeout(window, config),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
async function setInactivityTimeout(
|
||||||
const output = await prompt({
|
win: Electron.BrowserWindow,
|
||||||
title: 'Set Inactivity Timeout',
|
options: DiscordPluginConfig,
|
||||||
label: 'Enter inactivity timeout in seconds:',
|
) {
|
||||||
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
|
const output = await prompt(
|
||||||
type: 'counter',
|
{
|
||||||
counterOptions: { minimum: 0, multiFire: true },
|
title: t('plugins.discord.prompt.set-inactivity-timeout.title'),
|
||||||
width: 450,
|
label: t('plugins.discord.prompt.set-inactivity-timeout.label'),
|
||||||
...promptOptions(),
|
value: String(Math.round((options.activityTimeoutTime ?? 0) / 1e3)),
|
||||||
}, win);
|
type: 'counter',
|
||||||
|
counterOptions: { minimum: 0, multiFire: true },
|
||||||
|
width: 450,
|
||||||
|
...promptOptions(),
|
||||||
|
},
|
||||||
|
win,
|
||||||
|
);
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
options.activityTimoutTime = Math.round(~~output * 1e3);
|
options.activityTimeoutTime = Math.round(~~output * 1e3);
|
||||||
setMenuOptions('discord', options);
|
setMenuOptions('discord', options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { PluginConfig } from '../../config/dynamic';
|
|
||||||
|
|
||||||
const config = new PluginConfig('downloader');
|
|
||||||
export default config;
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { ipcRenderer } from 'electron';
|
|
||||||
|
|
||||||
import downloadHTML from './templates/download.html';
|
|
||||||
|
|
||||||
import defaultConfig from '../../config/defaults';
|
|
||||||
import { getSongMenu } from '../../providers/dom-elements';
|
|
||||||
import { ElementFromHtml } from '../utils';
|
|
||||||
import { getSongInfo } from '../../providers/song-info-front';
|
|
||||||
|
|
||||||
let menu: Element | null = null;
|
|
||||||
let progress: Element | null = null;
|
|
||||||
const downloadButton = ElementFromHtml(downloadHTML);
|
|
||||||
|
|
||||||
let doneFirstLoad = false;
|
|
||||||
|
|
||||||
const menuObserver = new MutationObserver(() => {
|
|
||||||
if (!menu) {
|
|
||||||
menu = getSongMenu();
|
|
||||||
if (!menu) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menu.contains(downloadButton)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
|
|
||||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
menu.prepend(downloadButton);
|
|
||||||
progress = document.querySelector('#ytmcustom-download');
|
|
||||||
|
|
||||||
if (doneFirstLoad) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: re-enable once contextIsolation is set to true
|
|
||||||
// contextBridge.exposeInMainWorld("downloader", {
|
|
||||||
// download: () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
|
||||||
(global as any).download = () => {
|
|
||||||
let videoUrl = getSongMenu()
|
|
||||||
// Selector of first button which is always "Start Radio"
|
|
||||||
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
|
|
||||||
?.getAttribute('href');
|
|
||||||
if (videoUrl) {
|
|
||||||
if (videoUrl.startsWith('watch?')) {
|
|
||||||
videoUrl = defaultConfig.url + '/' + videoUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoUrl.includes('?playlist=')) {
|
|
||||||
ipcRenderer.send('download-playlist-request', videoUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
videoUrl = getSongInfo().url || window.location.href;
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcRenderer.send('download-song', videoUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default () => {
|
|
||||||
document.addEventListener('apiLoaded', () => {
|
|
||||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
});
|
|
||||||
}, { once: true, passive: true });
|
|
||||||
|
|
||||||
ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
|
|
||||||
if (progress) {
|
|
||||||
progress.innerHTML = feedback || 'Download';
|
|
||||||
} else {
|
|
||||||
console.warn('Cannot update progress');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
44
src/plugins/downloader/index.ts
Normal file
44
src/plugins/downloader/index.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { DefaultPresetList, Preset } from './types';
|
||||||
|
|
||||||
|
import style from './style.css?inline';
|
||||||
|
|
||||||
|
import { createPlugin } from '@/utils';
|
||||||
|
import { onConfigChange, onMainLoad } from './main';
|
||||||
|
import { onPlayerApiReady, onRendererLoad } from './renderer';
|
||||||
|
import { onMenu } from './menu';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export type DownloaderPluginConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
downloadFolder?: string;
|
||||||
|
selectedPreset: string;
|
||||||
|
customPresetSetting: Preset;
|
||||||
|
skipExisting: boolean;
|
||||||
|
playlistMaxItems?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const defaultConfig: DownloaderPluginConfig = {
|
||||||
|
enabled: false,
|
||||||
|
downloadFolder: undefined,
|
||||||
|
selectedPreset: 'mp3 (256kbps)', // Selected preset
|
||||||
|
customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
|
||||||
|
skipExisting: false,
|
||||||
|
playlistMaxItems: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createPlugin({
|
||||||
|
name: () => t('plugins.downloader.name'),
|
||||||
|
description: () => t('plugins.downloader.description'),
|
||||||
|
restartNeeded: true,
|
||||||
|
config: defaultConfig,
|
||||||
|
stylesheets: [style],
|
||||||
|
menu: onMenu,
|
||||||
|
backend: {
|
||||||
|
start: onMainLoad,
|
||||||
|
onConfigChange,
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
start: onRendererLoad,
|
||||||
|
onPlayerApiReady,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -7,7 +7,7 @@ import {
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
import { app, BrowserWindow, dialog } from 'electron';
|
||||||
import {
|
import {
|
||||||
ClientType,
|
ClientType,
|
||||||
Innertube,
|
Innertube,
|
||||||
@ -27,16 +27,20 @@ import {
|
|||||||
sendFeedback as sendFeedback_,
|
sendFeedback as sendFeedback_,
|
||||||
setBadge,
|
setBadge,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import config from './config';
|
|
||||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
|
|
||||||
|
|
||||||
import style from './style.css';
|
import { fetchFromGenius } from '@/plugins/lyrics-genius/main';
|
||||||
|
import { isEnabled } from '@/config/plugins';
|
||||||
|
import { cleanupName, getImage, SongInfo } from '@/providers/song-info';
|
||||||
|
import { getNetFetchAsFetch } from '@/plugins/utils/main';
|
||||||
|
import { cache } from '@/providers/decorators';
|
||||||
|
|
||||||
import { fetchFromGenius } from '../lyrics-genius/back';
|
import { t } from '@/i18n';
|
||||||
import { isEnabled } from '../../config/plugins';
|
|
||||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
||||||
import { injectCSS } from '../utils';
|
|
||||||
import { cache } from '../../providers/decorators';
|
import type { DownloaderPluginConfig } from '../index';
|
||||||
|
|
||||||
|
import type { BackendContext } from '@/types/contexts';
|
||||||
|
|
||||||
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||||
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||||
@ -44,7 +48,7 @@ import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
|
|||||||
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
||||||
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
||||||
|
|
||||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
import type { GetPlayerResponse } from '@/types/get-player-response';
|
||||||
|
|
||||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||||
|
|
||||||
@ -68,12 +72,13 @@ const sendError = (error: Error, source?: string) => {
|
|||||||
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
|
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
|
||||||
const message = `${error.toString()}${songNameMessage}${cause}`;
|
const message = `${error.toString()}${songNameMessage}${cause}`;
|
||||||
|
|
||||||
console.error(message, error, error?.stack);
|
console.error(message);
|
||||||
dialog.showMessageBox({
|
console.trace(error);
|
||||||
|
dialog.showMessageBox(win, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
buttons: ['OK'],
|
buttons: [t('plugins.downloader.backend.dialog.error.buttons.ok')],
|
||||||
title: 'Error in download!',
|
title: t('plugins.downloader.backend.dialog.error.title'),
|
||||||
message: 'Argh! Apologies, download failed…',
|
message: t('plugins.downloader.backend.dialog.error.message'),
|
||||||
detail: message,
|
detail: message,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -88,43 +93,35 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
|||||||
.join(';');
|
.join(';');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async (win_: BrowserWindow) => {
|
let config: DownloaderPluginConfig;
|
||||||
win = win_;
|
|
||||||
injectCSS(win.webContents, style);
|
export const onMainLoad = async ({
|
||||||
|
window: _win,
|
||||||
|
getConfig,
|
||||||
|
ipc,
|
||||||
|
}: BackendContext<DownloaderPluginConfig>) => {
|
||||||
|
win = _win;
|
||||||
|
config = await getConfig();
|
||||||
|
|
||||||
yt = await Innertube.create({
|
yt = await Innertube.create({
|
||||||
cache: new UniversalCache(false),
|
cache: new UniversalCache(false),
|
||||||
cookie: await getCookieFromWindow(win),
|
cookie: await getCookieFromWindow(win),
|
||||||
generate_session_locally: true,
|
generate_session_locally: true,
|
||||||
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
|
fetch: getNetFetchAsFetch(),
|
||||||
const url =
|
|
||||||
typeof input === 'string'
|
|
||||||
? new URL(input)
|
|
||||||
: input instanceof URL
|
|
||||||
? input
|
|
||||||
: new URL(input.url);
|
|
||||||
|
|
||||||
if (init?.body && !init.method) {
|
|
||||||
init.method = 'POST';
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = new Request(
|
|
||||||
url,
|
|
||||||
input instanceof Request ? input : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
return net.fetch(request, init);
|
|
||||||
}) as typeof fetch,
|
|
||||||
});
|
});
|
||||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
ipc.handle('download-song', (url: string) => downloadSong(url));
|
||||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
ipc.on('video-src-changed', (data: GetPlayerResponse) => {
|
||||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||||
});
|
});
|
||||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
ipc.handle('download-playlist-request', async (url: string) =>
|
||||||
downloadPlaylist(url),
|
downloadPlaylist(url),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
|
||||||
|
config = newConfig;
|
||||||
|
};
|
||||||
|
|
||||||
export async function downloadSong(
|
export async function downloadSong(
|
||||||
url: string,
|
url: string,
|
||||||
playlistFolder: string | undefined = undefined,
|
playlistFolder: string | undefined = undefined,
|
||||||
@ -184,20 +181,25 @@ async function downloadSongUnsafe(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
sendFeedback('Downloading...', 2);
|
sendFeedback(t('plugins.downloader.backend.feedback.downloading'), 2);
|
||||||
|
|
||||||
let id: string | null;
|
let id: string | null;
|
||||||
if (isId) {
|
if (isId) {
|
||||||
id = idOrUrl;
|
id = idOrUrl;
|
||||||
} else {
|
} else {
|
||||||
id = getVideoId(idOrUrl);
|
id = getVideoId(idOrUrl);
|
||||||
if (typeof id !== 'string') throw new Error('Video not found');
|
if (typeof id !== 'string')
|
||||||
|
throw new Error(
|
||||||
|
t('plugins.downloader.backend.feedback.video-id-not-found'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
|
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
throw new Error('Video not found');
|
throw new Error(
|
||||||
|
t('plugins.downloader.backend.feedback.video-id-not-found'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = getMetadata(info);
|
const metadata = getMetadata(info);
|
||||||
@ -208,7 +210,7 @@ async function downloadSongUnsafe(
|
|||||||
metadata.trackId = trackId;
|
metadata.trackId = trackId;
|
||||||
|
|
||||||
const dir =
|
const dir =
|
||||||
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
playlistFolder || config.downloadFolder || app.getPath('downloads');
|
||||||
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
||||||
metadata.title
|
metadata.title
|
||||||
}`;
|
}`;
|
||||||
@ -238,11 +240,10 @@ async function downloadSongUnsafe(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)';
|
const selectedPreset = config.selectedPreset ?? 'mp3 (256kbps)';
|
||||||
let presetSetting: Preset;
|
let presetSetting: Preset;
|
||||||
if (selectedPreset === 'Custom') {
|
if (selectedPreset === 'Custom') {
|
||||||
presetSetting =
|
presetSetting = config.customPresetSetting ?? DefaultPresetList['Custom'];
|
||||||
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
|
|
||||||
} else if (selectedPreset === 'Source') {
|
} else if (selectedPreset === 'Source') {
|
||||||
presetSetting = DefaultPresetList['Source'];
|
presetSetting = DefaultPresetList['Source'];
|
||||||
} else {
|
} else {
|
||||||
@ -275,7 +276,7 @@ async function downloadSongUnsafe(
|
|||||||
}
|
}
|
||||||
const filePath = join(dir, filename);
|
const filePath = join(dir, filename);
|
||||||
|
|
||||||
if (config.get('skipExisting') && existsSync(filePath)) {
|
if (config.skipExisting && existsSync(filePath)) {
|
||||||
sendFeedback(null, -1);
|
sendFeedback(null, -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -283,7 +284,11 @@ async function downloadSongUnsafe(
|
|||||||
const stream = await info.download(downloadOptions);
|
const stream = await info.download(downloadOptions);
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
|
t('plugins.downloader.backend.feedback.download-info', {
|
||||||
|
artist: metadata.artist,
|
||||||
|
title: metadata.title,
|
||||||
|
videoId: metadata.videoId,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const iterableStream = Utils.streamToIterable(stream);
|
const iterableStream = Utils.streamToIterable(stream);
|
||||||
@ -318,7 +323,11 @@ async function downloadSongUnsafe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendFeedback(null, -1);
|
sendFeedback(null, -1);
|
||||||
console.info(`Done: "${filePath}"`);
|
console.info(
|
||||||
|
t('plugins.downloader.backend.feedback.done', {
|
||||||
|
filePath,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function iterableStreamToTargetFile(
|
async function iterableStreamToTargetFile(
|
||||||
@ -329,7 +338,7 @@ async function iterableStreamToTargetFile(
|
|||||||
contentLength: number,
|
contentLength: number,
|
||||||
sendFeedback: (str: string, value?: number) => void,
|
sendFeedback: (str: string, value?: number) => void,
|
||||||
increasePlaylistProgress: (value: number) => void = () => {},
|
increasePlaylistProgress: (value: number) => void = () => {},
|
||||||
) {
|
): Promise<Uint8Array | null> {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
for await (const chunk of stream) {
|
for await (const chunk of stream) {
|
||||||
@ -337,13 +346,18 @@ async function iterableStreamToTargetFile(
|
|||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
const ratio = downloaded / contentLength;
|
const ratio = downloaded / contentLength;
|
||||||
const progress = Math.floor(ratio * 100);
|
const progress = Math.floor(ratio * 100);
|
||||||
sendFeedback(`Download: ${progress}%`, ratio);
|
sendFeedback(
|
||||||
|
t('plugins.downloader.backend.feedback.download-progress', {
|
||||||
|
percent: progress,
|
||||||
|
}),
|
||||||
|
ratio,
|
||||||
|
);
|
||||||
// 15% for download, 85% for conversion
|
// 15% for download, 85% for conversion
|
||||||
// This is a very rough estimate, trying to make the progress bar look nice
|
// This is a very rough estimate, trying to make the progress bar look nice
|
||||||
increasePlaylistProgress(ratio * 0.15);
|
increasePlaylistProgress(ratio * 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFeedback('Loading…', 2); // Indefinite progress bar after download
|
sendFeedback(t('plugins.downloader.backend.feedback.loading'), 2); // Indefinite progress bar after download
|
||||||
|
|
||||||
const buffer = Buffer.concat(chunks);
|
const buffer = Buffer.concat(chunks);
|
||||||
const safeVideoName = randomBytes(32).toString('hex');
|
const safeVideoName = randomBytes(32).toString('hex');
|
||||||
@ -354,13 +368,18 @@ async function iterableStreamToTargetFile(
|
|||||||
await ffmpeg.load();
|
await ffmpeg.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFeedback('Preparing file…');
|
sendFeedback(t('plugins.downloader.backend.feedback.preparing-file'));
|
||||||
ffmpeg.FS('writeFile', safeVideoName, buffer);
|
ffmpeg.FS('writeFile', safeVideoName, buffer);
|
||||||
|
|
||||||
sendFeedback('Converting…');
|
sendFeedback(t('plugins.downloader.backend.feedback.converting'));
|
||||||
|
|
||||||
ffmpeg.setProgress(({ ratio }) => {
|
ffmpeg.setProgress(({ ratio }) => {
|
||||||
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
|
sendFeedback(
|
||||||
|
t('plugins.downloader.backend.feedback.conversion-progress', {
|
||||||
|
percent: Math.floor(ratio * 100),
|
||||||
|
}),
|
||||||
|
ratio,
|
||||||
|
);
|
||||||
increasePlaylistProgress(0.15 + (ratio * 0.85));
|
increasePlaylistProgress(0.15 + (ratio * 0.85));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -377,7 +396,7 @@ async function iterableStreamToTargetFile(
|
|||||||
ffmpeg.FS('unlink', safeVideoName);
|
ffmpeg.FS('unlink', safeVideoName);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendFeedback('Saving…');
|
sendFeedback(t('plugins.downloader.backend.feedback.saving'));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return ffmpeg.FS('readFile', safeVideoNameWithExtension);
|
return ffmpeg.FS('readFile', safeVideoNameWithExtension);
|
||||||
@ -389,6 +408,7 @@ async function iterableStreamToTargetFile(
|
|||||||
} finally {
|
} finally {
|
||||||
releaseFFmpegMutex();
|
releaseFFmpegMutex();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCoverBuffer = cache(async (url: string) => {
|
const getCoverBuffer = cache(async (url: string) => {
|
||||||
@ -402,7 +422,7 @@ async function writeID3(
|
|||||||
sendFeedback: (str: string, value?: number) => void,
|
sendFeedback: (str: string, value?: number) => void,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
sendFeedback('Writing ID3 tags...');
|
sendFeedback(t('plugins.downloader.backend.feedback.writing-id3'));
|
||||||
const tags: NodeID3.Tags = {};
|
const tags: NodeID3.Tags = {};
|
||||||
|
|
||||||
// Create the metadata tags
|
// Create the metadata tags
|
||||||
@ -454,18 +474,23 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playlistId =
|
const playlistId =
|
||||||
getPlaylistID(givenUrl) ||
|
getPlaylistID(givenUrl) || getPlaylistID(new URL(playingUrl));
|
||||||
getPlaylistID(new URL(playingUrl));
|
|
||||||
|
|
||||||
if (!playlistId) {
|
if (!playlistId) {
|
||||||
sendError(new Error('No playlist ID found'));
|
sendError(
|
||||||
|
new Error(t('plugins.downloader.backend.feedback.playlist-id-not-found')),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
|
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
|
||||||
|
|
||||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
console.log(
|
||||||
sendFeedback('Getting playlist info…');
|
t('plugins.downloader.backend.feedback.trying-to-get-playlist-id', {
|
||||||
|
playlistId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sendFeedback(t('plugins.downloader.backend.feedback.getting-playlist-info'));
|
||||||
let playlist: Playlist;
|
let playlist: Playlist;
|
||||||
const items: YTNodes.MusicResponsiveListItem[] = [];
|
const items: YTNodes.MusicResponsiveListItem[] = [];
|
||||||
try {
|
try {
|
||||||
@ -476,16 +501,18 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
sendError(
|
sendError(
|
||||||
Error(
|
Error(
|
||||||
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(
|
t('plugins.downloader.backend.feedback.playlist-is-mix-or-private', {
|
||||||
error,
|
error: String(error),
|
||||||
)}`,
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||||
sendError(new Error('Playlist is empty'));
|
sendError(
|
||||||
|
new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalPlaylistTitle = playlist.header?.title?.text;
|
const normalPlaylistTitle = playlist.header?.title?.text;
|
||||||
@ -506,7 +533,9 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (items.length === 1) {
|
if (items.length === 1) {
|
||||||
sendFeedback('Playlist has only one item, downloading it directly');
|
sendFeedback(
|
||||||
|
t('plugins.downloader.backend.feedback.playlist-has-only-one-song'),
|
||||||
|
);
|
||||||
await downloadSongFromId(items.at(0)!.id!);
|
await downloadSongFromId(items.at(0)!.id!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -516,28 +545,50 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
||||||
}
|
}
|
||||||
|
|
||||||
const folder = getFolder(config.get('downloadFolder') ?? '');
|
const folder = getFolder(config.downloadFolder ?? '');
|
||||||
const playlistFolder = join(folder, safePlaylistTitle);
|
const playlistFolder = join(folder, safePlaylistTitle);
|
||||||
if (existsSync(playlistFolder)) {
|
if (existsSync(playlistFolder)) {
|
||||||
if (!config.get('skipExisting')) {
|
if (!config.skipExisting) {
|
||||||
sendError(new Error(`The folder ${playlistFolder} already exists`));
|
sendError(
|
||||||
|
new Error(
|
||||||
|
t('plugins.downloader.backend.feedback.folder-already-exists', {
|
||||||
|
playlistFolder,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mkdirSync(playlistFolder, { recursive: true });
|
mkdirSync(playlistFolder, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.showMessageBox({
|
dialog.showMessageBox(win, {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
buttons: ['OK'],
|
buttons: [
|
||||||
title: 'Started Download',
|
t('plugins.downloader.backend.dialog.start-download-playlist.buttons.ok'),
|
||||||
message: `Downloading Playlist "${playlistTitle}"`,
|
],
|
||||||
detail: `(${items.length} songs)`,
|
title: t('plugins.downloader.backend.dialog.start-download-playlist.title'),
|
||||||
|
message: t(
|
||||||
|
'plugins.downloader.backend.dialog.start-download-playlist.message',
|
||||||
|
{
|
||||||
|
playlistTitle,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
detail: t(
|
||||||
|
'plugins.downloader.backend.dialog.start-download-playlist.detail',
|
||||||
|
{
|
||||||
|
playlistSize: items.length,
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log(
|
console.log(
|
||||||
`Downloading playlist "${playlistTitle}" - ${items.length} songs (${playlistId})`,
|
t('plugins.downloader.backend.feedback.downloading-playlist', {
|
||||||
|
playlistTitle,
|
||||||
|
playlistSize: items.length,
|
||||||
|
playlistId,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -557,7 +608,12 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const song of items) {
|
for (const song of items) {
|
||||||
sendFeedback(`Downloading ${counter}/${items.length}...`);
|
sendFeedback(
|
||||||
|
t('plugins.downloader.backend.feedback.downloading-counter', {
|
||||||
|
current: counter,
|
||||||
|
total: items.length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const trackId = isAlbum ? counter : undefined;
|
const trackId = isAlbum ? counter : undefined;
|
||||||
await downloadSongFromId(
|
await downloadSongFromId(
|
||||||
song.id!,
|
song.id!,
|
||||||
@ -567,9 +623,11 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
).catch((error) =>
|
).catch((error) =>
|
||||||
sendError(
|
sendError(
|
||||||
new Error(
|
new Error(
|
||||||
`Error downloading "${
|
t('plugins.downloader.backend.feedback.error-while-downloading', {
|
||||||
song.author!.name
|
author: song.author!.name,
|
||||||
} - ${song.title!}":\n ${error}`,
|
title: song.title!,
|
||||||
|
error: String(error),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -636,6 +694,7 @@ const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
|||||||
client_type: ClientType.TV_EMBEDDED,
|
client_type: ClientType.TV_EMBEDDED,
|
||||||
generate_session_locally: true,
|
generate_session_locally: true,
|
||||||
retrieve_player: true,
|
retrieve_player: true,
|
||||||
|
fetch: getNetFetchAsFetch(),
|
||||||
});
|
});
|
||||||
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
||||||
// that's fine as we only need the streaming data
|
// that's fine as we only need the streaming data
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
|
|
||||||
export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads');
|
export const getFolder = (customFolder: string) =>
|
||||||
export const defaultMenuDownloadLabel = 'Download playlist';
|
customFolder || app.getPath('downloads');
|
||||||
|
|
||||||
export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
|
export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
|
||||||
win.webContents.send('downloader-feedback', message);
|
win.webContents.send('downloader-feedback', message);
|
||||||
@ -1,46 +1,57 @@
|
|||||||
import { dialog } from 'electron';
|
import { dialog } from 'electron';
|
||||||
|
|
||||||
import { downloadPlaylist } from './back';
|
import { downloadPlaylist } from './main';
|
||||||
import { defaultMenuDownloadLabel, getFolder } from './utils';
|
import { getFolder } from './main/utils';
|
||||||
import { DefaultPresetList } from './types';
|
import { DefaultPresetList } from './types';
|
||||||
import config from './config';
|
|
||||||
|
|
||||||
import { MenuTemplate } from '../../menu';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
export default (): MenuTemplate => [
|
import type { MenuContext } from '@/types/contexts';
|
||||||
{
|
import type { MenuTemplate } from '@/menu';
|
||||||
label: defaultMenuDownloadLabel,
|
|
||||||
click: () => downloadPlaylist(),
|
import type { DownloaderPluginConfig } from './index';
|
||||||
},
|
|
||||||
{
|
export const onMenu = async ({
|
||||||
label: 'Choose download folder',
|
getConfig,
|
||||||
click() {
|
setConfig,
|
||||||
const result = dialog.showOpenDialogSync({
|
}: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
|
||||||
properties: ['openDirectory', 'createDirectory'],
|
const config = await getConfig();
|
||||||
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
|
|
||||||
});
|
return [
|
||||||
if (result) {
|
{
|
||||||
config.set('downloadFolder', result[0]);
|
label: t('plugins.downloader.menu.download-playlist'),
|
||||||
} // Else = user pressed cancel
|
click: () => downloadPlaylist(),
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
label: t('plugins.downloader.menu.choose-download-folder'),
|
||||||
label: 'Presets',
|
|
||||||
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
|
||||||
label: preset,
|
|
||||||
type: 'radio',
|
|
||||||
checked: config.get('selectedPreset') === preset,
|
|
||||||
click() {
|
click() {
|
||||||
config.set('selectedPreset', preset);
|
const result = dialog.showOpenDialogSync({
|
||||||
|
properties: ['openDirectory', 'createDirectory'],
|
||||||
|
defaultPath: getFolder(config.downloadFolder ?? ''),
|
||||||
|
});
|
||||||
|
if (result) {
|
||||||
|
setConfig({ downloadFolder: result[0] });
|
||||||
|
} // Else = user pressed cancel
|
||||||
},
|
},
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Skip existing files',
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: config.get('skipExisting'),
|
|
||||||
click(item) {
|
|
||||||
config.set('skipExisting', item.checked);
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
];
|
label: t('plugins.downloader.menu.presets'),
|
||||||
|
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
||||||
|
label: preset,
|
||||||
|
type: 'radio',
|
||||||
|
checked: config.selectedPreset === preset,
|
||||||
|
click() {
|
||||||
|
setConfig({ selectedPreset: preset });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('plugins.downloader.menu.skip-existing'),
|
||||||
|
type: 'checkbox',
|
||||||
|
checked: config.skipExisting,
|
||||||
|
click(item) {
|
||||||
|
setConfig({ skipExisting: item.checked });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
95
src/plugins/downloader/renderer.ts
Normal file
95
src/plugins/downloader/renderer.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import downloadHTML from './templates/download.html?raw';
|
||||||
|
|
||||||
|
import defaultConfig from '@/config/defaults';
|
||||||
|
import { getSongMenu } from '@/providers/dom-elements';
|
||||||
|
import { getSongInfo } from '@/providers/song-info-front';
|
||||||
|
|
||||||
|
import { LoggerPrefix } from '@/utils';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
import { ElementFromHtml } from '../utils/renderer';
|
||||||
|
|
||||||
|
import type { RendererContext } from '@/types/contexts';
|
||||||
|
|
||||||
|
import type { DownloaderPluginConfig } from './index';
|
||||||
|
|
||||||
|
let menu: Element | null = null;
|
||||||
|
let progress: Element | null = null;
|
||||||
|
const downloadButton = ElementFromHtml(downloadHTML);
|
||||||
|
|
||||||
|
let doneFirstLoad = false;
|
||||||
|
|
||||||
|
const menuObserver = new MutationObserver(() => {
|
||||||
|
if (!menu) {
|
||||||
|
menu = getSongMenu();
|
||||||
|
if (!menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.contains(downloadButton)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuUrl = document.querySelector<HTMLAnchorElement>(
|
||||||
|
'tp-yt-paper-listbox [tabindex="-1"] #navigation-endpoint',
|
||||||
|
)?.href;
|
||||||
|
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
menu.prepend(downloadButton);
|
||||||
|
progress = document.querySelector('#ytmcustom-download');
|
||||||
|
|
||||||
|
if (doneFirstLoad) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => (doneFirstLoad ||= true), 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const onRendererLoad = ({
|
||||||
|
ipc,
|
||||||
|
}: RendererContext<DownloaderPluginConfig>) => {
|
||||||
|
window.download = () => {
|
||||||
|
let videoUrl = getSongMenu()
|
||||||
|
// Selector of first button which is always "Start Radio"
|
||||||
|
?.querySelector(
|
||||||
|
'ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint',
|
||||||
|
)
|
||||||
|
?.getAttribute('href');
|
||||||
|
if (videoUrl) {
|
||||||
|
if (videoUrl.startsWith('watch?')) {
|
||||||
|
videoUrl = defaultConfig.url + '/' + videoUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoUrl.includes('?playlist=')) {
|
||||||
|
ipc.invoke('download-playlist-request', videoUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
videoUrl = getSongInfo().url || window.location.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
ipc.invoke('download-song', videoUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipc.on('downloader-feedback', (feedback: string) => {
|
||||||
|
if (progress) {
|
||||||
|
progress.innerHTML = feedback || 'Download';
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
LoggerPrefix,
|
||||||
|
t('plugins.downloader.renderer.can-not-update-progress'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onPlayerApiReady = () => {
|
||||||
|
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user