mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 02:31:45 +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
|
||||
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-requiring-type-checking',
|
||||
],
|
||||
plugins: ['@typescript-eslint', 'import'],
|
||||
plugins: ['prettier', '@typescript-eslint', 'import'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
@ -26,6 +26,7 @@ module.exports = {
|
||||
'import/newline-after-import': 'error',
|
||||
'import/no-default-export': 'off',
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
@ -66,4 +67,14 @@ module.exports = {
|
||||
es6: true,
|
||||
},
|
||||
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
|
||||
description: (If applicable) What is the last version of YouTube Music this worked in?
|
||||
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
|
||||
attributes:
|
||||
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:
|
||||
required: true
|
||||
- type: textarea
|
||||
@ -69,6 +76,13 @@ body:
|
||||
description: A clear description of what actually happens.
|
||||
validations:
|
||||
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
|
||||
attributes:
|
||||
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
|
||||
if: startsWith(matrix.os, 'macOS')
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Only rollup build without release if it is a fork
|
||||
- name: Rollup Build
|
||||
# Only vite build without release if it is a fork, or it is a pull-request
|
||||
- name: Vite Build
|
||||
if: github.repository == 'th-ch/youtube-music' && github.event_name == 'pull_request'
|
||||
run: |
|
||||
pnpm build
|
||||
@ -96,14 +96,14 @@ jobs:
|
||||
|
||||
- name: Setup NodeJS
|
||||
if: startsWith(matrix.os, 'macOS') != true
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Setup NodeJS for macOS
|
||||
if: startsWith(matrix.os, 'macOS')
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@ -146,6 +146,8 @@ jobs:
|
||||
|
||||
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
|
||||
if: ${{ env.VERSION_HASH == '' }}
|
||||
run: |
|
||||
|
||||
6
.github/workflows/winget-submission.yml
vendored
6
.github/workflows/winget-submission.yml
vendored
@ -15,12 +15,16 @@ jobs:
|
||||
name: Publish winget package
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
uses: vedantmgoyal2009/winget-releaser@v2
|
||||
with:
|
||||
identifier: th-ch.YouTubeMusic
|
||||
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 }}
|
||||
token: ${{ secrets.WINGET_ACC_TOKEN }}
|
||||
fork-user: youtube-music-winget
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -12,3 +12,4 @@ electron-builder.yml
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.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/.eslintrc.js)
|
||||
[](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://aur.archlinux.org/packages/youtube-music-bin)
|
||||
[](https://snyk.io/test/github/th-ch/youtube-music)
|
||||
|
||||
</div>
|
||||
|
||||
@ -20,12 +20,23 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Read this in other languages: [🇰🇷](./docs/readme/README-ko.md)
|
||||
|
||||
**Electron wrapper around YouTube Music featuring:**
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
xattr -cr /Applications/YouTube\ Music.app
|
||||
@ -75,6 +91,14 @@ winget install th-ch.YouTubeMusic
|
||||
- Place them in the **same directory**.
|
||||
- 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:
|
||||
|
||||
- **Ad Blocker**: Block all ads and tracking out of the box
|
||||
@ -164,15 +188,6 @@ winget install th-ch.YouTubeMusic
|
||||
|
||||
- **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
|
||||
|
||||
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
|
||||
cd youtube-music
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm start
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Build your own plugins
|
||||
@ -199,47 +214,70 @@ Using plugins, you can:
|
||||
|
||||
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
|
||||
// file: back.ts
|
||||
export default (win: Electron.BrowserWindow, config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
|
||||
// something
|
||||
};
|
||||
```
|
||||
import style from './style.css?inline'; // import style as inline
|
||||
|
||||
then, register the plugin in `index.ts`:
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
```typescript
|
||||
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/back';
|
||||
export default 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,
|
||||
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();
|
||||
|
||||
// ...
|
||||
|
||||
const mainPlugins = {
|
||||
// ...
|
||||
'YOUR-PLUGIN-NAME': yourPlugin,
|
||||
};
|
||||
```
|
||||
|
||||
- if you need to change the front, create a file with the following template:
|
||||
|
||||
```typescript
|
||||
// file: front.ts
|
||||
export default (config: ConfigType<'YOUR-PLUGIN-NAME'>) => {
|
||||
// This function will be called as a preload script
|
||||
// So you can use front features like `document.querySelector`
|
||||
};
|
||||
```
|
||||
|
||||
then, register the plugin in `preload.ts`:
|
||||
|
||||
```typescript
|
||||
import yourPlugin from './plugins/YOUR-PLUGIN-NAME/front';
|
||||
|
||||
const rendererPlugins: PluginMapper<'renderer'> = {
|
||||
// ...
|
||||
'YOUR-PLUGIN-NAME': yourPlugin,
|
||||
};
|
||||
// you can communicate with renderer plugin
|
||||
ipc.handle('some-event', () => {
|
||||
return 'hello';
|
||||
});
|
||||
},
|
||||
// it fired when config changed
|
||||
onConfigChange(newConfig) { /* ... */ },
|
||||
// it fired when plugin disabled
|
||||
stop(context) { /* ... */ },
|
||||
},
|
||||
renderer: {
|
||||
async start(context) {
|
||||
console.log(await context.ipc.invoke('some-event'));
|
||||
},
|
||||
// Only renderer available hook
|
||||
onPlayerApiReady(api: YoutubePlayer, context: RendererContext) {
|
||||
// set plugin config easily
|
||||
context.setConfig({ myConfig: api.getVolume() });
|
||||
},
|
||||
onConfigChange(newConfig) { /* ... */ },
|
||||
stop(_context) { /* ... */ },
|
||||
},
|
||||
preload: {
|
||||
async start({ getConfig }) {
|
||||
const config = await getConfig();
|
||||
},
|
||||
onConfigChange(newConfig) {},
|
||||
stop(_context) {},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Common use cases
|
||||
@ -247,27 +285,42 @@ const rendererPlugins: PluginMapper<'renderer'> = {
|
||||
- injecting custom CSS: create a `style.css` file in the same folder then:
|
||||
|
||||
```typescript
|
||||
import path from 'node:path';
|
||||
import { injectCSS } from '../utils';
|
||||
// index.ts
|
||||
import style from './style.css?inline'; // import style as inline
|
||||
|
||||
// back.ts
|
||||
export default (win: Electron.BrowserWindow) => {
|
||||
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
|
||||
};
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
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
|
||||
// front.ts
|
||||
export default () => {
|
||||
// Remove the login button
|
||||
document.querySelector(".sign-in-link.ytmusic-nav-bar").remove();
|
||||
};
|
||||
import { createPlugin } from '@/utils';
|
||||
|
||||
const builder = createPlugin({
|
||||
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
|
||||
example in `navigation` plugin.
|
||||
- communicating between the front and back: can be done using the ipcMain module from electron. See `index.ts` file and
|
||||
example in `sponsorblock` plugin.
|
||||
|
||||
## Build
|
||||
|
||||
@ -283,6 +336,12 @@ export default () => {
|
||||
Builds the app for macOS, Linux, and Windows,
|
||||
using [electron-builder](https://github.com/electron-userland/electron-builder).
|
||||
|
||||
## Production Preview
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
@ -294,3 +353,10 @@ Uses [Playwright](https://playwright.dev/) to test the app.
|
||||
## License
|
||||
|
||||
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.
|
||||
|
||||
#### [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)
|
||||
|
||||
> 23 October 2023
|
||||
|
||||
- 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)
|
||||
- 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",
|
||||
"productName": "YouTube Music",
|
||||
"version": "2.2.0",
|
||||
"version": "3.0.2",
|
||||
"description": "YouTube Music Desktop App - including custom plugins",
|
||||
"main": "./dist/index.js",
|
||||
"main": "./dist/main/index.js",
|
||||
"license": "MIT",
|
||||
"repository": "th-ch/youtube-music",
|
||||
"author": {
|
||||
@ -17,6 +17,7 @@
|
||||
"files": [
|
||||
"!*",
|
||||
"dist",
|
||||
"assets",
|
||||
"license",
|
||||
"!node_modules",
|
||||
"node_modules/custom-electron-prompt/**",
|
||||
@ -25,6 +26,9 @@
|
||||
"!node_modules/**/*.map",
|
||||
"!node_modules/**/*.ts"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"assets"
|
||||
],
|
||||
"mac": {
|
||||
"identity": null,
|
||||
"target": [
|
||||
@ -89,24 +93,24 @@
|
||||
"scripts": {
|
||||
"test": "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",
|
||||
"rollup:main": "rollup -c rollup.main.config.ts --configPlugin @rollup/plugin-typescript --bundleConfigAsCjs",
|
||||
"build": "yarpm-pnpm run rollup:preload && yarpm-pnpm run rollup:main",
|
||||
"start": "yarpm-pnpm run build && electron ./dist/index.js",
|
||||
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 yarpm-pnpm run start",
|
||||
"postinstall": "patch-package",
|
||||
"clean": "del-cli dist && del-cli pack",
|
||||
"dist": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win --mac --linux -p never",
|
||||
"dist:linux": "yarpm-pnpm run clean && yarpm-pnpm run 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:arm64": "yarpm-pnpm run clean && yarpm-pnpm run 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:x64": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win nsis-web:x64 -p never",
|
||||
"build": "electron-vite build",
|
||||
"vite:inspect": "pnpm clean && electron-vite build --mode development && pnpm exec serve .vite-inspect",
|
||||
"start": "electron-vite preview",
|
||||
"start:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm start",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:debug": "cross-env ELECTRON_ENABLE_LOGGING=1 pnpm dev",
|
||||
"clean": "del-cli dist && del-cli pack && del-cli .vite-inspect",
|
||||
"dist": "pnpm clean && pnpm build && electron-builder --win --mac --linux -p never",
|
||||
"dist:linux": "pnpm clean && pnpm build && electron-builder --linux -p never",
|
||||
"dist:mac": "pnpm clean && pnpm build && electron-builder --mac dmg:x64 -p never",
|
||||
"dist:mac:arm64": "pnpm clean && pnpm build && electron-builder --mac dmg:arm64 -p never",
|
||||
"dist:win": "pnpm clean && pnpm build && electron-builder --win -p never",
|
||||
"dist:win:x64": "pnpm clean && pnpm build && electron-builder --win nsis-web:x64 -p never",
|
||||
"lint": "eslint .",
|
||||
"changelog": "auto-changelog",
|
||||
"release:linux": "yarpm-pnpm run clean && yarpm-pnpm run 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:win": "yarpm-pnpm run clean && yarpm-pnpm run build && electron-builder --win -p always",
|
||||
"changelog": "npx --yes auto-changelog",
|
||||
"release:linux": "pnpm clean && pnpm build && electron-builder --linux -p always -c.snap.publish=github",
|
||||
"release:mac": "pnpm clean && pnpm build && electron-builder --mac -p always",
|
||||
"release:win": "pnpm clean && pnpm build && electron-builder --win -p always",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"engines": {
|
||||
@ -114,87 +118,86 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"rollup": "4.1.4",
|
||||
"node-gyp": "9.4.0",
|
||||
"usocket": "1.0.1",
|
||||
"rollup": "4.6.1",
|
||||
"node-gyp": "10.0.1",
|
||||
"xml2js": "0.6.2",
|
||||
"node-fetch": "2.7.0",
|
||||
"@electron/universal": "1.4.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"@electron/universal": "2.0.0",
|
||||
"@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": {
|
||||
"@cliqz/adblocker-electron": "1.26.8",
|
||||
"@cliqz/adblocker-electron-preload": "1.26.8",
|
||||
"@cliqz/adblocker-electron": "1.26.12",
|
||||
"@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/main": "0.12.0",
|
||||
"@foobar404/wave": "2.0.4",
|
||||
"@jellybrick/electron-better-web-request": "1.0.4",
|
||||
"@jellybrick/mpris-service": "2.1.4",
|
||||
"@xhayper/discord-rpc": "1.0.24",
|
||||
"@xhayper/discord-rpc": "1.1.1",
|
||||
"async-mutex": "0.4.0",
|
||||
"butterchurn": "3.0.0-beta.4",
|
||||
"butterchurn-presets": "3.0.0-beta.4",
|
||||
"conf": "10.2.0",
|
||||
"custom-electron-prompt": "1.5.7",
|
||||
"dbus-next": "0.10.2",
|
||||
"deepmerge-ts": "5.1.0",
|
||||
"electron-debug": "3.2.0",
|
||||
"electron-is": "3.0.0",
|
||||
"electron-localshortcut": "3.2.1",
|
||||
"electron-store": "8.1.0",
|
||||
"electron-unhandled": "4.0.1",
|
||||
"electron-updater": "6.1.4",
|
||||
"electron-updater": "6.1.7",
|
||||
"fast-average-color": "9.4.0",
|
||||
"fast-equals": "5.0.1",
|
||||
"filenamify": "6.0.0",
|
||||
"howler": "2.2.4",
|
||||
"html-to-text": "9.0.5",
|
||||
"i18next": "23.7.7",
|
||||
"keyboardevent-from-electron-accelerator": "2.0.0",
|
||||
"keyboardevents-areequal": "0.2.2",
|
||||
"node-html-parser": "6.1.11",
|
||||
"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",
|
||||
"x11": "2.3.0",
|
||||
"youtubei.js": "6.4.1"
|
||||
"youtubei.js": "8.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@milahu/patch-package": "6.4.14",
|
||||
"@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",
|
||||
"@playwright/test": "1.40.1",
|
||||
"@total-typescript/ts-reset": "0.5.1",
|
||||
"@types/electron-localshortcut": "3.1.2",
|
||||
"@types/howler": "2.2.10",
|
||||
"@types/html-to-text": "9.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "6.9.0",
|
||||
"auto-changelog": "2.4.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"@types/electron-localshortcut": "3.1.3",
|
||||
"@types/howler": "2.2.11",
|
||||
"@types/html-to-text": "9.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "6.13.1",
|
||||
"bufferutil": "4.0.8",
|
||||
"builtin-modules": "3.3.0",
|
||||
"cross-env": "7.0.3",
|
||||
"del-cli": "5.1.0",
|
||||
"electron": "27.0.2",
|
||||
"electron-builder": "24.6.4",
|
||||
"electron": "27.1.3",
|
||||
"electron-builder": "24.9.1",
|
||||
"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-prettier": "5.0.1",
|
||||
"node-gyp": "9.4.0",
|
||||
"playwright": "1.39.0",
|
||||
"rollup": "4.1.4",
|
||||
"rollup-plugin-copy": "3.5.0",
|
||||
"rollup-plugin-import-css": "3.3.5",
|
||||
"rollup-plugin-string": "3.0.0",
|
||||
"typescript": "5.2.2",
|
||||
"yarpm": "1.2.0"
|
||||
"glob": "10.3.10",
|
||||
"node-gyp": "10.0.1",
|
||||
"playwright": "1.40.1",
|
||||
"rollup": "4.6.1",
|
||||
"typescript": "5.3.2",
|
||||
"utf-8-validate": "6.0.3",
|
||||
"vite": "4.5.0",
|
||||
"vite-plugin-inspect": "0.8.1",
|
||||
"vite-plugin-resolve": "2.5.1",
|
||||
"ws": "8.14.2"
|
||||
},
|
||||
"auto-changelog": {
|
||||
"hideCredit": true,
|
||||
@ -202,5 +205,5 @@
|
||||
"unreleased": true,
|
||||
"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 {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface WindowPositionConfig {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DefaultConfig {
|
||||
'window-size': {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
'window-size': WindowSizeConfig;
|
||||
'window-maximized': boolean;
|
||||
'window-position': {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
'window-position': WindowPositionConfig;
|
||||
url: string;
|
||||
options: {
|
||||
language?: string;
|
||||
tray: boolean;
|
||||
appVisible: boolean;
|
||||
autoUpdates: boolean;
|
||||
@ -37,10 +33,11 @@ export interface DefaultConfig {
|
||||
startingPage: string;
|
||||
overrideUserAgent: boolean;
|
||||
themes: string[];
|
||||
}
|
||||
};
|
||||
plugins: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const defaultConfig = {
|
||||
const defaultConfig: DefaultConfig = {
|
||||
'window-size': {
|
||||
width: 1100,
|
||||
height: 550,
|
||||
@ -69,229 +66,9 @@ const defaultConfig = {
|
||||
proxy: '',
|
||||
startingPage: '',
|
||||
overrideUserAgent: false,
|
||||
themes: [] as string[],
|
||||
},
|
||||
/** 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
themes: [],
|
||||
},
|
||||
'plugins': {},
|
||||
};
|
||||
|
||||
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 { deepmerge } from 'deepmerge-ts';
|
||||
|
||||
import defaultConfig from './defaults';
|
||||
import plugins from './plugins';
|
||||
|
||||
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) => {
|
||||
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) {
|
||||
set(key, value);
|
||||
@ -20,34 +25,65 @@ function setMenuOption(key: string, value: unknown) {
|
||||
|
||||
// MAGIC OF TYPESCRIPT
|
||||
|
||||
type Prev = [never, 0, 1, 2, 3, 4, 5, 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;
|
||||
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]>>
|
||||
type Prev = [
|
||||
never,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
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
|
||||
}[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 PathValue<T, K extends string> =
|
||||
SplitKey<K> extends [infer A extends keyof T, infer B extends string]
|
||||
? PathValue<T[A], B>
|
||||
: T;
|
||||
const get = <Key extends Paths<typeof defaultConfig>>(key: Key) => store.get(key) as PathValue<typeof defaultConfig, typeof key>;
|
||||
type PathValue<T, K extends string> = SplitKey<K> extends [
|
||||
infer A extends keyof T,
|
||||
infer B extends string,
|
||||
]
|
||||
? 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 {
|
||||
defaultConfig,
|
||||
get,
|
||||
set,
|
||||
setPartial,
|
||||
setMenuOption,
|
||||
edit: () => store.openInEditor(),
|
||||
watch(cb: Parameters<Store['onDidChange']>[1]) {
|
||||
store.onDidChange('options', cb);
|
||||
store.onDidChange('plugins', cb);
|
||||
watch(cb: Parameters<Store['onDidAnyChange']>[0]) {
|
||||
store.onDidAnyChange(cb);
|
||||
},
|
||||
plugins,
|
||||
};
|
||||
|
||||
@ -1,24 +1,21 @@
|
||||
import { deepmerge } from 'deepmerge-ts';
|
||||
import { allPlugins } from 'virtual:plugins';
|
||||
|
||||
import store from './store';
|
||||
import defaultConfig from './defaults';
|
||||
|
||||
import { restart } from '../providers/app-controls';
|
||||
import { Entries } from '../utils/type-utils';
|
||||
import { restart } from '@/providers/app-controls';
|
||||
|
||||
interface Plugin {
|
||||
enabled: boolean;
|
||||
}
|
||||
import type { PluginConfig } from '@/types/plugins';
|
||||
|
||||
type DefaultPluginsConfig = typeof defaultConfig.plugins;
|
||||
|
||||
export function getEnabled() {
|
||||
const plugins = store.get('plugins') as DefaultPluginsConfig;
|
||||
return (Object.entries(plugins) as Entries<DefaultPluginsConfig>).filter(([plugin]) =>
|
||||
isEnabled(plugin),
|
||||
);
|
||||
export function getPlugins() {
|
||||
return store.get('plugins') as Record<string, PluginConfig>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -28,7 +25,11 @@ export function isEnabled(plugin: string) {
|
||||
* @param options Options to set
|
||||
* @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>;
|
||||
// HACK: This is a workaround for preventing changed options from being overwritten
|
||||
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);
|
||||
if (store.get('options.restartOnConfigChanges')) {
|
||||
restart();
|
||||
@ -66,7 +71,7 @@ export function disable(plugin: string) {
|
||||
|
||||
export default {
|
||||
isEnabled,
|
||||
getEnabled,
|
||||
getPlugins,
|
||||
enable,
|
||||
disable,
|
||||
setOptions,
|
||||
|
||||
@ -1,25 +1,33 @@
|
||||
import Store from 'electron-store';
|
||||
import Conf from 'conf';
|
||||
import is from 'electron-is';
|
||||
|
||||
import defaults from './defaults';
|
||||
|
||||
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]);
|
||||
}
|
||||
};
|
||||
import { DefaultPresetList, type Preset } from '@/plugins/downloader/types';
|
||||
|
||||
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>>) {
|
||||
const listenAlong = store.get('plugins.discord.listenAlong');
|
||||
if (listenAlong !== undefined) {
|
||||
@ -28,19 +36,24 @@ const migrations = {
|
||||
}
|
||||
},
|
||||
'>=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 !== 'opus') {
|
||||
store.set('plugins.downloader.selectedPreset', 'Custom');
|
||||
store.set('plugins.downloader.customPresetSetting', {
|
||||
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);
|
||||
} else {
|
||||
store.set('plugins.downloader.selectedPreset', 'Source');
|
||||
store.set('plugins.downloader.customPresetSetting', {
|
||||
extension: null,
|
||||
ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [],
|
||||
ffmpegArgs:
|
||||
(store.get('plugins.downloader.ffmpegArgs') as string[]) ?? [],
|
||||
} satisfies Preset);
|
||||
}
|
||||
store.delete('plugins.downloader.preset');
|
||||
@ -48,12 +61,11 @@ const migrations = {
|
||||
}
|
||||
},
|
||||
'>=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) {
|
||||
const pluginOptions = store.get('plugins.notifications') || {};
|
||||
store.set('plugins.notifications', {
|
||||
...defaults.plugins.notifications,
|
||||
...pluginOptions,
|
||||
});
|
||||
}
|
||||
@ -64,7 +76,7 @@ const migrations = {
|
||||
}
|
||||
},
|
||||
'>=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) {
|
||||
store.set('plugins.video-toggle.mode', 'custom');
|
||||
@ -88,31 +100,41 @@ const migrations = {
|
||||
}
|
||||
},
|
||||
'>=1.12.0'(store: Conf<Record<string, unknown>>) {
|
||||
const options = store.get('plugins.shortcuts') as Record<string, {
|
||||
action: string;
|
||||
shortcut: unknown;
|
||||
}[] | Record<string, unknown>>;
|
||||
let updated = false;
|
||||
for (const optionType of ['global', 'local']) {
|
||||
if (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;
|
||||
const options = store.get('plugins.shortcuts') as
|
||||
| Record<
|
||||
string,
|
||||
| {
|
||||
action: string;
|
||||
shortcut: unknown;
|
||||
}[]
|
||||
| Record<string, unknown>
|
||||
>
|
||||
| undefined;
|
||||
if (options) {
|
||||
let updated = false;
|
||||
for (const optionType of ['global', 'local']) {
|
||||
if (
|
||||
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>>) {
|
||||
@ -155,7 +177,10 @@ const migrations = {
|
||||
};
|
||||
|
||||
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,
|
||||
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'> {
|
||||
counterOptions: CounterOptions;
|
||||
}
|
||||
export interface MultiInputPromptOptions extends BasePromptOptions<'multiInput'> {
|
||||
export interface MultiInputPromptOptions
|
||||
extends BasePromptOptions<'multiInput'> {
|
||||
multiInputOptions: InputOptions[];
|
||||
}
|
||||
export interface KeybindPromptOptions extends BasePromptOptions<'keybind'> {
|
||||
keybindOptions: KeybindOptions[];
|
||||
}
|
||||
|
||||
export type PromptOptions<T extends string> = (
|
||||
T extends 'input' ? InputPromptOptions :
|
||||
T extends 'select' ? SelectPromptOptions :
|
||||
T extends 'counter' ? CounterPromptOptions :
|
||||
T extends 'keybind' ? KeybindPromptOptions :
|
||||
T extends 'multiInput' ? MultiInputPromptOptions :
|
||||
never
|
||||
);
|
||||
export type PromptOptions<T extends string> = T extends 'input'
|
||||
? InputPromptOptions
|
||||
: T extends 'select'
|
||||
? SelectPromptOptions
|
||||
: T extends 'counter'
|
||||
? CounterPromptOptions
|
||||
: T extends 'keybind'
|
||||
? KeybindPromptOptions
|
||||
: T extends 'multiInput'
|
||||
? MultiInputPromptOptions
|
||||
: never;
|
||||
|
||||
type PromptResult<T extends string> = T extends 'input' ? string :
|
||||
T extends 'select' ? string :
|
||||
T extends 'counter' ? number :
|
||||
T extends 'keybind' ? {
|
||||
value: string;
|
||||
accelerator: string
|
||||
}[] :
|
||||
T extends 'multiInput' ? string[] :
|
||||
never;
|
||||
type PromptResult<T extends string> = T extends 'input'
|
||||
? string
|
||||
: T extends 'select'
|
||||
? string
|
||||
: T extends 'counter'
|
||||
? number
|
||||
: T extends 'keybind'
|
||||
? {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,50 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Cannot load YouTube Music</title>
|
||||
<style>
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0;
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-right: -50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.container {
|
||||
margin: 0;
|
||||
font-family: Roboto, Arial, sans-serif;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-right: -50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.button {
|
||||
background: #065fd4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: white;
|
||||
font: inherit;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
padding: 8px 22px;
|
||||
display: inline-block;
|
||||
}
|
||||
.button {
|
||||
background: #065fd4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: white;
|
||||
font: inherit;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
text-align: center;
|
||||
padding: 8px 22px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<p>Cannot load YouTube Music… Internet disconnected?</p>
|
||||
<a class="button" href="#" onclick="reload()">Retry</a>
|
||||
</div>
|
||||
</body>
|
||||
<body>
|
||||
<div class="container">
|
||||
<p>Cannot load YouTube Music… Internet disconnected?</p>
|
||||
<a class="button" href="#" onclick="reload()">Retry</a>
|
||||
</div>
|
||||
</body>
|
||||
</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 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 enhanceWebRequest, { BetterSession } from '@jellybrick/electron-better-web-request';
|
||||
import {
|
||||
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 unhandled from 'electron-unhandled';
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
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 { 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 { allPlugins, mainPlugins } from 'virtual:plugins';
|
||||
|
||||
import adblocker from './plugins/adblocker/back';
|
||||
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 { languageResources } from 'virtual:i18n';
|
||||
|
||||
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
|
||||
unhandled({
|
||||
@ -64,7 +76,10 @@ if (!gotTheLock) {
|
||||
|
||||
// SharedArrayBuffer: Required for downloader (@ffmpeg/core-mt)
|
||||
// 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 (is.dev()) {
|
||||
console.log('Disabling hardware acceleration');
|
||||
@ -100,49 +115,116 @@ function onClosed() {
|
||||
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));
|
||||
|
||||
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);
|
||||
// Load user CSS
|
||||
const themes: string[] = config.get('options.themes');
|
||||
@ -154,7 +236,10 @@ async function loadPlugins(win: BrowserWindow) {
|
||||
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', () => {
|
||||
if (is.dev()) {
|
||||
console.log('did finish load');
|
||||
console.debug(LoggerPrefix, t('main.console.did-finish-load.dev-tools'));
|
||||
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() {
|
||||
@ -188,6 +259,12 @@ async function createMainWindow() {
|
||||
const windowPosition: Electron.Point = config.get('window-position');
|
||||
const useInlineMenu = config.plugins.isEnabled('in-app-menu');
|
||||
|
||||
const defaultTitleBarOverlayOptions: Electron.TitleBarOverlayOptions = {
|
||||
color: '#00000000',
|
||||
symbolColor: '#ffffff',
|
||||
height: 32,
|
||||
};
|
||||
|
||||
const win = new BrowserWindow({
|
||||
icon,
|
||||
width: windowSize.width,
|
||||
@ -195,53 +272,62 @@ async function createMainWindow() {
|
||||
backgroundColor: '#000',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
// TODO: re-enable contextIsolation once it can work with FFMpeg.wasm
|
||||
// Possible bundling? https://github.com/ffmpegwasm/ffmpeg.wasm/issues/126
|
||||
contextIsolation: false,
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegrationInSubFrames: true,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, '..', 'preload', 'preload.js'),
|
||||
...(isTesting()
|
||||
? undefined
|
||||
: {
|
||||
// Sandbox is only enabled in tests for now
|
||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||
sandbox: false,
|
||||
}),
|
||||
// Sandbox is only enabled in tests for now
|
||||
// See https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts
|
||||
sandbox: false,
|
||||
}),
|
||||
},
|
||||
frame: !is.macOS() && !useInlineMenu,
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: '#ffffff',
|
||||
height: 36,
|
||||
},
|
||||
titleBarOverlay: defaultTitleBarOverlayOptions,
|
||||
titleBarStyle: useInlineMenu
|
||||
? 'hidden'
|
||||
: (is.macOS()
|
||||
? 'hiddenInset'
|
||||
: 'default'),
|
||||
: is.macOS()
|
||||
? 'hiddenInset'
|
||||
: 'default',
|
||||
autoHideMenuBar: config.get('options.hideMenu'),
|
||||
});
|
||||
await loadPlugins(win);
|
||||
initHook(win);
|
||||
initTheme(win);
|
||||
|
||||
await loadAllMainPlugins(win);
|
||||
|
||||
if (windowPosition) {
|
||||
const { x: windowX, y: windowY } = windowPosition;
|
||||
const winSize = win.getSize();
|
||||
const displaySize
|
||||
= screen.getDisplayNearestPoint(windowPosition).bounds;
|
||||
const display = screen.getDisplayNearestPoint(windowPosition);
|
||||
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 (
|
||||
windowX + winSize[0] < displaySize.x - 8
|
||||
|| windowX - winSize[0] > displaySize.x + displaySize.width
|
||||
|| windowY < displaySize.y - 8
|
||||
|| windowY > displaySize.y + displaySize.height
|
||||
scaledX + scaledWidth < display.bounds.x - 8 ||
|
||||
scaledX - scaledWidth > display.bounds.x + display.bounds.width ||
|
||||
scaledY < display.bounds.y - 8 ||
|
||||
scaledY > display.bounds.y + display.bounds.height
|
||||
) {
|
||||
// Window is offscreen
|
||||
if (is.dev()) {
|
||||
console.log(
|
||||
`Window tried to render offscreen, windowSize=${String(winSize)}, displaySize=${String(displaySize)}, position=${String(windowPosition)}`,
|
||||
console.warn(
|
||||
LoggerPrefix,
|
||||
t('main.console.window.tried-to-render-offscreen', {
|
||||
winSize: String(winSize),
|
||||
displaySize: String(display.bounds),
|
||||
windowPosition: String(windowPosition),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
win.setPosition(windowX, windowY);
|
||||
win.setSize(scaledWidth, scaledHeight);
|
||||
win.setPosition(scaledX, scaledY);
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,40 +344,22 @@ async function createMainWindow() {
|
||||
: config.defaultConfig.url;
|
||||
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', () => {
|
||||
if (win.isMaximized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = win.getPosition();
|
||||
const isPiPEnabled: boolean
|
||||
= 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);
|
||||
}
|
||||
const [x, y] = win.getPosition();
|
||||
lateSave('window-position', { x, y });
|
||||
});
|
||||
|
||||
let winWasMaximized: boolean;
|
||||
|
||||
win.on('resize', () => {
|
||||
const windowSize = win.getSize();
|
||||
const [width, height] = win.getSize();
|
||||
const isMaximized = win.isMaximized();
|
||||
|
||||
const isPiPEnabled
|
||||
= config.plugins.isEnabled('picture-in-picture')
|
||||
&& config.plugins.getOptions<PiPOptions>('picture-in-picture').isInPiP;
|
||||
|
||||
if (!isPiPEnabled && winWasMaximized !== isMaximized) {
|
||||
if (winWasMaximized !== isMaximized) {
|
||||
winWasMaximized = isMaximized;
|
||||
config.set('window-maximized', isMaximized);
|
||||
}
|
||||
@ -300,19 +368,19 @@ async function createMainWindow() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPiPEnabled) {
|
||||
lateSave('window-size', {
|
||||
width: windowSize[0],
|
||||
height: windowSize[1],
|
||||
});
|
||||
} else if (config.plugins.getOptions<PiPOptions>('picture-in-picture').saveSize) {
|
||||
lateSave('pip-size', windowSize, setPiPOptions);
|
||||
}
|
||||
lateSave('window-size', {
|
||||
width,
|
||||
height,
|
||||
});
|
||||
});
|
||||
|
||||
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]) {
|
||||
clearTimeout(savedTimeouts[key]);
|
||||
}
|
||||
@ -323,7 +391,7 @@ async function createMainWindow() {
|
||||
}, 600);
|
||||
}
|
||||
|
||||
app.on('render-process-gone', (event, webContents, details) => {
|
||||
app.on('render-process-gone', (_event, _webContents, details) => {
|
||||
showUnresponsiveDialog(win, details);
|
||||
});
|
||||
|
||||
@ -335,32 +403,88 @@ async function createMainWindow() {
|
||||
|
||||
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);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
app.once('browser-window-created', (event, win) => {
|
||||
app.once('browser-window-created', (_event, win) => {
|
||||
if (config.get('options.overrideUserAgent')) {
|
||||
// User agents are from https://developers.whatismybrowser.com/useragents/explore/
|
||||
const originalUserAgent = win.webContents.userAgent;
|
||||
const userAgents = {
|
||||
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',
|
||||
};
|
||||
|
||||
const updatedUserAgent
|
||||
= is.macOS() ? userAgents.mac
|
||||
: (is.windows() ? userAgents.windows
|
||||
: userAgents.linux);
|
||||
const updatedUserAgent = is.macOS()
|
||||
? userAgents.mac
|
||||
: is.windows()
|
||||
? userAgents.windows
|
||||
: userAgents.linux;
|
||||
|
||||
win.webContents.userAgent = updatedUserAgent;
|
||||
app.userAgentFallback = updatedUserAgent;
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, cb) => {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -371,33 +495,41 @@ app.once('browser-window-created', (event, win) => {
|
||||
setupSongInfo(win);
|
||||
setupAppControls();
|
||||
|
||||
win.webContents.on('did-fail-load', (
|
||||
_event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
) => {
|
||||
const log = JSON.stringify({
|
||||
error: 'did-fail-load',
|
||||
win.webContents.on(
|
||||
'did-fail-load',
|
||||
(
|
||||
_event,
|
||||
errorCode,
|
||||
errorDescription,
|
||||
validatedURL,
|
||||
isMainFrame,
|
||||
frameProcessId,
|
||||
frameRoutingId,
|
||||
}, null, '\t');
|
||||
if (is.dev()) {
|
||||
console.log(log);
|
||||
}
|
||||
) => {
|
||||
const log = JSON.stringify(
|
||||
{
|
||||
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
|
||||
win.webContents.send('log', log);
|
||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
||||
}
|
||||
});
|
||||
if (errorCode !== -3) {
|
||||
// -3 is a false positive
|
||||
win.webContents.send('log', log);
|
||||
win.webContents.loadFile(path.join(__dirname, 'error.html'));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
win.webContents.on('will-prevent-unload', (event) => {
|
||||
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')) {
|
||||
// Clear cache after 20s
|
||||
const clearCacheTimeout = setTimeout(() => {
|
||||
if (is.dev()) {
|
||||
console.log('Clearing app cache.');
|
||||
console.log(
|
||||
LoggerPrefix,
|
||||
t('main.console.when-ready.clearing-cache-after-20s'),
|
||||
);
|
||||
}
|
||||
|
||||
session.defaultSession.clearCache();
|
||||
@ -443,17 +593,29 @@ app.on('ready', async () => {
|
||||
const appLocation = process.execPath;
|
||||
const appData = app.getPath('appData');
|
||||
// Check shortcut validity if not in dev mode / running portable app
|
||||
if (!is.dev() && !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
|
||||
if (
|
||||
!is.dev() &&
|
||||
!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
|
||||
if (
|
||||
shortcutDetails.target !== appLocation
|
||||
|| shortcutDetails.appUserModelId !== appID
|
||||
shortcutDetails.target !== appLocation ||
|
||||
shortcutDetails.appUserModelId !== appID
|
||||
) {
|
||||
throw 'needUpdate';
|
||||
}
|
||||
} catch (error) { // If not valid -> Register shortcut
|
||||
} catch (error) {
|
||||
// If not valid -> Register shortcut
|
||||
shell.writeShortcutLink(
|
||||
shortcutPath,
|
||||
error === 'needUpdate' ? 'update' : 'create',
|
||||
@ -469,8 +631,8 @@ app.on('ready', async () => {
|
||||
}
|
||||
|
||||
mainWindow = await createMainWindow();
|
||||
setApplicationMenu(mainWindow);
|
||||
refreshMenu(mainWindow);
|
||||
await setApplicationMenu(mainWindow);
|
||||
await refreshMenu(mainWindow);
|
||||
setUpTray(app, mainWindow);
|
||||
|
||||
setupProtocolHandler(mainWindow);
|
||||
@ -482,7 +644,10 @@ app.on('ready', async () => {
|
||||
const lastIndex = protocolArgv.endsWith('/') ? -1 : undefined;
|
||||
const command = protocolArgv.slice(uri.length, lastIndex);
|
||||
if (is.dev()) {
|
||||
console.debug(`Received command over protocol: "${command}"`);
|
||||
console.debug(
|
||||
LoggerPrefix,
|
||||
t('main.console.second-instance.receive-command', { command }),
|
||||
);
|
||||
}
|
||||
|
||||
handleProtocol(command);
|
||||
@ -515,16 +680,28 @@ app.on('ready', async () => {
|
||||
clearTimeout(updateTimeout);
|
||||
}, 2000);
|
||||
autoUpdater.on('update-available', () => {
|
||||
const downloadLink
|
||||
= 'https://github.com/th-ch/youtube-music/releases/latest';
|
||||
const downloadLink =
|
||||
'https://github.com/th-ch/youtube-music/releases/latest';
|
||||
const dialogOptions: Electron.MessageBoxOptions = {
|
||||
type: 'info',
|
||||
buttons: ['OK', 'Download', 'Disable updates'],
|
||||
title: 'Application Update',
|
||||
message: 'A new version is available',
|
||||
detail: `A new version is available and can be downloaded at ${downloadLink}`,
|
||||
buttons: [
|
||||
t('main.dialog.update-available.buttons.download'),
|
||||
t('main.dialog.update-available.buttons.ok'),
|
||||
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) {
|
||||
// Download
|
||||
case 1: {
|
||||
@ -548,8 +725,9 @@ app.on('ready', async () => {
|
||||
|
||||
if (config.get('options.hideMenu') && !config.get('options.hideMenuWarned')) {
|
||||
dialog.showMessageBox(mainWindow, {
|
||||
type: 'info', title: 'Hide Menu Enabled',
|
||||
message: "Menu is hidden, use 'Alt' to show it (or 'Escape' if using in-app-menu)",
|
||||
type: 'info',
|
||||
title: t('main.dialog.hide-menu-enabled.title'),
|
||||
message: t('main.dialog.hide-menu-enabled.message'),
|
||||
});
|
||||
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) {
|
||||
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, {
|
||||
type: 'error',
|
||||
title: 'Window Unresponsive',
|
||||
message: 'The Application is Unresponsive',
|
||||
detail: 'We are sorry for the inconvenience! please choose what to do:',
|
||||
buttons: ['Wait', 'Relaunch', 'Quit'],
|
||||
cancelId: 0,
|
||||
}).then((result) => {
|
||||
switch (result.response) {
|
||||
case 1: {
|
||||
restart();
|
||||
break;
|
||||
}
|
||||
dialog
|
||||
.showMessageBox(win, {
|
||||
type: 'error',
|
||||
title: t('main.dialog.unresponsive.title'),
|
||||
message: t('main.dialog.unresponsive.message'),
|
||||
detail: t('main.dialog.unresponsive.detail'),
|
||||
buttons: [
|
||||
t('main.dialog.unresponsive.buttons.wait'),
|
||||
t('main.dialog.unresponsive.buttons.relaunch'),
|
||||
t('main.dialog.unresponsive.buttons.quit'),
|
||||
],
|
||||
cancelId: 0,
|
||||
})
|
||||
.then((result) => {
|
||||
switch (result.response) {
|
||||
case 1: {
|
||||
restart();
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
app.quit();
|
||||
break;
|
||||
case 2: {
|
||||
app.quit();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function removeContentSecurityPolicy(
|
||||
@ -622,18 +814,21 @@ function removeContentSecurityPolicy(
|
||||
});
|
||||
|
||||
// When multiple listeners are defined, apply them all
|
||||
betterSession.webRequest.setResolver('onHeadersReceived', async (listeners) => {
|
||||
return listeners.reduce(
|
||||
async (accumulator, listener) => {
|
||||
const acc = await accumulator;
|
||||
if (acc.cancel) {
|
||||
return acc;
|
||||
}
|
||||
betterSession.webRequest.setResolver(
|
||||
'onHeadersReceived',
|
||||
async (listeners) => {
|
||||
return listeners.reduce(
|
||||
async (accumulator, listener) => {
|
||||
const acc = await accumulator;
|
||||
if (acc.cancel) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const result = await listener.apply();
|
||||
return { ...accumulator, ...result };
|
||||
},
|
||||
Promise.resolve({ cancel: false }),
|
||||
);
|
||||
});
|
||||
const result = await listener.apply();
|
||||
return { ...accumulator, ...result };
|
||||
},
|
||||
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 { app, BrowserWindow, clipboard, dialog, Menu } from 'electron';
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
clipboard,
|
||||
dialog,
|
||||
Menu,
|
||||
MenuItem,
|
||||
shell,
|
||||
} from 'electron';
|
||||
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 { restart } from './providers/app-controls';
|
||||
import { startingPages } from './providers/extracted-data';
|
||||
import promptOptions from './providers/prompt-options';
|
||||
|
||||
import adblockerMenu from './plugins/adblocker/menu';
|
||||
import ambientModeMenu from './plugins/ambient-mode/menu';
|
||||
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';
|
||||
import { getAllMenuTemplate, loadAllMenuPlugins } from './loader/menu';
|
||||
import { setLanguage, t } from '@/i18n';
|
||||
|
||||
export type MenuTemplate = Electron.MenuItemConstructorOptions[];
|
||||
|
||||
// True only if in-app-menu was loaded on launch
|
||||
const inAppMenuActive = config.plugins.isEnabled('in-app-menu');
|
||||
|
||||
const betaPlugins = ['crossfade', 'lumiastream'];
|
||||
|
||||
const pluginMenus = {
|
||||
'adblocker': adblockerMenu,
|
||||
'ambient-mode': ambientModeMenu,
|
||||
'disable-autoplay': disableAutoplayMenu,
|
||||
'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 => ({
|
||||
const pluginEnabledMenu = (
|
||||
plugin: string,
|
||||
label = '',
|
||||
hasSubmenu = false,
|
||||
refreshMenu: (() => void) | undefined = undefined,
|
||||
): Electron.MenuItemConstructorOptions => ({
|
||||
label: label || plugin,
|
||||
type: 'checkbox',
|
||||
checked: config.plugins.isEnabled(plugin),
|
||||
@ -66,78 +52,107 @@ const pluginEnabledMenu = (plugin: string, label = '', hasSubmenu = false, refre
|
||||
},
|
||||
});
|
||||
|
||||
export const refreshMenu = (win: BrowserWindow) => {
|
||||
setApplicationMenu(win);
|
||||
export const refreshMenu = async (win: BrowserWindow) => {
|
||||
await setApplicationMenu(win);
|
||||
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);
|
||||
|
||||
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 [
|
||||
{
|
||||
label: 'Plugins',
|
||||
submenu:
|
||||
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: t('main.menu.plugins.label'),
|
||||
submenu: pluginMenus,
|
||||
},
|
||||
{
|
||||
label: 'Options',
|
||||
label: t('main.menu.options.label'),
|
||||
submenu: [
|
||||
{
|
||||
label: 'Auto-update',
|
||||
label: t('main.menu.options.submenu.auto-update'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.autoUpdates'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.autoUpdates', item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Resume last song when app starts',
|
||||
label: t('main.menu.options.submenu.resume-on-start'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.resumeOnStart'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.resumeOnStart', item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Starting page',
|
||||
label: t('main.menu.options.submenu.starting-page.label'),
|
||||
submenu: (() => {
|
||||
const subMenuArray: Electron.MenuItemConstructorOptions[] = Object.keys(startingPages).map((name) => ({
|
||||
label: name,
|
||||
type: 'radio',
|
||||
checked: config.get('options.startingPage') === name,
|
||||
click() {
|
||||
config.set('options.startingPage', name);
|
||||
},
|
||||
}));
|
||||
const subMenuArray: Electron.MenuItemConstructorOptions[] =
|
||||
Object.keys(startingPages).map((name) => ({
|
||||
label: name,
|
||||
type: 'radio',
|
||||
checked: config.get('options.startingPage') === name,
|
||||
click() {
|
||||
config.set('options.startingPage', name);
|
||||
},
|
||||
}));
|
||||
subMenuArray.unshift({
|
||||
label: 'Unset',
|
||||
label: t('main.menu.options.submenu.starting-page.unset'),
|
||||
type: 'radio',
|
||||
checked: config.get('options.startingPage') === '',
|
||||
click() {
|
||||
@ -148,21 +163,30 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
||||
})(),
|
||||
},
|
||||
{
|
||||
label: 'Visual Tweaks',
|
||||
label: t('main.menu.options.submenu.visual-tweaks.label'),
|
||||
submenu: [
|
||||
{
|
||||
label: 'Remove upgrade button',
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.remove-upgrade-button',
|
||||
),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.removeUpgradeButton'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.removeUpgradeButton', item.checked);
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption(
|
||||
'options.removeUpgradeButton',
|
||||
item.checked,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Like buttons',
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.label',
|
||||
),
|
||||
submenu: [
|
||||
{
|
||||
label: 'Default',
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.default',
|
||||
),
|
||||
type: 'radio',
|
||||
checked: !config.get('options.likeButtons'),
|
||||
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',
|
||||
checked: config.get('options.likeButtons') === 'force',
|
||||
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',
|
||||
checked: config.get('options.likeButtons') === 'hide',
|
||||
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: [
|
||||
{
|
||||
label: 'No theme',
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.no-theme',
|
||||
),
|
||||
type: 'radio',
|
||||
checked: config.get('options.themes')?.length === 0, // Todo rename "themes"
|
||||
click() {
|
||||
@ -200,7 +232,9 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Import custom CSS file',
|
||||
label: t(
|
||||
'main.menu.options.submenu.visual-tweaks.submenu.theme.submenu.import-css-file',
|
||||
),
|
||||
type: 'normal',
|
||||
async click() {
|
||||
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',
|
||||
checked: true,
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
if (!item.checked && app.hasSingleInstanceLock()) {
|
||||
app.releaseSingleInstanceLock();
|
||||
} 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',
|
||||
checked: config.get('options.alwaysOnTop'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.alwaysOnTop', item.checked);
|
||||
win.setAlwaysOnTop(item.checked);
|
||||
},
|
||||
},
|
||||
...(is.windows() || is.linux()
|
||||
...((is.windows() || is.linux()
|
||||
? [
|
||||
{
|
||||
label: 'Hide menu',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.hideMenu'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.hideMenu', item.checked);
|
||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info', title: 'Hide Menu Enabled',
|
||||
message: 'Menu will be hidden on next launch, use [Alt] to show it (or backtick [`] if using in-app-menu)',
|
||||
});
|
||||
}
|
||||
{
|
||||
label: t('main.menu.options.submenu.hide-menu.label'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.hideMenu'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.hideMenu', item.checked);
|
||||
if (item.checked && !config.get('options.hideMenuWarned')) {
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: t(
|
||||
'main.menu.options.submenu.hide-menu.dialog.title',
|
||||
),
|
||||
message: t(
|
||||
'main.menu.options.submenu.hide-menu.dialog.message',
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
||||
...(is.windows() || is.macOS()
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||
...((is.windows() || is.macOS()
|
||||
? // Only works on Win/Mac
|
||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
||||
[
|
||||
{
|
||||
label: 'Start at login',
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.startAtLogin'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.startAtLogin', item.checked);
|
||||
// https://www.electronjs.org/docs/api/app#appsetloginitemsettingssettings-macos-windows
|
||||
[
|
||||
{
|
||||
label: t('main.menu.options.submenu.start-at-login'),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.startAtLogin'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.startAtLogin', item.checked);
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[],
|
||||
]
|
||||
: []) satisfies Electron.MenuItemConstructorOptions[]),
|
||||
{
|
||||
label: 'Tray',
|
||||
label: t('main.menu.options.submenu.tray.label'),
|
||||
submenu: [
|
||||
{
|
||||
label: 'Disabled',
|
||||
label: t('main.menu.options.submenu.tray.submenu.disabled'),
|
||||
type: 'radio',
|
||||
checked: !config.get('options.tray'),
|
||||
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',
|
||||
checked: config.get('options.tray') && config.get('options.appVisible'),
|
||||
checked:
|
||||
config.get('options.tray') && config.get('options.appVisible'),
|
||||
click() {
|
||||
config.setMenuOption('options.tray', true);
|
||||
config.setMenuOption('options.appVisible', true);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Enabled + app hidden',
|
||||
label: t(
|
||||
'main.menu.options.submenu.tray.submenu.enabled-and-hide-app',
|
||||
),
|
||||
type: 'radio',
|
||||
checked: config.get('options.tray') && !config.get('options.appVisible'),
|
||||
checked:
|
||||
config.get('options.tray') && !config.get('options.appVisible'),
|
||||
click() {
|
||||
config.setMenuOption('options.tray', true);
|
||||
config.setMenuOption('options.appVisible', false);
|
||||
@ -301,75 +346,143 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Play/Pause on click',
|
||||
label: t(
|
||||
'main.menu.options.submenu.tray.submenu.play-pause-on-click',
|
||||
),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.trayClickPlayPause'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.trayClickPlayPause', item.checked);
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption(
|
||||
'options.trayClickPlayPause',
|
||||
item.checked,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Advanced options',
|
||||
label: t('main.menu.options.submenu.language.label') + ' (Language)',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Set Proxy',
|
||||
label: t(
|
||||
'main.menu.options.submenu.language.submenu.to-help-translate',
|
||||
),
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Override useragent',
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.override-user-agent',
|
||||
),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.overrideUserAgent'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.overrideUserAgent', item.checked);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Disable hardware acceleration',
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.disable-hardware-acceleration',
|
||||
),
|
||||
type: 'checkbox',
|
||||
checked: config.get('options.disableHardwareAcceleration'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.disableHardwareAcceleration', item.checked);
|
||||
click(item: MenuItem) {
|
||||
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',
|
||||
checked: config.get('options.restartOnConfigChanges'),
|
||||
click(item) {
|
||||
config.setMenuOption('options.restartOnConfigChanges', item.checked);
|
||||
click(item: MenuItem) {
|
||||
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',
|
||||
checked: config.get('options.autoResetAppCache'),
|
||||
click(item) {
|
||||
click(item: MenuItem) {
|
||||
config.setMenuOption('options.autoResetAppCache', item.checked);
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
is.macOS()
|
||||
? {
|
||||
label: 'Toggle DevTools',
|
||||
// Cannot use "toggleDevTools" role in macOS
|
||||
click() {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDevToolsOpened()) {
|
||||
webContents.closeDevTools();
|
||||
} else {
|
||||
webContents.openDevTools();
|
||||
}
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.toggle-dev-tools',
|
||||
),
|
||||
// Cannot use "toggleDevTools" role in macOS
|
||||
click() {
|
||||
const { webContents } = win;
|
||||
if (webContents.isDevToolsOpened()) {
|
||||
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() {
|
||||
config.edit();
|
||||
},
|
||||
@ -379,23 +492,55 @@ export const mainMenuTemplate = (win: BrowserWindow): MenuTemplate => {
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
label: t('main.menu.view.label'),
|
||||
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' },
|
||||
{ role: 'zoomIn', accelerator: process.platform === 'darwin' ? 'Cmd+I' : 'Ctrl+I' },
|
||||
{ role: 'zoomOut', accelerator: process.platform === 'darwin' ? 'Cmd+O' : 'Ctrl+O' },
|
||||
{ role: 'resetZoom' },
|
||||
{
|
||||
label: t('main.menu.view.submenu.zoom-in'),
|
||||
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' },
|
||||
{ role: 'togglefullscreen' },
|
||||
{
|
||||
label: t('main.menu.view.submenu.toggle-fullscreen'),
|
||||
role: 'togglefullscreen',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Navigation',
|
||||
label: t('main.menu.navigation.label'),
|
||||
submenu: [
|
||||
{
|
||||
label: 'Go back',
|
||||
label: t('main.menu.navigation.submenu.go-back'),
|
||||
click() {
|
||||
if (win.webContents.canGoBack()) {
|
||||
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() {
|
||||
if (win.webContents.canGoForward()) {
|
||||
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() {
|
||||
const currentURL = win.webContents.getURL();
|
||||
clipboard.writeText(currentURL);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Restart App',
|
||||
label: t('main.menu.navigation.submenu.restart'),
|
||||
click: restart,
|
||||
},
|
||||
{ role: 'quit' },
|
||||
{
|
||||
label: t('main.menu.navigation.submenu.quit'),
|
||||
role: 'quit',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'About',
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
],
|
||||
}
|
||||
label: t('main.menu.about'),
|
||||
submenu: [{ role: 'about' }],
|
||||
},
|
||||
];
|
||||
};
|
||||
export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
||||
const menuTemplate: MenuTemplate = [...mainMenuTemplate(win)];
|
||||
export const setApplicationMenu = async (win: Electron.BrowserWindow) => {
|
||||
const menuTemplate: MenuTemplate = [...(await mainMenuTemplate(win))];
|
||||
if (process.platform === 'darwin') {
|
||||
const { name } = app;
|
||||
menuTemplate.unshift({
|
||||
@ -462,23 +608,33 @@ export const setApplicationMenu = (win: Electron.BrowserWindow) => {
|
||||
};
|
||||
|
||||
async function setProxy(item: Electron.MenuItem, win: BrowserWindow) {
|
||||
const output = await prompt({
|
||||
title: 'Set Proxy',
|
||||
label: 'Enter Proxy Address: (leave empty to disable)',
|
||||
value: config.get('options.proxy'),
|
||||
type: 'input',
|
||||
inputAttrs: {
|
||||
type: 'url',
|
||||
placeholder: "Example: 'socks5://127.0.0.1:9999",
|
||||
const output = await prompt(
|
||||
{
|
||||
title: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.title',
|
||||
),
|
||||
label: t(
|
||||
'main.menu.options.submenu.advanced-options.submenu.set-proxy.prompt.label',
|
||||
),
|
||||
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,
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
win,
|
||||
);
|
||||
|
||||
if (typeof output === 'string') {
|
||||
config.setMenuOption('options.proxy', output);
|
||||
item.checked = output !== '';
|
||||
} else { // User pressed cancel
|
||||
} else {
|
||||
// User pressed cancel
|
||||
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;
|
||||
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 {
|
||||
@ -84,5 +87,5 @@ type NavigationHistoryBehavior = 'auto' | 'push' | 'replace';
|
||||
|
||||
declare const 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',
|
||||
];
|
||||
|
||||
let blocker: ElectronBlocker | undefined;
|
||||
|
||||
export const loadAdBlockerEngine = async (
|
||||
session: Electron.Session | undefined = undefined,
|
||||
cache = true,
|
||||
additionalBlockLists = [],
|
||||
cache: boolean = true,
|
||||
additionalBlockLists: string[] = [],
|
||||
disableDefaultLists: boolean | unknown[] = false,
|
||||
) => {
|
||||
// Only use cache if no additional blocklists are passed
|
||||
@ -28,24 +30,24 @@ export const loadAdBlockerEngine = async (
|
||||
if (!fs.existsSync(cacheDirectory)) {
|
||||
fs.mkdirSync(cacheDirectory);
|
||||
}
|
||||
const cachingOptions
|
||||
= cache && additionalBlockLists.length === 0
|
||||
? {
|
||||
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
|
||||
read: promises.readFile,
|
||||
write: promises.writeFile,
|
||||
}
|
||||
: undefined;
|
||||
const cachingOptions =
|
||||
cache && additionalBlockLists.length === 0
|
||||
? {
|
||||
path: path.join(cacheDirectory, 'adblocker-engine.bin'),
|
||||
read: promises.readFile,
|
||||
write: promises.writeFile,
|
||||
}
|
||||
: undefined;
|
||||
const lists = [
|
||||
...(
|
||||
(disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
|
||||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0) ? [] : SOURCES
|
||||
),
|
||||
...((disableDefaultLists && !Array.isArray(disableDefaultLists)) ||
|
||||
(Array.isArray(disableDefaultLists) && disableDefaultLists.length > 0)
|
||||
? []
|
||||
: SOURCES),
|
||||
...additionalBlockLists,
|
||||
];
|
||||
|
||||
try {
|
||||
const blocker = await ElectronBlocker.fromLists(
|
||||
blocker = await ElectronBlocker.fromLists(
|
||||
(url: string) => net.fetch(url),
|
||||
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:
|
||||
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) {
|
||||
delete o.playerAds;
|
||||
@ -67,8 +73,7 @@ module.exports = () => {
|
||||
}
|
||||
|
||||
case 'noopFunc': {
|
||||
cValue = function () {
|
||||
};
|
||||
cValue = function () {};
|
||||
|
||||
break;
|
||||
}
|
||||
@ -97,7 +102,7 @@ module.exports = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(cValue) > 0x7F_FF) {
|
||||
if (Math.abs(cValue) > 0x7f_ff) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -113,12 +118,12 @@ module.exports = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
aborted
|
||||
= v !== undefined
|
||||
&& v !== null
|
||||
&& cValue !== undefined
|
||||
&& cValue !== null
|
||||
&& typeof v !== typeof cValue;
|
||||
aborted =
|
||||
v !== undefined &&
|
||||
v !== null &&
|
||||
cValue !== undefined &&
|
||||
cValue !== null &&
|
||||
typeof v !== typeof cValue;
|
||||
return aborted;
|
||||
};
|
||||
|
||||
@ -266,8 +271,7 @@ module.exports = () => {
|
||||
}
|
||||
|
||||
case 'noopFunc': {
|
||||
cValue = function () {
|
||||
};
|
||||
cValue = function () {};
|
||||
|
||||
break;
|
||||
}
|
||||
@ -296,7 +300,7 @@ module.exports = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(cValue) > 0x7F_FF) {
|
||||
if (Math.abs(cValue) > 0x7f_ff) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@ -312,12 +316,12 @@ module.exports = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
aborted
|
||||
= v !== undefined
|
||||
&& v !== null
|
||||
&& cValue !== undefined
|
||||
&& cValue !== null
|
||||
&& typeof v !== typeof cValue;
|
||||
aborted =
|
||||
v !== undefined &&
|
||||
v !== null &&
|
||||
cValue !== undefined &&
|
||||
cValue !== null &&
|
||||
typeof v !== typeof cValue;
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
|
||||
@ -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' {
|
||||
const nothing: never;
|
||||
export default nothing;
|
||||
export const inject: () => void;
|
||||
}
|
||||
|
||||
@ -1,19 +1,33 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
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) => {
|
||||
ipcMain.handle('captionsSelector', async (_, captionLabels: Record<string, string>, currentIndex: string) => await prompt(
|
||||
{
|
||||
title: 'Choose Caption',
|
||||
label: `Current Caption: ${captionLabels[currentIndex] || 'None'}`,
|
||||
type: 'select',
|
||||
value: currentIndex,
|
||||
selectOptions: captionLabels,
|
||||
resizable: true,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
));
|
||||
};
|
||||
export default createBackend({
|
||||
start({ ipc: { handle }, window }) {
|
||||
handle(
|
||||
'captionsSelector',
|
||||
async (captionLabels: Record<string, string>, currentIndex: string) =>
|
||||
await prompt(
|
||||
{
|
||||
title: t('plugins.captions-selector.prompt.selector.title'),
|
||||
label: t('plugins.captions-selector.prompt.selector.label', {
|
||||
language:
|
||||
captionLabels[currentIndex] ||
|
||||
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"
|
||||
class="player-captions-button style-scope ytmusic-player" icon="yt-icons:subtitles"
|
||||
role="button" tabindex="0"
|
||||
title="Open captions selector">
|
||||
<tp-yt-paper-icon-button
|
||||
aria-disabled="false"
|
||||
aria-label="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">
|
||||
<svg class="style-scope yt-icon"
|
||||
focusable="false" preserveAspectRatio="xMidYMid meet"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%;"
|
||||
viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="style-scope yt-icon"
|
||||
focusable="false"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g class="style-scope yt-icon">
|
||||
<path
|
||||
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>
|
||||
</svg>
|
||||
</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
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// Internal utility: check if value is a valid volume level and throw if not
|
||||
const validateVolumeLevel = (value: number) => {
|
||||
// Number between 0 and 1?
|
||||
if (!Number.isNaN(value) && value >= 0 && value <= 1) {
|
||||
// Yup, that's fine
|
||||
|
||||
} else {
|
||||
// Abort and throw an exception
|
||||
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 {
|
||||
/**
|
||||
* logging `function(stuff, …)` for execution information (default: no logging)
|
||||
@ -73,7 +73,6 @@ export class VolumeFader {
|
||||
private active: boolean = false;
|
||||
private fade: VolumeFade | undefined;
|
||||
|
||||
|
||||
/**
|
||||
* VolumeFader Constructor
|
||||
*
|
||||
@ -121,17 +120,17 @@ export class VolumeFader {
|
||||
|
||||
// Default dynamic range?
|
||||
if (
|
||||
options.fadeScaling === undefined
|
||||
|| options.fadeScaling === 'logarithmic'
|
||||
options.fadeScaling === undefined ||
|
||||
options.fadeScaling === 'logarithmic'
|
||||
) {
|
||||
// Set default of 60 dB
|
||||
dynamicRange = 3;
|
||||
}
|
||||
// Custom dynamic range?
|
||||
else if (
|
||||
typeof options.fadeScaling === 'number'
|
||||
&& !Number.isNaN(options.fadeScaling)
|
||||
&& options.fadeScaling > 0
|
||||
typeof options.fadeScaling === 'number' &&
|
||||
!Number.isNaN(options.fadeScaling) &&
|
||||
options.fadeScaling > 0
|
||||
) {
|
||||
// Turn amplitude dB into a multiple of 10 power dB
|
||||
dynamicRange = options.fadeScaling / 2 / 10;
|
||||
@ -153,13 +152,13 @@ export class VolumeFader {
|
||||
};
|
||||
|
||||
// Log setting if not default
|
||||
options.fadeScaling
|
||||
&& this.logger
|
||||
&& this.logger(
|
||||
'Using logarithmic fading with '
|
||||
+ String(10 * dynamicRange)
|
||||
+ ' dB dynamic range.',
|
||||
);
|
||||
options.fadeScaling &&
|
||||
this.logger &&
|
||||
this.logger(
|
||||
'Using logarithmic fading with ' +
|
||||
String(10 * dynamicRange) +
|
||||
' dB dynamic range.',
|
||||
);
|
||||
}
|
||||
|
||||
// Set initial volume?
|
||||
@ -171,10 +170,8 @@ export class VolumeFader {
|
||||
this.media.volume = options.initialVolume;
|
||||
|
||||
// Log setting
|
||||
this.logger
|
||||
&& this.logger(
|
||||
'Set initial volume to ' + String(this.media.volume) + '.',
|
||||
);
|
||||
this.logger &&
|
||||
this.logger('Set initial volume to ' + String(this.media.volume) + '.');
|
||||
}
|
||||
|
||||
// Fade duration given?
|
||||
@ -239,8 +236,8 @@ export class VolumeFader {
|
||||
this.fadeDuration = fadeDuration;
|
||||
|
||||
// Log setting
|
||||
this.logger
|
||||
&& this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
||||
this.logger &&
|
||||
this.logger('Set fade duration to ' + String(fadeDuration) + ' ms.');
|
||||
} else {
|
||||
// Abort and throw an exception
|
||||
throw new TypeError('Positive number expected as fade duration!');
|
||||
@ -310,13 +307,14 @@ export class VolumeFader {
|
||||
// Time left for fading?
|
||||
if (now < this.fade.time.end) {
|
||||
// Compute current fade progress
|
||||
const progress
|
||||
= (now - this.fade.time.start)
|
||||
/ (this.fade.time.end - this.fade.time.start);
|
||||
const progress =
|
||||
(now - this.fade.time.start) /
|
||||
(this.fade.time.end - this.fade.time.start);
|
||||
|
||||
// Compute current level on internal scale
|
||||
const level
|
||||
= (progress * (this.fade.volume.end - this.fade.volume.start)) + this.fade.volume.start;
|
||||
const level =
|
||||
(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
|
||||
this.media.volume = this.scale.internalToVolume(level);
|
||||
@ -325,10 +323,8 @@ export class VolumeFader {
|
||||
window.requestAnimationFrame(this.updateVolume.bind(this));
|
||||
} else {
|
||||
// Log end of fade
|
||||
this.logger
|
||||
&& this.logger(
|
||||
'Fade to ' + String(this.fade.volume.end) + ' complete.',
|
||||
);
|
||||
this.logger &&
|
||||
this.logger('Fade to ' + String(this.fade.volume.end) + ' complete.');
|
||||
|
||||
// Time is up, jump to target volume
|
||||
this.media.volume = this.scale.internalToVolume(this.fade.volume.end);
|
||||
@ -391,5 +387,5 @@ export class VolumeFader {
|
||||
}
|
||||
|
||||
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 { clear, connect, isConnected, registerRefresh } from './back';
|
||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
||||
|
||||
import { setMenuOptions } from '../../config/plugins';
|
||||
import promptOptions from '../../providers/prompt-options';
|
||||
import { singleton } from '../../providers/decorators';
|
||||
import { MenuTemplate } from '../../menu';
|
||||
import { singleton } from '@/providers/decorators';
|
||||
import promptOptions from '@/providers/prompt-options';
|
||||
import { setMenuOptions } from '@/config/plugins';
|
||||
|
||||
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) => {
|
||||
registerRefresh(refreshMenu);
|
||||
});
|
||||
|
||||
type DiscordOptions = ConfigType<'discord'>;
|
||||
|
||||
export default (win: Electron.BrowserWindow, options: DiscordOptions, refreshMenu: () => void): MenuTemplate => {
|
||||
registerRefreshOnce(refreshMenu);
|
||||
export const onMenu = async ({
|
||||
window,
|
||||
getConfig,
|
||||
setConfig,
|
||||
refresh,
|
||||
}: MenuContext<DiscordPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
registerRefreshOnce(refresh);
|
||||
|
||||
return [
|
||||
{
|
||||
label: isConnected() ? 'Connected' : 'Reconnect',
|
||||
label: isConnected()
|
||||
? t('plugins.discord.menu.connected')
|
||||
: t('plugins.discord.menu.disconnected'),
|
||||
enabled: !isConnected(),
|
||||
click: () => connect(),
|
||||
},
|
||||
{
|
||||
label: 'Auto reconnect',
|
||||
label: t('plugins.discord.menu.auto-reconnect'),
|
||||
type: 'checkbox',
|
||||
checked: options.autoReconnect,
|
||||
checked: config.autoReconnect,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.autoReconnect = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
autoReconnect: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Clear activity',
|
||||
label: t('plugins.discord.menu.clear-activity'),
|
||||
click: clear,
|
||||
},
|
||||
{
|
||||
label: 'Clear activity after timeout',
|
||||
label: t('plugins.discord.menu.clear-activity-after-timeout'),
|
||||
type: 'checkbox',
|
||||
checked: options.activityTimoutEnabled,
|
||||
checked: config.activityTimeoutEnabled,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.activityTimoutEnabled = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
activityTimeoutEnabled: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Play on YouTube Music',
|
||||
label: t('plugins.discord.menu.play-on-youtube-music'),
|
||||
type: 'checkbox',
|
||||
checked: options.playOnYouTubeMusic,
|
||||
checked: config.playOnYouTubeMusic,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.playOnYouTubeMusic = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
playOnYouTubeMusic: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hide GitHub link Button',
|
||||
label: t('plugins.discord.menu.hide-github-button'),
|
||||
type: 'checkbox',
|
||||
checked: options.hideGitHubButton,
|
||||
checked: config.hideGitHubButton,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.hideGitHubButton = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
hideGitHubButton: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Hide duration left',
|
||||
label: t('plugins.discord.menu.hide-duration-left'),
|
||||
type: 'checkbox',
|
||||
checked: options.hideDurationLeft,
|
||||
checked: config.hideDurationLeft,
|
||||
click(item: Electron.MenuItem) {
|
||||
options.hideDurationLeft = item.checked;
|
||||
setMenuOptions('discord', options);
|
||||
setConfig({
|
||||
hideGitHubButton: item.checked,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Set inactivity timeout',
|
||||
click: () => setInactivityTimeout(win, options),
|
||||
label: t('plugins.discord.menu.set-inactivity-timeout'),
|
||||
click: () => setInactivityTimeout(window, config),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
async function setInactivityTimeout(win: Electron.BrowserWindow, options: DiscordOptions) {
|
||||
const output = await prompt({
|
||||
title: 'Set Inactivity Timeout',
|
||||
label: 'Enter inactivity timeout in seconds:',
|
||||
value: String(Math.round((options.activityTimoutTime ?? 0) / 1e3)),
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, multiFire: true },
|
||||
width: 450,
|
||||
...promptOptions(),
|
||||
}, win);
|
||||
async function setInactivityTimeout(
|
||||
win: Electron.BrowserWindow,
|
||||
options: DiscordPluginConfig,
|
||||
) {
|
||||
const output = await prompt(
|
||||
{
|
||||
title: t('plugins.discord.prompt.set-inactivity-timeout.title'),
|
||||
label: t('plugins.discord.prompt.set-inactivity-timeout.label'),
|
||||
value: String(Math.round((options.activityTimeoutTime ?? 0) / 1e3)),
|
||||
type: 'counter',
|
||||
counterOptions: { minimum: 0, multiFire: true },
|
||||
width: 450,
|
||||
...promptOptions(),
|
||||
},
|
||||
win,
|
||||
);
|
||||
|
||||
if (output) {
|
||||
options.activityTimoutTime = Math.round(~~output * 1e3);
|
||||
options.activityTimeoutTime = Math.round(~~output * 1e3);
|
||||
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 { randomBytes } from 'node:crypto';
|
||||
|
||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
||||
import { app, BrowserWindow, dialog } from 'electron';
|
||||
import {
|
||||
ClientType,
|
||||
Innertube,
|
||||
@ -27,16 +27,20 @@ import {
|
||||
sendFeedback as sendFeedback_,
|
||||
setBadge,
|
||||
} 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 { isEnabled } from '../../config/plugins';
|
||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
||||
import { injectCSS } from '../utils';
|
||||
import { cache } from '../../providers/decorators';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from '../types';
|
||||
|
||||
import type { DownloaderPluginConfig } from '../index';
|
||||
|
||||
import type { BackendContext } from '@/types/contexts';
|
||||
|
||||
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||
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 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 };
|
||||
|
||||
@ -68,12 +72,13 @@ const sendError = (error: Error, source?: string) => {
|
||||
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
|
||||
const message = `${error.toString()}${songNameMessage}${cause}`;
|
||||
|
||||
console.error(message, error, error?.stack);
|
||||
dialog.showMessageBox({
|
||||
console.error(message);
|
||||
console.trace(error);
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
buttons: ['OK'],
|
||||
title: 'Error in download!',
|
||||
message: 'Argh! Apologies, download failed…',
|
||||
buttons: [t('plugins.downloader.backend.dialog.error.buttons.ok')],
|
||||
title: t('plugins.downloader.backend.dialog.error.title'),
|
||||
message: t('plugins.downloader.backend.dialog.error.message'),
|
||||
detail: message,
|
||||
});
|
||||
};
|
||||
@ -88,43 +93,35 @@ export const getCookieFromWindow = async (win: BrowserWindow) => {
|
||||
.join(';');
|
||||
};
|
||||
|
||||
export default async (win_: BrowserWindow) => {
|
||||
win = win_;
|
||||
injectCSS(win.webContents, style);
|
||||
let config: DownloaderPluginConfig;
|
||||
|
||||
export const onMainLoad = async ({
|
||||
window: _win,
|
||||
getConfig,
|
||||
ipc,
|
||||
}: BackendContext<DownloaderPluginConfig>) => {
|
||||
win = _win;
|
||||
config = await getConfig();
|
||||
|
||||
yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
cookie: await getCookieFromWindow(win),
|
||||
generate_session_locally: true,
|
||||
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
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,
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
||||
ipc.handle('download-song', (url: string) => downloadSong(url));
|
||||
ipc.on('video-src-changed', (data: GetPlayerResponse) => {
|
||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
||||
ipc.handle('download-playlist-request', async (url: string) =>
|
||||
downloadPlaylist(url),
|
||||
);
|
||||
};
|
||||
|
||||
export const onConfigChange = (newConfig: DownloaderPluginConfig) => {
|
||||
config = newConfig;
|
||||
};
|
||||
|
||||
export async function downloadSong(
|
||||
url: string,
|
||||
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;
|
||||
if (isId) {
|
||||
id = idOrUrl;
|
||||
} else {
|
||||
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);
|
||||
|
||||
if (!info) {
|
||||
throw new Error('Video not found');
|
||||
throw new Error(
|
||||
t('plugins.downloader.backend.feedback.video-id-not-found'),
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = getMetadata(info);
|
||||
@ -208,7 +210,7 @@ async function downloadSongUnsafe(
|
||||
metadata.trackId = trackId;
|
||||
|
||||
const dir =
|
||||
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
||||
playlistFolder || config.downloadFolder || app.getPath('downloads');
|
||||
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
||||
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;
|
||||
if (selectedPreset === 'Custom') {
|
||||
presetSetting =
|
||||
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
|
||||
presetSetting = config.customPresetSetting ?? DefaultPresetList['Custom'];
|
||||
} else if (selectedPreset === 'Source') {
|
||||
presetSetting = DefaultPresetList['Source'];
|
||||
} else {
|
||||
@ -275,7 +276,7 @@ async function downloadSongUnsafe(
|
||||
}
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
if (config.get('skipExisting') && existsSync(filePath)) {
|
||||
if (config.skipExisting && existsSync(filePath)) {
|
||||
sendFeedback(null, -1);
|
||||
return;
|
||||
}
|
||||
@ -283,7 +284,11 @@ async function downloadSongUnsafe(
|
||||
const stream = await info.download(downloadOptions);
|
||||
|
||||
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);
|
||||
@ -318,7 +323,11 @@ async function downloadSongUnsafe(
|
||||
}
|
||||
|
||||
sendFeedback(null, -1);
|
||||
console.info(`Done: "${filePath}"`);
|
||||
console.info(
|
||||
t('plugins.downloader.backend.feedback.done', {
|
||||
filePath,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function iterableStreamToTargetFile(
|
||||
@ -329,7 +338,7 @@ async function iterableStreamToTargetFile(
|
||||
contentLength: number,
|
||||
sendFeedback: (str: string, value?: number) => void,
|
||||
increasePlaylistProgress: (value: number) => void = () => {},
|
||||
) {
|
||||
): Promise<Uint8Array | null> {
|
||||
const chunks = [];
|
||||
let downloaded = 0;
|
||||
for await (const chunk of stream) {
|
||||
@ -337,13 +346,18 @@ async function iterableStreamToTargetFile(
|
||||
chunks.push(chunk);
|
||||
const ratio = downloaded / contentLength;
|
||||
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
|
||||
// This is a very rough estimate, trying to make the progress bar look nice
|
||||
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 safeVideoName = randomBytes(32).toString('hex');
|
||||
@ -354,13 +368,18 @@ async function iterableStreamToTargetFile(
|
||||
await ffmpeg.load();
|
||||
}
|
||||
|
||||
sendFeedback('Preparing file…');
|
||||
sendFeedback(t('plugins.downloader.backend.feedback.preparing-file'));
|
||||
ffmpeg.FS('writeFile', safeVideoName, buffer);
|
||||
|
||||
sendFeedback('Converting…');
|
||||
sendFeedback(t('plugins.downloader.backend.feedback.converting'));
|
||||
|
||||
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));
|
||||
});
|
||||
|
||||
@ -377,7 +396,7 @@ async function iterableStreamToTargetFile(
|
||||
ffmpeg.FS('unlink', safeVideoName);
|
||||
}
|
||||
|
||||
sendFeedback('Saving…');
|
||||
sendFeedback(t('plugins.downloader.backend.feedback.saving'));
|
||||
|
||||
try {
|
||||
return ffmpeg.FS('readFile', safeVideoNameWithExtension);
|
||||
@ -389,6 +408,7 @@ async function iterableStreamToTargetFile(
|
||||
} finally {
|
||||
releaseFFmpegMutex();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const getCoverBuffer = cache(async (url: string) => {
|
||||
@ -402,7 +422,7 @@ async function writeID3(
|
||||
sendFeedback: (str: string, value?: number) => void,
|
||||
) {
|
||||
try {
|
||||
sendFeedback('Writing ID3 tags...');
|
||||
sendFeedback(t('plugins.downloader.backend.feedback.writing-id3'));
|
||||
const tags: NodeID3.Tags = {};
|
||||
|
||||
// Create the metadata tags
|
||||
@ -454,18 +474,23 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
}
|
||||
|
||||
const playlistId =
|
||||
getPlaylistID(givenUrl) ||
|
||||
getPlaylistID(new URL(playingUrl));
|
||||
getPlaylistID(givenUrl) || getPlaylistID(new URL(playingUrl));
|
||||
|
||||
if (!playlistId) {
|
||||
sendError(new Error('No playlist ID found'));
|
||||
sendError(
|
||||
new Error(t('plugins.downloader.backend.feedback.playlist-id-not-found')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
|
||||
|
||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
||||
sendFeedback('Getting playlist info…');
|
||||
console.log(
|
||||
t('plugins.downloader.backend.feedback.trying-to-get-playlist-id', {
|
||||
playlistId,
|
||||
}),
|
||||
);
|
||||
sendFeedback(t('plugins.downloader.backend.feedback.getting-playlist-info'));
|
||||
let playlist: Playlist;
|
||||
const items: YTNodes.MusicResponsiveListItem[] = [];
|
||||
try {
|
||||
@ -476,16 +501,18 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
} catch (error: unknown) {
|
||||
sendError(
|
||||
Error(
|
||||
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(
|
||||
error,
|
||||
)}`,
|
||||
t('plugins.downloader.backend.feedback.playlist-is-mix-or-private', {
|
||||
error: String(error),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
@ -506,7 +533,9 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
}
|
||||
|
||||
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!);
|
||||
return;
|
||||
}
|
||||
@ -516,28 +545,50 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
||||
}
|
||||
|
||||
const folder = getFolder(config.get('downloadFolder') ?? '');
|
||||
const folder = getFolder(config.downloadFolder ?? '');
|
||||
const playlistFolder = join(folder, safePlaylistTitle);
|
||||
if (existsSync(playlistFolder)) {
|
||||
if (!config.get('skipExisting')) {
|
||||
sendError(new Error(`The folder ${playlistFolder} already exists`));
|
||||
if (!config.skipExisting) {
|
||||
sendError(
|
||||
new Error(
|
||||
t('plugins.downloader.backend.feedback.folder-already-exists', {
|
||||
playlistFolder,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
mkdirSync(playlistFolder, { recursive: true });
|
||||
}
|
||||
|
||||
dialog.showMessageBox({
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
buttons: ['OK'],
|
||||
title: 'Started Download',
|
||||
message: `Downloading Playlist "${playlistTitle}"`,
|
||||
detail: `(${items.length} songs)`,
|
||||
buttons: [
|
||||
t('plugins.downloader.backend.dialog.start-download-playlist.buttons.ok'),
|
||||
],
|
||||
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()) {
|
||||
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 {
|
||||
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;
|
||||
await downloadSongFromId(
|
||||
song.id!,
|
||||
@ -567,9 +623,11 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
).catch((error) =>
|
||||
sendError(
|
||||
new Error(
|
||||
`Error downloading "${
|
||||
song.author!.name
|
||||
} - ${song.title!}":\n ${error}`,
|
||||
t('plugins.downloader.backend.feedback.error-while-downloading', {
|
||||
author: song.author!.name,
|
||||
title: song.title!,
|
||||
error: String(error),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -636,6 +694,7 @@ const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
||||
client_type: ClientType.TV_EMBEDDED,
|
||||
generate_session_locally: true,
|
||||
retrieve_player: true,
|
||||
fetch: getNetFetchAsFetch(),
|
||||
});
|
||||
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
||||
// that's fine as we only need the streaming data
|
||||
@ -1,8 +1,8 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import is from 'electron-is';
|
||||
|
||||
export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads');
|
||||
export const defaultMenuDownloadLabel = 'Download playlist';
|
||||
export const getFolder = (customFolder: string) =>
|
||||
customFolder || app.getPath('downloads');
|
||||
|
||||
export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
|
||||
win.webContents.send('downloader-feedback', message);
|
||||
@ -1,46 +1,57 @@
|
||||
import { dialog } from 'electron';
|
||||
|
||||
import { downloadPlaylist } from './back';
|
||||
import { defaultMenuDownloadLabel, getFolder } from './utils';
|
||||
import { downloadPlaylist } from './main';
|
||||
import { getFolder } from './main/utils';
|
||||
import { DefaultPresetList } from './types';
|
||||
import config from './config';
|
||||
|
||||
import { MenuTemplate } from '../../menu';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
export default (): MenuTemplate => [
|
||||
{
|
||||
label: defaultMenuDownloadLabel,
|
||||
click: () => downloadPlaylist(),
|
||||
},
|
||||
{
|
||||
label: 'Choose download folder',
|
||||
click() {
|
||||
const result = dialog.showOpenDialogSync({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
|
||||
});
|
||||
if (result) {
|
||||
config.set('downloadFolder', result[0]);
|
||||
} // Else = user pressed cancel
|
||||
import type { MenuContext } from '@/types/contexts';
|
||||
import type { MenuTemplate } from '@/menu';
|
||||
|
||||
import type { DownloaderPluginConfig } from './index';
|
||||
|
||||
export const onMenu = async ({
|
||||
getConfig,
|
||||
setConfig,
|
||||
}: MenuContext<DownloaderPluginConfig>): Promise<MenuTemplate> => {
|
||||
const config = await getConfig();
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('plugins.downloader.menu.download-playlist'),
|
||||
click: () => downloadPlaylist(),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Presets',
|
||||
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
||||
label: preset,
|
||||
type: 'radio',
|
||||
checked: config.get('selectedPreset') === preset,
|
||||
{
|
||||
label: t('plugins.downloader.menu.choose-download-folder'),
|
||||
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