├── .editorconfig
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.yml
└── workflows
│ ├── build.yml
│ ├── dependency-review.yml
│ ├── pr-build-artifacts.yml
│ ├── reviewdog.yml
│ ├── winget-cla.yml
│ └── winget-submission.yml
├── .gitignore
├── .npmrc
├── .prettierrc
├── README.md
├── assets
├── error.html
└── media-icons-black
│ ├── next.png
│ ├── pause.png
│ ├── play.png
│ └── previous.png
├── changelog.md
├── electron-builder.yml
├── electron.vite.config.mts
├── eslint.config.mjs
├── license
├── package.json
├── patches
├── @malept__flatpak-bundler@0.4.0.patch
├── electron-is@3.0.0.patch
├── file-type@16.5.4.patch
├── kuromoji@0.1.2.patch
└── vudio@2.1.1.patch
├── pnpm-lock.yaml
├── renovate.json
├── src
├── config
│ ├── defaults.ts
│ ├── index.ts
│ ├── plugins.ts
│ └── store.ts
├── custom-electron-prompt.d.ts
├── i18n
│ ├── index.ts
│ └── resources
│ │ ├── @types
│ │ └── index.ts
│ │ ├── ar.json
│ │ ├── az.json
│ │ ├── bg.json
│ │ ├── bn.json
│ │ ├── bs.json
│ │ ├── ca.json
│ │ ├── cs.json
│ │ ├── da.json
│ │ ├── de.json
│ │ ├── el.json
│ │ ├── en.json
│ │ ├── es.json
│ │ ├── et.json
│ │ ├── eu.json
│ │ ├── fa.json
│ │ ├── fi.json
│ │ ├── fil.json
│ │ ├── fr.json
│ │ ├── gl.json
│ │ ├── he.json
│ │ ├── hi.json
│ │ ├── hr.json
│ │ ├── hu.json
│ │ ├── id.json
│ │ ├── is.json
│ │ ├── it.json
│ │ ├── ja.json
│ │ ├── ka.json
│ │ ├── kn.json
│ │ ├── ko.json
│ │ ├── lt.json
│ │ ├── lv.json
│ │ ├── ml.json
│ │ ├── ms.json
│ │ ├── nb.json
│ │ ├── ne.json
│ │ ├── nl.json
│ │ ├── pl.json
│ │ ├── pt-BR.json
│ │ ├── pt.json
│ │ ├── ro.json
│ │ ├── ru.json
│ │ ├── si.json
│ │ ├── sk.json
│ │ ├── sl.json
│ │ ├── sq.json
│ │ ├── sr.json
│ │ ├── sv.json
│ │ ├── ta.json
│ │ ├── th.json
│ │ ├── tr.json
│ │ ├── uk.json
│ │ ├── ur.json
│ │ ├── vi.json
│ │ ├── zh-CN.json
│ │ └── zh-TW.json
├── index.html
├── index.ts
├── loader
│ ├── main.ts
│ ├── menu.ts
│ ├── preload.ts
│ └── renderer.ts
├── menu.ts
├── navigation.d.ts
├── plugins
│ ├── album-actions
│ │ ├── index.tsx
│ │ └── templates
│ │ │ ├── dislike-button.tsx
│ │ │ ├── index.ts
│ │ │ ├── like-button.tsx
│ │ │ ├── undislike-button.tsx
│ │ │ └── unlike-button.tsx
│ ├── album-color-theme
│ │ ├── index.ts
│ │ └── style.css
│ ├── ambient-mode
│ │ ├── index.ts
│ │ ├── menu.ts
│ │ ├── style.css
│ │ └── types.ts
│ ├── amuse
│ │ ├── backend.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── api-server
│ │ ├── backend
│ │ │ ├── api-version.ts
│ │ │ ├── index.ts
│ │ │ ├── main.ts
│ │ │ ├── routes
│ │ │ │ ├── auth.ts
│ │ │ │ ├── control.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── websocket.ts
│ │ │ ├── scheme
│ │ │ │ ├── auth.ts
│ │ │ │ ├── go-back.ts
│ │ │ │ ├── go-forward.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── queue.ts
│ │ │ │ ├── search.ts
│ │ │ │ ├── seek.ts
│ │ │ │ ├── set-fullscreen.ts
│ │ │ │ ├── set-volume.ts
│ │ │ │ ├── song-info.ts
│ │ │ │ └── switch-repeat.ts
│ │ │ └── types.ts
│ │ ├── config.ts
│ │ ├── index.ts
│ │ └── menu.ts
│ ├── audio-compressor.ts
│ ├── auth-proxy-adapter
│ │ ├── backend
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── config.ts
│ │ ├── index.ts
│ │ └── menu.ts
│ ├── blur-nav-bar
│ │ ├── index.ts
│ │ └── style.css
│ ├── captions-selector
│ │ ├── back.ts
│ │ ├── index.ts
│ │ ├── renderer.tsx
│ │ └── templates
│ │ │ └── captions-settings-template.tsx
│ ├── compact-sidebar
│ │ └── index.ts
│ ├── crossfade
│ │ ├── fader.ts
│ │ └── index.ts
│ ├── custom-output-device
│ │ ├── index.ts
│ │ └── renderer.ts
│ ├── disable-autoplay
│ │ └── index.ts
│ ├── discord
│ │ ├── constants.ts
│ │ ├── discord-service.ts
│ │ ├── index.ts
│ │ ├── main.ts
│ │ ├── menu.ts
│ │ ├── timer-manager.ts
│ │ └── utils.ts
│ ├── downloader
│ │ ├── index.ts
│ │ ├── main
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── menu.ts
│ │ ├── renderer.tsx
│ │ ├── style.css
│ │ ├── templates
│ │ │ └── download.tsx
│ │ └── types.ts
│ ├── equalizer
│ │ ├── index.ts
│ │ └── presets.ts
│ ├── exponential-volume
│ │ └── index.ts
│ ├── in-app-menu
│ │ ├── constants.ts
│ │ ├── index.ts
│ │ ├── main.ts
│ │ ├── menu.ts
│ │ ├── renderer.tsx
│ │ ├── renderer
│ │ │ ├── IconButton.tsx
│ │ │ ├── MenuButton.tsx
│ │ │ ├── Panel.tsx
│ │ │ ├── PanelItem.tsx
│ │ │ ├── TitleBar.tsx
│ │ │ └── WindowController.tsx
│ │ └── titlebar.css
│ ├── lumiastream
│ │ └── index.ts
│ ├── music-together
│ │ ├── connection.ts
│ │ ├── element.ts
│ │ ├── icons
│ │ │ ├── connect.svg
│ │ │ ├── key.svg
│ │ │ ├── music-cast.svg
│ │ │ ├── off.svg
│ │ │ └── tune.svg
│ │ ├── index.ts
│ │ ├── queue
│ │ │ ├── client.ts
│ │ │ ├── index.ts
│ │ │ ├── queue.ts
│ │ │ ├── sha1hash.ts
│ │ │ ├── song.ts
│ │ │ └── utils.ts
│ │ ├── style.css
│ │ ├── templates
│ │ │ ├── item.html
│ │ │ ├── popup.html
│ │ │ ├── setting.html
│ │ │ └── status.html
│ │ ├── types.ts
│ │ └── ui
│ │ │ ├── guest.ts
│ │ │ ├── host.ts
│ │ │ ├── setting.ts
│ │ │ └── status.ts
│ ├── navigation
│ │ ├── components
│ │ │ ├── back-button.tsx
│ │ │ └── forward-button.tsx
│ │ ├── index.tsx
│ │ └── style.css
│ ├── notifications
│ │ ├── index.ts
│ │ ├── interactive.ts
│ │ ├── main.ts
│ │ ├── menu.ts
│ │ └── utils.ts
│ ├── performance-improvement
│ │ ├── index.ts
│ │ └── scripts
│ │ │ ├── cpu-tamer
│ │ │ ├── cpu-tamer-by-animationframe.d.ts
│ │ │ ├── cpu-tamer-by-animationframe.js
│ │ │ ├── cpu-tamer-by-dom-mutation.d.ts
│ │ │ ├── cpu-tamer-by-dom-mutation.js
│ │ │ └── index.ts
│ │ │ └── rm3
│ │ │ ├── index.ts
│ │ │ ├── rm3.d.ts
│ │ │ └── rm3.js
│ ├── picture-in-picture
│ │ ├── index.ts
│ │ ├── keyboardevent-from-electron-accelerator.d.ts
│ │ ├── keyboardevents-areequal.d.ts
│ │ ├── main.ts
│ │ ├── menu.ts
│ │ ├── renderer.tsx
│ │ ├── style.css
│ │ └── templates
│ │ │ └── picture-in-picture-button.tsx
│ ├── playback-speed
│ │ ├── components
│ │ │ └── slider.tsx
│ │ ├── index.ts
│ │ └── renderer.tsx
│ ├── precise-volume
│ │ ├── index.ts
│ │ ├── override.ts
│ │ ├── renderer.ts
│ │ └── volume-hud.css
│ ├── quality-changer
│ │ ├── index.tsx
│ │ └── templates
│ │ │ └── quality-setting-button.tsx
│ ├── scrobbler
│ │ ├── index.ts
│ │ ├── main.ts
│ │ ├── menu.ts
│ │ └── services
│ │ │ ├── base.ts
│ │ │ ├── lastfm.ts
│ │ │ └── listenbrainz.ts
│ ├── shortcuts
│ │ ├── index.ts
│ │ ├── main.ts
│ │ ├── menu.ts
│ │ ├── mpris-service.d.ts
│ │ └── mpris.ts
│ ├── skip-disliked-songs
│ │ └── index.ts
│ ├── skip-silences
│ │ ├── index.ts
│ │ └── renderer.ts
│ ├── sponsorblock
│ │ ├── index.ts
│ │ ├── segments.ts
│ │ ├── tests
│ │ │ └── segments.test.js
│ │ └── types.ts
│ ├── synced-lyrics
│ │ ├── backend.ts
│ │ ├── index.ts
│ │ ├── menu.ts
│ │ ├── parsers
│ │ │ └── lrc.ts
│ │ ├── providers
│ │ │ ├── LRCLib.ts
│ │ │ ├── LyricsGenius.ts
│ │ │ ├── Megalobiz.ts
│ │ │ ├── MusixMatch.ts
│ │ │ ├── YTMusic.ts
│ │ │ ├── index.ts
│ │ │ └── renderer.ts
│ │ ├── renderer
│ │ │ ├── components
│ │ │ │ ├── ErrorDisplay.tsx
│ │ │ │ ├── LoadingKaomoji.tsx
│ │ │ │ ├── LyricsPicker.tsx
│ │ │ │ ├── NotFoundKaomoji.tsx
│ │ │ │ ├── PlainLyrics.tsx
│ │ │ │ ├── SyncedLine.tsx
│ │ │ │ └── index.ts
│ │ │ ├── index.ts
│ │ │ ├── renderer.tsx
│ │ │ ├── store.ts
│ │ │ └── utils.tsx
│ │ ├── style.css
│ │ └── types.ts
│ ├── taskbar-mediacontrol
│ │ └── index.ts
│ ├── touchbar
│ │ └── index.ts
│ ├── transparent-player
│ │ ├── index.ts
│ │ ├── style.css
│ │ └── types.ts
│ ├── tuna-obs
│ │ └── index.ts
│ ├── unobtrusive-player
│ │ ├── index.ts
│ │ └── style.css
│ ├── utils
│ │ ├── common
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ ├── main
│ │ │ ├── css.ts
│ │ │ ├── fetch.ts
│ │ │ ├── fs.ts
│ │ │ ├── index.ts
│ │ │ └── types.ts
│ │ └── renderer
│ │ │ ├── check.ts
│ │ │ ├── html.ts
│ │ │ └── index.ts
│ ├── video-toggle
│ │ ├── button-switcher.css
│ │ ├── force-hide.css
│ │ ├── index.tsx
│ │ └── templates
│ │ │ └── video-switch-button.tsx
│ └── visualizer
│ │ ├── butterchurn.d.ts
│ │ ├── empty-player.css
│ │ ├── index.ts
│ │ ├── visualizers
│ │ ├── butterchurn.ts
│ │ ├── index.ts
│ │ ├── visualizer.ts
│ │ ├── vudio.ts
│ │ └── wave.ts
│ │ └── vudio.d.ts
├── preload.ts
├── providers
│ ├── app-controls.ts
│ ├── decorators.ts
│ ├── dom-elements.ts
│ ├── extracted-data.ts
│ ├── prompt-options.ts
│ ├── protocol-handler.ts
│ ├── song-controls.ts
│ ├── song-info-front.ts
│ └── song-info.ts
├── renderer.ts
├── reset.d.ts
├── tray.ts
├── ts-declarations
│ ├── kuroshiro-analyzer-kuromoji.d.ts
│ └── kuroshiro.d.ts
├── types
│ ├── contexts.ts
│ ├── datahost-get-state.ts
│ ├── get-player-response.ts
│ ├── icons.ts
│ ├── media-icons.ts
│ ├── player-api-events.ts
│ ├── plugins.ts
│ ├── queue.ts
│ ├── search-box-element.ts
│ ├── video-data-changed.ts
│ ├── video-details.ts
│ ├── youtube-music-app-element.ts
│ ├── youtube-music-desktop-internal.ts
│ └── youtube-player.ts
├── utils
│ ├── custom-element.ts
│ ├── index.ts
│ ├── testing.ts
│ ├── trusted-types.ts
│ ├── type-utils.ts
│ └── wait-for-element.ts
├── virtual-module.d.ts
├── youtube-music.css
├── youtube-music.d.ts
└── yt-web-components.d.ts
├── tests
└── index.test.js
├── tsconfig.json
├── vite-plugins
├── i18n-importer.mts
├── plugin-importer.mts
└── plugin-loader.mts
└── web
└── screenshot.png
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 | *.js text
3 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Suggest an idea for YouTube Music
3 | title: "[Feature Request]: "
4 | labels: "enhancement :sparkles:"
5 | body:
6 | - type: checkboxes
7 | attributes:
8 | label: Preflight Checklist
9 | description: Please ensure you've completed all of the following.
10 | options:
11 | - label: I use the latest version of YouTube Music (Application).
12 | required: true
13 | - label: I have searched the [issue tracker](https://github.com/th-ch/youtube-music/issues) for a feature request that matches the one I want to file, without success.
14 | required: true
15 | - type: textarea
16 | attributes:
17 | label: Problem Description
18 | description: A clear and concise description of the problem you are seeking to solve with this feature request.
19 | validations:
20 | required: true
21 | - type: textarea
22 | attributes:
23 | label: Proposed Solution
24 | description: Describe the solution you'd like in a clear and concise manner.
25 | validations:
26 | required: true
27 | - type: textarea
28 | attributes:
29 | label: Alternatives Considered
30 | description: A clear and concise description of any alternative solutions or features you've considered.
31 | validations:
32 | required: true
33 | - type: textarea
34 | attributes:
35 | label: Additional Information
36 | description: Any other context about the problem.
37 | validations:
38 | required: false
39 |
--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
1 | # Dependency Review Action
2 | #
3 | # This Action will scan dependency manifest files that change as part of a Pull Reqest, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
4 | #
5 | # Source repository: https://github.com/actions/dependency-review-action
6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
7 | name: "Dependency Review"
8 | on: [ pull_request ]
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | dependency-review:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: "Checkout Repository"
18 | uses: actions/checkout@v5
19 |
20 | - name: "Dependency Review"
21 | uses: actions/dependency-review-action@v4
22 |
--------------------------------------------------------------------------------
/.github/workflows/reviewdog.yml:
--------------------------------------------------------------------------------
1 | name: reviewdog
2 |
3 | on: [pull_request_target]
4 |
5 | env:
6 | NODE_VERSION: "22.x"
7 |
8 | jobs:
9 | eslint:
10 | name: runner / eslint
11 | runs-on: ubuntu-latest
12 | permissions:
13 | contents: read
14 | pull-requests: write
15 | checks: write
16 | steps:
17 | - uses: actions/checkout@v5
18 | with:
19 | ref: ${{ github.event.pull_request.head.sha }}
20 |
21 | - name: Install pnpm
22 | uses: pnpm/action-setup@v4
23 | with:
24 | version: 10
25 | run_install: false
26 |
27 | - name: Setup NodeJS
28 | uses: actions/setup-node@v5
29 | with:
30 | node-version: ${{ env.NODE_VERSION }}
31 | cache: 'pnpm'
32 |
33 | - name: Install dependencies
34 | run: pnpm install --frozen-lockfile
35 |
36 | - uses: reviewdog/action-eslint@v1.34.0
37 | with:
38 | github_token: ${{ secrets.GITHUB_TOKEN }}
39 | reporter: github-pr-review # Change reporter.
40 | eslint_flags: './src'
41 | fail_level: error
42 |
--------------------------------------------------------------------------------
/.github/workflows/winget-cla.yml:
--------------------------------------------------------------------------------
1 | name: Submit CLA to Winget PR
2 |
3 | on:
4 | workflow_dispatch:
5 | inputs:
6 | pr_url:
7 | description: "Specific PR URL"
8 | required: true
9 | type: string
10 |
11 | jobs:
12 | comment:
13 | name: Comment to PR
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Submit CLA to Windows Package Manager Community Repository Pull Request
17 | run: gh pr comment $PR_URL --body "@microsoft-github-policy-service agree"
18 | env:
19 | GITHUB_TOKEN: ${{ secrets.WINGET_ACC_TOKEN }}
20 | PR_URL: ${{ inputs.pr_url }}
21 |
--------------------------------------------------------------------------------
/.github/workflows/winget-submission.yml:
--------------------------------------------------------------------------------
1 | name: Submit to Windows Package Manager Community Repository
2 |
3 | on:
4 | release:
5 | types: [ released ]
6 | workflow_dispatch:
7 | inputs:
8 | tag_name:
9 | description: "Specific tag name"
10 | required: true
11 | type: string
12 |
13 | jobs:
14 | winget:
15 | name: Publish winget package
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Set winget version env
19 | env:
20 | TAG_NAME: ${{ inputs.tag_name || github.event.release.tag_name }}
21 | run: echo "WINGET_TAG_NAME=$(echo ${TAG_NAME#v})" >> $GITHUB_ENV
22 | - name: Submit package to Windows Package Manager Community Repository
23 | uses: vedantmgoyal2009/winget-releaser@main
24 | with:
25 | identifier: th-ch.YouTubeMusic
26 | installers-regex: '^YouTube-Music-Web-Setup-[\d\.]+\.exe$'
27 | version: ${{ env.WINGET_TAG_NAME }}
28 | release-tag: ${{ inputs.tag_name || github.event.release.tag_name }}
29 | token: ${{ secrets.WINGET_ACC_TOKEN }}
30 | fork-user: youtube-music-winget
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | /dist
3 | /pack
4 | .vscode/settings.json
5 | .idea
6 |
7 | .pnp.*
8 | .yarn/*
9 | !.yarn/patches
10 | !.yarn/plugins
11 | !.yarn/releases
12 | !.yarn/sdks
13 | !.yarn/versions
14 | .vite-inspect
15 |
16 | .DS_Store
17 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | scripts-prepend-node-path=true
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "singleQuote": true,
5 | "trailingComma": "all",
6 | "quoteProps": "consistent"
7 | }
8 |
--------------------------------------------------------------------------------
/assets/error.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Cannot load YouTube Music
6 |
42 |
43 |
44 |
45 |
46 |
Cannot load YouTube Music… Internet disconnected?
47 |
Retry
48 |
49 |
50 |
51 |
--------------------------------------------------------------------------------
/assets/media-icons-black/next.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pear-devs/pear-desktop/cbc00776909d2de7e7b9094a4ec3b172d136333d/assets/media-icons-black/next.png
--------------------------------------------------------------------------------
/assets/media-icons-black/pause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pear-devs/pear-desktop/cbc00776909d2de7e7b9094a4ec3b172d136333d/assets/media-icons-black/pause.png
--------------------------------------------------------------------------------
/assets/media-icons-black/play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pear-devs/pear-desktop/cbc00776909d2de7e7b9094a4ec3b172d136333d/assets/media-icons-black/play.png
--------------------------------------------------------------------------------
/assets/media-icons-black/previous.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pear-devs/pear-desktop/cbc00776909d2de7e7b9094a4ec3b172d136333d/assets/media-icons-black/previous.png
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) th-ch (https://github.com/th-ch/youtube-music)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/patches/@malept__flatpak-bundler@0.4.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/index.js b/index.js
2 | index 5968fcf47b69094993b0f861c03f5560e4a6a9b7..0fe16d4f40612c0abfa57898909ce0083f56944c 100644
3 | --- a/index.js
4 | +++ b/index.js
5 | @@ -56,19 +56,23 @@ function getOptionsWithDefaults (options, manifest) {
6 | async function spawnWithLogging (options, command, args, allowFail) {
7 | return new Promise((resolve, reject) => {
8 | logger(`$ ${command} ${args.join(' ')}`)
9 | + const output = []
10 | const child = childProcess.spawn(command, args, { cwd: options['working-dir'] })
11 | child.stdout.on('data', (data) => {
12 | + output.push(data)
13 | logger(`1> ${data}`)
14 | })
15 | child.stderr.on('data', (data) => {
16 | + output.push(data)
17 | logger(`2> ${data}`)
18 | })
19 | child.on('error', (error) => {
20 | + logger(`error - ${error.message} ${error.stack}`)
21 | reject(error)
22 | })
23 | child.on('close', (code) => {
24 | if (!allowFail && code !== 0) {
25 | - reject(new Error(`${command} failed with status code ${code}`))
26 | + reject(new Error(`${command} ${args.join(' ')} failed with status code ${code} ${output.join(' ')}`))
27 | }
28 | resolve(code === 0)
29 | })
30 |
--------------------------------------------------------------------------------
/patches/electron-is@3.0.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/is.d.ts b/is.d.ts
2 | index fb861f7b401914f0f89cb4edf25c51df5cb05812..82144733cd34d88e2deb2e4713b104418e673f2e 100644
3 | --- a/is.d.ts
4 | +++ b/is.d.ts
5 | @@ -5,6 +5,7 @@ declare namespace is {
6 | export function macOS(): boolean;
7 | export function windows(): boolean;
8 | export function linux(): boolean;
9 | + export function freebsd(): boolean;
10 | export function x86(): boolean;
11 | export function x64(): boolean;
12 | export function production(): boolean;
13 | diff --git a/is.js b/is.js
14 | index a76bb1755a2728bde185b35d847031d3b8ea4ab0..f6b03406c17342f5af078de069e5bbbd2246e152 100644
15 | --- a/is.js
16 | +++ b/is.js
17 | @@ -39,6 +39,10 @@ module.exports = {
18 | linux: function () {
19 | return process.platform === 'linux'
20 | },
21 | + // Checks if we are under FreeBSD OS
22 | + freebsd: function () {
23 | + return process.platform === "freebsd"
24 | + },
25 | // Checks if we are the processor's arch is x86
26 | x86: function () {
27 | return process.arch === 'ia32'
28 |
--------------------------------------------------------------------------------
/patches/file-type@16.5.4.patch:
--------------------------------------------------------------------------------
1 | diff --git a/core.js b/core.js
2 | index d653e66a4056c27cca777d4e25222acae3b2ec85..a91741d67df85fd9627889a6c7197ac4e6a3a813 100644
3 | --- a/core.js
4 | +++ b/core.js
5 | @@ -1415,8 +1415,7 @@ async function _fromTokenizer(tokenizer) {
6 | }
7 |
8 | const stream = readableStream => new Promise((resolve, reject) => {
9 | - // Using `eval` to work around issues when bundling with Webpack
10 | - const stream = eval('require')('stream'); // eslint-disable-line no-eval
11 | + const stream = require('node:stream');
12 |
13 | readableStream.on('error', reject);
14 | readableStream.once('readable', async () => {
15 |
--------------------------------------------------------------------------------
/patches/vudio@2.1.1.patch:
--------------------------------------------------------------------------------
1 | diff --git a/umd/vudio.js b/umd/vudio.js
2 | index d0d1127e57125ad4e77442af2db4a26998c7b385..c0b66bd4327c65c31dc6e588bfa4ae6ec70bd3b8 100644
3 | --- a/umd/vudio.js
4 | +++ b/umd/vudio.js
5 | @@ -147,7 +147,6 @@
6 |
7 | source.connect(this.analyser);
8 | this.analyser.fftSize = this.option.accuracy * 2;
9 | - this.analyser.connect(audioContext.destination);
10 |
11 | this.freqByteData = new Uint8Array(this.analyser.frequencyBinCount);
12 |
13 | @@ -207,7 +206,6 @@
14 |
15 | source.connect(this.analyser);
16 | this.analyser.fftSize = this.option.accuracy * 2;
17 | - this.analyser.connect(audioContext.destination);
18 | },
19 |
20 | __rebuildData : function (freqByteData, horizontalAlign) {
21 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["config:recommended"],
4 | "labels": ["dependencies"],
5 | "postUpdateOptions": ["pnpmDedupe"]
6 | }
7 |
--------------------------------------------------------------------------------
/src/config/defaults.ts:
--------------------------------------------------------------------------------
1 | export interface WindowSizeConfig {
2 | width: number;
3 | height: number;
4 | }
5 |
6 | export interface WindowPositionConfig {
7 | x: number;
8 | y: number;
9 | }
10 |
11 | export interface DefaultConfig {
12 | 'window-size': WindowSizeConfig;
13 | 'window-maximized': boolean;
14 | 'window-position': WindowPositionConfig;
15 | 'url': string;
16 | 'options': {
17 | language?: string;
18 | tray: boolean;
19 | appVisible: boolean;
20 | autoUpdates: boolean;
21 | alwaysOnTop: boolean;
22 | hideMenu: boolean;
23 | hideMenuWarned: boolean;
24 | startAtLogin: boolean;
25 | disableHardwareAcceleration: boolean;
26 | removeUpgradeButton: boolean;
27 | restartOnConfigChanges: boolean;
28 | trayClickPlayPause: boolean;
29 | autoResetAppCache: boolean;
30 | resumeOnStart: boolean;
31 | likeButtons: string;
32 | proxy: string;
33 | startingPage: string;
34 | backgroundMaterial?: 'none' | 'mica' | 'acrylic' | 'tabbed';
35 | overrideUserAgent: boolean;
36 | usePodcastParticipantAsArtist: boolean;
37 | themes: string[];
38 | customWindowTitle?: string;
39 | };
40 | 'plugins': Record;
41 | }
42 |
43 | export const defaultConfig: DefaultConfig = {
44 | 'window-size': {
45 | width: 1100,
46 | height: 550,
47 | },
48 | 'window-maximized': false,
49 | 'window-position': {
50 | x: -1,
51 | y: -1,
52 | },
53 | 'url': 'https://music.youtube.com',
54 | 'options': {
55 | tray: false,
56 | appVisible: true,
57 | autoUpdates: true,
58 | alwaysOnTop: false,
59 | hideMenu: false,
60 | hideMenuWarned: false,
61 | startAtLogin: false,
62 | disableHardwareAcceleration: false,
63 | removeUpgradeButton: false,
64 | restartOnConfigChanges: false,
65 | trayClickPlayPause: false,
66 | autoResetAppCache: false,
67 | resumeOnStart: true,
68 | likeButtons: '',
69 | proxy: '',
70 | startingPage: '',
71 | overrideUserAgent: false,
72 | usePodcastParticipantAsArtist: false,
73 | themes: [],
74 | },
75 | 'plugins': {},
76 | };
77 |
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import { deepmergeCustom } from 'deepmerge-ts';
2 |
3 | import { store, type IStore } from './store';
4 | import { restart } from '@/providers/app-controls';
5 |
6 | import type { defaultConfig } from './defaults';
7 |
8 | const deepmerge = deepmergeCustom({
9 | mergeArrays: false,
10 | });
11 |
12 | export { defaultConfig } from './defaults';
13 | export * as plugins from './plugins';
14 |
15 | export const set = (key: string, value: unknown) => {
16 | store.set(key, value);
17 | };
18 |
19 | export const setPartial = (
20 | key: string,
21 | value: object,
22 | defaultValue?: object,
23 | ) => {
24 | const newValue = deepmerge(defaultValue ?? {}, store.get(key) ?? {}, value);
25 | store.set(key, newValue);
26 | };
27 |
28 | export const setMenuOption = (key: string, value: unknown) => {
29 | set(key, value);
30 | if (store.get('options.restartOnConfigChanges')) {
31 | restart();
32 | }
33 | };
34 |
35 | // MAGIC OF TYPESCRIPT
36 |
37 | type Prev = [
38 | never,
39 | 0,
40 | 1,
41 | 2,
42 | 3,
43 | 4,
44 | 5,
45 | 6,
46 | 7,
47 | 8,
48 | 9,
49 | 10,
50 | 11,
51 | 12,
52 | 13,
53 | 14,
54 | 15,
55 | 16,
56 | 17,
57 | 18,
58 | 19,
59 | 20,
60 | ...0[],
61 | ];
62 | type Join = K extends string | number
63 | ? P extends string | number
64 | ? `${K}${'' extends P ? '' : '.'}${P}`
65 | : never
66 | : never;
67 | type Paths = [D] extends [never]
68 | ? never
69 | : T extends object
70 | ? {
71 | [K in keyof T]-?: K extends string | number
72 | ? `${K}` | Join>
73 | : never;
74 | }[keyof T]
75 | : '';
76 |
77 | type SplitKey = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
78 | type PathValue =
79 | SplitKey extends [infer A extends keyof T, infer B extends string]
80 | ? PathValue
81 | : T;
82 |
83 | export const get = >(key: Key) =>
84 | store.get(key) as PathValue;
85 |
86 | export const edit = () => store.openInEditor();
87 |
88 | export const watch = (cb: Parameters[0]) => {
89 | store.onDidAnyChange(cb);
90 | };
91 |
--------------------------------------------------------------------------------
/src/config/plugins.ts:
--------------------------------------------------------------------------------
1 | import { deepmerge } from 'deepmerge-ts';
2 | import { allPlugins } from 'virtual:plugins';
3 |
4 | import { store } from './store';
5 |
6 | import { restart } from '@/providers/app-controls';
7 |
8 | import type { PluginConfig } from '@/types/plugins';
9 |
10 | export function getPlugins() {
11 | return store.get('plugins') as Record;
12 | }
13 |
14 | export async function isEnabled(plugin: string) {
15 | const pluginConfig = deepmerge(
16 | (await allPlugins())[plugin]?.config ?? { enabled: false },
17 | (store.get('plugins') as Record)[plugin] ?? {},
18 | );
19 | return pluginConfig !== undefined && pluginConfig.enabled;
20 | }
21 |
22 | /**
23 | * Set options for a plugin
24 | * @param plugin Plugin name
25 | * @param options Options to set
26 | * @param exclude Options to exclude from the options object
27 | */
28 | export function setOptions(
29 | plugin: string,
30 | options: T,
31 | exclude: string[] = ['enabled'],
32 | ) {
33 | const plugins = store.get('plugins') as Record;
34 | // HACK: This is a workaround for preventing changed options from being overwritten
35 | exclude.forEach((key) => {
36 | if (Object.prototype.hasOwnProperty.call(options, key)) {
37 | delete options[key as keyof T];
38 | }
39 | });
40 | store.set('plugins', {
41 | ...plugins,
42 | [plugin]: {
43 | ...plugins[plugin],
44 | ...options,
45 | },
46 | });
47 | }
48 |
49 | export function setMenuOptions(
50 | plugin: string,
51 | options: T,
52 | exclude: string[] = ['enabled'],
53 | ) {
54 | setOptions(plugin, options, exclude);
55 | if (store.get('options.restartOnConfigChanges')) {
56 | restart();
57 | }
58 | }
59 |
60 | export function getOptions(plugin: string): T {
61 | return (store.get('plugins') as Record)[plugin];
62 | }
63 |
64 | export function enable(plugin: string) {
65 | setMenuOptions(plugin, { enabled: true }, []);
66 | }
67 |
68 | export function disable(plugin: string) {
69 | setMenuOptions(plugin, { enabled: false }, []);
70 | }
71 |
--------------------------------------------------------------------------------
/src/i18n/index.ts:
--------------------------------------------------------------------------------
1 | import i18next, { init, t as i18t, changeLanguage } from 'i18next';
2 |
3 | import { languageResources } from 'virtual:i18n';
4 |
5 | export const loadI18n = async () =>
6 | await init({
7 | resources: await languageResources(),
8 | lng: 'en',
9 | fallbackLng: 'en',
10 | interpolation: {
11 | escapeValue: false,
12 | },
13 | });
14 |
15 | export const setLanguage = async (language: string) =>
16 | await changeLanguage(language);
17 |
18 | export const t = i18t.bind(i18next);
19 |
--------------------------------------------------------------------------------
/src/i18n/resources/@types/index.ts:
--------------------------------------------------------------------------------
1 | export interface LanguageResources {
2 | [lang: string]: {
3 | translation: Record & {
4 | language?: {
5 | 'name': string;
6 | 'local-name': string;
7 | 'code': string;
8 | };
9 | };
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/src/i18n/resources/eu.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": {
3 | "code": "eu",
4 | "local-name": "Euskara",
5 | "name": "Basque"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/i18n/resources/gl.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "console": {
4 | "plugins": {
5 | "execute-failed": "Error ao executar o plugin {{pluginName}}::{{contextName}}",
6 | "executed-at-ms": "O plugin {{pluginName}}::{{contextName}} foi executado a {{ms}}milisegundos",
7 | "initialize-failed": "Erro ao iniciar o plugin \"{{pluginName}}\"",
8 | "load-all": "Cargando todos os plugins",
9 | "load-failed": "Erro ao cargar o plugin \"{{pluginName}}\"",
10 | "loaded": "Plugin \"{{pluginName}}\" cargado",
11 | "unload-failed": "Erro descargando o plugin {{pluginName}}",
12 | "unloaded": "Plugin {{pluginName}} decargado"
13 | }
14 | }
15 | },
16 | "language": {
17 | "code": "gl",
18 | "local-name": "Galego",
19 | "name": "Galego"
20 | },
21 | "main": {
22 | "console": {
23 | "did-finish-load": {
24 | "dev-tools": "Carga completada. DevTools aberto"
25 | },
26 | "i18n": {
27 | "loaded": "i18n cargado"
28 | },
29 | "second-instance": {
30 | "receive-command": "Recibido comando sobre protocolo \"{{command}}\""
31 | },
32 | "theme": {
33 | "css-file-not-found": "O arquivo CSS \"{{cssFile}}\" non existe, ignorando"
34 | },
35 | "unresponsive": {
36 | "details": "Error irresponsivo!\n{{error}}"
37 | },
38 | "when-ready": {
39 | "clearing-cache-after-20s": "Limpando a caché da app"
40 | },
41 | "window": {
42 | "tried-to-render-offscreen": "A ventana tentou de renderizarse fora da pantalla, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
43 | }
44 | },
45 | "dialog": {
46 | "hide-menu-enabled": {
47 | "detail": "O menú está agochado, use 'Alt' para mostralo (ou 'Escape' se usa o menú dentro da app)",
48 | "message": "Esconder Menú está deshabilitado",
49 | "title": "Esconder Menú Habilitado"
50 | },
51 | "need-to-restart": {
52 | "buttons": {
53 | "later": "Despois",
54 | "restart-now": "Reiniciar Agora"
55 | },
56 | "detail": "O plugin \"{{pluginName}}\" precisa dun reinicio para tomar efecto"
57 | }
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/i18n/resources/kn.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": {
3 | "code": "kn",
4 | "local-name": "ಕನ್ನಡ",
5 | "name": "Kannada"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/i18n/resources/sq.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "console": {
4 | "plugins": {
5 | "execute-failed": "Dështoi në ekzekutimin e plugin-it {{pluginName}}::{{contextName}}",
6 | "executed-at-ms": "Shtojca {{pluginName}}::{{contextName}} u ekzekutua në {{ms}}ms"
7 | }
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/loader/menu.ts:
--------------------------------------------------------------------------------
1 | import { deepmerge } from 'deepmerge-ts';
2 | import { allPlugins } from 'virtual:plugins';
3 |
4 | import * as config from '@/config';
5 | import { setApplicationMenu } from '@/menu';
6 |
7 | import { LoggerPrefix } from '@/utils';
8 |
9 | import { t } from '@/i18n';
10 |
11 | import type { MenuContext } from '@/types/contexts';
12 | import type { BrowserWindow, MenuItemConstructorOptions } from 'electron';
13 | import type { PluginConfig } from '@/types/plugins';
14 |
15 | const menuTemplateMap: Record = {};
16 | const createContext = (
17 | id: string,
18 | win: BrowserWindow,
19 | ): MenuContext => ({
20 | getConfig: async () =>
21 | deepmerge(
22 | (await allPlugins())[id].config ?? { enabled: false },
23 | config.get(`plugins.${id}`) ?? {},
24 | ) as PluginConfig,
25 | setConfig: async (newConfig) => {
26 | config.setPartial(
27 | `plugins.${id}`,
28 | newConfig,
29 | (await allPlugins())[id].config,
30 | );
31 | },
32 | window: win,
33 | refresh: async () => {
34 | await setApplicationMenu(win);
35 |
36 | if (await config.plugins.isEnabled('in-app-menu')) {
37 | win.webContents.send('refresh-in-app-menu');
38 | }
39 | },
40 | });
41 |
42 | export const forceLoadMenuPlugin = async (id: string, win: BrowserWindow) => {
43 | try {
44 | const plugin = (await allPlugins())[id];
45 | if (!plugin) return;
46 |
47 | const menu = plugin.menu?.(createContext(id, win));
48 | if (menu) {
49 | const result = await menu;
50 | if (result.length > 0) {
51 | menuTemplateMap[id] = result;
52 | } else {
53 | return;
54 | }
55 | } else return;
56 |
57 | console.log(
58 | LoggerPrefix,
59 | t('common.console.plugins.loaded', { pluginName: `${id}::menu` }),
60 | );
61 | } catch (err) {
62 | console.error(
63 | LoggerPrefix,
64 | t('common.console.plugins.initialize-failed', {
65 | pluginName: `${id}::menu`,
66 | }),
67 | );
68 | console.trace(err);
69 | }
70 | };
71 |
72 | export const loadAllMenuPlugins = async (win: BrowserWindow) => {
73 | const pluginConfigs = config.plugins.getPlugins();
74 |
75 | for (const [pluginId, pluginDef] of Object.entries(await allPlugins())) {
76 | const config = deepmerge(
77 | pluginDef.config ?? { enabled: false },
78 | pluginConfigs[pluginId] ?? {},
79 | );
80 |
81 | if (config.enabled) {
82 | await forceLoadMenuPlugin(pluginId, win);
83 | }
84 | }
85 | };
86 |
87 | export const getMenuTemplate = (
88 | id: string,
89 | ): MenuItemConstructorOptions[] | undefined => {
90 | return menuTemplateMap[id];
91 | };
92 |
93 | export const getAllMenuTemplate = () => {
94 | return menuTemplateMap;
95 | };
96 |
--------------------------------------------------------------------------------
/src/plugins/album-actions/templates/index.ts:
--------------------------------------------------------------------------------
1 | export * from './like-button';
2 | export * from './dislike-button';
3 | export * from './undislike-button';
4 | export * from './unlike-button';
5 |
--------------------------------------------------------------------------------
/src/plugins/album-color-theme/style.css:
--------------------------------------------------------------------------------
1 | yt-page-navigation-progress {
2 | --yt-page-navigation-container-color: #00000046 !important;
3 | --yt-page-navigation-progress-color: white !important;
4 | }
5 |
6 | #player-page {
7 | transition: transform 300ms,
8 | background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
9 | }
10 |
11 | #nav-bar-background {
12 | transition: opacity 200ms,
13 | background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
14 | }
15 |
16 | #mini-guide-background {
17 | transition: opacity 200ms,
18 | background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
19 | border-right: 0px !important;
20 | }
21 |
22 | #guide-wrapper {
23 | transition: opacity 200ms,
24 | background-color 300ms cubic-bezier(0.2, 0, 0.6, 1) !important;
25 | }
26 |
27 | #items {
28 | border-radius: 10px !important;
29 | }
30 |
31 | /* fix blur navigation bar */
32 |
33 | ytmusic-app-layout > [slot="player-page"]:not([is-mweb-modernization-enabled]):not(:has(ytmusic-player[player-ui-state=FULLSCREEN])) {
34 | padding-top: 90px;
35 | margin-top: calc(-90px + var(--menu-bar-height, 0px)) !important;
36 | }
37 |
38 | /* fix icon color */
39 |
40 | .duration.ytmusic-player-queue-item, .byline.ytmusic-player-queue-item {
41 | color: rgba(255, 255, 255, 0.5) !important;
42 | --yt-endpoint-color: rgba(255, 255, 255, 0.5) !important;
43 | --yt-endpoint-hover-color: rgba(255, 255, 255, 0.5) !important;
44 | --yt-endpoint-visited-color: rgba(255, 255, 255, 0.5) !important;
45 | }
46 |
47 | .icon.ytmusic-menu-navigation-item-renderer {
48 | color: rgba(255, 255, 255, 0.5) !important;
49 | }
50 |
51 | .menu.ytmusic-player-bar {
52 | --iron-icon-fill-color: rgba(255, 255, 255, 0.5) !important;
53 | }
54 |
55 | ytmusic-player-bar {
56 | color: rgba(255, 255, 255, 0.5) !important;
57 | }
58 |
59 | .time-info.ytmusic-player-bar {
60 | color: rgba(255, 255, 255, 0.5) !important;
61 | }
62 |
63 | .volume-slider.ytmusic-player-bar, .expand-volume-slider.ytmusic-player-bar {
64 | --paper-slider-container-color: rgba(255, 255, 255, 0.5) !important;
65 | }
66 |
67 | /* fix background image */
68 | ytmusic-fullbleed-thumbnail-renderer img {
69 | mask: linear-gradient(to bottom, #000 0%, #000 50%, transparent 100%);
70 | }
71 |
72 | .background-gradient.style-scope,
73 | ytmusic-app-layout[is-bauhaus-sidenav-enabled] #mini-guide-background.ytmusic-app-layout {
74 | background: var(--ytmusic-background) !important;
75 | }
76 |
77 | ytmusic-browse-response[has-background]:not([disable-gradient]) .background-gradient.ytmusic-browse-response {
78 | background: unset !important;
79 | }
80 |
81 | #background.immersive-background.style-scope.ytmusic-browse-response {
82 | opacity: 0.6;
83 | }
84 |
--------------------------------------------------------------------------------
/src/plugins/ambient-mode/style.css:
--------------------------------------------------------------------------------
1 | #song-video canvas.html5-blur-canvas,
2 | #song-image .html5-blur-image {
3 | filter: blur(var(--blur, 100px));
4 | opacity: var(--opacity, 1);
5 | width: var(--width, 100%);
6 | height: var(--height, 100%);
7 |
8 | pointer-events: none;
9 | }
10 |
11 | #song-video canvas.html5-blur-canvas:not(.fullscreen),
12 | #song-image .html5-blur-image {
13 | position: absolute;
14 | left: 50%;
15 | top: 50%;
16 | transform: translate(-50%, -50%);
17 | }
18 |
19 | #song-video canvas.html5-blur-canvas.fullscreen {
20 | position: fixed;
21 | left: 0;
22 | top: 0;
23 |
24 | width: 100%;
25 | height: 100%;
26 | }
27 |
28 | #song-video .html5-video-container {
29 | height: 100%;
30 | }
31 |
32 | #player:not([video-mode]):not(.video-mode):not([player-ui-state='MINIPLAYER']):not([is-mweb-modernization-enabled]) {
33 | width: 100%;
34 | margin: 0 auto !important;
35 | overflow: visible;
36 | }
37 |
38 | /* Fix ambient mode overlapping other elements #2520 */
39 | .song-button.ytmusic-av-toggle, .video-button.ytmusic-av-toggle {
40 | z-index: 1;
41 | background-color: transparent;
42 | }
43 | #side-panel.side-panel.ytmusic-player-page {
44 | z-index: 0;
45 | }
46 |
--------------------------------------------------------------------------------
/src/plugins/ambient-mode/types.ts:
--------------------------------------------------------------------------------
1 | export type AmbientModePluginConfig = {
2 | enabled: boolean;
3 | quality: number;
4 | buffer: number;
5 | interpolationTime: number;
6 | blur: number;
7 | size: number;
8 | opacity: number;
9 | fullscreen: boolean;
10 | };
11 |
--------------------------------------------------------------------------------
/src/plugins/amuse/backend.ts:
--------------------------------------------------------------------------------
1 | import { t } from 'i18next';
2 |
3 | import { type Context, Hono } from 'hono';
4 | import { cors } from 'hono/cors';
5 | import { serve } from '@hono/node-server';
6 |
7 | import { registerCallback, type SongInfo } from '@/providers/song-info';
8 | import { createBackend } from '@/utils';
9 |
10 | import type { AmuseSongInfo } from './types';
11 |
12 | const amusePort = 9863;
13 |
14 | const formatSongInfo = (info: SongInfo) => {
15 | const formattedSongInfo: AmuseSongInfo = {
16 | player: {
17 | hasSong: !!(info.artist && info.title),
18 | isPaused: info.isPaused ?? false,
19 | seekbarCurrentPosition: info.elapsedSeconds ?? 0,
20 | },
21 | track: {
22 | duration: info.songDuration,
23 | title: info.title,
24 | author: info.artist,
25 | cover: info.imageSrc ?? '',
26 | url: info.url ?? '',
27 | id: info.videoId,
28 | isAdvertisement: false,
29 | },
30 | };
31 | return formattedSongInfo;
32 | };
33 |
34 | export default createBackend({
35 | currentSongInfo: {} as SongInfo,
36 | app: null as Hono | null,
37 | server: null as ReturnType | null,
38 | start() {
39 | registerCallback((songInfo) => {
40 | this.currentSongInfo = songInfo;
41 | });
42 |
43 | this.app = new Hono();
44 | this.app.use('*', cors());
45 | this.app.get('/', (ctx) =>
46 | ctx.body(t('plugins.amuse.response.query'), 200),
47 | );
48 |
49 | const queryAndApiHandler = (ctx: Context) => {
50 | return ctx.json(formatSongInfo(this.currentSongInfo), 200);
51 | };
52 |
53 | this.app.get('/query', queryAndApiHandler);
54 | this.app.get('/api', queryAndApiHandler);
55 |
56 | try {
57 | this.server = serve({
58 | fetch: this.app.fetch.bind(this.app),
59 | port: amusePort,
60 | });
61 | } catch (err) {
62 | console.error(err);
63 | }
64 | },
65 |
66 | stop() {
67 | if (this.server) {
68 | this.server?.close();
69 | }
70 | },
71 | });
72 |
--------------------------------------------------------------------------------
/src/plugins/amuse/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import backend from './backend';
3 | import { t } from '@/i18n';
4 |
5 | export interface MusicWidgetConfig {
6 | enabled: boolean;
7 | }
8 |
9 | export const defaultConfig: MusicWidgetConfig = {
10 | enabled: false,
11 | };
12 |
13 | export default createPlugin({
14 | name: () => t('plugins.amuse.name'),
15 | description: () => t('plugins.amuse.description'),
16 | addedVersion: '3.7.X',
17 | restartNeeded: true,
18 | config: defaultConfig,
19 | backend,
20 | });
21 |
--------------------------------------------------------------------------------
/src/plugins/amuse/types.ts:
--------------------------------------------------------------------------------
1 | export interface PlayerInfo {
2 | hasSong: boolean;
3 | isPaused: boolean;
4 | seekbarCurrentPosition: number;
5 | }
6 |
7 | export interface TrackInfo {
8 | author: string;
9 | title: string;
10 | cover: string;
11 | duration: number;
12 | url: string;
13 | id: string;
14 | isAdvertisement: boolean;
15 | }
16 |
17 | export interface AmuseSongInfo {
18 | player: PlayerInfo;
19 | track: TrackInfo;
20 | }
21 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/api-version.ts:
--------------------------------------------------------------------------------
1 | export const API_VERSION = 'v1';
2 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/index.ts:
--------------------------------------------------------------------------------
1 | export * from './main';
2 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/routes/auth.ts:
--------------------------------------------------------------------------------
1 | import { createRoute, z } from '@hono/zod-openapi';
2 | import { dialog } from 'electron';
3 | import { sign } from 'hono/jwt';
4 |
5 | import { getConnInfo } from '@hono/node-server/conninfo';
6 |
7 | import { t } from '@/i18n';
8 |
9 | import { type APIServerConfig, AuthStrategy } from '../../config';
10 |
11 | import type { JWTPayload } from '../scheme';
12 | import type { HonoApp } from '../types';
13 | import type { BackendContext } from '@/types/contexts';
14 |
15 | const routes = {
16 | request: createRoute({
17 | method: 'post',
18 | path: '/auth/{id}',
19 | summary: '',
20 | description: '',
21 | security: [],
22 | request: {
23 | params: z.object({
24 | id: z.string(),
25 | }),
26 | },
27 | responses: {
28 | 200: {
29 | description: 'Success',
30 | content: {
31 | 'application/json': {
32 | schema: z.object({
33 | accessToken: z.string(),
34 | }),
35 | },
36 | },
37 | },
38 | 403: {
39 | description: 'Forbidden',
40 | },
41 | },
42 | }),
43 | };
44 |
45 | export const register = (
46 | app: HonoApp,
47 | { getConfig, setConfig }: BackendContext,
48 | ) => {
49 | app.openapi(routes.request, async (ctx) => {
50 | const config = await getConfig();
51 | const { id } = ctx.req.param();
52 |
53 | if (config.authorizedClients.includes(id)) {
54 | // SKIP CHECK
55 | } else if (config.authStrategy === AuthStrategy.AUTH_AT_FIRST) {
56 | const result = await dialog.showMessageBox({
57 | title: t('plugins.api-server.dialog.request.title'),
58 | message: t('plugins.api-server.dialog.request.message', {
59 | origin: getConnInfo(ctx).remote.address,
60 | ID: id,
61 | }),
62 | buttons: [
63 | t('plugins.api-server.dialog.request.buttons.allow'),
64 | t('plugins.api-server.dialog.request.buttons.deny'),
65 | ],
66 | defaultId: 1,
67 | cancelId: 1,
68 | });
69 |
70 | if (result.response === 1) {
71 | ctx.status(403);
72 | return ctx.body(null);
73 | }
74 | } else if (config.authStrategy === AuthStrategy.NONE) {
75 | // SKIP CHECK
76 | }
77 |
78 | if (!config.authorizedClients.includes(id)) {
79 | setConfig({
80 | authorizedClients: [...config.authorizedClients, id],
81 | });
82 | }
83 |
84 | const token = await sign(
85 | {
86 | id,
87 | iat: ~~(Date.now() / 1000),
88 | } satisfies JWTPayload,
89 | config.secret,
90 | );
91 |
92 | ctx.status(200);
93 | return ctx.json({
94 | accessToken: token,
95 | });
96 | });
97 | };
98 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/routes/index.ts:
--------------------------------------------------------------------------------
1 | export { register as registerControl } from './control';
2 | export { register as registerAuth } from './auth';
3 | export { register as registerWebsocket } from './websocket';
4 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/auth.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export type JWTPayload = z.infer;
4 | export const JWTPayloadSchema = z.object({
5 | id: z.string(),
6 | iat: z.number(),
7 | });
8 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/go-back.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const GoBackSchema = z.object({
4 | seconds: z.number(),
5 | });
6 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/go-forward.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const GoForwardScheme = z.object({
4 | seconds: z.number(),
5 | });
6 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth';
2 | export * from './song-info';
3 | export * from './seek';
4 | export * from './go-back';
5 | export * from './go-forward';
6 | export * from './switch-repeat';
7 | export * from './set-volume';
8 | export * from './set-fullscreen';
9 | export * from './queue';
10 | export * from './search';
11 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/queue.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const QueueParamsSchema = z.object({
4 | index: z.coerce.number().int().nonnegative(),
5 | });
6 |
7 | export const AddSongToQueueSchema = z.object({
8 | videoId: z.string(),
9 | insertPosition: z
10 | .enum(['INSERT_AT_END', 'INSERT_AFTER_CURRENT_VIDEO'])
11 | .optional()
12 | .default('INSERT_AT_END'),
13 | });
14 | export const MoveSongInQueueSchema = z.object({
15 | toIndex: z.number(),
16 | });
17 | export const SetQueueIndexSchema = z.object({
18 | index: z.number().int().nonnegative(),
19 | });
20 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/search.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const SearchSchema = z.object({
4 | query: z.string(),
5 | params: z.string().optional(),
6 | continuation: z.string().optional(),
7 | });
8 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/seek.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const SeekSchema = z.object({
4 | seconds: z.number(),
5 | });
6 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/set-fullscreen.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const SetFullscreenSchema = z.object({
4 | state: z.boolean(),
5 | });
6 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/set-volume.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const SetVolumeSchema = z.object({
4 | volume: z.number(),
5 | });
6 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/song-info.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | import { MediaType } from '@/providers/song-info';
4 |
5 | export type ResponseSongInfo = z.infer;
6 | export const SongInfoSchema = z.object({
7 | title: z.string(),
8 | artist: z.string(),
9 | views: z.number(),
10 | uploadDate: z.string().optional(),
11 | imageSrc: z.string().nullable().optional(),
12 | isPaused: z.boolean().optional(),
13 | songDuration: z.number(),
14 | elapsedSeconds: z.number().optional(),
15 | url: z.string().optional(),
16 | album: z.string().nullable().optional(),
17 | videoId: z.string(),
18 | playlistId: z.string().optional(),
19 | mediaType: z.enum([
20 | MediaType.Audio,
21 | MediaType.OriginalMusicVideo,
22 | MediaType.UserGeneratedContent,
23 | MediaType.PodcastEpisode,
24 | MediaType.OtherVideo,
25 | ]),
26 | });
27 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/scheme/switch-repeat.ts:
--------------------------------------------------------------------------------
1 | import { z } from '@hono/zod-openapi';
2 |
3 | export const SwitchRepeatSchema = z.object({
4 | iteration: z.number(),
5 | });
6 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/types.ts:
--------------------------------------------------------------------------------
1 | import { type OpenAPIHono as Hono } from '@hono/zod-openapi';
2 | import { type serve } from '@hono/node-server';
3 |
4 | import type { RepeatMode, VolumeState } from '@/types/datahost-get-state';
5 | import type { BackendContext } from '@/types/contexts';
6 | import type { SongInfo } from '@/providers/song-info';
7 | import type { APIServerConfig } from '../config';
8 |
9 | export type HonoApp = Hono;
10 | export type BackendType = {
11 | app?: HonoApp;
12 | server?: ReturnType;
13 | oldConfig?: APIServerConfig;
14 | songInfo?: SongInfo;
15 | currentRepeatMode?: RepeatMode;
16 | volumeState?: VolumeState;
17 | injectWebSocket?: (server: ReturnType) => void;
18 |
19 | init: (ctx: BackendContext) => void;
20 | run: (hostname: string, port: number) => void;
21 | end: () => void;
22 | };
23 |
--------------------------------------------------------------------------------
/src/plugins/api-server/config.ts:
--------------------------------------------------------------------------------
1 | export enum AuthStrategy {
2 | AUTH_AT_FIRST = 'AUTH_AT_FIRST',
3 | NONE = 'NONE',
4 | }
5 |
6 | export interface APIServerConfig {
7 | enabled: boolean;
8 | hostname: string;
9 | port: number;
10 | authStrategy: AuthStrategy;
11 | secret: string;
12 |
13 | authorizedClients: string[];
14 | }
15 |
16 | export const defaultAPIServerConfig: APIServerConfig = {
17 | enabled: false,
18 | hostname: '0.0.0.0',
19 | port: 26538,
20 | authStrategy: AuthStrategy.AUTH_AT_FIRST,
21 | secret: Date.now().toString(36),
22 |
23 | authorizedClients: [],
24 | };
25 |
--------------------------------------------------------------------------------
/src/plugins/api-server/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | import { defaultAPIServerConfig } from './config';
5 | import { onMenu } from './menu';
6 | import { backend } from './backend';
7 |
8 | export default createPlugin({
9 | name: () => t('plugins.api-server.name'),
10 | description: () => t('plugins.api-server.description'),
11 | restartNeeded: false,
12 | config: defaultAPIServerConfig,
13 | addedVersion: '3.6.X',
14 | menu: onMenu,
15 |
16 | backend,
17 | });
18 |
--------------------------------------------------------------------------------
/src/plugins/auth-proxy-adapter/backend/types.ts:
--------------------------------------------------------------------------------
1 | import type net from 'net';
2 | import type { AuthProxyConfig } from '../config';
3 | import type { Server } from 'http';
4 |
5 | export type BackendType = {
6 | server?: Server | net.Server;
7 | oldConfig?: AuthProxyConfig;
8 | startServer: (serverConfig: AuthProxyConfig) => void;
9 | stopServer: () => void;
10 | handleSocks5: (
11 | clientSocket: net.Socket,
12 | chunk: Buffer,
13 | upstreamProxyUrl: string,
14 | ) => void;
15 | processSocks5Request: (
16 | clientSocket: net.Socket,
17 | data: Buffer,
18 | upstreamProxyUrl: string,
19 | ) => void;
20 | };
21 |
--------------------------------------------------------------------------------
/src/plugins/auth-proxy-adapter/config.ts:
--------------------------------------------------------------------------------
1 | export interface AuthProxyConfig {
2 | enabled: boolean;
3 | hostname: string;
4 | port: number;
5 | }
6 |
7 | export const defaultAuthProxyConfig: AuthProxyConfig = {
8 | enabled: false,
9 | hostname: '127.0.0.1',
10 | port: 4545,
11 | };
12 |
--------------------------------------------------------------------------------
/src/plugins/auth-proxy-adapter/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | import { defaultAuthProxyConfig } from './config';
5 | import { onMenu } from './menu';
6 | import { backend } from './backend';
7 |
8 | export default createPlugin({
9 | name: () => t('plugins.auth-proxy-adapter.name'),
10 | description: () => t('plugins.auth-proxy-adapter.description'),
11 | restartNeeded: true,
12 | config: defaultAuthProxyConfig,
13 | addedVersion: '3.10.X',
14 | menu: onMenu,
15 | backend,
16 | });
17 |
--------------------------------------------------------------------------------
/src/plugins/auth-proxy-adapter/menu.ts:
--------------------------------------------------------------------------------
1 | import prompt from 'custom-electron-prompt';
2 |
3 | import { t } from '@/i18n';
4 | import promptOptions from '@/providers/prompt-options';
5 |
6 | import { type AuthProxyConfig, defaultAuthProxyConfig } from './config';
7 |
8 | import type { MenuContext } from '@/types/contexts';
9 | import type { MenuTemplate } from '@/menu';
10 |
11 | export const onMenu = async ({
12 | getConfig,
13 | setConfig,
14 | window,
15 | }: MenuContext): Promise => {
16 | await getConfig();
17 | return [
18 | {
19 | label: t('plugins.auth-proxy-adapter.menu.hostname.label'),
20 | type: 'normal',
21 | async click() {
22 | const config = await getConfig();
23 |
24 | const newHostname =
25 | (await prompt(
26 | {
27 | title: t('plugins.auth-proxy-adapter.prompt.hostname.title'),
28 | label: t('plugins.auth-proxy-adapter.prompt.hostname.label'),
29 | value: config.hostname,
30 | type: 'input',
31 | width: 380,
32 | ...promptOptions(),
33 | },
34 | window,
35 | )) ??
36 | config.hostname ??
37 | defaultAuthProxyConfig.hostname;
38 |
39 | setConfig({ ...config, hostname: newHostname });
40 | },
41 | },
42 | {
43 | label: t('plugins.auth-proxy-adapter.menu.port.label'),
44 | type: 'normal',
45 | async click() {
46 | const config = await getConfig();
47 |
48 | const newPort =
49 | (await prompt(
50 | {
51 | title: t('plugins.auth-proxy-adapter.prompt.port.title'),
52 | label: t('plugins.auth-proxy-adapter.prompt.port.label'),
53 | value: config.port,
54 | type: 'counter',
55 | counterOptions: { minimum: 0, maximum: 65535 },
56 | width: 380,
57 | ...promptOptions(),
58 | },
59 | window,
60 | )) ??
61 | config.port ??
62 | defaultAuthProxyConfig.port;
63 |
64 | setConfig({ ...config, port: newPort });
65 | },
66 | },
67 | ];
68 | };
69 |
--------------------------------------------------------------------------------
/src/plugins/blur-nav-bar/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 |
3 | import { t } from '@/i18n';
4 |
5 | import style from './style.css?inline';
6 |
7 | export default createPlugin({
8 | name: () => t('plugins.blur-nav-bar.name'),
9 | description: () => t('plugins.blur-nav-bar.description'),
10 | restartNeeded: false,
11 | renderer: {
12 | styleSheet: null as CSSStyleSheet | null,
13 |
14 | async start() {
15 | this.styleSheet = new CSSStyleSheet();
16 | await this.styleSheet.replace(style);
17 |
18 | document.adoptedStyleSheets = [
19 | ...document.adoptedStyleSheets,
20 | this.styleSheet,
21 | ];
22 | },
23 | async stop() {
24 | await this.styleSheet?.replace('');
25 | },
26 | },
27 | });
28 |
--------------------------------------------------------------------------------
/src/plugins/blur-nav-bar/style.css:
--------------------------------------------------------------------------------
1 | #nav-bar-background,
2 | #header.ytmusic-item-section-renderer {
3 | background: rgba(0, 0, 0, 0.3) !important;
4 | backdrop-filter: blur(8px) !important;
5 | }
6 |
7 | ytmusic-tabs {
8 | backdrop-filter: blur(8px) !important;
9 | }
10 |
11 | ytmusic-tabs.stuck {
12 | background: rgba(0, 0, 0, 0.3) !important;
13 | }
14 |
15 | #nav-bar-divider {
16 | display: none !important;
17 | }
18 |
--------------------------------------------------------------------------------
/src/plugins/captions-selector/back.ts:
--------------------------------------------------------------------------------
1 | import prompt from 'custom-electron-prompt';
2 |
3 | import promptOptions from '@/providers/prompt-options';
4 | import { createBackend } from '@/utils';
5 | import { t } from '@/i18n';
6 |
7 | export default createBackend({
8 | start({ ipc: { handle }, window }) {
9 | handle(
10 | 'ytmd:captions-selector',
11 | async (captionLabels: Record, currentIndex: string) =>
12 | await prompt(
13 | {
14 | title: t('plugins.captions-selector.prompt.selector.title'),
15 | label: t('plugins.captions-selector.prompt.selector.label', {
16 | language:
17 | captionLabels[currentIndex] ||
18 | t('plugins.captions-selector.prompt.selector.none'),
19 | }),
20 | type: 'select',
21 | value: currentIndex,
22 | selectOptions: captionLabels,
23 | resizable: true,
24 | ...promptOptions(),
25 | },
26 | window,
27 | ),
28 | );
29 | },
30 | stop({ ipc: { removeHandler } }) {
31 | removeHandler('captionsSelector');
32 | },
33 | });
34 |
--------------------------------------------------------------------------------
/src/plugins/captions-selector/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | import backend from './back';
5 | import renderer, {
6 | type CaptionsSelectorConfig,
7 | type LanguageOptions,
8 | } from './renderer';
9 |
10 | import type { YoutubePlayer } from '@/types/youtube-player';
11 |
12 | export default createPlugin<
13 | unknown,
14 | unknown,
15 | {
16 | captionsSettingsButton?: HTMLElement;
17 | captionTrackList: LanguageOptions[] | null;
18 | api: YoutubePlayer | null;
19 | config: CaptionsSelectorConfig | null;
20 | videoChangeListener: () => void;
21 | },
22 | CaptionsSelectorConfig
23 | >({
24 | name: () => t('plugins.captions-selector.name'),
25 | description: () => t('plugins.captions-selector.description'),
26 | config: {
27 | enabled: false,
28 | disableCaptions: false,
29 | autoload: false,
30 | lastCaptionsCode: '',
31 | },
32 |
33 | async menu({ getConfig, setConfig }) {
34 | const config = await getConfig();
35 | return [
36 | {
37 | label: t('plugins.captions-selector.menu.autoload'),
38 | type: 'checkbox',
39 | checked: config.autoload,
40 | click(item) {
41 | setConfig({ autoload: item.checked });
42 | },
43 | },
44 | {
45 | label: t('plugins.captions-selector.menu.disable-captions'),
46 | type: 'checkbox',
47 | checked: config.disableCaptions,
48 | click(item) {
49 | setConfig({ disableCaptions: item.checked });
50 | },
51 | },
52 | ];
53 | },
54 |
55 | backend,
56 | renderer,
57 | });
58 |
--------------------------------------------------------------------------------
/src/plugins/captions-selector/templates/captions-settings-template.tsx:
--------------------------------------------------------------------------------
1 | export interface CaptionsSettingsButtonProps {
2 | label: string;
3 | onClick: (event: MouseEvent) => void;
4 | }
5 |
6 | export const CaptionsSettingButton = (props: CaptionsSettingsButtonProps) => (
7 | props.onClick(e)}
13 | role={'button'}
14 | tabindex={0}
15 | title={props.label}
16 | >
17 |
18 |
26 |
44 |
45 |
46 |
47 | );
48 |
--------------------------------------------------------------------------------
/src/plugins/compact-sidebar/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | export default createPlugin<
5 | unknown,
6 | unknown,
7 | {
8 | getCompactSidebar: () => HTMLElement | null;
9 | isCompactSidebarDisabled: () => boolean;
10 | }
11 | >({
12 | name: () => t('plugins.compact-sidebar.name'),
13 | description: () => t('plugins.compact-sidebar.description'),
14 | restartNeeded: false,
15 | config: {
16 | enabled: false,
17 | },
18 | renderer: {
19 | getCompactSidebar: () => document.querySelector('#mini-guide'),
20 | isCompactSidebarDisabled() {
21 | const compactSidebar = this.getCompactSidebar();
22 | return (
23 | compactSidebar === null ||
24 | window.getComputedStyle(compactSidebar).display === 'none'
25 | );
26 | },
27 | start() {
28 | if (this.isCompactSidebarDisabled()) {
29 | document.querySelector('#button')?.click();
30 | }
31 | },
32 | stop() {
33 | if (this.isCompactSidebarDisabled()) {
34 | document.querySelector('#button')?.click();
35 | }
36 | },
37 | onConfigChange() {
38 | if (this.isCompactSidebarDisabled()) {
39 | document.querySelector('#button')?.click();
40 | }
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------
/src/plugins/custom-output-device/index.ts:
--------------------------------------------------------------------------------
1 | import prompt from 'custom-electron-prompt';
2 |
3 | import { t } from '@/i18n';
4 | import promptOptions from '@/providers/prompt-options';
5 | import { createPlugin } from '@/utils';
6 | import { renderer } from './renderer';
7 |
8 | export interface CustomOutputPluginConfig {
9 | enabled: boolean;
10 | output: string;
11 | devices: Record;
12 | }
13 |
14 | export default createPlugin({
15 | name: () => t('plugins.custom-output-device.name'),
16 | description: () => t('plugins.custom-output-device.description'),
17 | restartNeeded: true,
18 | config: {
19 | enabled: false,
20 | output: 'default',
21 | devices: {},
22 | } as CustomOutputPluginConfig,
23 | menu: ({ setConfig, getConfig, window }) => {
24 | const promptDeviceSelector = async () => {
25 | const options = await getConfig();
26 |
27 | const response = await prompt(
28 | {
29 | title: t('plugins.custom-output-device.prompt.device-selector.title'),
30 | label: t('plugins.custom-output-device.prompt.device-selector.label'),
31 | value: options.output || 'default',
32 | type: 'select',
33 | selectOptions: options.devices,
34 | width: 500,
35 | ...promptOptions(),
36 | },
37 | window,
38 | ).catch(console.error);
39 |
40 | if (!response) return;
41 | options.output = response;
42 | setConfig(options);
43 | };
44 |
45 | return [
46 | {
47 | label: t('plugins.custom-output-device.menu.device-selector'),
48 | click: promptDeviceSelector,
49 | },
50 | ];
51 | },
52 |
53 | renderer,
54 | });
55 |
--------------------------------------------------------------------------------
/src/plugins/custom-output-device/renderer.ts:
--------------------------------------------------------------------------------
1 | import { createRenderer } from '@/utils';
2 |
3 | import type { YoutubePlayer } from '@/types/youtube-player';
4 | import type { RendererContext } from '@/types/contexts';
5 | import type { CustomOutputPluginConfig } from './index';
6 |
7 | const updateDeviceList = async (
8 | context: RendererContext,
9 | ) => {
10 | const newDevices: Record = {};
11 | const devices = await navigator.mediaDevices
12 | .enumerateDevices()
13 | .then((devices) =>
14 | devices.filter((device) => device.kind === 'audiooutput'),
15 | );
16 | for (const device of devices) {
17 | newDevices[device.deviceId] = device.label;
18 | }
19 | const options = await context.getConfig();
20 | options.devices = newDevices;
21 | context.setConfig(options);
22 | };
23 |
24 | const updateSinkId = async (
25 | audioContext?: AudioContext & {
26 | setSinkId?: (sinkId: string) => Promise;
27 | },
28 | sinkId?: string,
29 | ) => {
30 | if (!audioContext || !sinkId) return;
31 | if (!('setSinkId' in audioContext)) return;
32 |
33 | if (typeof audioContext.setSinkId === 'function') {
34 | await audioContext.setSinkId(sinkId);
35 | }
36 | };
37 |
38 | export const renderer = createRenderer<
39 | {
40 | options?: CustomOutputPluginConfig;
41 | audioContext?: AudioContext;
42 | audioCanPlayHandler: (event: CustomEvent) => Promise;
43 | },
44 | CustomOutputPluginConfig
45 | >({
46 | async audioCanPlayHandler({ detail: { audioContext } }) {
47 | this.audioContext = audioContext;
48 | await updateSinkId(audioContext, this.options!.output);
49 | },
50 |
51 | async onPlayerApiReady(_: YoutubePlayer, context) {
52 | this.options = await context.getConfig();
53 | await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
54 | navigator.mediaDevices.ondevicechange = async () =>
55 | await updateDeviceList(context);
56 |
57 | document.addEventListener('ytmd:audio-can-play', this.audioCanPlayHandler, {
58 | once: true,
59 | passive: true,
60 | });
61 | await updateDeviceList(context);
62 | },
63 |
64 | stop() {
65 | document.removeEventListener(
66 | 'ytmd:audio-can-play',
67 | this.audioCanPlayHandler,
68 | );
69 | navigator.mediaDevices.ondevicechange = null;
70 | },
71 |
72 | async onConfigChange(config) {
73 | this.options = config;
74 | await updateSinkId(this.audioContext, config.output);
75 | },
76 | });
77 |
--------------------------------------------------------------------------------
/src/plugins/disable-autoplay/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 |
3 | import { t } from '@/i18n';
4 |
5 | import type { VideoDataChanged } from '@/types/video-data-changed';
6 | import type { YoutubePlayer } from '@/types/youtube-player';
7 |
8 | export type DisableAutoPlayPluginConfig = {
9 | enabled: boolean;
10 | applyOnce: boolean;
11 | };
12 |
13 | export default createPlugin<
14 | unknown,
15 | unknown,
16 | {
17 | config: DisableAutoPlayPluginConfig | null;
18 | api: YoutubePlayer | null;
19 | eventListener: (event: CustomEvent) => void;
20 | timeUpdateListener: (e: Event) => void;
21 | },
22 | DisableAutoPlayPluginConfig
23 | >({
24 | name: () => t('plugins.disable-autoplay.name'),
25 | description: () => t('plugins.disable-autoplay.description'),
26 | restartNeeded: false,
27 | config: {
28 | enabled: false,
29 | applyOnce: false,
30 | },
31 | menu: async ({ getConfig, setConfig }) => {
32 | const config = await getConfig();
33 |
34 | return [
35 | {
36 | label: t('plugins.disable-autoplay.menu.apply-once'),
37 | type: 'checkbox',
38 | checked: config.applyOnce,
39 | async click() {
40 | const nowConfig = await getConfig();
41 | setConfig({
42 | applyOnce: !nowConfig.applyOnce,
43 | });
44 | },
45 | },
46 | ];
47 | },
48 | renderer: {
49 | config: null,
50 | api: null,
51 | eventListener(event: CustomEvent) {
52 | if (this.config?.applyOnce) {
53 | document.removeEventListener('videodatachange', this.eventListener);
54 | }
55 |
56 | if (event.detail.name === 'dataloaded') {
57 | this.api?.pauseVideo();
58 | document
59 | .querySelector('video')
60 | ?.addEventListener('timeupdate', this.timeUpdateListener, {
61 | once: true,
62 | });
63 | }
64 | },
65 | timeUpdateListener(e: Event) {
66 | if (e.target instanceof HTMLVideoElement) {
67 | e.target.pause();
68 | }
69 | },
70 | async start({ getConfig }) {
71 | this.config = await getConfig();
72 | },
73 | onPlayerApiReady(api) {
74 | this.api = api;
75 |
76 | document.addEventListener('videodatachange', this.eventListener);
77 | },
78 | stop() {
79 | document.removeEventListener('videodatachange', this.eventListener);
80 | },
81 | onConfigChange(newConfig) {
82 | this.config = newConfig;
83 | },
84 | },
85 | });
86 |
--------------------------------------------------------------------------------
/src/plugins/discord/constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Application ID registered by @th-ch/youtube-music dev team
3 | */
4 | export const clientId = '1177081335727267940';
5 | /**
6 | * Throttle time for progress updates in milliseconds
7 | */
8 | export const PROGRESS_THROTTLE_MS = 15_000;
9 | /**
10 | * Time in milliseconds to wait before sending a time update
11 | */
12 | export const TIME_UPDATE_DEBOUNCE_MS = 5000;
13 | /**
14 | * Filler character for padding short Hangul strings (Discord requires min 2 chars)
15 | */
16 | export const HANGUL_FILLER = '\u3164';
17 |
18 | /**
19 | * Enum for keys used in TimerManager.
20 | */
21 | export enum TimerKey {
22 | ClearActivity = 'clearActivity', // Timer to clear activity when paused
23 | UpdateTimeout = 'updateTimeout', // Timer for throttled activity updates
24 | DiscordConnectRetry = 'discordConnectRetry', // Timer for Discord connection retries
25 | }
26 |
--------------------------------------------------------------------------------
/src/plugins/discord/index.ts:
--------------------------------------------------------------------------------
1 | import { StatusDisplayType } from 'discord-api-types/v10';
2 |
3 | import { createPlugin } from '@/utils';
4 | import { backend } from './main';
5 | import { onMenu } from './menu';
6 | import { t } from '@/i18n';
7 |
8 | export type DiscordPluginConfig = {
9 | enabled: boolean;
10 | /**
11 | * If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
12 | *
13 | * @default true
14 | */
15 | autoReconnect: boolean;
16 | /**
17 | * If enabled, the discord rich presence gets cleared when music paused after the time specified below
18 | */
19 | activityTimeoutEnabled: boolean;
20 | /**
21 | * The time in milliseconds after which the discord rich presence gets cleared when music paused
22 | *
23 | * @default 10 * 60 * 1000 (10 minutes)
24 | */
25 | activityTimeoutTime: number;
26 | /**
27 | * Add a "Play on YouTube Music" button to rich presence
28 | */
29 | playOnYouTubeMusic: boolean;
30 | /**
31 | * Hide the "View App On GitHub" button in the rich presence
32 | */
33 | hideGitHubButton: boolean;
34 | /**
35 | * Hide the "duration left" in the rich presence
36 | */
37 | hideDurationLeft: boolean;
38 | /**
39 | * Controls which field is displayed in the Discord status text
40 | */
41 | statusDisplayType: (typeof StatusDisplayType)[keyof typeof StatusDisplayType];
42 | };
43 |
44 | export default createPlugin({
45 | name: () => t('plugins.discord.name'),
46 | description: () => t('plugins.discord.description'),
47 | restartNeeded: false,
48 | config: {
49 | enabled: false,
50 | autoReconnect: true,
51 | activityTimeoutEnabled: true,
52 | activityTimeoutTime: 10 * 60 * 1000,
53 | playOnYouTubeMusic: true,
54 | hideGitHubButton: false,
55 | hideDurationLeft: false,
56 | statusDisplayType: StatusDisplayType.Details,
57 | } as DiscordPluginConfig,
58 | menu: onMenu,
59 | backend,
60 | });
61 |
--------------------------------------------------------------------------------
/src/plugins/discord/main.ts:
--------------------------------------------------------------------------------
1 | import { app } from 'electron';
2 |
3 | import { registerCallback, SongInfoEvent } from '@/providers/song-info';
4 | import { createBackend } from '@/utils';
5 |
6 | import { DiscordService } from './discord-service';
7 | import { TIME_UPDATE_DEBOUNCE_MS } from './constants';
8 |
9 | import type { DiscordPluginConfig } from './index';
10 |
11 | export let discordService = null as DiscordService | null;
12 |
13 | export const backend = createBackend<
14 | {
15 | config?: DiscordPluginConfig;
16 | lastTimeUpdateSent: number;
17 | },
18 | DiscordPluginConfig
19 | >({
20 | lastTimeUpdateSent: 0,
21 |
22 | async start(ctx) {
23 | // Get initial configuration from the context
24 | const config = await ctx.getConfig();
25 | discordService = new DiscordService(ctx.window, config);
26 |
27 | if (config.enabled) {
28 | ctx.window.once('ready-to-show', () => {
29 | discordService?.connect(!config.autoReconnect);
30 |
31 | registerCallback((songInfo, event) => {
32 | if (!discordService?.isConnected()) return;
33 |
34 | if (event !== SongInfoEvent.TimeChanged) {
35 | discordService?.updateActivity(songInfo);
36 | this.lastTimeUpdateSent = Date.now();
37 | } else {
38 | const now = Date.now();
39 | if (now - this.lastTimeUpdateSent > TIME_UPDATE_DEBOUNCE_MS) {
40 | discordService?.updateActivity(songInfo);
41 | this.lastTimeUpdateSent = now; // Record the time of this debounced update
42 | }
43 | }
44 | });
45 | });
46 | }
47 |
48 | ctx.ipc.on('ytmd:player-api-loaded', () => {
49 | ctx.ipc.send('ytmd:setup-time-changed-listener');
50 | });
51 |
52 | app.on('before-quit', () => {
53 | discordService?.cleanup();
54 | });
55 | },
56 |
57 | stop() {
58 | discordService?.cleanup();
59 | },
60 |
61 | onConfigChange(newConfig) {
62 | discordService?.onConfigChange(newConfig);
63 |
64 | const currentlyConnected = discordService?.isConnected() ?? false;
65 | if (newConfig.enabled && !currentlyConnected) {
66 | discordService?.connect(!newConfig.autoReconnect);
67 | } else if (!newConfig.enabled && currentlyConnected) {
68 | discordService?.disconnect();
69 | }
70 | },
71 | });
72 |
--------------------------------------------------------------------------------
/src/plugins/discord/timer-manager.ts:
--------------------------------------------------------------------------------
1 | import type { TimerKey } from './constants';
2 |
3 | /**
4 | * Manages NodeJS Timers, ensuring only one timer exists per key.
5 | */
6 | export class TimerManager {
7 | timers = new Map();
8 |
9 | /**
10 | * Sets a timer for a given key, clearing any existing timer with the same key.
11 | * @param key - The unique key for the timer (using TimerKey enum).
12 | * @param fn - The function to execute after the delay.
13 | * @param delay - The delay in milliseconds.
14 | */
15 | set(key: TimerKey, fn: () => void, delay: number): void {
16 | this.clear(key);
17 | this.timers.set(key, setTimeout(fn, delay));
18 | }
19 |
20 | /**
21 | * Clears the timer associated with the given key.
22 | * @param key - The key of the timer to clear (using TimerKey enum).
23 | */
24 | clear(key: TimerKey): void {
25 | const timer = this.timers.get(key);
26 | if (timer) {
27 | clearTimeout(timer);
28 | this.timers.delete(key);
29 | }
30 | }
31 |
32 | /**
33 | * Clears all managed timers.
34 | */
35 | clearAll(): void {
36 | for (const timer of this.timers.values()) {
37 | clearTimeout(timer);
38 | }
39 | this.timers.clear();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/plugins/discord/utils.ts:
--------------------------------------------------------------------------------
1 | import { HANGUL_FILLER } from './constants';
2 |
3 | import type { GatewayActivityButton } from 'discord-api-types/v10';
4 | import type { SongInfo } from '@/providers/song-info';
5 | import type { DiscordPluginConfig } from './index';
6 |
7 | /**
8 | * Truncates a string to a specified length, adding ellipsis if truncated.
9 | * @param str - The string to truncate.
10 | * @param length - The maximum allowed length.
11 | * @returns The truncated string.
12 | */
13 | export const truncateString = (str: string, length: number): string => {
14 | if (str.length > length) {
15 | return `${str.substring(0, length - 3)}...`;
16 | }
17 | return str;
18 | };
19 |
20 | /**
21 | * Builds the array of buttons for the Discord Rich Presence activity.
22 | * @param config - The plugin configuration.
23 | * @param songInfo - The current song information.
24 | * @returns An array of buttons or undefined if no buttons are configured.
25 | */
26 | export const buildDiscordButtons = (
27 | config: DiscordPluginConfig,
28 | songInfo: SongInfo,
29 | ): GatewayActivityButton[] | undefined => {
30 | const buttons: GatewayActivityButton[] = [];
31 | if (config.playOnYouTubeMusic && songInfo.url) {
32 | buttons.push({
33 | label: 'Play on YouTube Music',
34 | url: songInfo.url,
35 | });
36 | }
37 | if (!config.hideGitHubButton) {
38 | buttons.push({
39 | label: 'View App On GitHub',
40 | url: 'https://github.com/th-ch/youtube-music',
41 | });
42 | }
43 | return buttons.length ? buttons : undefined;
44 | };
45 |
46 | /**
47 | * Pads Hangul fields (title, artist, album) in SongInfo if they are less than 2 characters long.
48 | * Discord requires fields to be at least 2 characters.
49 | * @param songInfo - The song information object (will be mutated).
50 | */
51 | export const padHangulFields = (songInfo: SongInfo): void => {
52 | (['title', 'artist', 'album'] as const).forEach((key) => {
53 | const value = songInfo[key];
54 | if (typeof value === 'string' && value.length > 0 && value.length < 2) {
55 | songInfo[key] = value + HANGUL_FILLER.repeat(2 - value.length);
56 | }
57 | });
58 | };
59 |
60 | /**
61 | * Checks if the difference between two time values indicates a seek operation.
62 | * @param oldSeconds - The previous elapsed time in seconds.
63 | * @param newSeconds - The current elapsed time in seconds.
64 | * @returns True if the time difference suggests a seek, false otherwise.
65 | */
66 | export const isSeek = (oldSeconds: number, newSeconds: number): boolean => {
67 | // Consider it a seek if the time difference is greater than 2 seconds
68 | // (allowing for minor discrepancies in reporting)
69 | return Math.abs(newSeconds - oldSeconds) > 2;
70 | };
71 |
--------------------------------------------------------------------------------
/src/plugins/downloader/index.ts:
--------------------------------------------------------------------------------
1 | import { DefaultPresetList, type Preset } from './types';
2 |
3 | import style from './style.css?inline';
4 |
5 | import { createPlugin } from '@/utils';
6 | import { onConfigChange, onMainLoad } from './main';
7 | import { onPlayerApiReady, onRendererLoad } from './renderer';
8 | import { onMenu } from './menu';
9 | import { t } from '@/i18n';
10 |
11 | export type DownloaderPluginConfig = {
12 | enabled: boolean;
13 | downloadFolder?: string;
14 | downloadOnFinish?: {
15 | enabled: boolean;
16 | seconds: number;
17 | percent: number;
18 | mode: 'percent' | 'seconds';
19 | folder?: string;
20 | };
21 | selectedPreset: string;
22 | customPresetSetting: Preset;
23 | skipExisting: boolean;
24 | playlistMaxItems?: number;
25 | };
26 |
27 | export const defaultConfig: DownloaderPluginConfig = {
28 | enabled: false,
29 | downloadFolder: undefined,
30 | downloadOnFinish: {
31 | enabled: false,
32 | seconds: 20,
33 | percent: 10,
34 | mode: 'seconds',
35 | folder: undefined,
36 | },
37 | selectedPreset: 'mp3 (256kbps)', // Selected preset
38 | customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets
39 | skipExisting: false,
40 | playlistMaxItems: undefined,
41 | };
42 |
43 | export default createPlugin({
44 | name: () => t('plugins.downloader.name'),
45 | description: () => t('plugins.downloader.description'),
46 | restartNeeded: true,
47 | config: defaultConfig,
48 | stylesheets: [style],
49 | menu: onMenu,
50 | backend: {
51 | start: onMainLoad,
52 | onConfigChange,
53 | },
54 | renderer: {
55 | start: onRendererLoad,
56 | onPlayerApiReady,
57 | },
58 | });
59 |
--------------------------------------------------------------------------------
/src/plugins/downloader/main/utils.ts:
--------------------------------------------------------------------------------
1 | import { app, type BrowserWindow } from 'electron';
2 | import is from 'electron-is';
3 |
4 | export const getFolder = (customFolder?: string) =>
5 | customFolder ?? app.getPath('downloads');
6 |
7 | export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
8 | win.webContents.send('downloader-feedback', message);
9 | };
10 |
11 | export const cropMaxWidth = (image: Electron.NativeImage) => {
12 | const imageSize = image.getSize();
13 | // Standart YouTube artwork width with margins from both sides is 280 + 720 + 280
14 | if (imageSize.width === 1280 && imageSize.height === 720) {
15 | return image.crop({
16 | x: 280,
17 | y: 0,
18 | width: 720,
19 | height: 720,
20 | });
21 | }
22 |
23 | return image;
24 | };
25 |
26 | export const setBadge = (n: number) => {
27 | if (is.linux() || is.macOS()) {
28 | app.setBadgeCount(n);
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/plugins/downloader/style.css:
--------------------------------------------------------------------------------
1 | .ytmd-menu-item {
2 | display: var(--ytmusic-menu-item_-_display);
3 | height: var(--ytmusic-menu-item_-_height);
4 | align-items: var(--ytmusic-menu-item_-_align-items);
5 | padding: var(--ytmusic-menu-item_-_padding);
6 | cursor: pointer;
7 | }
8 |
9 | .ytmd-menu-item > .yt-simple-endpoint:hover {
10 | background-color: var(--ytmusic-menu-item-hover-background-color);
11 | }
12 |
13 | .ytmd-menu-item {
14 | flex: var(--ytmusic-menu-item-icon_-_flex);
15 | margin: var(--ytmusic-menu-item-icon_-_margin);
16 | fill: var(--ytmusic-menu-item-icon_-_fill);
17 | stroke: var(--iron-icon-stroke-color, none);
18 | width: var(--iron-icon-width, 24px);
19 | height: var(--iron-icon-height, 24px);
20 | animation: var(--iron-icon_-_animation);
21 | }
22 |
--------------------------------------------------------------------------------
/src/plugins/downloader/templates/download.tsx:
--------------------------------------------------------------------------------
1 | export const DownloadButton = (props: {
2 | onClick: () => void;
3 | text: string;
4 | }) => (
5 |
44 | );
45 |
--------------------------------------------------------------------------------
/src/plugins/equalizer/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | import {
5 | defaultPresets,
6 | presetConfigs,
7 | type Preset,
8 | type FilterConfig,
9 | } from './presets';
10 |
11 | import type { MenuContext } from '@/types/contexts';
12 | import type { MenuTemplate } from '@/menu';
13 |
14 | export type EqualizerPluginConfig = {
15 | enabled: boolean;
16 | filters: FilterConfig[];
17 | presets: { [preset in Preset]: boolean };
18 | };
19 |
20 | let appliedFilters: BiquadFilterNode[] = [];
21 |
22 | export default createPlugin({
23 | name: () => t('plugins.equalizer.name'),
24 | description: () => t('plugins.equalizer.description'),
25 | restartNeeded: false,
26 | addedVersion: '3.7.X',
27 | config: {
28 | enabled: false,
29 | filters: [],
30 | presets: { 'bass-booster': false },
31 | } as EqualizerPluginConfig,
32 | menu: async ({
33 | getConfig,
34 | setConfig,
35 | }: MenuContext): Promise => {
36 | const config = await getConfig();
37 |
38 | return [
39 | {
40 | label: t('plugins.equalizer.menu.presets.label'),
41 | type: 'submenu',
42 | submenu: defaultPresets.map((preset) => ({
43 | label: t(`plugins.equalizer.menu.presets.list.${preset}`),
44 | type: 'radio',
45 | checked: config.presets[preset],
46 | click() {
47 | setConfig({
48 | presets: { ...config.presets, [preset]: !config.presets[preset] },
49 | });
50 | },
51 | })),
52 | },
53 | ];
54 | },
55 | renderer: {
56 | async start({ getConfig }) {
57 | const config = await getConfig();
58 |
59 | document.addEventListener(
60 | 'ytmd:audio-can-play',
61 | ({ detail: { audioSource, audioContext } }) => {
62 | const filtersToApply = config.filters.concat(
63 | defaultPresets
64 | .filter((preset) => config.presets[preset])
65 | .map((preset) => presetConfigs[preset]),
66 | );
67 | filtersToApply.forEach((filter) => {
68 | const biquadFilter = audioContext.createBiquadFilter();
69 | biquadFilter.type = filter.type;
70 | biquadFilter.frequency.value = filter.frequency; // filter frequency in Hz
71 | biquadFilter.Q.value = filter.Q;
72 | biquadFilter.gain.value = filter.gain; // filter gain in dB
73 |
74 | audioSource.connect(biquadFilter);
75 | biquadFilter.connect(audioContext.destination);
76 |
77 | appliedFilters.push(biquadFilter);
78 | });
79 | },
80 | { once: true, passive: true },
81 | );
82 | },
83 | stop() {
84 | appliedFilters.forEach((filter) => filter.disconnect());
85 | appliedFilters = [];
86 | },
87 | },
88 | });
89 |
--------------------------------------------------------------------------------
/src/plugins/equalizer/presets.ts:
--------------------------------------------------------------------------------
1 | export const defaultPresets = ['bass-booster'] as const;
2 | export type Preset = (typeof defaultPresets)[number];
3 |
4 | export type FilterConfig = {
5 | type: BiquadFilterType;
6 | frequency: number;
7 | Q: number;
8 | gain: number;
9 | };
10 |
11 | export const presetConfigs: Record = {
12 | 'bass-booster': {
13 | type: 'lowshelf',
14 | frequency: 80,
15 | Q: 100,
16 | gain: 12.0,
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/constants.ts:
--------------------------------------------------------------------------------
1 | export interface InAppMenuConfig {
2 | enabled: boolean;
3 | hideDOMWindowControls: boolean;
4 | }
5 | export const defaultInAppMenuConfig: InAppMenuConfig = {
6 | enabled:
7 | ((typeof window !== 'undefined' &&
8 | !window.navigator?.userAgent?.toLowerCase().includes('mac')) ||
9 | (typeof global !== 'undefined' &&
10 | global.process?.platform !== 'darwin')) &&
11 | ((typeof window !== 'undefined' &&
12 | !window.navigator?.userAgent?.toLowerCase().includes('linux')) ||
13 | (typeof global !== 'undefined' && global.process?.platform !== 'linux')),
14 | hideDOMWindowControls: false,
15 | };
16 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/index.ts:
--------------------------------------------------------------------------------
1 | import titlebarStyle from './titlebar.css?inline';
2 | import { createPlugin } from '@/utils';
3 | import { onMainLoad } from './main';
4 | import { onMenu } from './menu';
5 | import { onConfigChange, onPlayerApiReady, onRendererLoad } from './renderer';
6 | import { t } from '@/i18n';
7 | import { defaultInAppMenuConfig } from './constants';
8 |
9 | export default createPlugin({
10 | name: () => t('plugins.in-app-menu.name'),
11 | description: () => t('plugins.in-app-menu.description'),
12 | restartNeeded: true,
13 | config: defaultInAppMenuConfig,
14 | stylesheets: [titlebarStyle],
15 | menu: onMenu,
16 |
17 | backend: onMainLoad,
18 | renderer: {
19 | start: onRendererLoad,
20 | onPlayerApiReady,
21 | onConfigChange,
22 | },
23 | });
24 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/main.ts:
--------------------------------------------------------------------------------
1 | import { register } from 'electron-localshortcut';
2 |
3 | import {
4 | BrowserWindow,
5 | Menu,
6 | type MenuItem,
7 | ipcMain,
8 | nativeImage,
9 | type WebContents,
10 | } from 'electron';
11 |
12 | import type { BackendContext } from '@/types/contexts';
13 | import type { InAppMenuConfig } from './constants';
14 |
15 | export const onMainLoad = ({
16 | window: win,
17 | ipc: { handle, send },
18 | }: BackendContext) => {
19 | win.on('close', () => {
20 | send('close-all-in-app-menu-panel');
21 | });
22 |
23 | win.once('ready-to-show', () => {
24 | register(win, '`', () => {
25 | send('toggle-in-app-menu');
26 | });
27 | });
28 |
29 | handle('get-menu', () =>
30 | JSON.parse(
31 | JSON.stringify(
32 | Menu.getApplicationMenu(),
33 | (key: string, value: unknown) =>
34 | key !== 'commandsMap' && key !== 'menu' ? value : undefined,
35 | ),
36 | ),
37 | );
38 |
39 | const getMenuItemById = (commandId: number): MenuItem | null => {
40 | const menu = Menu.getApplicationMenu();
41 |
42 | let target: MenuItem | null = null;
43 | const stack = [...(menu?.items ?? [])];
44 | while (stack.length > 0) {
45 | const now = stack.shift();
46 | now?.submenu?.items.forEach((item) => stack.push(item));
47 |
48 | if (now?.commandId === commandId) {
49 | target = now;
50 | break;
51 | }
52 | }
53 |
54 | return target;
55 | };
56 |
57 | ipcMain.handle('ytmd:menu-event', (event, commandId: number) => {
58 | const target = getMenuItemById(commandId);
59 | if (target)
60 | (
61 | target.click as (
62 | args0: unknown,
63 | args1: BrowserWindow | null,
64 | args3: WebContents,
65 | ) => void
66 | )(undefined, BrowserWindow.fromWebContents(event.sender), event.sender);
67 | });
68 |
69 | handle('get-menu-by-id', (commandId: number) => {
70 | const result = getMenuItemById(commandId);
71 |
72 | return JSON.parse(
73 | JSON.stringify(result, (key: string, value: unknown) =>
74 | key !== 'commandsMap' && key !== 'menu' ? value : undefined,
75 | ),
76 | );
77 | });
78 |
79 | handle('window-is-maximized', () => win.isMaximized());
80 |
81 | handle('window-close', () => win.close());
82 | handle('window-minimize', () => win.minimize());
83 | handle('window-maximize', () => win.maximize());
84 | win.on('maximize', () => send('window-maximize'));
85 | handle('window-unmaximize', () => win.unmaximize());
86 | win.on('unmaximize', () => send('window-unmaximize'));
87 |
88 | handle('image-path-to-data-url', (imagePath: string) => {
89 | const nativeImageIcon = nativeImage.createFromPath(imagePath);
90 | return nativeImageIcon?.toDataURL();
91 | });
92 | };
93 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/menu.ts:
--------------------------------------------------------------------------------
1 | import is from 'electron-is';
2 |
3 | import { t } from '@/i18n';
4 |
5 | import type { InAppMenuConfig } from './constants';
6 | import type { MenuContext } from '@/types/contexts';
7 | import type { MenuTemplate } from '@/menu';
8 |
9 | export const onMenu = async ({
10 | getConfig,
11 | setConfig,
12 | }: MenuContext): Promise => {
13 | const config = await getConfig();
14 |
15 | if (is.linux()) {
16 | return [
17 | {
18 | label: t('plugins.in-app-menu.menu.hide-dom-window-controls'),
19 | type: 'checkbox',
20 | checked: config.hideDOMWindowControls,
21 | click(item) {
22 | config.hideDOMWindowControls = item.checked;
23 | setConfig(config);
24 | },
25 | },
26 | ];
27 | }
28 |
29 | return [];
30 | };
31 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/renderer.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal } from 'solid-js';
2 | import { render } from 'solid-js/web';
3 |
4 | import { TitleBar } from './renderer/TitleBar';
5 | import { defaultInAppMenuConfig, type InAppMenuConfig } from './constants';
6 |
7 | import type { RendererContext } from '@/types/contexts';
8 |
9 | const scrollStyle = `
10 | html::-webkit-scrollbar {
11 | background-color: red;
12 | }
13 | `;
14 |
15 | const isMacOS = navigator.userAgent.includes('Macintosh');
16 | const isNotWindowsOrMacOS =
17 | !navigator.userAgent.includes('Windows') && !isMacOS;
18 |
19 | const [config, setConfig] = createSignal(
20 | defaultInAppMenuConfig,
21 | );
22 | export const onRendererLoad = async ({
23 | getConfig,
24 | ipc,
25 | }: RendererContext) => {
26 | setConfig(await getConfig());
27 |
28 | document.title = 'YouTube Music';
29 | const stylesheet = new CSSStyleSheet();
30 | stylesheet.replaceSync(scrollStyle);
31 | document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet];
32 |
33 | render(
34 | () => (
35 |
43 | ),
44 | document.body,
45 | );
46 | };
47 |
48 | export const onPlayerApiReady = () => {
49 | // NOT WORKING AFTER YTM UPDATE (last checked 2024-02-04)
50 | //
51 | // const htmlHeadStyle = document.querySelector('head > div > style');
52 | // if (htmlHeadStyle) {
53 | // // HACK: This is a hack to remove the scrollbar width
54 | // htmlHeadStyle.innerHTML = htmlHeadStyle.innerHTML.replace(
55 | // 'html::-webkit-scrollbar {width: var(--ytmusic-scrollbar-width);',
56 | // 'html::-webkit-scrollbar { width: 0;',
57 | // );
58 | // }
59 | };
60 |
61 | export const onConfigChange = (newConfig: InAppMenuConfig) => {
62 | setConfig(newConfig);
63 | };
64 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/renderer/IconButton.tsx:
--------------------------------------------------------------------------------
1 | import { type JSX } from 'solid-js';
2 | import { css } from 'solid-styled-components';
3 |
4 | import { cacheNoArgs } from '@/providers/decorators';
5 |
6 | const iconButton = cacheNoArgs(
7 | () => css`
8 | -webkit-app-region: none;
9 |
10 | background: transparent;
11 |
12 | width: 24px;
13 | height: 24px;
14 |
15 | padding: 2px;
16 | border-radius: 2px;
17 |
18 | display: flex;
19 | justify-content: center;
20 | align-items: center;
21 |
22 | color: white;
23 |
24 | outline: none;
25 | border: none;
26 |
27 | transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
28 |
29 | &:hover {
30 | background: rgba(255, 255, 255, 0.1);
31 | }
32 |
33 | &:active {
34 | scale: 0.9;
35 | }
36 | `,
37 | );
38 |
39 | type CollapseIconButtonProps = JSX.HTMLAttributes;
40 | export const IconButton = (props: CollapseIconButtonProps) => {
41 | return (
42 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/plugins/in-app-menu/renderer/MenuButton.tsx:
--------------------------------------------------------------------------------
1 | import { type JSX, splitProps } from 'solid-js';
2 | import { css } from 'solid-styled-components';
3 |
4 | import { cacheNoArgs } from '@/providers/decorators';
5 |
6 | const menuStyle = cacheNoArgs(
7 | () => css`
8 | -webkit-app-region: none;
9 |
10 | display: flex;
11 | justify-content: center;
12 | align-items: center;
13 | align-self: stretch;
14 |
15 | padding: 2px 8px;
16 | border-radius: 4px;
17 |
18 | cursor: pointer;
19 | transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
20 |
21 | &:hover {
22 | background-color: rgba(255, 255, 255, 0.1);
23 | }
24 | &:active {
25 | scale: 0.9;
26 | }
27 |
28 | &[data-selected='true'] {
29 | background-color: rgba(255, 255, 255, 0.2);
30 | }
31 | `,
32 | );
33 |
34 | export type MenuButtonProps = JSX.HTMLAttributes & {
35 | text?: string;
36 | selected?: boolean;
37 | };
38 | export const MenuButton = (props: MenuButtonProps) => {
39 | const [local, leftProps] = splitProps(props, ['text']);
40 |
41 | return (
42 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/plugins/music-together/icons/connect.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/plugins/music-together/icons/key.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/plugins/music-together/icons/music-cast.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/plugins/music-together/icons/off.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/src/plugins/music-together/icons/tune.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/plugins/music-together/queue/client.ts:
--------------------------------------------------------------------------------
1 | import { SHA1Hash } from './sha1hash';
2 |
3 | export const extractToken = (cookie = document.cookie) =>
4 | cookie.match(/SAPISID=([^;]+);/)?.[1] ??
5 | cookie.match(/__Secure-3PAPISID=([^;]+);/)?.[1];
6 |
7 | export const getHash = async (
8 | papisid: string,
9 | millis = Date.now(),
10 | origin: string = 'https://music.youtube.com',
11 | ) => (await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase();
12 |
13 | export const getAuthorizationHeader = async (
14 | papisid: string,
15 | millis = Date.now(),
16 | origin: string = 'https://music.youtube.com',
17 | ) => {
18 | return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`;
19 | };
20 |
21 | export const getClient = () => {
22 | return {
23 | hl: navigator.language.split('-')[0] ?? 'en',
24 | gl: navigator.language.split('-')[1] ?? 'US',
25 | deviceMake: '',
26 | deviceModel: '',
27 | userAgent: navigator.userAgent,
28 | clientName: 'WEB_REMIX',
29 | clientVersion: '1.20231208.05.02',
30 | osName: '',
31 | osVersion: '',
32 | platform: 'DESKTOP',
33 | timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
34 | locationInfo: {
35 | locationPermissionAuthorizationStatus:
36 | 'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED',
37 | },
38 | musicAppInfo: {
39 | pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
40 | webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
41 | storeDigitalGoodsApiSupportStatus: {
42 | playStoreDigitalGoodsApiSupportStatus:
43 | 'DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED',
44 | },
45 | },
46 | utcOffsetMinutes: -1 * new Date().getTimezoneOffset(),
47 | };
48 | };
49 |
--------------------------------------------------------------------------------
/src/plugins/music-together/queue/index.ts:
--------------------------------------------------------------------------------
1 | export * from './queue';
2 |
--------------------------------------------------------------------------------
/src/plugins/music-together/queue/sha1hash.ts:
--------------------------------------------------------------------------------
1 | export const SHA1Hash = async (str: string) => {
2 | const enc = new TextEncoder();
3 | const hash = await crypto.subtle.digest('SHA-1', enc.encode(str));
4 | return Array.from(new Uint8Array(hash))
5 | .map((v) => v.toString(16).padStart(2, '0'))
6 | .join('');
7 | };
8 |
--------------------------------------------------------------------------------
/src/plugins/music-together/queue/song.ts:
--------------------------------------------------------------------------------
1 | import type { YouTubeMusicAppElement } from '@/types/youtube-music-app-element';
2 | import type { QueueElement } from '@/types/queue';
3 |
4 | type QueueRendererResponse = {
5 | queueDatas: {
6 | content: unknown;
7 | }[];
8 | responseContext: unknown;
9 | trackingParams: string;
10 | };
11 |
12 | export const getMusicQueueRenderer = async (
13 | videoIds: string[],
14 | ): Promise => {
15 | const queue = document.querySelector('#queue');
16 | const app = document.querySelector('ytmusic-app');
17 | if (!app) return null;
18 |
19 | const store = queue?.queue.store.store;
20 | if (!store) return null;
21 |
22 | return await app.networkManager.fetch<
23 | QueueRendererResponse,
24 | {
25 | queueContextParams: string;
26 | videoIds: string[];
27 | }
28 | >('/music/get_queue', {
29 | queueContextParams: store.getState().queue.queueContextParams,
30 | videoIds,
31 | });
32 | };
33 |
--------------------------------------------------------------------------------
/src/plugins/music-together/queue/utils.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | ItemPlaylistPanelVideoRenderer,
3 | PlaylistPanelVideoWrapperRenderer,
4 | QueueItem,
5 | } from '@/types/datahost-get-state';
6 |
7 | export const mapQueueItem = (
8 | map: (item?: ItemPlaylistPanelVideoRenderer) => T,
9 | array: QueueItem[],
10 | ): T[] =>
11 | array
12 | .map((item) => {
13 | if ('playlistPanelVideoWrapperRenderer' in item) {
14 | const keys = Object.keys(
15 | item.playlistPanelVideoWrapperRenderer!.primaryRenderer,
16 | ) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[];
17 | return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]];
18 | }
19 | if ('playlistPanelVideoRenderer' in item) {
20 | return item.playlistPanelVideoRenderer;
21 | }
22 |
23 | console.error('Music Together: Unknown item', item);
24 | return undefined;
25 | })
26 | .map(map);
27 |
--------------------------------------------------------------------------------
/src/plugins/music-together/templates/item.html:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
--------------------------------------------------------------------------------
/src/plugins/music-together/templates/popup.html:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/plugins/music-together/templates/setting.html:
--------------------------------------------------------------------------------
1 |
7 |
8 |
--------------------------------------------------------------------------------
/src/plugins/music-together/templates/status.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
![Profile Image]()
4 |
5 |
6 |
7 |
8 |
9 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/src/plugins/music-together/types.ts:
--------------------------------------------------------------------------------
1 | export type Profile = {
2 | id: string;
3 | handleId: string;
4 | name: string;
5 | thumbnail: string;
6 | };
7 | export type VideoData = {
8 | videoId: string;
9 | ownerId: string;
10 | };
11 | export type Permission = 'host-only' | 'playlist' | 'all';
12 |
13 | export const getDefaultProfile = (
14 | connectionID: string,
15 | id: string = Date.now().toString(),
16 | ): Profile => {
17 | const name = `Guest ${id.slice(0, 4)}`;
18 |
19 | return {
20 | id: connectionID,
21 | handleId: `#music-together:${id}`,
22 | name,
23 | thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/src/plugins/music-together/ui/guest.ts:
--------------------------------------------------------------------------------
1 | import { ElementFromHtml } from '@/plugins/utils/renderer';
2 |
3 | import { t } from '@/i18n';
4 |
5 | import { Popup } from '../element';
6 | import { createStatus } from '../ui/status';
7 |
8 | import IconOff from '../icons/off.svg?raw';
9 |
10 | export type GuestPopupProps = {
11 | onItemClick: (id: string) => void;
12 | };
13 | export const createGuestPopup = (props: GuestPopupProps) => {
14 | const status = createStatus();
15 | status.setStatus('guest');
16 |
17 | const result = Popup({
18 | data: [
19 | {
20 | type: 'custom',
21 | element: status.element,
22 | },
23 | {
24 | type: 'divider',
25 | },
26 | {
27 | type: 'item',
28 | id: 'music-together-disconnect',
29 | icon: ElementFromHtml(IconOff),
30 | text: t('plugins.music-together.menu.disconnect'),
31 | onClick: () => props.onItemClick('music-together-disconnect'),
32 | },
33 | ],
34 | anchorAt: 'bottom-right',
35 | popupAt: 'top-right',
36 | });
37 |
38 | return {
39 | ...status,
40 | ...result,
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/src/plugins/music-together/ui/host.ts:
--------------------------------------------------------------------------------
1 | import { t } from '@/i18n';
2 | import { ElementFromHtml } from '@/plugins/utils/renderer';
3 |
4 | import { Popup } from '../element';
5 | import { createStatus } from '../ui/status';
6 |
7 | import IconKey from '../icons/key.svg?raw';
8 | import IconOff from '../icons/off.svg?raw';
9 | import IconTune from '../icons/tune.svg?raw';
10 |
11 | export type HostPopupProps = {
12 | onItemClick: (id: string) => void;
13 | };
14 | export const createHostPopup = (props: HostPopupProps) => {
15 | const status = createStatus();
16 | status.setStatus('host');
17 |
18 | const result = Popup({
19 | data: [
20 | {
21 | type: 'custom',
22 | element: status.element,
23 | },
24 | {
25 | type: 'divider',
26 | },
27 | {
28 | id: 'music-together-copy-id',
29 | type: 'item',
30 | icon: ElementFromHtml(IconKey),
31 | text: t('plugins.music-together.menu.click-to-copy-id'),
32 | onClick: () => props.onItemClick('music-together-copy-id'),
33 | },
34 | {
35 | id: 'music-together-permission',
36 | type: 'item',
37 | icon: ElementFromHtml(IconTune),
38 | text: t('plugins.music-together.menu.set-permission', {
39 | permission: t('plugins.music-together.menu.permission.host-only'),
40 | }),
41 | onClick: () => props.onItemClick('music-together-permission'),
42 | },
43 | {
44 | type: 'divider',
45 | },
46 | {
47 | type: 'item',
48 | id: 'music-together-close',
49 | icon: ElementFromHtml(IconOff),
50 | text: t('plugins.music-together.menu.close'),
51 | onClick: () => props.onItemClick('music-together-close'),
52 | },
53 | ],
54 | anchorAt: 'bottom-right',
55 | popupAt: 'top-right',
56 | });
57 |
58 | return {
59 | ...status,
60 | ...result,
61 | };
62 | };
63 |
--------------------------------------------------------------------------------
/src/plugins/music-together/ui/setting.ts:
--------------------------------------------------------------------------------
1 | import { Popup } from '@/plugins/music-together/element';
2 | import { ElementFromHtml } from '@/plugins/utils/renderer';
3 |
4 | import { createStatus } from './status';
5 |
6 | import { t } from '@/i18n';
7 |
8 | import IconMusicCast from '../icons/music-cast.svg?raw';
9 | import IconConnect from '../icons/connect.svg?raw';
10 |
11 | export type SettingPopupProps = {
12 | onItemClick: (id: string) => void;
13 | };
14 | export const createSettingPopup = (props: SettingPopupProps) => {
15 | const status = createStatus();
16 | status.setStatus('disconnected');
17 |
18 | const result = Popup({
19 | data: [
20 | {
21 | type: 'custom',
22 | element: status.element,
23 | },
24 | {
25 | type: 'divider',
26 | },
27 | {
28 | id: 'music-together-host',
29 | type: 'item',
30 | icon: ElementFromHtml(IconMusicCast),
31 | text: t('plugins.music-together.menu.host'),
32 | onClick: () => props.onItemClick('music-together-host'),
33 | },
34 | {
35 | type: 'item',
36 | icon: ElementFromHtml(IconConnect),
37 | text: t('plugins.music-together.menu.join'),
38 | onClick: () => props.onItemClick('music-together-join'),
39 | },
40 | ],
41 | anchorAt: 'bottom-right',
42 | popupAt: 'top-right',
43 | });
44 |
45 | return {
46 | ...status,
47 | ...result,
48 | };
49 | };
50 |
--------------------------------------------------------------------------------
/src/plugins/navigation/components/back-button.tsx:
--------------------------------------------------------------------------------
1 | export interface BackButtonProps {
2 | onClick?: (e: MouseEvent) => void;
3 | title: string;
4 | }
5 |
6 | export const BackButton = (props: BackButtonProps) => (
7 | props.onClick?.(e)}
10 | role="tab"
11 | tab-id="FEmusic_back"
12 | >
13 |
41 |
42 | );
43 |
--------------------------------------------------------------------------------
/src/plugins/navigation/components/forward-button.tsx:
--------------------------------------------------------------------------------
1 | export interface ForwardButtonProps {
2 | onClick?: (e: MouseEvent) => void;
3 | title: string;
4 | }
5 |
6 | export const ForwardButton = (props: ForwardButtonProps) => (
7 | props.onClick?.(e)}
10 | role="tab"
11 | tab-id="FEmusic_next"
12 | >
13 |
45 |
46 | );
47 |
--------------------------------------------------------------------------------
/src/plugins/navigation/index.tsx:
--------------------------------------------------------------------------------
1 | import { render } from 'solid-js/web';
2 |
3 | import style from './style.css?inline';
4 | import { createPlugin } from '@/utils';
5 |
6 | import { t } from '@/i18n';
7 |
8 | import { ForwardButton } from './components/forward-button';
9 | import { BackButton } from './components/back-button';
10 |
11 | export default createPlugin({
12 | name: () => t('plugins.navigation.name'),
13 | description: () => t('plugins.navigation.description'),
14 | restartNeeded: false,
15 | config: {
16 | enabled: true,
17 | },
18 | stylesheets: [style],
19 | renderer: {
20 | buttonContainer: document.createElement('div'),
21 | start() {
22 | if (!this.buttonContainer) {
23 | this.buttonContainer = document.createElement('div');
24 | }
25 |
26 | render(
27 | () => (
28 | <>
29 | history.back()}
31 | title={t('plugins.navigation.templates.back.title')}
32 | />
33 | history.forward()}
35 | title={t('plugins.navigation.templates.forward.title')}
36 | />
37 | >
38 | ),
39 | this.buttonContainer,
40 | );
41 | const menu = document.querySelector('#right-content');
42 | menu?.prepend(this.buttonContainer);
43 | },
44 | stop() {
45 | this.buttonContainer.remove();
46 | },
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/plugins/navigation/style.css:
--------------------------------------------------------------------------------
1 | .navigation-item {
2 | font-family:
3 | Roboto,
4 | Noto Naskh Arabic UI,
5 | Arial,
6 | sans-serif;
7 | font-size: 20px;
8 | line-height: var(--ytmusic-title-1_-_line-height);
9 | font-weight: 500;
10 | --yt-endpoint-color: #fff;
11 | --yt-endpoint-hover-color: #fff;
12 | --yt-endpoint-visited-color: #fff;
13 | display: inline-flex;
14 | align-items: center;
15 | color: rgba(255, 255, 255, 0.5);
16 | cursor: pointer;
17 | margin: 0 var(--ytd-margin-2x, 8px);
18 | }
19 |
20 | .navigation-item:hover {
21 | color: #fff;
22 | }
23 |
24 | .navigation-icon {
25 | display: inline-flex;
26 | align-items: center;
27 | justify-content: center;
28 | position: relative;
29 | vertical-align: middle;
30 | fill: var(--iron-icon-fill-color, currentcolor);
31 | stroke: none;
32 | width: var(--iron-icon-width, 24px);
33 | height: var(--iron-icon-height, 24px);
34 | animation: var(--iron-icon_-_animation);
35 | padding: var(--ytd-margin-base, 4px) var(--ytd-margin-2x, 8px);
36 | }
37 |
--------------------------------------------------------------------------------
/src/plugins/notifications/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 |
3 | import { onConfigChange, onMainLoad } from './main';
4 | import { onMenu } from './menu';
5 | import { t } from '@/i18n';
6 |
7 | export interface NotificationsPluginConfig {
8 | enabled: boolean;
9 | unpauseNotification: boolean;
10 | /**
11 | * Has effect only on Linux
12 | */
13 | urgency: 'low' | 'normal' | 'critical';
14 | /**
15 | * the following has effect only on Windows
16 | */
17 | interactive: boolean;
18 | /**
19 | * See plugins/notifications/utils for more info
20 | */
21 | toastStyle: number;
22 | refreshOnPlayPause: boolean;
23 | trayControls: boolean;
24 | hideButtonText: boolean;
25 | }
26 |
27 | export const defaultConfig: NotificationsPluginConfig = {
28 | enabled: false,
29 | unpauseNotification: false,
30 | urgency: 'normal',
31 | interactive: true,
32 | toastStyle: 1,
33 | refreshOnPlayPause: false,
34 | trayControls: true,
35 | hideButtonText: false,
36 | };
37 |
38 | export default createPlugin({
39 | name: () => t('plugins.notifications.name'),
40 | description: () => t('plugins.notifications.description'),
41 | restartNeeded: true,
42 | config: defaultConfig,
43 | menu: onMenu,
44 | backend: {
45 | start: onMainLoad,
46 | onConfigChange,
47 | },
48 | });
49 |
--------------------------------------------------------------------------------
/src/plugins/notifications/main.ts:
--------------------------------------------------------------------------------
1 | import { Notification } from 'electron';
2 |
3 | import is from 'electron-is';
4 |
5 | import { notificationImage } from './utils';
6 | import interactive from './interactive';
7 |
8 | import {
9 | registerCallback,
10 | type SongInfo,
11 | SongInfoEvent,
12 | } from '@/providers/song-info';
13 |
14 | import type { NotificationsPluginConfig } from './index';
15 | import type { BackendContext } from '@/types/contexts';
16 |
17 | let config: NotificationsPluginConfig;
18 |
19 | const notify = (info: SongInfo) => {
20 | // Send the notification
21 | const currentNotification = new Notification({
22 | title: info.title || 'Playing',
23 | body: info.artist,
24 | icon: notificationImage(info, config),
25 | silent: true,
26 | urgency: config.urgency,
27 | });
28 | currentNotification.show();
29 |
30 | return currentNotification;
31 | };
32 |
33 | const setup = () => {
34 | let oldNotification: Notification;
35 | let currentUrl: string | undefined;
36 |
37 | registerCallback((songInfo: SongInfo, event) => {
38 | if (
39 | event !== SongInfoEvent.TimeChanged &&
40 | !songInfo.isPaused &&
41 | (songInfo.url !== currentUrl || config.unpauseNotification)
42 | ) {
43 | // Close the old notification
44 | oldNotification?.close();
45 | currentUrl = songInfo.url;
46 | // This fixes a weird bug that would cause the notification to be updated instead of showing
47 | setTimeout(() => {
48 | oldNotification = notify(songInfo);
49 | }, 10);
50 | }
51 | });
52 | };
53 |
54 | export const onMainLoad = async (
55 | context: BackendContext,
56 | ) => {
57 | config = await context.getConfig();
58 |
59 | // Register the callback for new song information
60 | if (is.windows() && config.interactive)
61 | interactive(context.window, () => config, context);
62 | else setup();
63 | };
64 |
65 | export const onConfigChange = (newConfig: NotificationsPluginConfig) => {
66 | config = newConfig;
67 | };
68 |
--------------------------------------------------------------------------------
/src/plugins/notifications/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import fs from 'node:fs';
3 |
4 | import { app, type NativeImage } from 'electron';
5 |
6 | import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
7 |
8 | import { type SongInfo } from '@/providers/song-info';
9 |
10 | import type { NotificationsPluginConfig } from './index';
11 |
12 | const userData = app.getPath('userData');
13 | const temporaryIcon = path.join(userData, 'tempIcon.png');
14 | const temporaryBanner = path.join(userData, 'tempBanner.png');
15 |
16 | export const ToastStyles = {
17 | logo: 1,
18 | banner_centered_top: 2,
19 | hero: 3,
20 | banner_top_custom: 4,
21 | banner_centered_bottom: 5,
22 | banner_bottom: 6,
23 | legacy: 7,
24 | };
25 |
26 | export const urgencyLevels = [
27 | { name: 'Low', value: 'low' } as const,
28 | { name: 'Normal', value: 'normal' } as const,
29 | { name: 'High', value: 'critical' } as const,
30 | ];
31 |
32 | const nativeImageToLogo = (nativeImage: NativeImage) => {
33 | const temporaryImage = nativeImage.resize({ height: 256 });
34 | const margin = Math.max(temporaryImage.getSize().width - 256, 0);
35 |
36 | return temporaryImage.crop({
37 | x: Math.round(margin / 2),
38 | y: 0,
39 | width: 256,
40 | height: 256,
41 | });
42 | };
43 |
44 | export const notificationImage = (
45 | songInfo: SongInfo,
46 | config: NotificationsPluginConfig,
47 | ) => {
48 | if (!songInfo.image) {
49 | return youtubeMusicIcon;
50 | }
51 |
52 | if (!config.interactive) {
53 | return nativeImageToLogo(songInfo.image);
54 | }
55 |
56 | switch (config.toastStyle) {
57 | case ToastStyles.logo:
58 | case ToastStyles.legacy: {
59 | return saveImage(nativeImageToLogo(songInfo.image), temporaryIcon);
60 | }
61 |
62 | default: {
63 | return saveImage(songInfo.image, temporaryBanner);
64 | }
65 | }
66 | };
67 |
68 | export const saveImage = (img: NativeImage, savePath: string) => {
69 | try {
70 | fs.writeFileSync(savePath, img.toPNG());
71 | } catch (error: unknown) {
72 | console.error('Error writing song icon to disk:');
73 | console.trace(error);
74 | return youtubeMusicIcon;
75 | }
76 |
77 | return savePath;
78 | };
79 |
80 | export const snakeToCamel = (string_: string) =>
81 | string_.replaceAll(/([-_][a-z]|^[a-z])/g, (group) =>
82 | group.toUpperCase().replace('-', ' ').replace('_', ' '),
83 | );
84 |
85 | export const secondsToMinutes = (seconds: number) => {
86 | const minutes = Math.floor(seconds / 60);
87 | const secondsLeft = seconds % 60;
88 | return `${minutes}:${secondsLeft < 10 ? '0' : ''}${secondsLeft}`;
89 | };
90 |
--------------------------------------------------------------------------------
/src/plugins/performance-improvement/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | import { injectRm3 } from './scripts/rm3';
5 | import { injectCpuTamer } from './scripts/cpu-tamer';
6 |
7 | export default createPlugin({
8 | name: () => t('plugins.performance-improvement.name'),
9 | description: () => t('plugins.performance-improvement.description'),
10 | restartNeeded: true,
11 | addedVersion: '3.9.X',
12 | config: {
13 | enabled: true,
14 | },
15 | renderer() {
16 | injectRm3();
17 | injectCpuTamer();
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/src/plugins/performance-improvement/scripts/cpu-tamer/cpu-tamer-by-animationframe.d.ts:
--------------------------------------------------------------------------------
1 | export declare const injectCpuTamerByAnimationFrame: (
2 | __CONTEXT__: unknown,
3 | ) => void;
4 |
--------------------------------------------------------------------------------
/src/plugins/performance-improvement/scripts/cpu-tamer/cpu-tamer-by-dom-mutation.d.ts:
--------------------------------------------------------------------------------
1 | export declare const injectCpuTamerByDomMutation: (
2 | __CONTEXT__: unknown,
3 | ) => void;
4 |
--------------------------------------------------------------------------------
/src/plugins/performance-improvement/scripts/cpu-tamer/index.ts:
--------------------------------------------------------------------------------
1 | import { injectCpuTamerByAnimationFrame } from './cpu-tamer-by-animationframe';
2 | import { injectCpuTamerByDomMutation } from './cpu-tamer-by-dom-mutation';
3 |
4 | const isGPUAccelerationAvailable = () => {
5 | // https://gist.github.com/cvan/042b2448fcecefafbb6a91469484cdf8
6 | try {
7 | const canvas = document.createElement('canvas');
8 | return !!(
9 | canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
10 | );
11 | } catch {
12 | return false;
13 | }
14 | };
15 |
16 | export const injectCpuTamer = () => {
17 | if (isGPUAccelerationAvailable()) {
18 | injectCpuTamerByAnimationFrame(null);
19 | } else {
20 | injectCpuTamerByDomMutation(null);
21 | }
22 | };
23 |
--------------------------------------------------------------------------------
/src/plugins/performance-improvement/scripts/rm3/index.ts:
--------------------------------------------------------------------------------
1 | export * from './rm3';
2 |
--------------------------------------------------------------------------------
/src/plugins/picture-in-picture/index.ts:
--------------------------------------------------------------------------------
1 | import style from './style.css?inline';
2 | import { createPlugin } from '@/utils';
3 |
4 | import { onConfigChange, onMainLoad } from './main';
5 | import { onMenu } from './menu';
6 | import { onPlayerApiReady } from './renderer';
7 | import { t } from '@/i18n';
8 |
9 | export type PictureInPicturePluginConfig = {
10 | 'enabled': boolean;
11 | 'alwaysOnTop': boolean;
12 | 'savePosition': boolean;
13 | 'saveSize': boolean;
14 | 'hotkey': 'P';
15 | 'pip-position': [number, number];
16 | 'pip-size': [number, number];
17 | 'isInPiP': boolean;
18 | 'useNativePiP': boolean;
19 | };
20 |
21 | export default createPlugin({
22 | name: () => t('plugins.picture-in-picture.name'),
23 | description: () => t('plugins.picture-in-picture.description'),
24 | restartNeeded: true,
25 | config: {
26 | 'enabled': false,
27 | 'alwaysOnTop': true,
28 | 'savePosition': true,
29 | 'saveSize': false,
30 | 'hotkey': 'P',
31 | 'pip-position': [10, 10],
32 | 'pip-size': [450, 275],
33 | 'isInPiP': false,
34 | 'useNativePiP': true,
35 | } as PictureInPicturePluginConfig,
36 | stylesheets: [style],
37 | menu: onMenu,
38 |
39 | backend: {
40 | start: onMainLoad,
41 | onConfigChange,
42 | },
43 | renderer: {
44 | onPlayerApiReady,
45 | },
46 | });
47 |
--------------------------------------------------------------------------------
/src/plugins/picture-in-picture/keyboardevent-from-electron-accelerator.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'keyboardevent-from-electron-accelerator' {
2 | interface KeyboardEvent {
3 | key?: string;
4 | code?: string;
5 | metaKey?: boolean;
6 | altKey?: boolean;
7 | ctrlKey?: boolean;
8 | shiftKey?: boolean;
9 | }
10 |
11 | export const toKeyEvent: (accelerator: string) => KeyboardEvent;
12 | }
13 |
--------------------------------------------------------------------------------
/src/plugins/picture-in-picture/keyboardevents-areequal.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'keyboardevents-areequal' {
2 | interface KeyboardEvent {
3 | key?: string;
4 | code?: string;
5 | metaKey?: boolean;
6 | altKey?: boolean;
7 | ctrlKey?: boolean;
8 | shiftKey?: boolean;
9 | }
10 |
11 | const areEqual: (event1: KeyboardEvent, event2: KeyboardEvent) => boolean;
12 |
13 | export default areEqual;
14 | }
15 |
--------------------------------------------------------------------------------
/src/plugins/picture-in-picture/menu.ts:
--------------------------------------------------------------------------------
1 | import prompt from 'custom-electron-prompt';
2 |
3 | import promptOptions from '@/providers/prompt-options';
4 |
5 | import { t } from '@/i18n';
6 |
7 | import type { PictureInPicturePluginConfig } from './index';
8 |
9 | import type { MenuContext } from '@/types/contexts';
10 | import type { MenuTemplate } from '@/menu';
11 |
12 | export const onMenu = async ({
13 | window,
14 | getConfig,
15 | setConfig,
16 | }: MenuContext): Promise => {
17 | const config = await getConfig();
18 |
19 | return [
20 | {
21 | label: t('plugins.picture-in-picture.menu.always-on-top'),
22 | type: 'checkbox',
23 | checked: config.alwaysOnTop,
24 | click(item) {
25 | setConfig({ alwaysOnTop: item.checked });
26 | window.setAlwaysOnTop(item.checked);
27 | },
28 | },
29 | {
30 | label: t('plugins.picture-in-picture.menu.save-window-position'),
31 | type: 'checkbox',
32 | checked: config.savePosition,
33 | click(item) {
34 | setConfig({ savePosition: item.checked });
35 | },
36 | },
37 | {
38 | label: t('plugins.picture-in-picture.menu.save-window-size'),
39 | type: 'checkbox',
40 | checked: config.saveSize,
41 | click(item) {
42 | setConfig({ saveSize: item.checked });
43 | },
44 | },
45 | {
46 | label: t('plugins.picture-in-picture.menu.hotkey.label'),
47 | type: 'checkbox',
48 | checked: !!config.hotkey,
49 | async click(item) {
50 | const output = await prompt(
51 | {
52 | title: t('plugins.picture-in-picture.menu.prompt.title'),
53 | label: t('plugins.picture-in-picture.menu.prompt.label'),
54 | type: 'keybind',
55 | keybindOptions: [
56 | {
57 | value: 'hotkey',
58 | label: t(
59 | 'plugins.picture-in-picture.menu.prompt.keybind-options.hotkey',
60 | ),
61 | default: config.hotkey,
62 | },
63 | ],
64 | ...promptOptions(),
65 | },
66 | window,
67 | );
68 |
69 | if (output) {
70 | const { value, accelerator } = output[0];
71 | setConfig({ [value]: accelerator });
72 |
73 | item.checked = !!accelerator;
74 | } else {
75 | // Reset checkbox if prompt was canceled
76 | item.checked = !item.checked;
77 | }
78 | },
79 | },
80 | {
81 | label: t('plugins.picture-in-picture.menu.use-native-pip'),
82 | type: 'checkbox',
83 | checked: config.useNativePiP,
84 | click(item) {
85 | setConfig({ useNativePiP: item.checked });
86 | },
87 | },
88 | ];
89 | };
90 |
--------------------------------------------------------------------------------
/src/plugins/picture-in-picture/style.css:
--------------------------------------------------------------------------------
1 | /* improve visibility of the player bar elements */
2 | ytmusic-app-layout.pip ytmusic-player-bar svg,
3 | ytmusic-app-layout.pip ytmusic-player-bar .time-info,
4 | ytmusic-app-layout.pip ytmusic-player-bar yt-formatted-string,
5 | ytmusic-app-layout.pip ytmusic-player-bar .yt-formatted-string {
6 | filter: drop-shadow(2px 4px 6px black);
7 | color: white !important;
8 | fill: white !important;
9 | }
10 |
11 | /* improve the style of the player bar expanding menu */
12 | ytmusic-app-layout.pip ytmusic-player-expanding-menu {
13 | border-radius: 30px;
14 | background-color: rgba(0, 0, 0, 0.3);
15 | backdrop-filter: blur(5px) brightness(20%);
16 | }
17 |
18 | /* fix volumeHud position when both in-app-menu and PiP are active */
19 | .cet-container ytmusic-app-layout.pip #volumeHud {
20 | top: 22px !important;
21 | }
22 |
23 | /* make player-bar not draggable if in-app-menu is enabled */
24 | .cet-container ytmusic-app-layout.pip ytmusic-player-bar {
25 | -webkit-app-region: no-drag !important;
26 | }
27 |
28 | /* make player draggable if in-app-menu is enabled */
29 | .cet-container ytmusic-app-layout.pip #player {
30 | -webkit-app-region: drag !important;
31 | }
32 |
33 | /* remove info, thumbnail and menu from player-bar */
34 | ytmusic-app-layout.pip ytmusic-player-bar .content-info-wrapper,
35 | ytmusic-app-layout.pip ytmusic-player-bar .thumbnail-image-wrapper,
36 | ytmusic-app-layout.pip ytmusic-player-bar ytmusic-menu-renderer {
37 | display: none !important;
38 | }
39 |
40 | /* disable the video-toggle button when in PiP mode */
41 | ytmusic-app-layout.pip .video-switch-button {
42 | display: none !important;
43 | }
44 |
--------------------------------------------------------------------------------
/src/plugins/picture-in-picture/templates/picture-in-picture-button.tsx:
--------------------------------------------------------------------------------
1 | export interface PictureInPictureButtonProps {
2 | onClick?: (e: MouseEvent) => void;
3 | text: string;
4 | }
5 |
6 | export const PictureInPictureButton = (props: PictureInPictureButtonProps) => (
7 |
47 | );
48 |
--------------------------------------------------------------------------------
/src/plugins/playback-speed/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { onPlayerApiReady, onUnload } from './renderer';
3 | import { t } from '@/i18n';
4 |
5 | export default createPlugin({
6 | name: () => t('plugins.playback-speed.name'),
7 | description: () => t('plugins.playback-speed.description'),
8 | restartNeeded: false,
9 | config: {
10 | enabled: false,
11 | },
12 | renderer: {
13 | stop: onUnload,
14 | onPlayerApiReady,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/src/plugins/precise-volume/override.ts:
--------------------------------------------------------------------------------
1 | /* what */
2 |
3 | const ignored = {
4 | id: ['volume-slider', 'expand-volume-slider'],
5 | types: ['mousewheel', 'keydown', 'keyup'],
6 | } as const;
7 |
8 | function overrideAddEventListener() {
9 | // YO WHAT ARE YOU DOING NOW?!?!
10 | // Save native addEventListener
11 | // @ts-expect-error - We know what we're doing
12 | // eslint-disable-next-line @typescript-eslint/unbound-method
13 | Element.prototype._addEventListener = Element.prototype.addEventListener;
14 | // Override addEventListener to Ignore specific events in volume-slider
15 | Element.prototype.addEventListener = function (
16 | type: string,
17 | listener: (event: Event) => void,
18 | useCapture = false,
19 | ) {
20 | if (!(ignored.id.includes(this.id) && ignored.types.includes(type))) {
21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
22 | (this as any)._addEventListener(type, listener, useCapture);
23 | } else if (window.electronIs.dev()) {
24 | console.log(`Ignoring event: "${this.id}.${type}()"`);
25 | }
26 | };
27 | }
28 |
29 | export const overrideListener = () => {
30 | overrideAddEventListener();
31 | // Restore original function after finished loading to avoid keeping Element.prototype altered
32 | window.addEventListener(
33 | 'load',
34 | () => {
35 | /* eslint-disable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access */
36 | Element.prototype.addEventListener = (
37 | Element.prototype as any
38 | )._addEventListener;
39 | (Element.prototype as any)._addEventListener = undefined;
40 | /* eslint-enable @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access */
41 | },
42 | { once: true },
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/plugins/precise-volume/volume-hud.css:
--------------------------------------------------------------------------------
1 | #volumeHud {
2 | z-index: 999;
3 | position: absolute;
4 | transition: opacity 0.6s;
5 | pointer-events: none;
6 | padding: 10px;
7 |
8 | text-shadow: rgba(0, 0, 0, 0.5) 0px 0px 12px;
9 | }
10 |
11 | ytmusic-player[player-ui-state_='MINIPLAYER'] #volumeHud {
12 | top: 0 !important;
13 | }
14 |
--------------------------------------------------------------------------------
/src/plugins/quality-changer/templates/quality-setting-button.tsx:
--------------------------------------------------------------------------------
1 | export interface QualitySettingButtonProps {
2 | label: string;
3 | onClick: (event: MouseEvent) => void;
4 | }
5 |
6 | export const QualitySettingButton = (props: QualitySettingButtonProps) => (
7 | props.onClick(e)}
13 | role={'button'}
14 | tabindex={0}
15 | title={props.label}
16 | >
17 |
18 |
26 |
44 |
45 |
46 |
47 | );
48 |
--------------------------------------------------------------------------------
/src/plugins/scrobbler/services/base.ts:
--------------------------------------------------------------------------------
1 | import type { ScrobblerPluginConfig } from '../index';
2 | import type { SetConfType } from '../main';
3 | import type { SongInfo } from '@/providers/song-info';
4 |
5 | export abstract class ScrobblerBase {
6 | public abstract isSessionCreated(config: ScrobblerPluginConfig): boolean;
7 |
8 | public abstract createSession(
9 | config: ScrobblerPluginConfig,
10 | setConfig: SetConfType,
11 | ): Promise;
12 |
13 | public abstract setNowPlaying(
14 | songInfo: SongInfo,
15 | config: ScrobblerPluginConfig,
16 | setConfig: SetConfType,
17 | ): void;
18 |
19 | public abstract addScrobble(
20 | songInfo: SongInfo,
21 | config: ScrobblerPluginConfig,
22 | setConfig: SetConfType,
23 | ): void;
24 | }
25 |
--------------------------------------------------------------------------------
/src/plugins/shortcuts/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { onMainLoad } from './main';
3 | import { onMenu } from './menu';
4 | import { t } from '@/i18n';
5 |
6 | export type ShortcutMappingType = {
7 | previous: string;
8 | playPause: string;
9 | next: string;
10 | };
11 | export type ShortcutsPluginConfig = {
12 | enabled: boolean;
13 | overrideMediaKeys: boolean;
14 | global: ShortcutMappingType;
15 | local: ShortcutMappingType;
16 | };
17 |
18 | export default createPlugin({
19 | name: () => t('plugins.shortcuts.name'),
20 | description: () => t('plugins.shortcuts.description'),
21 | restartNeeded: true,
22 | config: {
23 | enabled: false,
24 | overrideMediaKeys: false,
25 | global: {
26 | previous: '',
27 | playPause: '',
28 | next: '',
29 | },
30 | local: {
31 | previous: '',
32 | playPause: '',
33 | next: '',
34 | },
35 | } as ShortcutsPluginConfig,
36 | menu: onMenu,
37 |
38 | backend: onMainLoad,
39 | });
40 |
--------------------------------------------------------------------------------
/src/plugins/shortcuts/menu.ts:
--------------------------------------------------------------------------------
1 | import prompt, { type KeybindOptions } from 'custom-electron-prompt';
2 |
3 | import promptOptions from '@/providers/prompt-options';
4 |
5 | import { t } from '@/i18n';
6 |
7 | import type { ShortcutsPluginConfig } from './index';
8 | import type { BrowserWindow } from 'electron';
9 | import type { MenuContext } from '@/types/contexts';
10 | import type { MenuTemplate } from '@/menu';
11 |
12 | export const onMenu = async ({
13 | window,
14 | getConfig,
15 | setConfig,
16 | }: MenuContext): Promise => {
17 | const config = await getConfig();
18 |
19 | /**
20 | * Helper function for keybind prompt
21 | */
22 | const kb = (
23 | label_: string,
24 | value_: string,
25 | default_?: string,
26 | ): KeybindOptions => ({ value: value_, label: label_, default: default_ });
27 |
28 | async function promptKeybind(
29 | config: ShortcutsPluginConfig,
30 | win: BrowserWindow,
31 | ) {
32 | const output = await prompt(
33 | {
34 | title: t('plugins.shortcuts.prompt.keybind.title'),
35 | label: t('plugins.shortcuts.prompt.keybind.label'),
36 | type: 'keybind',
37 | keybindOptions: [
38 | // If default=undefined then no default is used
39 | kb(
40 | t('plugins.shortcuts.prompt.keybind.keybind-options.previous'),
41 | 'previous',
42 | config.global?.previous,
43 | ),
44 | kb(
45 | t('plugins.shortcuts.prompt.keybind.keybind-options.play-pause'),
46 | 'playPause',
47 | config.global?.playPause,
48 | ),
49 | kb(
50 | t('plugins.shortcuts.prompt.keybind.keybind-options.next'),
51 | 'next',
52 | config.global?.next,
53 | ),
54 | ],
55 | height: 270,
56 | ...promptOptions(),
57 | },
58 | win,
59 | );
60 |
61 | if (output) {
62 | const newConfig = { ...config };
63 |
64 | for (const { value, accelerator } of output) {
65 | newConfig.global[value as keyof ShortcutsPluginConfig['global']] =
66 | accelerator;
67 | }
68 |
69 | setConfig(config);
70 | }
71 | // Else -> pressed cancel
72 | }
73 |
74 | return [
75 | {
76 | label: t('plugins.shortcuts.menu.set-keybinds'),
77 | click: () => promptKeybind(config, window),
78 | },
79 | {
80 | label: t('plugins.shortcuts.menu.override-media-keys'),
81 | type: 'checkbox',
82 | checked: config.overrideMediaKeys,
83 | click: (item) => setConfig({ overrideMediaKeys: item.checked }),
84 | },
85 | ];
86 | };
87 |
--------------------------------------------------------------------------------
/src/plugins/skip-disliked-songs/index.ts:
--------------------------------------------------------------------------------
1 | import { t } from '@/i18n';
2 | import { createPlugin } from '@/utils';
3 | import { waitForElement } from '@/utils/wait-for-element';
4 |
5 | export default createPlugin<
6 | unknown,
7 | unknown,
8 | {
9 | observer?: MutationObserver;
10 | start(): void;
11 | stop(): void;
12 | }
13 | >({
14 | name: () => t('plugins.skip-disliked-songs.name'),
15 | description: () => t('plugins.skip-disliked-songs.description'),
16 | restartNeeded: false,
17 | renderer: {
18 | start() {
19 | waitForElement('#like-button-renderer').then(
20 | (dislikeBtn) => {
21 | this.observer = new MutationObserver(() => {
22 | if (dislikeBtn?.getAttribute('like-status') == 'DISLIKE') {
23 | document
24 | .querySelector('yt-icon-button.next-button')
25 | ?.click();
26 | }
27 | });
28 | this.observer.observe(dislikeBtn, {
29 | attributes: true,
30 | childList: false,
31 | subtree: false,
32 | });
33 | },
34 | );
35 | },
36 | stop() {
37 | this.observer?.disconnect();
38 | },
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/src/plugins/skip-silences/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { onRendererLoad, onRendererUnload } from './renderer';
3 | import { t } from '@/i18n';
4 |
5 | export type SkipSilencesPluginConfig = {
6 | enabled: boolean;
7 | onlySkipBeginning: boolean;
8 | };
9 |
10 | export default createPlugin({
11 | name: () => t('plugins.skip-silences.name'),
12 | description: () => t('plugins.skip-silences.description'),
13 | restartNeeded: true,
14 | config: {
15 | enabled: false,
16 | onlySkipBeginning: false,
17 | } as SkipSilencesPluginConfig,
18 | renderer: {
19 | start: onRendererLoad,
20 | stop: onRendererUnload,
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/src/plugins/sponsorblock/segments.ts:
--------------------------------------------------------------------------------
1 | // Segments are an array [ [start, end], … ]
2 | import type { Segment } from './types';
3 |
4 | export const sortSegments = (segments: Segment[]) => {
5 | segments.sort((segment1, segment2) =>
6 | segment1[0] === segment2[0]
7 | ? segment1[1] - segment2[1]
8 | : segment1[0] - segment2[0],
9 | );
10 |
11 | const compiledSegments: Segment[] = [];
12 | let currentSegment: Segment | undefined;
13 |
14 | for (const segment of segments) {
15 | if (!currentSegment) {
16 | currentSegment = segment;
17 | continue;
18 | }
19 |
20 | if (currentSegment[1] < segment[0]) {
21 | compiledSegments.push(currentSegment);
22 | currentSegment = segment;
23 | continue;
24 | }
25 |
26 | currentSegment[1] = Math.max(currentSegment[1], segment[1]);
27 | }
28 |
29 | if (currentSegment) {
30 | compiledSegments.push(currentSegment);
31 | }
32 |
33 | return compiledSegments;
34 | };
35 |
--------------------------------------------------------------------------------
/src/plugins/sponsorblock/tests/segments.test.js:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 |
3 | import { sortSegments } from '../segments';
4 |
5 | test('Segment sorting', () => {
6 | expect(
7 | sortSegments([
8 | [0, 3],
9 | [7, 8],
10 | [5, 6],
11 | ]),
12 | ).toEqual([
13 | [0, 3],
14 | [5, 6],
15 | [7, 8],
16 | ]);
17 |
18 | expect(
19 | sortSegments([
20 | [0, 5],
21 | [6, 8],
22 | [4, 6],
23 | ]),
24 | ).toEqual([[0, 8]]);
25 |
26 | expect(
27 | sortSegments([
28 | [0, 6],
29 | [7, 8],
30 | [4, 6],
31 | ]),
32 | ).toEqual([
33 | [0, 6],
34 | [7, 8],
35 | ]);
36 | });
37 |
--------------------------------------------------------------------------------
/src/plugins/sponsorblock/types.ts:
--------------------------------------------------------------------------------
1 | export type Segment = [number, number];
2 |
3 | export interface SkipSegment {
4 | // Array of this object
5 | segment: Segment; //[0, 15.23] start and end time in seconds
6 | UUID: string;
7 | category: string; // [1]
8 | videoDuration: number; // Duration of video when submission occurred (to be used to determine when a submission is out of date). 0 when unknown. +- 1 second
9 | actionType: string; // [3]
10 | locked: number; // if submission is locked
11 | votes: number; // Votes on segment
12 | description: string; // title for chapters, empty string for other segments
13 | }
14 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/backend.ts:
--------------------------------------------------------------------------------
1 | import { net } from 'electron';
2 |
3 | import { createBackend } from '@/utils';
4 |
5 | const handlers = {
6 | // Note: This will only be used for Forbidden headers, e.g. User-Agent, Authority, Cookie, etc.
7 | // See: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header
8 | async fetch(
9 | url: string,
10 | init: RequestInit,
11 | ): Promise<[number, string, Record]> {
12 | const res = await net.fetch(url, init);
13 | return [
14 | res.status,
15 | await res.text(),
16 | Object.fromEntries(res.headers.entries()),
17 | ];
18 | },
19 | };
20 |
21 | export const backend = createBackend({
22 | start(ctx) {
23 | ctx.ipc.handle('synced-lyrics:fetch', (url: string, init: RequestInit) =>
24 | handlers.fetch(url, init),
25 | );
26 | },
27 | stop(ctx) {
28 | ctx.ipc.removeHandler('synced-lyrics:fetch');
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/index.ts:
--------------------------------------------------------------------------------
1 | import style from './style.css?inline';
2 | import { createPlugin } from '@/utils';
3 | import { t } from '@/i18n';
4 |
5 | import { menu } from './menu';
6 | import { renderer } from './renderer';
7 | import { backend } from './backend';
8 |
9 | import type { SyncedLyricsPluginConfig } from './types';
10 |
11 | export default createPlugin({
12 | name: () => t('plugins.synced-lyrics.name'),
13 | description: () => t('plugins.synced-lyrics.description'),
14 | authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer', 'Strvm'],
15 | restartNeeded: true,
16 | addedVersion: '3.5.X',
17 | config: {
18 | enabled: false,
19 | preciseTiming: true,
20 | showLyricsEvenIfInexact: true,
21 | showTimeCodes: false,
22 | defaultTextString: '♪',
23 | lineEffect: 'fancy',
24 | romanization: true,
25 | } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig,
26 |
27 | menu,
28 | renderer,
29 | backend,
30 | stylesheets: [style],
31 | });
32 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/parsers/lrc.ts:
--------------------------------------------------------------------------------
1 | interface LRCTag {
2 | tag: string;
3 | value: string;
4 | }
5 |
6 | interface LRCLine {
7 | time: string;
8 | timeInMs: number;
9 | duration: number;
10 | text: string;
11 | }
12 |
13 | interface LRC {
14 | tags: LRCTag[];
15 | lines: LRCLine[];
16 | }
17 |
18 | const tagRegex = /^\[(?\w+):\s*(?.+?)\s*\]$/;
19 | // prettier-ignore
20 | const lyricRegex = /^\[(?\d+):(?\d+)\.(?\d+)\](?.+)$/;
21 |
22 | export const LRC = {
23 | parse: (text: string): LRC => {
24 | const lrc: LRC = {
25 | tags: [],
26 | lines: [],
27 | };
28 |
29 | let offset = 0;
30 | let previousLine: LRCLine | null = null;
31 |
32 | for (const line of text.split('\n')) {
33 | if (!line.trim().startsWith('[')) continue;
34 |
35 | const lyric = line.match(lyricRegex)?.groups;
36 | if (!lyric) {
37 | const tag = line.match(tagRegex)?.groups;
38 | if (tag) {
39 | if (tag.tag === 'offset') {
40 | offset = parseInt(tag.value);
41 | continue;
42 | }
43 |
44 | lrc.tags.push({
45 | tag: tag.tag,
46 | value: tag.value,
47 | });
48 | }
49 | continue;
50 | }
51 |
52 | const { minutes, seconds, milliseconds, text } = lyric;
53 | const timeInMs =
54 | parseInt(minutes) * 60 * 1000 +
55 | parseInt(seconds) * 1000 +
56 | parseInt(milliseconds);
57 |
58 | const currentLine: LRCLine = {
59 | time: `${minutes}:${seconds}:${milliseconds}`,
60 | timeInMs,
61 | text: text.trim(),
62 | duration: Infinity,
63 | };
64 |
65 | if (previousLine) {
66 | previousLine.duration = timeInMs - previousLine.timeInMs;
67 | }
68 |
69 | previousLine = currentLine;
70 | lrc.lines.push(currentLine);
71 | }
72 |
73 | for (const line of lrc.lines) {
74 | line.timeInMs += offset;
75 | }
76 |
77 | const first = lrc.lines.at(0);
78 | if (first && first.timeInMs > 300) {
79 | lrc.lines.unshift({
80 | time: '0:0:0',
81 | timeInMs: 0,
82 | duration: first.timeInMs,
83 | text: '',
84 | });
85 | }
86 |
87 | return lrc;
88 | },
89 | };
90 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/providers/index.ts:
--------------------------------------------------------------------------------
1 | import * as z from 'zod';
2 |
3 | import type { LyricResult } from '../types';
4 |
5 | export enum ProviderNames {
6 | YTMusic = 'YTMusic',
7 | LRCLib = 'LRCLib',
8 | MusixMatch = 'MusixMatch',
9 | LyricsGenius = 'LyricsGenius',
10 | // Megalobiz = 'Megalobiz',
11 | }
12 |
13 | export const ProviderNameSchema = z.enum(ProviderNames);
14 | export type ProviderName = z.infer;
15 | export const providerNames = ProviderNameSchema.options;
16 |
17 | export type ProviderState = {
18 | state: 'fetching' | 'done' | 'error';
19 | data: LyricResult | null;
20 | error: Error | null;
21 | };
22 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/providers/renderer.ts:
--------------------------------------------------------------------------------
1 | import { ProviderNames } from './index';
2 | import { YTMusic } from './YTMusic';
3 | import { LRCLib } from './LRCLib';
4 | import { MusixMatch } from './MusixMatch';
5 | import { LyricsGenius } from './LyricsGenius';
6 |
7 | export const providers = {
8 | [ProviderNames.YTMusic]: new YTMusic(),
9 | [ProviderNames.LRCLib]: new LRCLib(),
10 | [ProviderNames.MusixMatch]: new MusixMatch(),
11 | [ProviderNames.LyricsGenius]: new LyricsGenius(),
12 | // [ProviderNames.Megalobiz]: new Megalobiz(), // Disabled because it is too unstable and slow
13 | } as const;
14 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/renderer/components/ErrorDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { t } from '@/i18n';
2 |
3 | import { getSongInfo } from '@/providers/song-info-front';
4 |
5 | import { lyricsStore, retrySearch } from '../store';
6 |
7 | interface ErrorDisplayProps {
8 | error: Error;
9 | }
10 |
11 | // prettier-ignore
12 | export const ErrorDisplay = (props: ErrorDisplayProps) => {
13 | return (
14 |
15 |
28 | {t('plugins.synced-lyrics.errors.fetch')}
29 |
30 |
44 | {props.error.stack}
45 |
46 |
47 |
retrySearch(lyricsStore.provider, getSongInfo())}
57 | style={{
58 | 'margin-top': '1em',
59 | 'width': '100%'
60 | }}
61 | />
62 |
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/renderer/components/LoadingKaomoji.tsx:
--------------------------------------------------------------------------------
1 | import { createSignal, onMount } from 'solid-js';
2 |
3 | const states = [
4 | '(>_<)',
5 | '{ (>_<) }',
6 | '{{ (>_<) }}',
7 | '{{{ (>_<) }}}',
8 | '{{ (>_<) }}',
9 | '{ (>_<) }',
10 | ];
11 | export const LoadingKaomoji = () => {
12 | const [counter, setCounter] = createSignal(0);
13 |
14 | onMount(() => {
15 | const interval = setInterval(() => setCounter((old) => old + 1), 500);
16 | return () => clearInterval(interval);
17 | });
18 |
19 | return (
20 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/renderer/components/NotFoundKaomoji.tsx:
--------------------------------------------------------------------------------
1 | export const NotFoundKaomoji = () => {
2 | return (
3 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/renderer/components/PlainLyrics.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, createSignal, Show } from 'solid-js';
2 |
3 | import { canonicalize, romanize, simplifyUnicode } from '../utils';
4 | import { config } from '../renderer';
5 |
6 | interface PlainLyricsProps {
7 | line: string;
8 | }
9 |
10 | export const PlainLyrics = (props: PlainLyricsProps) => {
11 | const [romanization, setRomanization] = createSignal('');
12 |
13 | createEffect(() => {
14 | if (!config()?.romanization) return;
15 |
16 | const input = canonicalize(props.line);
17 | romanize(input).then((result) => {
18 | setRomanization(canonicalize(result));
19 | });
20 | });
21 |
22 | return (
23 |
32 |
37 |
43 |
49 |
50 |
51 | );
52 | };
53 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/renderer/components/index.ts:
--------------------------------------------------------------------------------
1 | export { ErrorDisplay } from './ErrorDisplay';
2 | export { LoadingKaomoji } from './LoadingKaomoji';
3 | export { NotFoundKaomoji } from './NotFoundKaomoji';
4 | export { LyricsPicker } from './LyricsPicker';
5 | export { SyncedLine } from './SyncedLine';
6 | export { PlainLyrics } from './PlainLyrics';
7 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/renderer/index.ts:
--------------------------------------------------------------------------------
1 | import { createRenderer } from '@/utils';
2 | import { waitForElement } from '@/utils/wait-for-element';
3 |
4 | import { selectors, tabStates } from './utils';
5 | import { setConfig, setCurrentTime } from './renderer';
6 | import { fetchLyrics } from './store';
7 |
8 | import type { RendererContext } from '@/types/contexts';
9 | import type { YoutubePlayer } from '@/types/youtube-player';
10 | import type { SongInfo } from '@/providers/song-info';
11 | import type { SyncedLyricsPluginConfig } from '../types';
12 |
13 | export let _ytAPI: YoutubePlayer | null = null;
14 | export let netFetch: (
15 | url: string,
16 | init?: RequestInit,
17 | ) => Promise<[number, string, Record]>;
18 |
19 | export const renderer = createRenderer<
20 | {
21 | observerCallback: MutationCallback;
22 | observer?: MutationObserver;
23 | videoDataChange: () => Promise;
24 | updateTimestampInterval?: NodeJS.Timeout | string | number;
25 | },
26 | SyncedLyricsPluginConfig
27 | >({
28 | onConfigChange(newConfig) {
29 | setConfig(newConfig);
30 | },
31 |
32 | observerCallback(mutations: MutationRecord[]) {
33 | for (const mutation of mutations) {
34 | const header = mutation.target as HTMLElement;
35 |
36 | switch (mutation.attributeName) {
37 | case 'disabled':
38 | header.removeAttribute('disabled');
39 | break;
40 | case 'aria-selected':
41 | tabStates[header.ariaSelected ?? 'false']();
42 | break;
43 | }
44 | }
45 | },
46 |
47 | async onPlayerApiReady(api: YoutubePlayer) {
48 | _ytAPI = api;
49 |
50 | api.addEventListener('videodatachange', this.videoDataChange);
51 |
52 | await this.videoDataChange();
53 | },
54 | async videoDataChange() {
55 | if (!this.updateTimestampInterval) {
56 | this.updateTimestampInterval = setInterval(
57 | () => setCurrentTime((_ytAPI?.getCurrentTime() ?? 0) * 1000),
58 | 100,
59 | );
60 | }
61 |
62 | // prettier-ignore
63 | this.observer ??= new MutationObserver(this.observerCallback);
64 | this.observer.disconnect();
65 |
66 | // Force the lyrics tab to be enabled at all times.
67 | const header = await waitForElement(selectors.head);
68 | {
69 | header.removeAttribute('disabled');
70 | tabStates[header.ariaSelected ?? 'false']();
71 | }
72 |
73 | this.observer.observe(header, { attributes: true });
74 | header.removeAttribute('disabled');
75 | },
76 |
77 | async start(ctx: RendererContext) {
78 | netFetch = ctx.ipc.invoke.bind(ctx.ipc, 'synced-lyrics:fetch');
79 |
80 | setConfig(await ctx.getConfig());
81 |
82 | ctx.ipc.on('ytmd:update-song-info', (info: SongInfo) => {
83 | fetchLyrics(info);
84 | });
85 | },
86 | });
87 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/types.ts:
--------------------------------------------------------------------------------
1 | import type { SongInfo } from '@/providers/song-info';
2 | import type { ProviderName } from './providers';
3 |
4 | export type SyncedLyricsPluginConfig = {
5 | enabled: boolean;
6 | preferredProvider?: ProviderName;
7 | preciseTiming: boolean;
8 | showTimeCodes: boolean;
9 | defaultTextString: string | string[];
10 | showLyricsEvenIfInexact: boolean;
11 | lineEffect: LineEffect;
12 | romanization: boolean;
13 | };
14 |
15 | export type LineLyricsStatus = 'previous' | 'current' | 'upcoming';
16 |
17 | export type LineLyrics = {
18 | time: string;
19 | timeInMs: number;
20 | duration: number;
21 |
22 | text: string;
23 | status: LineLyricsStatus;
24 | };
25 |
26 | export type LineEffect = 'fancy' | 'scale' | 'offset' | 'focus';
27 |
28 | export interface LyricResult {
29 | title: string;
30 | artists: string[];
31 |
32 | lyrics?: string;
33 | lines?: LineLyrics[];
34 | }
35 |
36 | // prettier-ignore
37 | export type SearchSongInfo = Pick;
38 |
39 | export interface LyricProvider {
40 | name: string;
41 | baseUrl: string;
42 |
43 | search(songInfo: SearchSongInfo): Promise;
44 | }
45 |
--------------------------------------------------------------------------------
/src/plugins/transparent-player/types.ts:
--------------------------------------------------------------------------------
1 | export enum MaterialType {
2 | MICA = 'mica',
3 | ACRYLIC = 'acrylic',
4 | TABBED = 'tabbed',
5 | NONE = 'none',
6 | }
7 |
8 | export type TransparentPlayerConfig = {
9 | enabled: boolean;
10 | opacity: number;
11 | type: MaterialType;
12 | };
13 |
--------------------------------------------------------------------------------
/src/plugins/unobtrusive-player/style.css:
--------------------------------------------------------------------------------
1 | body.unobtrusive-player.unobtrusive-player--did-play {
2 | overflow: visible !important;
3 |
4 | & ytmusic-player-page,
5 | ytmusic-player-page * {
6 | visibility: hidden !important;
7 | }
8 |
9 | & #content {
10 | visibility: visible !important;
11 | }
12 |
13 | &
14 | ytmusic-app-layout:not(.content-scrolled)
15 | #nav-bar-background.ytmusic-app-layout,
16 | ytmusic-app-layout:not(.content-scrolled) #nav-bar-divider.ytmusic-app-layout,
17 | ytmusic-app-layout[is-bauhaus-sidenav-enabled][player-page-open]:not(
18 | .content-scrolled
19 | )
20 | #mini-guide-background.ytmusic-app-layout {
21 | opacity: 0 !important;
22 | }
23 |
24 | & .toggle-player-page-button {
25 | transform: rotate(180deg) !important;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/plugins/utils/common/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types';
2 |
--------------------------------------------------------------------------------
/src/plugins/utils/common/types.ts:
--------------------------------------------------------------------------------
1 | import type { BrowserWindow } from 'electron';
2 |
3 | export interface Config {
4 | enabled: boolean;
5 | }
6 |
7 | export interface Plugin {
8 | name: string;
9 | description: string;
10 | config: ConfigType;
11 | }
12 |
13 | export interface RendererPlugin
14 | extends Plugin {
15 | onEnable: (config: ConfigType) => void;
16 | }
17 |
18 | export interface MainPlugin
19 | extends Plugin {
20 | onEnable: (window: BrowserWindow, config: ConfigType) => string;
21 | }
22 |
23 | export interface PreloadPlugin
24 | extends Plugin {
25 | onEnable: (config: ConfigType) => void;
26 | }
27 |
28 | export interface MenuPlugin
29 | extends Plugin {
30 | onEnable: (config: ConfigType) => void;
31 | }
32 |
33 | const defaultPluginConfig: Record = {};
34 | export const definePluginConfig = (id: string, defaultValue: T): T => {
35 | defaultPluginConfig[id] = defaultValue;
36 |
37 | return defaultValue;
38 | };
39 |
--------------------------------------------------------------------------------
/src/plugins/utils/main/css.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 |
3 | type Unregister = () => void;
4 |
5 | let isLoaded = false;
6 |
7 | const cssToInject = new Map<
8 | string,
9 | ((unregister: Unregister) => void) | undefined
10 | >();
11 | const cssToInjectFile = new Map<
12 | string,
13 | ((unregister: Unregister) => void) | undefined
14 | >();
15 | export const injectCSS = async (
16 | webContents: Electron.WebContents,
17 | css: string,
18 | ): Promise => {
19 | if (isLoaded) {
20 | const key = await webContents.insertCSS(css);
21 | return async () => await webContents.removeInsertedCSS(key);
22 | }
23 |
24 | return new Promise((resolve) => {
25 | if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
26 | setupCssInjection(webContents);
27 | }
28 | cssToInject.set(css, resolve);
29 | });
30 | };
31 |
32 | export const injectCSSAsFile = async (
33 | webContents: Electron.WebContents,
34 | filepath: string,
35 | ): Promise => {
36 | if (isLoaded) {
37 | const key = await webContents.insertCSS(fs.readFileSync(filepath, 'utf-8'));
38 | return async () => await webContents.removeInsertedCSS(key);
39 | }
40 |
41 | return new Promise((resolve) => {
42 | if (cssToInject.size === 0 && cssToInjectFile.size === 0) {
43 | setupCssInjection(webContents);
44 | }
45 |
46 | cssToInjectFile.set(filepath, resolve);
47 | });
48 | };
49 |
50 | const setupCssInjection = (webContents: Electron.WebContents) => {
51 | webContents.on('did-finish-load', () => {
52 | isLoaded = true;
53 |
54 | cssToInject.forEach(async (callback, css) => {
55 | const key = await webContents.insertCSS(css);
56 | const remove = async () => await webContents.removeInsertedCSS(key);
57 |
58 | callback?.(remove);
59 | });
60 |
61 | cssToInjectFile.forEach(async (callback, filepath) => {
62 | const key = await webContents.insertCSS(
63 | fs.readFileSync(filepath, 'utf-8'),
64 | );
65 | const remove = async () => await webContents.removeInsertedCSS(key);
66 |
67 | callback?.(remove);
68 | });
69 | });
70 | };
71 |
--------------------------------------------------------------------------------
/src/plugins/utils/main/fetch.ts:
--------------------------------------------------------------------------------
1 | import { net } from 'electron';
2 |
3 | export const getNetFetchAsFetch = () =>
4 | (async (input: RequestInfo | URL, init?: RequestInit) => {
5 | const url =
6 | typeof input === 'string'
7 | ? new URL(input)
8 | : input instanceof URL
9 | ? input
10 | : new URL(input.url);
11 |
12 | if (init?.body && !init.method) {
13 | init.method = 'POST';
14 | }
15 |
16 | const request = new Request(
17 | url,
18 | input instanceof Request ? input : undefined,
19 | );
20 |
21 | return net.fetch(request, init);
22 | }) as typeof fetch;
23 |
--------------------------------------------------------------------------------
/src/plugins/utils/main/fs.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 |
3 | export const fileExists = (
4 | path: fs.PathLike,
5 | callbackIfExists: { (): void; (): void; (): void },
6 | callbackIfError: (() => void) | undefined = undefined,
7 | ) => {
8 | fs.access(path, fs.constants.F_OK, (error) => {
9 | if (error) {
10 | callbackIfError?.();
11 |
12 | return;
13 | }
14 |
15 | callbackIfExists();
16 | });
17 | };
18 |
--------------------------------------------------------------------------------
/src/plugins/utils/main/index.ts:
--------------------------------------------------------------------------------
1 | export * from './css';
2 | export * from './fs';
3 | export * from './types';
4 | export * from './fetch';
5 |
--------------------------------------------------------------------------------
/src/plugins/utils/main/types.ts:
--------------------------------------------------------------------------------
1 | import type { Config, MainPlugin, MenuPlugin, PreloadPlugin } from '../common';
2 |
3 | export const defineMainPlugin = (
4 | plugin: MainPlugin,
5 | ) => plugin;
6 |
7 | export const definePreloadPlugin = (
8 | plugin: PreloadPlugin,
9 | ) => plugin;
10 |
11 | export const defineMenuPlugin = (
12 | plugin: MenuPlugin,
13 | ) => plugin;
14 |
--------------------------------------------------------------------------------
/src/plugins/utils/renderer/check.ts:
--------------------------------------------------------------------------------
1 | export const isMusicOrVideoTrack = () => {
2 | for (const menuSelector of document.querySelectorAll<
3 | HTMLAnchorElement & {
4 | data: {
5 | watchEndpoint: {
6 | videoId: string;
7 | };
8 | addToPlaylistEndpoint: {
9 | videoId: string;
10 | };
11 | clickTrackingParams: string;
12 | };
13 | }
14 | >('tp-yt-paper-listbox #navigation-endpoint')) {
15 | if (
16 | menuSelector?.data?.addToPlaylistEndpoint?.videoId ||
17 | menuSelector?.data?.watchEndpoint?.videoId
18 | ) {
19 | return true;
20 | }
21 | }
22 | return false;
23 | };
24 |
25 | export const isAlbumOrPlaylist = () => {
26 | for (const menuSelector of document.querySelectorAll<
27 | HTMLAnchorElement & {
28 | data: {
29 | addToPlaylistEndpoint: {
30 | playlistId: string;
31 | };
32 | clickTrackingParams: string;
33 | };
34 | }
35 | >('tp-yt-paper-listbox #navigation-endpoint')) {
36 | if (menuSelector?.data?.addToPlaylistEndpoint?.playlistId) {
37 | return true;
38 | }
39 | }
40 | return false;
41 | };
42 |
43 | export const isPlayerMenu = (menu?: HTMLElement | null) => {
44 | return (
45 | menu?.parentElement as
46 | | (HTMLElement & {
47 | ytEventForwardingBehavior: {
48 | forwarder_: {
49 | eventSink: HTMLElement;
50 | };
51 | };
52 | })
53 | | null
54 | )?.ytEventForwardingBehavior?.forwarder_?.eventSink?.matches(
55 | 'ytmusic-menu-renderer.ytmusic-player-bar',
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/plugins/utils/renderer/html.ts:
--------------------------------------------------------------------------------
1 | const domParser = new DOMParser();
2 |
3 | /**
4 | * Creates a DOM element from an HTML string
5 | * @param html The HTML string
6 | * @returns The DOM element
7 | */
8 | export const ElementFromHtml = (html: string): HTMLElement => {
9 | return (domParser.parseFromString(html, 'text/html') as HTMLDocument).body
10 | .firstElementChild as HTMLElement;
11 | };
12 |
13 | /**
14 | * Creates a DOM element from a src string
15 | * @param src The source of the image
16 | * @returns The image element
17 | */
18 | export const ImageElementFromSrc = (src: string): HTMLImageElement => {
19 | const image = document.createElement('img');
20 | image.src = src;
21 | return image;
22 | };
23 |
--------------------------------------------------------------------------------
/src/plugins/utils/renderer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './html';
2 |
--------------------------------------------------------------------------------
/src/plugins/video-toggle/button-switcher.css:
--------------------------------------------------------------------------------
1 | .video-toggle-custom-mode #main-panel.ytmusic-player-page {
2 | align-items: unset !important;
3 | }
4 |
5 | .video-toggle-custom-mode #main-panel {
6 | position: relative;
7 | }
8 |
9 | .video-toggle-custom-mode .video-switch-button {
10 | z-index: 999;
11 | box-sizing: border-box;
12 | padding: 0;
13 | margin-top: 20px;
14 | margin-left: 10px;
15 | background: rgba(33, 33, 33, 0.4);
16 | border-radius: 30px;
17 | overflow: hidden;
18 | width: 20rem;
19 | text-align: center;
20 | font-size: 18px;
21 | letter-spacing: 1px;
22 | color: #fff;
23 | padding-right: 10rem;
24 | position: absolute;
25 | }
26 |
27 | .video-toggle-custom-mode .video-switch-button:before {
28 | content: attr(data-video-button-text);
29 | position: absolute;
30 | top: 0;
31 | bottom: 0;
32 | right: 0;
33 | width: 10rem;
34 | display: flex;
35 | align-items: center;
36 | justify-content: center;
37 | z-index: 3;
38 | pointer-events: none;
39 | }
40 |
41 | .video-toggle-custom-mode .video-switch-button-checkbox {
42 | cursor: pointer;
43 | position: absolute;
44 | top: 0;
45 | left: 0;
46 | bottom: 0;
47 | width: 100%;
48 | height: 100%;
49 | opacity: 0;
50 | z-index: 2;
51 | }
52 |
53 | .video-toggle-custom-mode .video-switch-button-label-span {
54 | position: relative;
55 | }
56 |
57 | .video-toggle-custom-mode
58 | .video-switch-button-checkbox:checked
59 | + .video-switch-button-label:before {
60 | transform: translateX(10rem);
61 | transition: transform 300ms linear;
62 | }
63 |
64 | .video-toggle-custom-mode
65 | .video-switch-button-checkbox
66 | + .video-switch-button-label {
67 | position: relative;
68 | padding: 15px 0;
69 | display: block;
70 | user-select: none;
71 | pointer-events: none;
72 | }
73 |
74 | .video-toggle-custom-mode
75 | .video-switch-button-checkbox
76 | + .video-switch-button-label:before {
77 | content: '';
78 | background: rgba(60, 60, 60, 0.4);
79 | height: 100%;
80 | width: 100%;
81 | position: absolute;
82 | left: 0;
83 | top: 0;
84 | border-radius: 30px;
85 | transform: translateX(0);
86 | transition: transform 300ms;
87 | }
88 |
89 | /* disable the native toggler */
90 | .video-toggle-custom-mode #av-id {
91 | display: none;
92 | }
93 |
--------------------------------------------------------------------------------
/src/plugins/video-toggle/force-hide.css:
--------------------------------------------------------------------------------
1 | /* Hide the video player */
2 | .video-toggle-force-hide #main-panel {
3 | display: none !important;
4 | }
5 |
6 | /* Make the side-panel full width */
7 | .video-toggle-force-hide .side-panel.ytmusic-player-page {
8 | max-width: 100% !important;
9 | width: 100% !important;
10 | margin: 0 !important;
11 | }
12 |
--------------------------------------------------------------------------------
/src/plugins/video-toggle/templates/video-switch-button.tsx:
--------------------------------------------------------------------------------
1 | export interface VideoSwitchButtonProps {
2 | onClick?: (event: MouseEvent) => void;
3 | onChange?: (event: Event) => void;
4 | songButtonText: string;
5 | videoButtonText: string;
6 | }
7 |
8 | export const VideoSwitchButton = (props: VideoSwitchButtonProps) => (
9 | props.onClick?.(e)}
13 | onChange={(e) => props.onChange?.(e)}
14 | >
15 |
21 |
27 |
28 | );
29 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/butterchurn.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'butterchurn' {
2 | interface VisualizerOptions {
3 | width?: number;
4 | height?: number;
5 | meshWidth?: number;
6 | meshHeight?: number;
7 | pixelRatio?: number;
8 | textureRatio?: number;
9 | outputFXAA?: boolean;
10 | }
11 |
12 | class Visualizer {
13 | constructor(
14 | audioContext: AudioContext,
15 | canvas: HTMLCanvasElement,
16 | opts: ButterchurnOptions,
17 | );
18 | loseGLContext(): void;
19 | connectAudio(audioNode: AudioNode): void;
20 | disconnectAudio(audioNode: AudioNode): void;
21 | static overrideDefaultVars(
22 | baseValsDefaults: unknown,
23 | baseVals: unknown,
24 | ): unknown;
25 | createQVars(): Record;
26 | createTVars(): Record;
27 | createPerFramePool(baseVals: unknown): Record;
28 | createPerPixelPool(baseVals: unknown): Record;
29 | createCustomShapePerFramePool(
30 | baseVals: unknown,
31 | ): Record;
32 | createCustomWavePerFramePool(
33 | baseVals: unknown,
34 | ): Record;
35 | static makeShapeResetPool(
36 | pool: Record,
37 | variables: string[],
38 | idx: number,
39 | ): Record;
40 | static base64ToArrayBuffer(base64: string): ArrayBuffer;
41 | loadPreset(presetMap: unknown, blendTime?: number): Promise;
42 | async loadWASMPreset(preset: unknown, blendTime: number): Promise;
43 | loadJSPreset(preset: unknown, blendTime: number): void;
44 | loadExtraImages(imageData: unknown): void;
45 | setRendererSize(
46 | width: number,
47 | height: number,
48 | opts?: VisualizerOptions,
49 | ): void;
50 | setInternalMeshSize(width: number, height: number): void;
51 | setOutputAA(useAA: boolean): void;
52 | setCanvas(canvas: HTMLCanvasElement): void;
53 | render(opts?: VisualizerOptions): unknown;
54 | launchSongTitleAnim(text: string): void;
55 | toDataURL(): string;
56 | warpBufferToDataURL(): string;
57 | }
58 |
59 | interface ButterchurnOptions {
60 | width?: number;
61 | height?: number;
62 | onlyUseWASM?: boolean;
63 | }
64 |
65 | export default class Butterchurn {
66 | static createVisualizer(
67 | audioContext: AudioContext,
68 | canvas: HTMLCanvasElement,
69 | options?: ButterchurnOptions,
70 | ): Visualizer;
71 | }
72 | }
73 |
74 | declare module 'butterchurn-presets' {
75 | const presets: Record;
76 |
77 | export default presets;
78 | }
79 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/empty-player.css:
--------------------------------------------------------------------------------
1 | #visualizer {
2 | position: absolute;
3 | z-index: 1;
4 | background-color: black;
5 | }
6 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/visualizers/butterchurn.ts:
--------------------------------------------------------------------------------
1 | import Butterchurn from 'butterchurn';
2 | import ButterchurnPresets from 'butterchurn-presets';
3 |
4 | import { Visualizer } from './visualizer';
5 |
6 | import type { VisualizerPluginConfig } from '../index';
7 |
8 | class ButterchurnVisualizer extends Visualizer {
9 | name = 'butterchurn';
10 |
11 | visualizer: ReturnType;
12 | private readonly renderingFrequencyInMs: number;
13 |
14 | constructor(
15 | audioContext: AudioContext,
16 | audioSource: MediaElementAudioSourceNode,
17 | visualizerContainer: HTMLElement,
18 | canvas: HTMLCanvasElement,
19 | audioNode: GainNode,
20 | stream: MediaStream,
21 | options: VisualizerPluginConfig,
22 | ) {
23 | super(
24 | audioContext,
25 | audioSource,
26 | visualizerContainer,
27 | canvas,
28 | audioNode,
29 | stream,
30 | options,
31 | );
32 |
33 | this.visualizer = Butterchurn.createVisualizer(audioContext, canvas, {
34 | width: canvas.width,
35 | height: canvas.height,
36 | });
37 |
38 | const preset = ButterchurnPresets[options.butterchurn.preset];
39 | this.visualizer.loadPreset(preset, options.butterchurn.blendTimeInSeconds);
40 |
41 | this.visualizer.connectAudio(audioNode);
42 |
43 | this.renderingFrequencyInMs = options.butterchurn.renderingFrequencyInMs;
44 | }
45 |
46 | resize(width: number, height: number) {
47 | this.visualizer.setRendererSize(width, height);
48 | }
49 |
50 | render() {
51 | const renderVisualizer = () => {
52 | requestAnimationFrame(renderVisualizer);
53 | this.visualizer.render();
54 | };
55 | setTimeout(renderVisualizer, this.renderingFrequencyInMs);
56 | }
57 | }
58 |
59 | export default ButterchurnVisualizer;
60 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/visualizers/index.ts:
--------------------------------------------------------------------------------
1 | import ButterchurnVisualizer from './butterchurn';
2 | import VudioVisualizer from './vudio';
3 | import WaveVisualizer from './wave';
4 |
5 | export { ButterchurnVisualizer, VudioVisualizer, WaveVisualizer };
6 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/visualizers/visualizer.ts:
--------------------------------------------------------------------------------
1 | import type { VisualizerPluginConfig } from '../index';
2 |
3 | export abstract class Visualizer {
4 | /**
5 | * The name must be the same as the file name.
6 | */
7 | abstract name: string;
8 | abstract visualizer: T;
9 |
10 | protected constructor(
11 | _audioContext: AudioContext,
12 | _audioSource: MediaElementAudioSourceNode,
13 | _visualizerContainer: HTMLElement,
14 | _canvas: HTMLCanvasElement,
15 | _audioNode: GainNode,
16 | _stream: MediaStream,
17 | _options: VisualizerPluginConfig,
18 | ) {}
19 |
20 | abstract resize(width: number, height: number): void;
21 | abstract render(): void;
22 | }
23 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/visualizers/vudio.ts:
--------------------------------------------------------------------------------
1 | import Vudio from 'vudio/umd/vudio';
2 |
3 | import { Visualizer } from './visualizer';
4 |
5 | import type { VisualizerPluginConfig } from '../index';
6 |
7 | class VudioVisualizer extends Visualizer {
8 | name = 'vudio';
9 |
10 | visualizer: Vudio;
11 |
12 | constructor(
13 | audioContext: AudioContext,
14 | audioSource: MediaElementAudioSourceNode,
15 | visualizerContainer: HTMLElement,
16 | canvas: HTMLCanvasElement,
17 | audioNode: GainNode,
18 | stream: MediaStream,
19 | options: VisualizerPluginConfig,
20 | ) {
21 | super(
22 | audioContext,
23 | audioSource,
24 | visualizerContainer,
25 | canvas,
26 | audioNode,
27 | stream,
28 | options,
29 | );
30 |
31 | this.visualizer = new Vudio(stream, canvas, {
32 | width: canvas.width,
33 | height: canvas.height,
34 | // Visualizer config
35 | ...options,
36 | });
37 |
38 | this.visualizer.dance();
39 | }
40 |
41 | resize(width: number, height: number) {
42 | this.visualizer.setOption({
43 | width,
44 | height,
45 | });
46 | }
47 |
48 | render() {}
49 | }
50 |
51 | export default VudioVisualizer;
52 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/visualizers/wave.ts:
--------------------------------------------------------------------------------
1 | import { Wave } from '@foobar404/wave';
2 |
3 | import { Visualizer } from './visualizer';
4 |
5 | import type { VisualizerPluginConfig } from '../index';
6 |
7 | class WaveVisualizer extends Visualizer {
8 | name = 'wave';
9 |
10 | visualizer: Wave;
11 |
12 | constructor(
13 | audioContext: AudioContext,
14 | audioSource: MediaElementAudioSourceNode,
15 | visualizerContainer: HTMLElement,
16 | canvas: HTMLCanvasElement,
17 | audioNode: GainNode,
18 | stream: MediaStream,
19 | options: VisualizerPluginConfig,
20 | ) {
21 | super(
22 | audioContext,
23 | audioSource,
24 | visualizerContainer,
25 | canvas,
26 | audioNode,
27 | stream,
28 | options,
29 | );
30 |
31 | this.visualizer = new Wave(
32 | { context: audioContext, source: audioSource },
33 | canvas,
34 | );
35 | for (const animation of options.wave.animations) {
36 | const TargetVisualizer =
37 | this.visualizer.animations[
38 | animation.type as keyof typeof this.visualizer.animations
39 | ];
40 |
41 | this.visualizer.addAnimation(
42 | new TargetVisualizer(animation.config as never), // Magic of Typescript
43 | );
44 | }
45 | }
46 |
47 | resize(_: number, __: number) {}
48 |
49 | render() {}
50 | }
51 |
52 | export default WaveVisualizer;
53 |
--------------------------------------------------------------------------------
/src/plugins/visualizer/vudio.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'vudio/umd/vudio' {
2 | interface NoneWaveformOptions {
3 | maxHeight?: number;
4 | minHeight?: number;
5 | spacing?: number;
6 | color?: string | string[];
7 | shadowBlur?: number;
8 | shadowColor?: string;
9 | fadeSide?: boolean;
10 | }
11 |
12 | interface WaveformOptions extends NoneWaveformOptions {
13 | horizontalAlign: 'left' | 'center' | 'right';
14 | verticalAlign: 'top' | 'middle' | 'bottom';
15 | }
16 |
17 | interface VudioOptions {
18 | effect?: 'waveform' | 'circlewave' | 'circlebar' | 'lighting';
19 | accuracy?: number;
20 | width?: number;
21 | height?: number;
22 | waveform?: WaveformOptions;
23 | }
24 |
25 | class Vudio {
26 | constructor(
27 | audio: HTMLAudioElement | MediaStream,
28 | canvas: HTMLCanvasElement,
29 | options: VudioOptions = {},
30 | );
31 |
32 | dance(): void;
33 | pause(): void;
34 | setOption(options: VudioOptions): void;
35 | }
36 |
37 | export default Vudio;
38 | }
39 |
--------------------------------------------------------------------------------
/src/preload.ts:
--------------------------------------------------------------------------------
1 | import {
2 | contextBridge,
3 | ipcRenderer,
4 | type IpcRendererEvent,
5 | webFrame,
6 | } from 'electron';
7 | import is from 'electron-is';
8 |
9 | import * as config from './config';
10 |
11 | import {
12 | forceLoadPreloadPlugin,
13 | forceUnloadPreloadPlugin,
14 | loadAllPreloadPlugins,
15 | } from './loader/preload';
16 | import { loadI18n, setLanguage } from '@/i18n';
17 |
18 | loadI18n().then(async () => {
19 | await setLanguage(config.get('options.language') ?? 'en');
20 | await loadAllPreloadPlugins();
21 | });
22 |
23 | ipcRenderer.on('plugin:unload', async (_, id: string) => {
24 | await forceUnloadPreloadPlugin(id);
25 | });
26 | ipcRenderer.on('plugin:enable', async (_, id: string) => {
27 | await forceLoadPreloadPlugin(id);
28 | });
29 |
30 | contextBridge.exposeInMainWorld('mainConfig', config);
31 | contextBridge.exposeInMainWorld('electronIs', is);
32 | contextBridge.exposeInMainWorld('ipcRenderer', {
33 | on: (
34 | channel: string,
35 | listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
36 | ) => ipcRenderer.on(channel, listener),
37 | off: (channel: string, listener: (...args: unknown[]) => void) =>
38 | ipcRenderer.off(channel, listener),
39 | once: (
40 | channel: string,
41 | listener: (event: IpcRendererEvent, ...args: unknown[]) => void,
42 | ) => ipcRenderer.once(channel, listener),
43 | send: (channel: string, ...args: unknown[]) =>
44 | ipcRenderer.send(channel, ...args),
45 | removeListener: (channel: string, listener: (...args: unknown[]) => void) =>
46 | ipcRenderer.removeListener(channel, listener),
47 | removeAllListeners: (channel: string) =>
48 | ipcRenderer.removeAllListeners(channel),
49 | invoke: async (channel: string, ...args: unknown[]): Promise =>
50 | ipcRenderer.invoke(channel, ...args),
51 | sendSync: (channel: string, ...args: unknown[]): unknown =>
52 | ipcRenderer.sendSync(channel, ...args),
53 | sendToHost: (channel: string, ...args: unknown[]) =>
54 | ipcRenderer.sendToHost(channel, ...args),
55 | });
56 | contextBridge.exposeInMainWorld('reload', () =>
57 | ipcRenderer.send('ytmd:reload'),
58 | );
59 | contextBridge.exposeInMainWorld(
60 | 'ELECTRON_RENDERER_URL',
61 | process.env.ELECTRON_RENDERER_URL,
62 | );
63 |
64 | const [path, script] = ipcRenderer.sendSync('get-renderer-script') as [
65 | string | null,
66 | string,
67 | ];
68 | let blocked = true;
69 | if (path) {
70 | webFrame.executeJavaScriptInIsolatedWorld(
71 | 0,
72 | [
73 | {
74 | code: script,
75 | url: path,
76 | },
77 | ],
78 | true,
79 | () => (blocked = false),
80 | );
81 | } else {
82 | webFrame.executeJavaScript(script, true, () => (blocked = false));
83 | }
84 |
85 | // HACK: Wait for the script to be executed
86 | while (blocked);
87 |
--------------------------------------------------------------------------------
/src/providers/app-controls.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import { app, BrowserWindow, ipcMain } from 'electron';
4 |
5 | import * as config from '@/config';
6 |
7 | export const restart = () => restartInternal();
8 |
9 | export const setupAppControls = () => {
10 | ipcMain.on('ytmd:restart', restart);
11 | ipcMain.handle('ytmd:get-downloads-folder', () => app.getPath('downloads'));
12 | ipcMain.on('ytmd:reload', () =>
13 | BrowserWindow.getFocusedWindow()?.webContents.loadURL(config.get('url')),
14 | );
15 | ipcMain.handle('ytmd:get-path', (_, ...args: string[]) => path.join(...args));
16 | };
17 |
18 | function restartInternal() {
19 | app.relaunch({ execPath: process.env.PORTABLE_EXECUTABLE_FILE });
20 | // ExecPath will be undefined if not running portable app, resulting in default behavior
21 | app.quit();
22 | }
23 |
24 | function sendToFrontInternal(channel: string, ...args: unknown[]) {
25 | for (const win of BrowserWindow.getAllWindows()) {
26 | win.webContents.send(channel, ...args);
27 | }
28 | }
29 |
30 | export const sendToFront =
31 | process.type === 'browser'
32 | ? sendToFrontInternal
33 | : () => {
34 | console.error('sendToFront called from renderer');
35 | };
36 |
--------------------------------------------------------------------------------
/src/providers/decorators.ts:
--------------------------------------------------------------------------------
1 | export function singleton unknown>(fn: T): T {
2 | let called = false;
3 |
4 | return ((...args) => {
5 | if (called) {
6 | return;
7 | }
8 |
9 | called = true;
10 | return fn(...args);
11 | }) as T;
12 | }
13 |
14 | export function debounce unknown>(
15 | fn: T,
16 | delay: number,
17 | ): T {
18 | let timeout: NodeJS.Timeout;
19 | return ((...args) => {
20 | clearTimeout(timeout);
21 | timeout = setTimeout(() => fn(...args), delay);
22 | }) as T;
23 | }
24 |
25 | export function cache R, P extends never[], R>(
26 | fn: T,
27 | ): T {
28 | let lastArgs: P;
29 | let lastResult: R;
30 | return ((...args: P) => {
31 | if (
32 | args.length !== lastArgs?.length ||
33 | args.some((arg, i) => arg !== lastArgs[i])
34 | ) {
35 | lastArgs = args;
36 | lastResult = fn(...args);
37 | }
38 |
39 | return lastResult;
40 | }) as T;
41 | }
42 |
43 | export function cacheNoArgs(fn: () => R): () => R {
44 | let cached: R;
45 | return () => {
46 | if (cached === undefined) {
47 | cached = fn();
48 | }
49 | return cached;
50 | };
51 | }
52 |
53 | /*
54 | The following are currently unused, but potentially useful in the future
55 | */
56 |
57 | export function throttle unknown>(
58 | fn: T,
59 | delay: number,
60 | ): T {
61 | let timeout: NodeJS.Timeout | undefined;
62 | return ((...args) => {
63 | if (timeout) {
64 | return;
65 | }
66 |
67 | timeout = setTimeout(() => {
68 | timeout = undefined;
69 | fn(...args);
70 | }, delay);
71 | }) as T;
72 | }
73 |
74 | export function memoize unknown>(fn: T): T {
75 | const cache = new Map();
76 |
77 | return ((...args) => {
78 | const key = JSON.stringify(args);
79 | if (!cache.has(key)) {
80 | cache.set(key, fn(...args));
81 | }
82 |
83 | return cache.get(key);
84 | }) as T;
85 | }
86 |
87 | export function retry Promise>(
88 | fn: T,
89 | { retries = 3, delay = 1000 } = {},
90 | ) {
91 | return async (...args: unknown[]) => {
92 | let latestError: unknown;
93 | while (retries > 0) {
94 | try {
95 | return await fn(...args);
96 | } catch (error) {
97 | retries--;
98 | await new Promise((resolve) => setTimeout(resolve, delay));
99 | latestError = error;
100 | }
101 | }
102 | throw latestError;
103 | };
104 | }
105 |
--------------------------------------------------------------------------------
/src/providers/dom-elements.ts:
--------------------------------------------------------------------------------
1 | export const getSongMenu = () =>
2 | document.querySelector(
3 | 'ytmusic-menu-popup-renderer tp-yt-paper-listbox',
4 | );
5 |
--------------------------------------------------------------------------------
/src/providers/extracted-data.ts:
--------------------------------------------------------------------------------
1 | export const startingPages: Record = {
2 | 'Default': '',
3 | 'Home': 'FEmusic_home',
4 | 'Explore': 'FEmusic_explore',
5 | 'New Releases': 'FEmusic_new_releases',
6 | 'Charts': 'FEmusic_charts',
7 | 'Moods & Genres': 'FEmusic_moods_and_genres',
8 | 'Library': 'FEmusic_library_landing',
9 | 'Playlists': 'FEmusic_liked_playlists',
10 | 'Songs': 'FEmusic_liked_videos',
11 | 'Albums': 'FEmusic_liked_albums',
12 | 'Artists': 'FEmusic_library_corpus_track_artists',
13 | 'Subscribed Artists': 'FEmusic_library_corpus_artists',
14 | 'Uploads': 'FEmusic_library_privately_owned_landing',
15 | 'Uploaded Playlists': 'FEmusic_liked_playlists',
16 | 'Uploaded Songs': 'FEmusic_library_privately_owned_tracks',
17 | 'Uploaded Albums': 'FEmusic_library_privately_owned_releases',
18 | 'Uploaded Artists': 'FEmusic_library_privately_owned_artists',
19 | };
20 |
--------------------------------------------------------------------------------
/src/providers/prompt-options.ts:
--------------------------------------------------------------------------------
1 | import youtubeMusicTrayIcon from '@assets/youtube-music-tray.png?asset&asarUnpack';
2 |
3 | const promptOptions = {
4 | customStylesheet: 'dark',
5 | icon: youtubeMusicTrayIcon,
6 | };
7 |
8 | export default () => promptOptions;
9 |
--------------------------------------------------------------------------------
/src/providers/protocol-handler.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import { app, type BrowserWindow } from 'electron';
4 |
5 | import { getSongControls } from './song-controls';
6 |
7 | export const APP_PROTOCOL = 'youtubemusic';
8 |
9 | let protocolHandler: ((cmd: string, ...args: string[]) => void) | undefined;
10 |
11 | export function setupProtocolHandler(win: BrowserWindow) {
12 | if (process.defaultApp && process.argv.length >= 2) {
13 | app.setAsDefaultProtocolClient(APP_PROTOCOL, process.execPath, [
14 | path.resolve(process.argv[1]),
15 | ]);
16 | } else {
17 | app.setAsDefaultProtocolClient(APP_PROTOCOL);
18 | }
19 |
20 | const songControls = getSongControls(win);
21 |
22 | protocolHandler = ((cmd: keyof typeof songControls, ...args) => {
23 | if (Object.keys(songControls).includes(cmd)) {
24 | // @ts-expect-error: cmd is a key of songControls
25 | songControls[cmd](...args);
26 | }
27 | }) as (cmd: string, ...args: string[]) => void;
28 | }
29 |
30 | export function handleProtocol(cmd: string, ...args: string[]) {
31 | protocolHandler?.(cmd, ...args);
32 | }
33 |
34 | export function changeProtocolHandler(
35 | f: (cmd: string, ...args: string[]) => void,
36 | ) {
37 | protocolHandler = f;
38 | }
39 |
--------------------------------------------------------------------------------
/src/reset.d.ts:
--------------------------------------------------------------------------------
1 | import '@total-typescript/ts-reset';
2 |
3 | import type { ipcRenderer as electronIpcRenderer } from 'electron';
4 | import type is from 'electron-is';
5 |
6 | import type * as config from './config';
7 | import type { VideoDataChanged } from '@/types/video-data-changed';
8 | import type { t } from '@/i18n';
9 | import type { trustedTypes } from 'trusted-types';
10 |
11 | declare global {
12 | interface Compressor {
13 | audioSource: MediaElementAudioSourceNode;
14 | audioContext: AudioContext;
15 | }
16 |
17 | interface DocumentEventMap {
18 | 'ytmd:audio-can-play': CustomEvent;
19 | 'videodatachange': CustomEvent;
20 | }
21 |
22 | declare var electronIs: typeof import('electron-is');
23 |
24 | interface Window {
25 | trustedTypes?: typeof trustedTypes;
26 | ipcRenderer: typeof electronIpcRenderer;
27 | mainConfig: typeof config;
28 | electronIs: typeof is;
29 | ELECTRON_RENDERER_URL: string | undefined;
30 | /**
31 | * YouTube Music internal variable (Last interaction time)
32 | */
33 | _lact: number;
34 | navigation: Navigation;
35 | download: () => void;
36 | togglePictureInPicture: () => void;
37 | reload: () => void;
38 | i18n: {
39 | t: typeof t;
40 | };
41 | }
42 | }
43 |
44 | // import { Howl as _Howl } from 'howler';
45 | declare module 'howler' {
46 | interface Howl {
47 | _sounds: {
48 | _paused: boolean;
49 | _ended: boolean;
50 | _id: string;
51 | _node: HTMLMediaElement;
52 | }[];
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/ts-declarations/kuroshiro-analyzer-kuromoji.d.ts:
--------------------------------------------------------------------------------
1 | // Stolen from https://github.com/hexenq/kuroshiro-analyzer-kuromoji/pull/7
2 | // Credit goes to https://github.com/ALOHACREPES345
3 |
4 | declare class KuromojiAnalyzer {
5 | constructor(dictPath?: { dictPath: string });
6 | init(): Promise;
7 | parse(str: string): Promise;
8 | }
9 |
10 | declare module 'kuroshiro-analyzer-kuromoji' {
11 | export = KuromojiAnalyzer;
12 | }
13 |
--------------------------------------------------------------------------------
/src/ts-declarations/kuroshiro.d.ts:
--------------------------------------------------------------------------------
1 | // Stolen from https://github.com/hexenq/kuroshiro/pull/93
2 | // Credit goes to https://github.com/ALOHACREPES345 and https://github.com/lcsvcn
3 | declare class Kuroshiro {
4 | constructor();
5 | _analyzer: import('kuroshiro-analyzer-kuromoji') | null;
6 | init(analyzer: import('kuroshiro-analyzer-kuromoji')): Promise;
7 | convert(
8 | str: string,
9 | options?: {
10 | to?: 'hiragana' | 'katakana' | 'romaji';
11 | mode?: 'normal' | 'spaced' | 'okurigana' | 'furigana';
12 | romajiSystem?: 'nippon' | 'passport' | 'hepburn';
13 | delimiter_start?: string;
14 | delimiter_end?: string;
15 | },
16 | ): Promise;
17 |
18 | static Util: {
19 | isHiragana: (ch: string) => boolean;
20 | isKatakana: (ch: string) => boolean;
21 | isKana: (ch: string) => boolean;
22 | isKanji: (ch: string) => boolean;
23 | isJapanese: (ch: string) => boolean;
24 | hasHiragana: (str: string) => boolean;
25 | hasKatakana: (str: string) => boolean;
26 | hasKana: (str: string) => boolean;
27 | hasKanji: (str: string) => boolean;
28 | hasJapanese: (str: string) => boolean;
29 | kanaToHiragana: (str: string) => string;
30 | kanaToKatakana: (str: string) => string;
31 | kanaToRomaji: (
32 | str: string,
33 | system: 'nippon' | 'passport' | 'hepburn',
34 | ) => string;
35 | };
36 | }
37 |
38 | declare module 'kuroshiro' {
39 | export = Kuroshiro;
40 | }
41 |
--------------------------------------------------------------------------------
/src/types/contexts.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | IpcMain,
3 | IpcRenderer,
4 | WebContents,
5 | BrowserWindow,
6 | } from 'electron';
7 | import type { PluginConfig } from '@/types/plugins';
8 |
9 | export interface BaseContext {
10 | getConfig: () => Promise | Config;
11 | setConfig: (conf: Partial>) => Promise | void;
12 | }
13 |
14 | export interface BackendContext
15 | extends BaseContext {
16 | ipc: {
17 | send: WebContents['send'];
18 | handle: (event: string, listener: CallableFunction) => void;
19 | on: (event: string, listener: CallableFunction) => void;
20 | removeHandler: IpcMain['removeHandler'];
21 | };
22 |
23 | window: BrowserWindow;
24 | }
25 |
26 | export interface MenuContext
27 | extends BaseContext {
28 | window: BrowserWindow;
29 | refresh: () => Promise | void;
30 | }
31 |
32 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
33 | export interface PreloadContext
34 | extends BaseContext {}
35 |
36 | export interface RendererContext
37 | extends BaseContext {
38 | ipc: {
39 | send: IpcRenderer['send'];
40 | invoke: IpcRenderer['invoke'];
41 | on: (event: string, listener: CallableFunction) => void;
42 | removeAllListeners: (event: string) => void;
43 | };
44 | }
45 |
--------------------------------------------------------------------------------
/src/types/media-icons.ts:
--------------------------------------------------------------------------------
1 | export const mediaIcons = {
2 | play: '\u{1405}', // ᐅ
3 | pause: '\u{2016}', // ‖
4 | next: '\u{1433}', // ᐳ
5 | previous: '\u{1438}', // ᐸ
6 | } as const;
7 |
--------------------------------------------------------------------------------
/src/types/plugins.ts:
--------------------------------------------------------------------------------
1 | import type { YoutubePlayer } from '@/types/youtube-player';
2 |
3 | import type {
4 | BackendContext,
5 | MenuContext,
6 | PreloadContext,
7 | RendererContext,
8 | } from './contexts';
9 |
10 | type Author = string;
11 |
12 | export type PluginConfig = {
13 | enabled: boolean;
14 | };
15 |
16 | export type PluginLifecycleSimple = (
17 | this: This,
18 | ctx: Context,
19 | ) => void | Promise;
20 | export type PluginLifecycleExtra = This & {
21 | start?: PluginLifecycleSimple;
22 | stop?: PluginLifecycleSimple;
23 | onConfigChange?: (this: This, newConfig: Config) => void | Promise;
24 | };
25 | export type RendererPluginLifecycleExtra = This &
26 | PluginLifecycleExtra & {
27 | onPlayerApiReady?: (
28 | this: This,
29 | playerApi: YoutubePlayer,
30 | context: Context,
31 | ) => void | Promise;
32 | };
33 |
34 | export type PluginLifecycle =
35 | | PluginLifecycleSimple
36 | | PluginLifecycleExtra;
37 | export type RendererPluginLifecycle =
38 | | PluginLifecycleSimple
39 | | RendererPluginLifecycleExtra;
40 |
41 | export enum Platform {
42 | Windows = 1 << 0,
43 | macOS = 1 << 1,
44 | Linux = 1 << 2,
45 | Freebsd = 1 << 3,
46 | }
47 |
48 | export interface PluginDef<
49 | BackendProperties,
50 | PreloadProperties,
51 | RendererProperties,
52 | Config extends PluginConfig = PluginConfig,
53 | > {
54 | name: () => string;
55 | authors?: Author[];
56 | description?: () => string;
57 | addedVersion?: string;
58 | config?: Config;
59 | platform?: Platform;
60 |
61 | menu?: (
62 | ctx: MenuContext,
63 | ) =>
64 | | Promise
65 | | Electron.MenuItemConstructorOptions[];
66 | stylesheets?: string[];
67 | restartNeeded?: boolean;
68 |
69 | backend?: {
70 | [Key in keyof BackendProperties]: BackendProperties[Key];
71 | } & PluginLifecycle, BackendProperties>;
72 | preload?: {
73 | [Key in keyof PreloadProperties]: PreloadProperties[Key];
74 | } & PluginLifecycle, PreloadProperties>;
75 | renderer?: {
76 | [Key in keyof RendererProperties]: RendererProperties[Key];
77 | } & RendererPluginLifecycle<
78 | Config,
79 | RendererContext,
80 | RendererProperties
81 | >;
82 | }
83 |
--------------------------------------------------------------------------------
/src/types/queue.ts:
--------------------------------------------------------------------------------
1 | import type { YoutubePlayer } from '@/types/youtube-player';
2 | import type { GetState, QueueItem } from '@/types/datahost-get-state';
3 |
4 | type StoreState = GetState;
5 | export type Store = {
6 | dispatch: (obj: { type: string; payload?: unknown }) => void;
7 |
8 | getState: () => StoreState;
9 | replaceReducer: (param1: unknown) => unknown;
10 | subscribe: (callback: () => void) => unknown;
11 | };
12 |
13 | export type QueueElement = HTMLElement & {
14 | dispatch(obj: { type: string; payload?: unknown }): void;
15 | queue: QueueAPI;
16 | };
17 | export type QueueAPI = {
18 | getItems(): QueueItem[];
19 | store: {
20 | store: Store;
21 | };
22 | continuation?: string;
23 | autoPlaying?: boolean;
24 | };
25 |
26 | export type ToastElement = HTMLElement & {
27 | autoFitOnAttach: boolean;
28 | duration: number;
29 | expandSizingTargetForScrollbars: boolean;
30 | horizontalAlign: 'left' | 'right' | 'center';
31 | importPath?: unknown;
32 | label: string;
33 | noAutoFocus: boolean;
34 | noCancelOnEscKey: boolean;
35 | noCancelOnOutsideClick: boolean;
36 | noIronAnnounce: boolean;
37 | restoreFocusOnClose: boolean;
38 | root: ToastElement;
39 | rootPath: string;
40 | sizingTarget: ToastElement;
41 | verticalAlign: 'bottom' | 'top' | 'center';
42 | };
43 |
44 | export interface ToastService {
45 | attached: boolean;
46 | displaying: boolean;
47 | messageQueue: string[];
48 | toastElement: ToastElement;
49 | show: (message: string) => void;
50 | }
51 |
52 | export type AppElement = HTMLElement & AppAPI;
53 | export type AppAPI = {
54 | queue: QueueAPI;
55 | playerApi: YoutubePlayer;
56 |
57 | toastService: ToastService;
58 |
59 | // TODO: Add more
60 | };
61 |
--------------------------------------------------------------------------------
/src/types/search-box-element.ts:
--------------------------------------------------------------------------------
1 | export interface SearchBoxElement extends HTMLElement {
2 | getSearchboxStats(): unknown;
3 | }
4 |
--------------------------------------------------------------------------------
/src/types/video-data-changed.ts:
--------------------------------------------------------------------------------
1 | import type { VideoDataChangeValue } from '@/types/player-api-events';
2 |
3 | export interface VideoDataChanged {
4 | name: string;
5 | videoData?: VideoDataChangeValue;
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/video-details.ts:
--------------------------------------------------------------------------------
1 | export interface VideoDetails {
2 | video_id: string;
3 | author: string;
4 | title: string;
5 | isPlayable: boolean;
6 | errorCode: null;
7 | video_quality: string;
8 | video_quality_features: unknown[];
9 | list: string;
10 | backgroundable: boolean;
11 | eventId: string;
12 | cpn: string;
13 | isLive: boolean;
14 | isWindowedLive: boolean;
15 | isManifestless: boolean;
16 | allowLiveDvr: boolean;
17 | isListed: boolean;
18 | isMultiChannelAudio: boolean;
19 | hasProgressBarBoundaries: boolean;
20 | isPremiere: boolean;
21 | itct: string;
22 | progressBarStartPositionUtcTimeMillis: number | null;
23 | progressBarEndPositionUtcTimeMillis: number | null;
24 | paidContentOverlayDurationMs: number;
25 | }
26 |
--------------------------------------------------------------------------------
/src/types/youtube-music-app-element.ts:
--------------------------------------------------------------------------------
1 | export interface YouTubeMusicAppElement extends HTMLElement {
2 | navigate(page: string): void;
3 | networkManager: {
4 | fetch: (url: string, data: Data) => Promise;
5 | };
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/youtube-music-desktop-internal.ts:
--------------------------------------------------------------------------------
1 | import type { QueueItem } from '@/types/datahost-get-state';
2 | import type { PlayerOverlays } from '@/types/player-api-events';
3 |
4 | export interface QueueResponse {
5 | items?: QueueItem[];
6 | autoPlaying?: boolean;
7 | continuation?: string;
8 | }
9 |
10 | export interface WatchNextResponse {
11 | playerOverlays?: PlayerOverlays;
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/custom-element.ts:
--------------------------------------------------------------------------------
1 | import { customElement, type ComponentType } from 'solid-element';
2 |
3 | export const anonymousCustomElement = (
4 | ComponentType: ComponentType,
5 | ): CustomElementConstructor =>
6 | customElement(`ytmd-${crypto.randomUUID()}`, ComponentType);
7 |
--------------------------------------------------------------------------------
/src/utils/testing.ts:
--------------------------------------------------------------------------------
1 | export const isTesting = () => process.env.NODE_ENV === 'test';
2 |
--------------------------------------------------------------------------------
/src/utils/trusted-types.ts:
--------------------------------------------------------------------------------
1 | import type { TrustedTypePolicy } from 'trusted-types/lib';
2 |
3 | export let defaultTrustedTypePolicy: Pick<
4 | TrustedTypePolicy<{
5 | createHTML: (input: string) => string;
6 | createScriptURL: (input: string) => string;
7 | createScript: (input: string) => string;
8 | }>,
9 | 'name' | 'createHTML' | 'createScript' | 'createScriptURL'
10 | >;
11 |
12 | export const registerWindowDefaultTrustedTypePolicy = () => {
13 | if (window.trustedTypes && window.trustedTypes.createPolicy) {
14 | defaultTrustedTypePolicy = window.trustedTypes.createPolicy('default', {
15 | createHTML: (input) => input,
16 | createScriptURL: (input) => input,
17 | createScript: (input) => input,
18 | });
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/src/utils/type-utils.ts:
--------------------------------------------------------------------------------
1 | export type Entries = {
2 | [K in keyof T]: [K, T[K]];
3 | }[keyof T][];
4 |
5 | export type ValueOf = T[keyof T];
6 |
--------------------------------------------------------------------------------
/src/utils/wait-for-element.ts:
--------------------------------------------------------------------------------
1 | export const waitForElement = (
2 | selector: string,
3 | options: {
4 | maxRetry?: number;
5 | retryInterval?: number;
6 | } = {
7 | maxRetry: -1,
8 | retryInterval: 100,
9 | },
10 | ): Promise => {
11 | return new Promise((resolve) => {
12 | let retryCount = 0;
13 | const maxRetry = options.maxRetry ?? -1;
14 | const retryInterval = options.retryInterval ?? 100;
15 | const interval = setInterval(() => {
16 | if (maxRetry > 0 && retryCount >= maxRetry) {
17 | clearInterval(interval);
18 | return;
19 | }
20 | const elem = document.querySelector(selector);
21 | if (!elem) {
22 | retryCount++;
23 | return;
24 | }
25 |
26 | clearInterval(interval);
27 | resolve(elem);
28 | }, retryInterval /* ms */);
29 | });
30 | };
31 |
--------------------------------------------------------------------------------
/src/virtual-module.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'virtual:plugins' {
2 | import type { PluginConfig, PluginDef } from '@/types/plugins';
3 |
4 | type Plugin = PluginDef;
5 |
6 | export const mainPlugins: () => Promise>;
7 | export const preloadPlugins: () => Promise>;
8 | export const rendererPlugins: () => Promise>;
9 |
10 | export const allPlugins: () => Promise<
11 | Record>
12 | >;
13 | }
14 |
15 | declare module 'virtual:i18n' {
16 | import type { LanguageResources } from '@/i18n/resources/@types';
17 |
18 | export const languageResources: () => Promise;
19 | }
20 |
--------------------------------------------------------------------------------
/src/youtube-music.css:
--------------------------------------------------------------------------------
1 | /**
2 | * Overriding YouTube Music style
3 | */
4 |
5 | /* Allow window dragging */
6 | ytmusic-nav-bar {
7 | position: relative;
8 | }
9 | ytmusic-nav-bar::before {
10 | content: '';
11 | position: absolute;
12 | inset: 0;
13 |
14 | -webkit-user-select: none;
15 | -webkit-app-region: drag;
16 | }
17 |
18 | ytmusic-nav-bar > .left-content > *,
19 | ytmusic-nav-bar > .center-content > *,
20 | ytmusic-nav-bar > .right-content > * {
21 | -webkit-app-region: no-drag;
22 | }
23 |
24 | iron-icon,
25 | ytmusic-pivot-bar-item-renderer,
26 | .tab-title,
27 | a {
28 | -webkit-app-region: no-drag;
29 | }
30 |
31 | /* custom style for navbar */
32 | ytmusic-app-layout {
33 | --ytmusic-nav-bar-height: 90px;
34 | }
35 |
36 | /* Disable Image Selection */
37 | img {
38 | -webkit-user-select: none;
39 | user-select: none;
40 | }
41 |
42 | /* Hide cast button which doesn't work */
43 | ytmusic-cast-button {
44 | display: none !important;
45 | }
46 |
47 | /* Remove useless inaccessible button on top-right corner of the video player */
48 | .ytp-chrome-top-buttons {
49 | display: none !important;
50 | }
51 |
52 | /* Make youtube-music logo un-draggable */
53 | ytmusic-nav-bar > div.left-content > a,
54 | ytmusic-nav-bar > div.left-content > a > picture > img {
55 | -webkit-user-drag: none;
56 | }
57 |
58 | /* yt-music bugs */
59 | tp-yt-paper-item.ytmusic-guide-entry-renderer::before {
60 | border-radius: 8px !important;
61 | }
62 |
63 | /* fix video player align */
64 | #av-id {
65 | padding-bottom: 0;
66 | }
67 |
68 | #av-id ~ #player.ytmusic-player-page:not([player-ui-state='FULLSCREEN']) {
69 | margin-top: auto !important;
70 | margin-bottom: auto !important;
71 | margin-left: var(--ytmusic-player-page-vertical-padding);
72 | margin-right: var(--ytmusic-player-page-vertical-padding);
73 | max-height: calc(100% - (var(--ytmusic-player-page-vertical-padding) * 2));
74 | max-width: calc(100% - var(--ytmusic-player-page-vertical-padding) * 2);
75 | }
76 |
77 | /* macos traffic lights fix */
78 | :where([data-os*='Macintosh']) ytmusic-app-layout#layout ytmusic-nav-bar {
79 | padding-top: var(--ytmusic-nav-bar-offset, 0);
80 | }
81 | :where([data-os*='Macintosh']) ytmusic-app-layout#layout {
82 | --ytmusic-nav-bar-offset: 24px;
83 | --ytmusic-nav-bar-height: calc(90px + var(--ytmusic-nav-bar-offset, 0));
84 | }
85 |
86 | tp-yt-iron-dropdown,
87 | tp-yt-paper-dialog {
88 | app-region: no-drag;
89 | }
--------------------------------------------------------------------------------
/src/youtube-music.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | declare module '*.html' {
4 | const html: string;
5 |
6 | export default html;
7 | }
8 | declare module '*.html?raw' {
9 | const html: string;
10 |
11 | export default html;
12 | }
13 | declare module '*.svg?inline' {
14 | const base64: string;
15 |
16 | export default base64;
17 | }
18 | declare module '*.svg?raw' {
19 | const html: string;
20 |
21 | export default html;
22 | }
23 | declare module '*.png' {
24 | const element: HTMLImageElement;
25 |
26 | export default element;
27 | }
28 | declare module '*.jpg' {
29 | const element: HTMLImageElement;
30 | export default element;
31 | }
32 | declare module '*.css' {
33 | const css: string;
34 |
35 | export default css;
36 | }
37 | declare module '*.css?inline' {
38 | const css: string;
39 |
40 | export default css;
41 | }
42 |
--------------------------------------------------------------------------------
/src/yt-web-components.d.ts:
--------------------------------------------------------------------------------
1 | import type { Icons } from '@/types/icons';
2 | import type { ComponentProps } from 'solid-js';
3 |
4 | declare module 'solid-js' {
5 | namespace JSX {
6 | interface YtFormattedStringProps {
7 | text?: {
8 | runs: { text: string }[];
9 | };
10 | data?: object;
11 | disabled?: boolean;
12 | hidden?: boolean;
13 | }
14 |
15 | interface YtButtonRendererProps {
16 | data?: {
17 | icon?: {
18 | iconType: string;
19 | };
20 | isDisabled?: boolean;
21 | style?: string;
22 | text?: {
23 | simpleText: string;
24 | };
25 | };
26 | }
27 |
28 | interface YpYtPaperSpinnerLiteProps {
29 | active?: boolean;
30 | }
31 |
32 | interface TpYtPaperIconButtonProps {
33 | icon: Icons;
34 | }
35 |
36 | interface YtmdTransProps {
37 | key?: string;
38 | }
39 |
40 | interface IntrinsicElements {
41 | 'center': ComponentProps<'div'>;
42 | 'ytmd-trans': ComponentProps<'span'> & YtmdTransProps;
43 | 'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
44 | 'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
45 | 'yt-touch-feedback-shape': ComponentProps<'div'>;
46 | 'tp-yt-paper-spinner-lite': ComponentProps<'div'> &
47 | YpYtPaperSpinnerLiteProps;
48 | 'tp-yt-paper-icon-button': ComponentProps<'div'> &
49 | TpYtPaperIconButtonProps;
50 | 'yt-icon-button': ComponentProps<'div'> & TpYtPaperIconButtonProps;
51 | 'tp-yt-iron-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps;
52 | 'yt-icon': ComponentProps<'div'> & TpYtPaperIconButtonProps;
53 | // input type="range" slider component
54 | 'tp-yt-paper-slider': ComponentProps<'input'> & {
55 | 'value'?: number | string;
56 | 'min'?: number | string;
57 | 'max'?: number | string;
58 | 'step'?: number | string;
59 | 'disabled'?: boolean;
60 | 'on:immediate-value-changed'?: (
61 | event: CustomEvent<{ value: number }>,
62 | ) => void;
63 | };
64 | 'tp-yt-paper-progress': ComponentProps<'input'>;
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/tests/index.test.js:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import process from 'node:process';
3 | import { _electron as electron } from 'playwright';
4 | import { test, expect } from '@playwright/test';
5 |
6 | process.env.NODE_ENV = 'test';
7 |
8 | const appPath = path.resolve(import.meta.dirname, '..');
9 |
10 | test('YouTube Music App - With default settings, app is launched and visible', async () => {
11 | const app = await electron.launch({
12 | cwd: appPath,
13 | args: [
14 | appPath,
15 | '--no-sandbox',
16 | '--disable-gpu',
17 | '--whitelisted-ips=',
18 | '--disable-dev-shm-usage',
19 | ],
20 | });
21 |
22 | const window = await app.firstWindow();
23 |
24 | const consentForm = await window.$(
25 | "form[action='https://consent.youtube.com/save']",
26 | );
27 | if (consentForm) {
28 | await consentForm.click('button');
29 | }
30 |
31 | // const title = await window.title();
32 | // expect(title.replaceAll(/\s/g, ' ')).toEqual('YouTube Music');
33 |
34 | const url = window.url();
35 | expect(url.startsWith('https://music.youtube.com')).toBe(true);
36 |
37 | await app.close();
38 | });
39 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./node_modules/@electron-toolkit/tsconfig/tsconfig.node.json",
3 | "compilerOptions": {
4 | "target": "esnext",
5 | "lib": ["dom", "dom.iterable", "es2022"],
6 | "module": "esnext",
7 | "types": ["electron-vite/node"],
8 | "allowSyntheticDefaultImports": true,
9 | "esModuleInterop": true,
10 | "resolveJsonModule": true,
11 | "moduleResolution": "bundler",
12 | "jsx": "preserve",
13 | "jsxImportSource": "solid-js",
14 | "baseUrl": ".",
15 | "outDir": "./dist",
16 | "strict": true,
17 | "noImplicitAny": true,
18 | "strictFunctionTypes": true,
19 | "skipLibCheck": true,
20 | "paths": {
21 | "@/*": ["./src/*"],
22 | "@assets/*": ["./assets/*"]
23 | }
24 | },
25 | "exclude": ["./dist"],
26 | "include": [
27 | "electron.vite.config.mts",
28 | "./src/**/*",
29 | "*.config.*js",
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/vite-plugins/i18n-importer.mts:
--------------------------------------------------------------------------------
1 | import { basename, relative, resolve, extname, dirname } from 'node:path';
2 | import { fileURLToPath } from 'node:url';
3 |
4 | import { globSync } from 'glob';
5 | import { Project } from 'ts-morph';
6 |
7 | const __dirname = dirname(fileURLToPath(import.meta.url));
8 | const globalProject = new Project({
9 | tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'),
10 | skipAddingFilesFromTsConfig: true,
11 | skipLoadingLibFiles: true,
12 | skipFileDependencyResolution: true,
13 | });
14 |
15 | export const i18nImporter = () => {
16 | const srcPath = resolve(__dirname, '..', 'src');
17 | const plugins = globSync(['src/i18n/resources/*.json']).map((path) => {
18 | const nameWithExt = basename(path);
19 | const name = nameWithExt.replace(extname(nameWithExt), '');
20 |
21 | return { name, path };
22 | });
23 |
24 | const src = globalProject.createSourceFile(
25 | 'vm:i18n',
26 | (writer) => {
27 | writer.writeLine('export const languageResources = async () => {');
28 | writer.writeLine(' const entries = await Promise.all([');
29 | for (const { name, path } of plugins) {
30 | const relativePath = relative(resolve(srcPath, '..'), path).replace(
31 | /\\/g,
32 | '/',
33 | );
34 |
35 | writer.writeLine(
36 | ` import('./${relativePath}').then((mod) => ({ "${name}": { translation: mod.default } })),`,
37 | );
38 | }
39 | writer.writeLine(' ]);');
40 | writer.writeLine(' return Object.assign({}, ...entries);');
41 | writer.writeLine('};');
42 | writer.blankLine();
43 | },
44 | { overwrite: true },
45 | );
46 |
47 | return src.getText();
48 | };
49 |
--------------------------------------------------------------------------------
/web/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/pear-devs/pear-desktop/cbc00776909d2de7e7b9094a4ec3b172d136333d/web/screenshot.png
--------------------------------------------------------------------------------