├── .editorconfig
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.yml
└── workflows
│ ├── build.yml
│ ├── dependency-review.yml
│ ├── winget-cla.yml
│ └── winget-submission.yml
├── .gitignore
├── .npmrc
├── .prettierrc
├── README.md
├── assets
├── error.html
├── generated
│ └── icons
│ │ ├── mac
│ │ └── icon.icns
│ │ ├── png
│ │ ├── 1024x1024.png
│ │ ├── 128x128.png
│ │ ├── 16x16.png
│ │ ├── 24x24.png
│ │ ├── 256x256.png
│ │ ├── 32x32.png
│ │ ├── 48x48.png
│ │ ├── 512x512.png
│ │ └── 64x64.png
│ │ └── win
│ │ └── icon.ico
├── media-icons-black
│ ├── next.png
│ ├── pause.png
│ ├── play.png
│ └── previous.png
├── youtube-music-tray-paused.png
├── youtube-music-tray.png
├── youtube-music.png
└── youtube-music.svg
├── changelog.md
├── docs
├── favicon
│ ├── favicon.ico
│ ├── favicon_144.png
│ ├── favicon_32.png
│ ├── favicon_48.png
│ └── favicon_96.png
├── img
│ ├── adblock.svg
│ ├── bg-bottom.svg
│ ├── bg-top.svg
│ ├── code.svg
│ ├── download.svg
│ ├── footer.svg
│ ├── plugins.svg
│ ├── youtube-music.png
│ └── youtube-music.svg
├── index.html
├── js
│ └── main.js
├── readme
│ ├── README-es.md
│ ├── README-fr.md
│ ├── README-hu.md
│ ├── README-is.md
│ ├── README-ja.md
│ ├── README-ko.md
│ ├── README-pt.md
│ ├── README-ru.md
│ └── README-uk.md
└── style
│ ├── fonts.css
│ └── style.css
├── electron.vite.config.mts
├── eslint.config.mjs
├── license
├── package.json
├── patches
├── @malept__flatpak-bundler@0.4.0.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
│ │ ├── bg.json
│ │ ├── bs.json
│ │ ├── ca.json
│ │ ├── cs.json
│ │ ├── da.json
│ │ ├── de.json
│ │ ├── el.json
│ │ ├── en.json
│ │ ├── es.json
│ │ ├── et.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
│ │ ├── ml.json
│ │ ├── ms.json
│ │ ├── nb.json
│ │ ├── ne.json
│ │ ├── nl.json
│ │ ├── pl.json
│ │ ├── pt-BR.json
│ │ ├── pt.json
│ │ ├── ro.json
│ │ ├── ru.json
│ │ ├── si.json
│ │ ├── sl.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
│ ├── adblocker
│ │ ├── .gitignore
│ │ ├── adSpeedup.ts
│ │ ├── blocker.ts
│ │ ├── index.ts
│ │ ├── injectors
│ │ │ ├── inject-cliqz-preload.ts
│ │ │ ├── inject.d.ts
│ │ │ └── inject.js
│ │ └── types
│ │ │ └── index.ts
│ ├── album-actions
│ │ ├── index.ts
│ │ └── templates
│ │ │ ├── dislike.html
│ │ │ ├── like.html
│ │ │ ├── undislike.html
│ │ │ └── unlike.html
│ ├── 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
│ │ │ ├── index.ts
│ │ │ ├── main.ts
│ │ │ ├── routes
│ │ │ │ ├── auth.ts
│ │ │ │ ├── control.ts
│ │ │ │ └── index.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
│ ├── bypass-age-restrictions
│ │ ├── index.ts
│ │ └── simple-youtube-age-restriction-bypass.d.ts
│ ├── captions-selector
│ │ ├── back.ts
│ │ ├── index.ts
│ │ ├── renderer.ts
│ │ └── templates
│ │ │ └── captions-settings-template.html
│ ├── compact-sidebar
│ │ └── index.ts
│ ├── crossfade
│ │ ├── fader.ts
│ │ └── index.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.ts
│ │ ├── style.css
│ │ ├── templates
│ │ │ └── download.html
│ │ └── 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
│ ├── lyrics-genius
│ │ ├── index.ts
│ │ ├── main.ts
│ │ ├── renderer.ts
│ │ ├── style.css
│ │ └── types.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
│ │ ├── index.ts
│ │ ├── style.css
│ │ └── templates
│ │ │ ├── back.html
│ │ │ └── forward.html
│ ├── no-google-login
│ │ ├── index.ts
│ │ └── 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.ts
│ │ ├── style.css
│ │ └── templates
│ │ │ └── picture-in-picture.html
│ ├── playback-speed
│ │ ├── index.ts
│ │ ├── renderer.ts
│ │ └── templates
│ │ │ └── slider.html
│ ├── precise-volume
│ │ ├── index.ts
│ │ ├── override.ts
│ │ ├── renderer.ts
│ │ └── volume-hud.css
│ ├── quality-changer
│ │ ├── index.ts
│ │ └── templates
│ │ │ └── qualitySettingsTemplate.html
│ ├── 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
│ │ ├── index.ts
│ │ ├── menu.ts
│ │ ├── parsers
│ │ │ └── lrc.ts
│ │ ├── providers
│ │ │ ├── LRCLib.ts
│ │ │ ├── LyricsGenius.ts
│ │ │ ├── Megalobiz.ts
│ │ │ ├── MusixMatch.ts
│ │ │ ├── YTMusic.ts
│ │ │ └── index.ts
│ │ ├── renderer
│ │ │ ├── components
│ │ │ │ ├── ErrorDisplay.tsx
│ │ │ │ ├── LoadingKaomoji.tsx
│ │ │ │ ├── LyricsContainer.tsx
│ │ │ │ ├── LyricsPicker.tsx
│ │ │ │ ├── PlainLyrics.tsx
│ │ │ │ └── SyncedLine.tsx
│ │ │ ├── index.ts
│ │ │ ├── renderer.tsx
│ │ │ └── utils.tsx
│ │ ├── style.css
│ │ └── types.ts
│ ├── taskbar-mediacontrol
│ │ └── index.ts
│ ├── touchbar
│ │ └── index.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
│ │ │ ├── html.ts
│ │ │ └── index.ts
│ ├── video-toggle
│ │ ├── button-switcher.css
│ │ ├── force-hide.css
│ │ ├── index.ts
│ │ └── templates
│ │ │ └── button_template.html
│ └── 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
│ ├── 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
├── youtube-music-hu.svg
└── youtube-music.svg
/.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@v4
19 | - name: "Dependency Review"
20 | uses: actions/dependency-review-action@v4
21 |
--------------------------------------------------------------------------------
/.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 | electron-builder.yml
5 | .vscode/settings.json
6 | .idea
7 |
8 | .pnp.*
9 | .yarn/*
10 | !.yarn/patches
11 | !.yarn/plugins
12 | !.yarn/releases
13 | !.yarn/sdks
14 | !.yarn/versions
15 | .vite-inspect
16 |
17 | .DS_Store
18 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | scripts-prepend-node-path=true
3 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "tabWidth": 2,
3 | "useTabs": false,
4 | "singleQuote": true
5 | }
6 |
--------------------------------------------------------------------------------
/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/generated/icons/mac/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/mac/icon.icns
--------------------------------------------------------------------------------
/assets/generated/icons/png/1024x1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/1024x1024.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/128x128.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/16x16.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/24x24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/24x24.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/256x256.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/32x32.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/48x48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/48x48.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/512x512.png
--------------------------------------------------------------------------------
/assets/generated/icons/png/64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/png/64x64.png
--------------------------------------------------------------------------------
/assets/generated/icons/win/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/generated/icons/win/icon.ico
--------------------------------------------------------------------------------
/assets/media-icons-black/next.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/media-icons-black/next.png
--------------------------------------------------------------------------------
/assets/media-icons-black/pause.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/media-icons-black/pause.png
--------------------------------------------------------------------------------
/assets/media-icons-black/play.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/media-icons-black/play.png
--------------------------------------------------------------------------------
/assets/media-icons-black/previous.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/media-icons-black/previous.png
--------------------------------------------------------------------------------
/assets/youtube-music-tray-paused.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/youtube-music-tray-paused.png
--------------------------------------------------------------------------------
/assets/youtube-music-tray.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/youtube-music-tray.png
--------------------------------------------------------------------------------
/assets/youtube-music.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/assets/youtube-music.png
--------------------------------------------------------------------------------
/assets/youtube-music.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/docs/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/docs/favicon/favicon.ico
--------------------------------------------------------------------------------
/docs/favicon/favicon_144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/docs/favicon/favicon_144.png
--------------------------------------------------------------------------------
/docs/favicon/favicon_32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/docs/favicon/favicon_32.png
--------------------------------------------------------------------------------
/docs/favicon/favicon_48.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/docs/favicon/favicon_48.png
--------------------------------------------------------------------------------
/docs/favicon/favicon_96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/docs/favicon/favicon_96.png
--------------------------------------------------------------------------------
/docs/img/adblock.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/docs/img/bg-bottom.svg:
--------------------------------------------------------------------------------
1 |
24 |
--------------------------------------------------------------------------------
/docs/img/bg-top.svg:
--------------------------------------------------------------------------------
1 |
33 |
--------------------------------------------------------------------------------
/docs/img/code.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/docs/img/download.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/docs/img/footer.svg:
--------------------------------------------------------------------------------
1 |
36 |
--------------------------------------------------------------------------------
/docs/img/plugins.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/docs/img/youtube-music.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/docs/img/youtube-music.png
--------------------------------------------------------------------------------
/docs/img/youtube-music.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/docs/style/fonts.css:
--------------------------------------------------------------------------------
1 | /* hebrew */
2 | @font-face {
3 | font-family: 'Heebo';
4 | font-style: normal;
5 | font-weight: 400;
6 | src: url(https://fonts.gstatic.com/s/heebo/v9/NGS6v5_NC0k9P9H0TbFhsqMA6aw.woff2) format('woff2');
7 | unicode-range: U+0590-05FF, U+20AA, U+25CC, U+FB1D-FB4F;
8 | }
9 |
10 | /* latin */
11 | @font-face {
12 | font-family: 'Heebo';
13 | font-style: normal;
14 | font-weight: 400;
15 | src: url(https://fonts.gstatic.com/s/heebo/v9/NGS6v5_NC0k9P9H2TbFhsqMA.woff2) format('woff2');
16 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
17 | }
18 |
19 | /* hebrew */
20 | @font-face {
21 | font-family: 'Heebo';
22 | font-style: normal;
23 | font-weight: 700;
24 | src: url(https://fonts.gstatic.com/s/heebo/v9/NGS6v5_NC0k9P9H0TbFhsqMA6aw.woff2) format('woff2');
25 | unicode-range: U+0590-05FF, U+20AA, U+25CC, U+FB1D-FB4F;
26 | }
27 |
28 | /* latin */
29 | @font-face {
30 | font-family: 'Heebo';
31 | font-style: normal;
32 | font-weight: 700;
33 | src: url(https://fonts.gstatic.com/s/heebo/v9/NGS6v5_NC0k9P9H2TbFhsqMA.woff2) format('woff2');
34 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
35 | }
36 |
37 | /* latin-ext */
38 | @font-face {
39 | font-family: 'Oxygen';
40 | font-style: normal;
41 | font-weight: 700;
42 | src: url(https://fonts.gstatic.com/s/oxygen/v10/2sDcZG1Wl4LcnbuCNWgzZmW5Kb8VZBHR.woff2) format('woff2');
43 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
44 | }
45 |
46 | /* latin */
47 | @font-face {
48 | font-family: 'Oxygen';
49 | font-style: normal;
50 | font-weight: 700;
51 | src: url(https://fonts.gstatic.com/s/oxygen/v10/2sDcZG1Wl4LcnbuCNWgzaGW5Kb8VZA.woff2) format('woff2');
52 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
53 | }
54 |
--------------------------------------------------------------------------------
/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/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 | overrideUserAgent: boolean;
35 | usePodcastParticipantAsArtist: boolean;
36 | themes: string[];
37 | };
38 | plugins: Record;
39 | }
40 |
41 | const defaultConfig: DefaultConfig = {
42 | 'window-size': {
43 | width: 1100,
44 | height: 550,
45 | },
46 | 'window-maximized': false,
47 | 'window-position': {
48 | x: -1,
49 | y: -1,
50 | },
51 | 'url': 'https://music.youtube.com',
52 | 'options': {
53 | tray: false,
54 | appVisible: true,
55 | autoUpdates: true,
56 | alwaysOnTop: false,
57 | hideMenu: false,
58 | hideMenuWarned: false,
59 | startAtLogin: false,
60 | disableHardwareAcceleration: false,
61 | removeUpgradeButton: false,
62 | restartOnConfigChanges: false,
63 | trayClickPlayPause: false,
64 | autoResetAppCache: false,
65 | resumeOnStart: true,
66 | likeButtons: '',
67 | proxy: '',
68 | startingPage: '',
69 | overrideUserAgent: false,
70 | usePodcastParticipantAsArtist: false,
71 | themes: [],
72 | },
73 | 'plugins': {},
74 | };
75 |
76 | export default defaultConfig;
77 |
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import { deepmergeCustom } from 'deepmerge-ts';
2 |
3 | import defaultConfig from './defaults';
4 |
5 | import store, { IStore } from './store';
6 | import plugins from './plugins';
7 |
8 | import { restart } from '@/providers/app-controls';
9 |
10 | const deepmerge = deepmergeCustom({
11 | mergeArrays: false,
12 | });
13 |
14 | const set = (key: string, value: unknown) => {
15 | store.set(key, value);
16 | };
17 | const setPartial = (key: string, value: object, defaultValue?: object) => {
18 | const newValue = deepmerge(defaultValue ?? {}, store.get(key) ?? {}, value);
19 | store.set(key, newValue);
20 | };
21 |
22 | function setMenuOption(key: string, value: unknown) {
23 | set(key, value);
24 | if (store.get('options.restartOnConfigChanges')) {
25 | restart();
26 | }
27 | }
28 |
29 | // MAGIC OF TYPESCRIPT
30 |
31 | type Prev = [
32 | never,
33 | 0,
34 | 1,
35 | 2,
36 | 3,
37 | 4,
38 | 5,
39 | 6,
40 | 7,
41 | 8,
42 | 9,
43 | 10,
44 | 11,
45 | 12,
46 | 13,
47 | 14,
48 | 15,
49 | 16,
50 | 17,
51 | 18,
52 | 19,
53 | 20,
54 | ...0[],
55 | ];
56 | type Join = K extends string | number
57 | ? P extends string | number
58 | ? `${K}${'' extends P ? '' : '.'}${P}`
59 | : never
60 | : never;
61 | type Paths = [D] extends [never]
62 | ? never
63 | : T extends object
64 | ? {
65 | [K in keyof T]-?: K extends string | number
66 | ? `${K}` | Join>
67 | : never;
68 | }[keyof T]
69 | : '';
70 |
71 | type SplitKey = K extends `${infer A}.${infer B}` ? [A, B] : [K, string];
72 | type PathValue =
73 | SplitKey extends [infer A extends keyof T, infer B extends string]
74 | ? PathValue
75 | : T;
76 |
77 | const get = >(key: Key) =>
78 | store.get(key) as PathValue;
79 |
80 | export default {
81 | defaultConfig,
82 | get,
83 | set,
84 | setPartial,
85 | setMenuOption,
86 | edit: () => store.openInEditor(),
87 | watch(cb: Parameters[0]) {
88 | store.onDidAnyChange(cb);
89 | },
90 | plugins,
91 | };
92 |
--------------------------------------------------------------------------------
/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 function isEnabled(plugin: string) {
15 | const pluginConfig = deepmerge(
16 | 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 |
72 | export default {
73 | isEnabled,
74 | getPlugins,
75 | enable,
76 | disable,
77 | setOptions,
78 | setMenuOptions,
79 | getOptions,
80 | };
81 |
--------------------------------------------------------------------------------
/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: 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/gl.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/src/i18n/resources/kn.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": {
3 | "code": "kn",
4 | "local-name": "ಕನ್ನಡ",
5 | "name": "Kannada"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/i18n/resources/ml.json:
--------------------------------------------------------------------------------
1 | {
2 | "common": {
3 | "console": {
4 | "plugins": {
5 | "initialize-failed": "{{pluginName}}എന്ന പ്ലഗിൻ തുടങ്ങുന്നതിൽ പരാജയപെട്ടു",
6 | "load-all": "എല്ലാ പ്ലേഗിനും ലോഡ് ചെയ്യുന്നു",
7 | "loaded": "{{pluginName}} എന്ന പ്ലഗിൻ ലോഡ് ചെയ്തു",
8 | "unloaded": "{{pluginName}} എന്ന പ്ലഗിൻ അൺലോഡ് ചെയ്തു"
9 | }
10 | }
11 | },
12 | "language": {
13 | "code": "ml",
14 | "local-name": "മലയാളം",
15 | "name": "Malayalam"
16 | },
17 | "main": {
18 | "console": {
19 | "did-finish-load": {
20 | "dev-tools": "ലോഡിങ് തീർത്തു. DevTools തുറന്നു"
21 | },
22 | "i18n": {
23 | "loaded": "i18n ലോഡ് ചെയ്തു"
24 | },
25 | "second-instance": {
26 | "receive-command": "പ്രോട്ടോക്കോളിലൂടെ കമാൻഡ് ലഭിച്ചു : \"{{command}}\""
27 | },
28 | "theme": {
29 | "css-file-not-found": "\"{{cssFile}}\" എന്ന CSS file നിലവിൽ ഇല്ല. ഉപേക്ഷിക്കുന്നു"
30 | },
31 | "unresponsive": {
32 | "details": "പ്രതികാരമില്ലാത്ത എറർ \n{{error}}"
33 | },
34 | "when-ready": {
35 | "clearing-cache-after-20s": "ആപ്പ് cache ക്ലിയർ ചെയ്യുന്നു"
36 | },
37 | "window": {
38 | "tried-to-render-offscreen": "Window സ്ക്രീനിനു വെളിയിൽ render ചെയ്യാൻ ശ്രമിച്ചു, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
39 | }
40 | },
41 | "dialog": {
42 | "hide-menu-enabled": {
43 | "detail": "Menu മറച്ചിരിക്കുന്നു, അവതരിപ്പിക്കാൻ 'Alt' ഉപയോഗിക്കു (In-App Menu ഉപയോഗിക്കുന്നെങ്കിൽ 'Escape' )",
44 | "message": "Hide Menu പ്രവർത്തിയിൽ",
45 | "title": "Hide Menu പ്രവർത്തിയിൽ"
46 | },
47 | "need-to-restart": {
48 | "buttons": {
49 | "later": "പിന്നീട",
50 | "restart-now": "ഇപ്പോൾ പുനരാരംഭിക്കുക"
51 | },
52 | "detail": "\"{{pluginName}}\" പ്രവർത്തിയിൽ ആകാൻ restart വേണ്ടിയിരിക്കുന്നു",
53 | "message": "\"{{pluginName}}\" restart ആവശ്യപെടുന്നു"
54 | }
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/src/i18n/resources/sr.json:
--------------------------------------------------------------------------------
1 | {
2 | "language": {
3 | "code": "sr",
4 | "local-name": "Српски",
5 | "name": "Serbian"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/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 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: () =>
21 | deepmerge(
22 | allPlugins[id].config ?? { enabled: false },
23 | config.get(`plugins.${id}`) ?? {},
24 | ) as PluginConfig,
25 | setConfig: (newConfig) => {
26 | config.setPartial(`plugins.${id}`, newConfig, allPlugins[id].config);
27 | },
28 | window: win,
29 | refresh: async () => {
30 | await setApplicationMenu(win);
31 |
32 | if (config.plugins.isEnabled('in-app-menu')) {
33 | win.webContents.send('refresh-in-app-menu');
34 | }
35 | },
36 | });
37 |
38 | export const forceLoadMenuPlugin = async (id: string, win: BrowserWindow) => {
39 | try {
40 | const plugin = allPlugins[id];
41 | if (!plugin) return;
42 |
43 | const menu = plugin.menu?.(createContext(id, win));
44 | if (menu) {
45 | const result = await menu;
46 | if (result.length > 0) {
47 | menuTemplateMap[id] = result;
48 | } else {
49 | return;
50 | }
51 | } else return;
52 |
53 | console.log(
54 | LoggerPrefix,
55 | t('common.console.plugins.loaded', { pluginName: `${id}::menu` }),
56 | );
57 | } catch (err) {
58 | console.error(
59 | LoggerPrefix,
60 | t('common.console.plugins.initialize-failed', {
61 | pluginName: `${id}::menu`,
62 | }),
63 | );
64 | console.trace(err);
65 | }
66 | };
67 |
68 | export const loadAllMenuPlugins = async (win: BrowserWindow) => {
69 | const pluginConfigs = config.plugins.getPlugins();
70 |
71 | for (const [pluginId, pluginDef] of Object.entries(allPlugins)) {
72 | const config = deepmerge(
73 | pluginDef.config ?? { enabled: false },
74 | pluginConfigs[pluginId] ?? {},
75 | );
76 |
77 | if (config.enabled) {
78 | await forceLoadMenuPlugin(pluginId, win);
79 | }
80 | }
81 | };
82 |
83 | export const getMenuTemplate = (
84 | id: string,
85 | ): MenuItemConstructorOptions[] | undefined => {
86 | return menuTemplateMap[id];
87 | };
88 |
89 | export const getAllMenuTemplate = () => {
90 | return menuTemplateMap;
91 | };
92 |
--------------------------------------------------------------------------------
/src/plugins/adblocker/.gitignore:
--------------------------------------------------------------------------------
1 | /ad-blocker-engine.bin
2 |
--------------------------------------------------------------------------------
/src/plugins/adblocker/adSpeedup.ts:
--------------------------------------------------------------------------------
1 | function skipAd(target: Element) {
2 | const skipButton = target.querySelector(
3 | 'button.ytp-ad-skip-button-modern',
4 | );
5 | if (skipButton) {
6 | skipButton.click();
7 | }
8 | }
9 |
10 | function speedUpAndMute(player: Element, isAdShowing: boolean) {
11 | const video = player.querySelector('video');
12 | if (!video) return;
13 | if (isAdShowing) {
14 | video.playbackRate = 16;
15 | video.muted = true;
16 | } else if (!isAdShowing) {
17 | video.playbackRate = 1;
18 | video.muted = false;
19 | }
20 | }
21 |
22 | export const loadAdSpeedup = () => {
23 | const player = document.querySelector('#movie_player');
24 | if (!player) return;
25 |
26 | new MutationObserver((mutations) => {
27 | for (const mutation of mutations) {
28 | if (
29 | mutation.type === 'attributes' &&
30 | mutation.attributeName === 'class'
31 | ) {
32 | const target = mutation.target as HTMLElement;
33 |
34 | const isAdShowing =
35 | target.classList.contains('ad-showing') ||
36 | target.classList.contains('ad-interrupting');
37 | speedUpAndMute(target, isAdShowing);
38 | }
39 | if (
40 | mutation.type === 'childList' &&
41 | mutation.addedNodes.length &&
42 | mutation.target instanceof HTMLElement
43 | ) {
44 | skipAd(mutation.target);
45 | }
46 | }
47 | }).observe(player, {
48 | attributes: true,
49 | childList: true,
50 | subtree: true,
51 | });
52 |
53 | const isAdShowing =
54 | player.classList.contains('ad-showing') ||
55 | player.classList.contains('ad-interrupting');
56 | speedUpAndMute(player, isAdShowing);
57 | skipAd(player);
58 | };
59 |
--------------------------------------------------------------------------------
/src/plugins/adblocker/injectors/inject-cliqz-preload.ts:
--------------------------------------------------------------------------------
1 | export default async () => {
2 | await import('@ghostery/adblocker-electron-preload');
3 | };
4 |
--------------------------------------------------------------------------------
/src/plugins/adblocker/injectors/inject.d.ts:
--------------------------------------------------------------------------------
1 | import type { ContextBridge } from 'electron';
2 |
3 | export const inject: (contextBridge: ContextBridge) => void;
4 |
5 | export const isInjected: () => boolean;
6 |
--------------------------------------------------------------------------------
/src/plugins/adblocker/types/index.ts:
--------------------------------------------------------------------------------
1 | export const blockers = {
2 | WithBlocklists: 'With blocklists',
3 | InPlayer: 'In player',
4 | AdSpeedup: 'Ad speedup',
5 | } as const;
6 |
--------------------------------------------------------------------------------
/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/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 | setConfig({
79 | authorizedClients: [...config.authorizedClients, id],
80 | });
81 |
82 | const token = await sign(
83 | {
84 | id,
85 | iat: ~~(Date.now() / 1000),
86 | } satisfies JWTPayload,
87 | config.secret,
88 | );
89 |
90 | ctx.status(200);
91 | return ctx.json({
92 | accessToken: token,
93 | });
94 | });
95 | };
96 |
--------------------------------------------------------------------------------
/src/plugins/api-server/backend/routes/index.ts:
--------------------------------------------------------------------------------
1 | export { register as registerControl } from './control';
2 | export { register as registerAuth } from './auth';
3 |
--------------------------------------------------------------------------------
/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 | });
6 |
--------------------------------------------------------------------------------
/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 { OpenAPIHono as Hono } from '@hono/zod-openapi';
2 | import { serve } from '@hono/node-server';
3 |
4 | import type { BackendContext } from '@/types/contexts';
5 | import type { SongInfo } from '@/providers/song-info';
6 | import type { RepeatMode } from '@/types/datahost-get-state';
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 | volume?: number;
17 |
18 | init: (ctx: BackendContext) => Promise;
19 | run: (hostname: string, port: number) => void;
20 | end: () => void;
21 | };
22 |
--------------------------------------------------------------------------------
/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/audio-compressor.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | export default createPlugin({
5 | name: () => t('plugins.audio-compressor.name'),
6 | description: () => t('plugins.audio-compressor.description'),
7 |
8 | renderer() {
9 | document.addEventListener(
10 | 'ytmd:audio-can-play',
11 | ({ detail: { audioSource, audioContext } }) => {
12 | const compressor = audioContext.createDynamicsCompressor();
13 |
14 | compressor.threshold.value = -50;
15 | compressor.ratio.value = 12;
16 | compressor.knee.value = 40;
17 | compressor.attack.value = 0;
18 | compressor.release.value = 0.25;
19 |
20 | audioSource.connect(compressor);
21 | compressor.connect(audioContext.destination);
22 | },
23 | { once: true, passive: true },
24 | );
25 | },
26 | });
27 |
--------------------------------------------------------------------------------
/src/plugins/auth-proxy-adapter/backend/types.ts:
--------------------------------------------------------------------------------
1 | import net from 'net';
2 |
3 | import type { AuthProxyConfig } from '../config';
4 | import type { Server } from 'http';
5 |
6 | export type BackendType = {
7 | server?: Server | net.Server;
8 | oldConfig?: AuthProxyConfig;
9 | startServer: (serverConfig: AuthProxyConfig) => void;
10 | stopServer: () => void;
11 | handleSocks5: (
12 | clientSocket: net.Socket,
13 | chunk: Buffer,
14 | upstreamProxyUrl: string,
15 | ) => void;
16 | processSocks5Request: (
17 | clientSocket: net.Socket,
18 | data: Buffer,
19 | upstreamProxyUrl: string,
20 | ) => void;
21 | };
22 |
--------------------------------------------------------------------------------
/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/bypass-age-restrictions/index.ts:
--------------------------------------------------------------------------------
1 | import { inject } from 'simple-youtube-age-restriction-bypass';
2 |
3 | import { createPlugin } from '@/utils';
4 | import { t } from '@/i18n';
5 |
6 | export default createPlugin({
7 | name: () => t('plugins.bypass-age-restrictions.name'),
8 | description: () => t('plugins.bypass-age-restrictions.description'),
9 | restartNeeded: true,
10 |
11 | // See https://github.com/organization/Simple-YouTube-Age-Restriction-Bypass#userscript
12 | renderer: () => inject(),
13 | });
14 |
--------------------------------------------------------------------------------
/src/plugins/bypass-age-restrictions/simple-youtube-age-restriction-bypass.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'simple-youtube-age-restriction-bypass' {
2 | export const inject: () => void;
3 | }
4 |
--------------------------------------------------------------------------------
/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 | 'captionsSelector',
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 { YoutubePlayer } from '@/types/youtube-player';
3 |
4 | import backend from './back';
5 | import renderer, { CaptionsSelectorConfig, LanguageOptions } from './renderer';
6 | import { t } from '@/i18n';
7 |
8 | export default createPlugin<
9 | unknown,
10 | unknown,
11 | {
12 | captionsSettingsButton: HTMLElement;
13 | captionTrackList: LanguageOptions[] | null;
14 | api: YoutubePlayer | null;
15 | config: CaptionsSelectorConfig | null;
16 | setConfig: (config: Partial) => void;
17 | videoChangeListener: () => void;
18 | captionsButtonClickListener: () => void;
19 | },
20 | CaptionsSelectorConfig
21 | >({
22 | name: () => t('plugins.captions-selector.name'),
23 | description: () => t('plugins.captions-selector.description'),
24 | config: {
25 | enabled: false,
26 | disableCaptions: false,
27 | autoload: false,
28 | lastCaptionsCode: '',
29 | },
30 |
31 | async menu({ getConfig, setConfig }) {
32 | const config = await getConfig();
33 | return [
34 | {
35 | label: t('plugins.captions-selector.menu.autoload'),
36 | type: 'checkbox',
37 | checked: config.autoload,
38 | click(item) {
39 | setConfig({ autoload: item.checked });
40 | },
41 | },
42 | {
43 | label: t('plugins.captions-selector.menu.disable-captions'),
44 | type: 'checkbox',
45 | checked: config.disableCaptions,
46 | click(item) {
47 | setConfig({ disableCaptions: item.checked });
48 | },
49 | },
50 | ];
51 | },
52 |
53 | backend,
54 | renderer,
55 | });
56 |
--------------------------------------------------------------------------------
/src/plugins/captions-selector/templates/captions-settings-template.html:
--------------------------------------------------------------------------------
1 |
10 |
11 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/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/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 { createPlugin } from '@/utils';
2 | import { backend } from './main';
3 | import { onMenu } from './menu';
4 | import { t } from '@/i18n';
5 |
6 | export type DiscordPluginConfig = {
7 | enabled: boolean;
8 | /**
9 | * If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
10 | *
11 | * @default true
12 | */
13 | autoReconnect: boolean;
14 | /**
15 | * If enabled, the discord rich presence gets cleared when music paused after the time specified below
16 | */
17 | activityTimeoutEnabled: boolean;
18 | /**
19 | * The time in milliseconds after which the discord rich presence gets cleared when music paused
20 | *
21 | * @default 10 * 60 * 1000 (10 minutes)
22 | */
23 | activityTimeoutTime: number;
24 | /**
25 | * Add a "Play on YouTube Music" button to rich presence
26 | */
27 | playOnYouTubeMusic: boolean;
28 | /**
29 | * Hide the "View App On GitHub" button in the rich presence
30 | */
31 | hideGitHubButton: boolean;
32 | /**
33 | * Hide the "duration left" in the rich presence
34 | */
35 | hideDurationLeft: boolean;
36 | };
37 |
38 | export default createPlugin({
39 | name: () => t('plugins.discord.name'),
40 | description: () => t('plugins.discord.description'),
41 | restartNeeded: false,
42 | config: {
43 | enabled: false,
44 | autoReconnect: true,
45 | activityTimeoutEnabled: true,
46 | activityTimeoutTime: 10 * 60 * 1000,
47 | playOnYouTubeMusic: true,
48 | hideGitHubButton: false,
49 | hideDurationLeft: false,
50 | } as DiscordPluginConfig,
51 | menu: onMenu,
52 | backend,
53 | });
54 |
--------------------------------------------------------------------------------
/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 { 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/downloader/index.ts:
--------------------------------------------------------------------------------
1 | import { DefaultPresetList, 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, 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.html:
--------------------------------------------------------------------------------
1 |
46 |
--------------------------------------------------------------------------------
/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/exponential-volume/index.ts:
--------------------------------------------------------------------------------
1 | import { createPlugin } from '@/utils';
2 | import { t } from '@/i18n';
3 |
4 | export default createPlugin({
5 | name: () => t('plugins.exponential-volume.name'),
6 | description: () => t('plugins.exponential-volume.description'),
7 | restartNeeded: true,
8 | config: {
9 | enabled: false,
10 | },
11 | renderer: {
12 | onPlayerApiReady() {
13 | // "YouTube Music fix volume ratio 0.4" by Marco Pfeiffer
14 | // https://greasyfork.org/en/scripts/397686-youtube-music-fix-volume-ratio/
15 |
16 | // Manipulation exponent, higher value = lower volume
17 | // 3 is the value used by pulseaudio, which Barteks2x figured out this gist here: https://gist.github.com/Barteks2x/a4e189a36a10c159bb1644ffca21c02a
18 | // 0.05 (or 5%) is the lowest you can select in the UI which with an exponent of 3 becomes 0.000125 or 0.0125%
19 | const EXPONENT = 3;
20 |
21 | const storedOriginalVolumes = new WeakMap();
22 | const propertyDescriptor = Object.getOwnPropertyDescriptor(
23 | HTMLMediaElement.prototype,
24 | 'volume',
25 | );
26 | Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
27 | get(this: HTMLMediaElement) {
28 | const lowVolume =
29 | (propertyDescriptor?.get?.call(this) as number) ?? 0;
30 | const calculatedOriginalVolume = lowVolume ** (1 / EXPONENT);
31 |
32 | // The calculated value has some accuracy issues which can lead to problems for implementations that expect exact values.
33 | // To avoid this, I'll store the unmodified volume to return it when read here.
34 | // This mostly solves the issue, but the initial read has no stored value and the volume can also change though external influences.
35 | // To avoid ill effects, I check if the stored volume is somewhere in the same range as the calculated volume.
36 | const storedOriginalVolume = storedOriginalVolumes.get(this) ?? 0;
37 | const storedDeviation = Math.abs(
38 | storedOriginalVolume - calculatedOriginalVolume,
39 | );
40 |
41 | return storedDeviation < 0.01
42 | ? storedOriginalVolume
43 | : calculatedOriginalVolume;
44 | },
45 | set(this: HTMLMediaElement, originalVolume: number) {
46 | const lowVolume = originalVolume ** EXPONENT;
47 | storedOriginalVolumes.set(this, originalVolume);
48 | propertyDescriptor?.set?.call(this, lowVolume);
49 | },
50 | });
51 | },
52 | },
53 | });
54 |
--------------------------------------------------------------------------------
/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/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, 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 { 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 { 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/lyrics-genius/index.ts:
--------------------------------------------------------------------------------
1 | import style from './style.css?inline';
2 | import { createPlugin } from '@/utils';
3 | import { onConfigChange, onMainLoad } from './main';
4 | import { onRendererLoad } from './renderer';
5 | import { t } from '@/i18n';
6 |
7 | export type LyricsGeniusPluginConfig = {
8 | enabled: boolean;
9 | romanizedLyrics: boolean;
10 | };
11 |
12 | export default createPlugin({
13 | name: () => t('plugins.lyrics-genius.name'),
14 | description: () => t('plugins.lyrics-genius.description'),
15 | restartNeeded: true,
16 | config: {
17 | enabled: false,
18 | romanizedLyrics: false,
19 | } as LyricsGeniusPluginConfig,
20 | stylesheets: [style],
21 | async menu({ getConfig, setConfig }) {
22 | const config = await getConfig();
23 |
24 | return [
25 | {
26 | label: t('plugins.lyrics-genius.menu.romanized-lyrics'),
27 | type: 'checkbox',
28 | checked: config.romanizedLyrics,
29 | click(item) {
30 | setConfig({
31 | romanizedLyrics: item.checked,
32 | });
33 | },
34 | },
35 | ];
36 | },
37 |
38 | backend: {
39 | start: onMainLoad,
40 | onConfigChange,
41 | },
42 | renderer: onRendererLoad,
43 | });
44 |
--------------------------------------------------------------------------------
/src/plugins/lyrics-genius/style.css:
--------------------------------------------------------------------------------
1 | /* Disable links in Genius lyrics */
2 | .genius-lyrics a {
3 | color: var(--ytmusic-text-primary);
4 | display: inline-block;
5 | pointer-events: none;
6 | text-decoration: none;
7 | }
8 |
9 | .description {
10 | font-size: clamp(1.4rem, 1.1vmax, 3rem) !important;
11 | text-align: center !important;
12 | }
13 |
--------------------------------------------------------------------------------
/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 { extractToken, getAuthorizationHeader, getClient } from './client';
2 |
3 | type QueueRendererResponse = {
4 | queueDatas: {
5 | content: unknown;
6 | }[];
7 | responseContext: unknown;
8 | trackingParams: string;
9 | };
10 |
11 | export const getMusicQueueRenderer = async (
12 | videoIds: string[],
13 | ): Promise => {
14 | const token = extractToken();
15 | if (!token) return null;
16 |
17 | const response = await fetch(
18 | 'https://music.youtube.com/youtubei/v1/music/get_queue?key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30&prettyPrint=false',
19 | {
20 | method: 'POST',
21 | credentials: 'include',
22 | body: JSON.stringify({
23 | context: {
24 | client: getClient(),
25 | request: {
26 | useSsl: true,
27 | internalExperimentFlags: [],
28 | consistencyTokenJars: [],
29 | },
30 | user: {
31 | lockedSafetyMode: false,
32 | },
33 | },
34 | videoIds,
35 | }),
36 | headers: {
37 | 'Content-Type': 'application/json',
38 | 'Origin': 'https://music.youtube.com',
39 | 'Authorization': await getAuthorizationHeader(token),
40 | },
41 | },
42 | );
43 |
44 | const text = await response.text();
45 | try {
46 | return JSON.parse(text) as QueueRendererResponse;
47 | } catch {}
48 |
49 | return null;
50 | };
51 |
--------------------------------------------------------------------------------
/src/plugins/music-together/queue/utils.ts:
--------------------------------------------------------------------------------
1 | import {
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/index.ts:
--------------------------------------------------------------------------------
1 | import style from './style.css?inline';
2 | import { createPlugin } from '@/utils';
3 | import { ElementFromHtml } from '@/plugins/utils/renderer';
4 |
5 | import { t } from '@/i18n';
6 |
7 | import forwardHTML from './templates/forward.html?raw';
8 | import backHTML from './templates/back.html?raw';
9 |
10 | export default createPlugin({
11 | name: () => t('plugins.navigation.name'),
12 | description: () => t('plugins.navigation.description'),
13 | restartNeeded: false,
14 | config: {
15 | enabled: true,
16 | },
17 | stylesheets: [style],
18 | renderer: {
19 | start() {
20 | const forwardButton = ElementFromHtml(forwardHTML);
21 | const backButton = ElementFromHtml(backHTML);
22 | const menu = document.querySelector('#right-content');
23 | menu?.prepend(backButton, forwardButton);
24 | },
25 | stop() {
26 | document.querySelector('[tab-id=FEmusic_back]')?.remove();
27 | document.querySelector('[tab-id=FEmusic_next]')?.remove();
28 | },
29 | },
30 | });
31 |
--------------------------------------------------------------------------------
/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/navigation/templates/back.html:
--------------------------------------------------------------------------------
1 |
34 |
--------------------------------------------------------------------------------
/src/plugins/navigation/templates/forward.html:
--------------------------------------------------------------------------------
1 |
36 |
--------------------------------------------------------------------------------
/src/plugins/no-google-login/index.ts:
--------------------------------------------------------------------------------
1 | import style from './style.css?inline';
2 | import { createPlugin } from '@/utils';
3 | import { t } from '@/i18n';
4 |
5 | export default createPlugin({
6 | name: () => t('plugins.no-google-login.name'),
7 | description: () => t('plugins.no-google-login.description'),
8 | restartNeeded: true,
9 | config: {
10 | enabled: false,
11 | },
12 | stylesheets: [style],
13 | renderer() {
14 | const elementsToRemove = [
15 | '.sign-in-link.ytmusic-nav-bar',
16 | '.ytmusic-pivot-bar-renderer[tab-id="FEmusic_liked"]',
17 | ];
18 |
19 | for (const selector of elementsToRemove) {
20 | const node = document.querySelector(selector);
21 | if (node) {
22 | node.remove();
23 | }
24 | }
25 | },
26 | });
--------------------------------------------------------------------------------
/src/plugins/no-google-login/style.css:
--------------------------------------------------------------------------------
1 | .ytmusic-pivot-bar-renderer[tab-id='FEmusic_liked'],
2 | ytmusic-guide-signin-promo-renderer,
3 | a[href='/music_premium'],
4 | .sign-in-link {
5 | display: none !important;
6 | }
7 |
--------------------------------------------------------------------------------
/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 registerCallback, {
9 | type SongInfo,
10 | SongInfoEvent,
11 | } from '@/providers/song-info';
12 |
13 | import type { NotificationsPluginConfig } from './index';
14 | import type { BackendContext } from '@/types/contexts';
15 |
16 | let config: NotificationsPluginConfig;
17 |
18 | const notify = (info: SongInfo) => {
19 | // Send the notification
20 | const currentNotification = new Notification({
21 | title: info.title || 'Playing',
22 | body: info.artist,
23 | icon: notificationImage(info, config),
24 | silent: true,
25 | urgency: config.urgency,
26 | });
27 | currentNotification.show();
28 |
29 | return currentNotification;
30 | };
31 |
32 | const setup = () => {
33 | let oldNotification: Notification;
34 | let currentUrl: string | undefined;
35 |
36 | registerCallback((songInfo: SongInfo, event) => {
37 | if (
38 | event !== SongInfoEvent.TimeChanged &&
39 | !songInfo.isPaused &&
40 | (songInfo.url !== currentUrl || config.unpauseNotification)
41 | ) {
42 | // Close the old notification
43 | oldNotification?.close();
44 | currentUrl = songInfo.url;
45 | // This fixes a weird bug that would cause the notification to be updated instead of showing
46 | setTimeout(() => {
47 | oldNotification = notify(songInfo);
48 | }, 10);
49 | }
50 | });
51 | };
52 |
53 | export const onMainLoad = async (
54 | context: BackendContext,
55 | ) => {
56 | config = await context.getConfig();
57 |
58 | // Register the callback for new song information
59 | if (is.windows() && config.interactive)
60 | interactive(context.window, () => config, context);
61 | else setup();
62 | };
63 |
64 | export const onConfigChange = (newConfig: NotificationsPluginConfig) => {
65 | config = newConfig;
66 | };
67 |
--------------------------------------------------------------------------------
/src/plugins/notifications/utils.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 | import fs from 'node:fs';
3 |
4 | import { app, NativeImage } from 'electron';
5 |
6 | import youtubeMusicIcon from '@assets/youtube-music.png?asset&asarUnpack';
7 |
8 | import { 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, onRendererLoad } 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 | start: onRendererLoad,
45 | onPlayerApiReady,
46 | },
47 | });
48 |
--------------------------------------------------------------------------------
/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/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.html:
--------------------------------------------------------------------------------
1 |
53 |
--------------------------------------------------------------------------------
/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/qualitySettingsTemplate.html:
--------------------------------------------------------------------------------
1 |
10 |
11 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/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, { 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(
25 | 'tp-yt-paper-icon-button.next-button',
26 | )
27 | ?.click();
28 | }
29 | });
30 | this.observer.observe(dislikeBtn, {
31 | attributes: true,
32 | childList: false,
33 | subtree: false,
34 | });
35 | },
36 | );
37 | },
38 | stop() {
39 | this.observer?.disconnect();
40 | },
41 | },
42 | });
43 |
--------------------------------------------------------------------------------
/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 { 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 | /* eslint-disable @typescript-eslint/no-require-imports */
2 |
3 | // eslint-disable-next-line no-undef
4 | const { test, expect } = require('@playwright/test');
5 |
6 | // eslint-disable-next-line no-undef
7 | const { sortSegments } = require('../segments');
8 |
9 | test('Segment sorting', () => {
10 | expect(
11 | sortSegments([
12 | [0, 3],
13 | [7, 8],
14 | [5, 6],
15 | ]),
16 | ).toEqual([
17 | [0, 3],
18 | [5, 6],
19 | [7, 8],
20 | ]);
21 |
22 | expect(
23 | sortSegments([
24 | [0, 5],
25 | [6, 8],
26 | [4, 6],
27 | ]),
28 | ).toEqual([[0, 8]]);
29 |
30 | expect(
31 | sortSegments([
32 | [0, 6],
33 | [7, 8],
34 | [4, 6],
35 | ]),
36 | ).toEqual([
37 | [0, 6],
38 | [7, 8],
39 | ]);
40 | });
41 |
--------------------------------------------------------------------------------
/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/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 |
8 | import type { SyncedLyricsPluginConfig } from './types';
9 |
10 | export default createPlugin({
11 | name: () => t('plugins.synced-lyrics.name'),
12 | description: () => t('plugins.synced-lyrics.description'),
13 | authors: ['Non0reo', 'ArjixWasTaken', 'KimJammer'],
14 | restartNeeded: true,
15 | addedVersion: '3.5.X',
16 | config: {
17 | enabled: false,
18 | preciseTiming: true,
19 | showLyricsEvenIfInexact: true,
20 | showTimeCodes: false,
21 | defaultTextString: '♪',
22 | lineEffect: 'fancy',
23 | romanization: true,
24 | } satisfies SyncedLyricsPluginConfig as SyncedLyricsPluginConfig,
25 |
26 | menu,
27 | renderer,
28 | stylesheets: [style],
29 | });
30 |
--------------------------------------------------------------------------------
/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/MusixMatch.ts:
--------------------------------------------------------------------------------
1 | import type { LyricProvider, LyricResult, SearchSongInfo } from '../types';
2 |
3 | export class MusixMatch implements LyricProvider {
4 | name = 'MusixMatch';
5 | baseUrl = 'https://www.musixmatch.com/';
6 |
7 | search(_: SearchSongInfo): Promise {
8 | throw new Error('Not implemented');
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/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 '../../providers';
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())}
49 | data={{
50 | icon: { iconType: 'REFRESH' },
51 | isDisabled: false,
52 | style: 'STYLE_DEFAULT',
53 | text: {
54 | simpleText: t('plugins.synced-lyrics.refetch-btn.normal')
55 | },
56 | }}
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/LyricsContainer.tsx:
--------------------------------------------------------------------------------
1 | import { createEffect, createSignal, For, Match, Show, Switch } from 'solid-js';
2 |
3 | import { SyncedLine } from './SyncedLine';
4 |
5 | import { ErrorDisplay } from './ErrorDisplay';
6 | import { LoadingKaomoji } from './LoadingKaomoji';
7 | import { PlainLyrics } from './PlainLyrics';
8 |
9 | import { hasJapaneseInString, hasKoreanInString } from '../utils';
10 | import { currentLyrics, lyricsStore } from '../../providers';
11 |
12 | export const [debugInfo, setDebugInfo] = createSignal();
13 | export const [currentTime, setCurrentTime] = createSignal(-1);
14 |
15 | // prettier-ignore
16 | export const LyricsContainer = () => {
17 | const [hasJapanese, setHasJapanese] = createSignal(false);
18 | const [hasKorean, setHasKorean] = createSignal(false);
19 |
20 | createEffect(() => {
21 | const data = currentLyrics()?.data;
22 | if (data) {
23 | setHasKorean(hasKoreanInString(data));
24 | setHasJapanese(hasJapaneseInString(data));
25 | } else {
26 | setHasKorean(false);
27 | setHasJapanese(false);
28 | }
29 | });
30 |
31 | return (
32 |
33 |
34 |
35 |
36 |
37 |
38 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | {(item) => }
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/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 } from './renderer';
6 | import { setCurrentTime } from './components/LyricsContainer';
7 |
8 | import { fetchLyrics } from '../providers';
9 |
10 | import type { RendererContext } from '@/types/contexts';
11 | import type { YoutubePlayer } from '@/types/youtube-player';
12 | import type { SongInfo } from '@/providers/song-info';
13 | import type { SyncedLyricsPluginConfig } from '../types';
14 |
15 | export let _ytAPI: YoutubePlayer | null = null;
16 |
17 | export const renderer = createRenderer<
18 | {
19 | observerCallback: MutationCallback;
20 | observer?: MutationObserver;
21 | videoDataChange: () => Promise;
22 | updateTimestampInterval?: NodeJS.Timeout | string | number;
23 | },
24 | SyncedLyricsPluginConfig
25 | >({
26 | onConfigChange(newConfig) {
27 | setConfig(newConfig);
28 | },
29 |
30 | observerCallback(mutations: MutationRecord[]) {
31 | for (const mutation of mutations) {
32 | const header = mutation.target as HTMLElement;
33 |
34 | switch (mutation.attributeName) {
35 | case 'disabled':
36 | header.removeAttribute('disabled');
37 | break;
38 | case 'aria-selected':
39 | tabStates[header.ariaSelected ?? 'false']();
40 | break;
41 | }
42 | }
43 | },
44 |
45 | async onPlayerApiReady(api: YoutubePlayer) {
46 | _ytAPI = api;
47 |
48 | api.addEventListener('videodatachange', this.videoDataChange);
49 |
50 | await this.videoDataChange();
51 | },
52 | async videoDataChange() {
53 | if (!this.updateTimestampInterval) {
54 | this.updateTimestampInterval = setInterval(
55 | () => setCurrentTime((_ytAPI?.getCurrentTime() ?? 0) * 1000),
56 | 100,
57 | );
58 | }
59 |
60 | // prettier-ignore
61 | this.observer ??= new MutationObserver(this.observerCallback);
62 | this.observer.disconnect();
63 |
64 | // Force the lyrics tab to be enabled at all times.
65 | const header = await waitForElement(selectors.head);
66 | {
67 | header.removeAttribute('disabled');
68 | tabStates[header.ariaSelected ?? 'false']();
69 | }
70 |
71 | this.observer.observe(header, { attributes: true });
72 | header.removeAttribute('disabled');
73 | },
74 |
75 | async start(ctx: RendererContext) {
76 | setConfig(await ctx.getConfig());
77 |
78 | ctx.ipc.on('ytmd:update-song-info', (info: SongInfo) => {
79 | fetchLyrics(info);
80 | });
81 | },
82 | });
83 |
--------------------------------------------------------------------------------
/src/plugins/synced-lyrics/types.ts:
--------------------------------------------------------------------------------
1 | import type { SongInfo } from '@/providers/song-info';
2 |
3 | export type SyncedLyricsPluginConfig = {
4 | enabled: boolean;
5 | preciseTiming: boolean;
6 | showTimeCodes: boolean;
7 | defaultTextString: string;
8 | showLyricsEvenIfInexact: boolean;
9 | lineEffect: LineEffect;
10 | romanization: boolean;
11 | };
12 |
13 | export type LineLyricsStatus = 'previous' | 'current' | 'upcoming';
14 |
15 | export type LineLyrics = {
16 | time: string;
17 | timeInMs: number;
18 | duration: number;
19 |
20 | text: string;
21 | status: LineLyricsStatus;
22 | };
23 |
24 | export type LineEffect = 'fancy' | 'scale' | 'offset' | 'focus';
25 |
26 | export interface LyricResult {
27 | title: string;
28 | artists: string[];
29 |
30 | lyrics?: string;
31 | lines?: LineLyrics[];
32 | }
33 |
34 | // prettier-ignore
35 | export type SearchSongInfo = Pick;
36 |
37 | export interface LyricProvider {
38 | name: string;
39 | baseUrl: string;
40 |
41 | search(songInfo: SearchSongInfo): Promise;
42 | }
43 |
--------------------------------------------------------------------------------
/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/html.ts:
--------------------------------------------------------------------------------
1 | import { defaultTrustedTypePolicy } from '@/utils/trusted-types';
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 | const template = document.createElement('template');
10 | html = html.trim(); // Never return a text node of whitespace as the result
11 | (template.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy
12 | ? defaultTrustedTypePolicy.createHTML(html)
13 | : html;
14 |
15 | return template.content.firstElementChild as HTMLElement;
16 | };
17 |
18 | /**
19 | * Creates a DOM element from a src string
20 | * @param src The source of the image
21 | * @returns The image element
22 | */
23 | export const ImageElementFromSrc = (src: string): HTMLImageElement => {
24 | const image = document.createElement('img');
25 | image.src = src;
26 | return image;
27 | };
28 |
--------------------------------------------------------------------------------
/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: 'Video';
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/button_template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
--------------------------------------------------------------------------------
/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/providers/app-controls.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import { app, BrowserWindow, ipcMain } from 'electron';
4 |
5 | import 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('ytmusic-menu-popup-renderer tp-yt-paper-listbox');
3 |
4 | export default { getSongMenu };
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, BrowserWindow } from 'electron';
4 |
5 | import getSongControls from './song-controls';
6 |
7 | export const APP_PROTOCOL = 'youtubemusic';
8 |
9 | let protocolHandler:
10 | | ((cmd: string, args: string[] | undefined) => void)
11 | | undefined;
12 |
13 | export function setupProtocolHandler(win: BrowserWindow) {
14 | if (process.defaultApp && process.argv.length >= 2) {
15 | app.setAsDefaultProtocolClient(APP_PROTOCOL, process.execPath, [
16 | path.resolve(process.argv[1]),
17 | ]);
18 | } else {
19 | app.setAsDefaultProtocolClient(APP_PROTOCOL);
20 | }
21 |
22 | const songControls = getSongControls(win);
23 |
24 | protocolHandler = ((
25 | cmd: keyof typeof songControls,
26 | args: string[] | undefined = undefined,
27 | ) => {
28 | if (Object.keys(songControls).includes(cmd)) {
29 | songControls[cmd](args as never);
30 | }
31 | }) as (cmd: string) => void;
32 | }
33 |
34 | export function handleProtocol(cmd: string, args: string[] | undefined) {
35 | protocolHandler?.(cmd, args);
36 | }
37 |
38 | export function changeProtocolHandler(
39 | f: (cmd: string, args: string[] | undefined) => void,
40 | ) {
41 | protocolHandler = f;
42 | }
43 |
44 | export default {
45 | APP_PROTOCOL,
46 | setupProtocolHandler,
47 | handleProtocol,
48 | changeProtocolHandler,
49 | };
50 |
--------------------------------------------------------------------------------
/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 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 | interface Window {
23 | trustedTypes?: typeof trustedTypes;
24 | ipcRenderer: typeof electronIpcRenderer;
25 | mainConfig: typeof config;
26 | electronIs: typeof is;
27 | ELECTRON_RENDERER_URL: string | undefined;
28 | /**
29 | * YouTube Music internal variable (Last interaction time)
30 | */
31 | _lact: number;
32 | navigation: Navigation;
33 | download: () => void;
34 | togglePictureInPicture: () => void;
35 | reload: () => void;
36 | i18n: {
37 | t: typeof t;
38 | };
39 | }
40 | }
41 |
42 | // import { Howl as _Howl } from 'howler';
43 | declare module 'howler' {
44 | interface Howl {
45 | _sounds: {
46 | _paused: boolean;
47 | _ended: boolean;
48 | _id: string;
49 | _node: HTMLMediaElement;
50 | }[];
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/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 interface PluginDef<
42 | BackendProperties,
43 | PreloadProperties,
44 | RendererProperties,
45 | Config extends PluginConfig = PluginConfig,
46 | > {
47 | name: () => string;
48 | authors?: Author[];
49 | description?: () => string;
50 | addedVersion?: string;
51 | config?: Config;
52 |
53 | menu?: (
54 | ctx: MenuContext,
55 | ) =>
56 | | Promise
57 | | Electron.MenuItemConstructorOptions[];
58 | stylesheets?: string[];
59 | restartNeeded?: boolean;
60 |
61 | backend?: {
62 | [Key in keyof BackendProperties]: BackendProperties[Key];
63 | } & PluginLifecycle, BackendProperties>;
64 | preload?: {
65 | [Key in keyof PreloadProperties]: PreloadProperties[Key];
66 | } & PluginLifecycle, PreloadProperties>;
67 | renderer?: {
68 | [Key in keyof RendererProperties]: RendererProperties[Key];
69 | } & RendererPluginLifecycle<
70 | Config,
71 | RendererContext,
72 | RendererProperties
73 | >;
74 | }
75 |
--------------------------------------------------------------------------------
/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: unknown) => 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/testing.ts:
--------------------------------------------------------------------------------
1 | export const isTesting = () => process.env.NODE_ENV === 'test';
2 |
3 | export default { isTesting };
4 |
--------------------------------------------------------------------------------
/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: Record;
7 | export const preloadPlugins: Record;
8 | export const rendererPlugins: Record;
9 |
10 | export const allPlugins: Record<
11 | string,
12 | Omit
13 | >;
14 | }
15 |
16 | declare module 'virtual:i18n' {
17 | import type { LanguageResources } from '@/i18n/resources/@types';
18 |
19 | export const languageResources: LanguageResources;
20 | }
21 |
--------------------------------------------------------------------------------
/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 | /* Blocking annoying elements */
37 | ytmusic-mealbar-promo-renderer {
38 | display: none !important;
39 | }
40 |
41 | /* Disable Image Selection */
42 | img {
43 | -webkit-user-select: none;
44 | user-select: none;
45 | }
46 |
47 | /* Hide cast button which doesn't work */
48 | ytmusic-cast-button {
49 | display: none !important;
50 | }
51 |
52 | /* Remove useless inaccessible button on top-right corner of the video player */
53 | .ytp-chrome-top-buttons {
54 | display: none !important;
55 | }
56 |
57 | /* Make youtube-music logo un-draggable */
58 | ytmusic-nav-bar > div.left-content > a,
59 | ytmusic-nav-bar > div.left-content > a > picture > img {
60 | -webkit-user-drag: none;
61 | }
62 |
63 | /* yt-music bugs */
64 | tp-yt-paper-item.ytmusic-guide-entry-renderer::before {
65 | border-radius: 8px !important;
66 | }
67 |
68 | /* fix video player align */
69 | #av-id {
70 | padding-bottom: 0;
71 | }
72 |
73 | #av-id ~ #player.ytmusic-player-page:not([player-ui-state='FULLSCREEN']) {
74 | margin-top: auto !important;
75 | margin-bottom: auto !important;
76 | margin-left: var(--ytmusic-player-page-vertical-padding);
77 | margin-right: var(--ytmusic-player-page-vertical-padding);
78 | max-height: calc(100% - (var(--ytmusic-player-page-vertical-padding) * 2));
79 | max-width: calc(100% - var(--ytmusic-player-page-vertical-padding) * 2);
80 | }
81 |
82 | /* macos traffic lights fix */
83 | :where([data-os*='Macintosh']) ytmusic-app-layout#layout ytmusic-nav-bar {
84 | padding-top: var(--ytmusic-nav-bar-offset, 0);
85 | }
86 | :where([data-os*='Macintosh']) ytmusic-app-layout#layout {
87 | --ytmusic-nav-bar-offset: 24px;
88 | --ytmusic-nav-bar-height: calc(90px + var(--ytmusic-nav-bar-offset, 0));
89 | }
90 |
91 | tp-yt-iron-dropdown,
92 | tp-yt-paper-dialog {
93 | app-region: no-drag;
94 | }
95 |
--------------------------------------------------------------------------------
/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 { Icons } from '@/types/icons';
2 |
3 | import type { ComponentProps } from 'solid-js';
4 |
5 | declare module 'solid-js' {
6 | namespace JSX {
7 | interface YtFormattedStringProps {
8 | text?: {
9 | runs: { text: string }[];
10 | };
11 | data?: object;
12 | disabled?: boolean;
13 | hidden?: boolean;
14 | }
15 |
16 | interface YtButtonRendererProps {
17 | data?: {
18 | icon?: {
19 | iconType: string;
20 | };
21 | isDisabled?: boolean;
22 | style?: string;
23 | text?: {
24 | simpleText: string;
25 | };
26 | };
27 | }
28 |
29 | interface YpYtPaperSpinnerLiteProps {
30 | active?: boolean;
31 | }
32 |
33 | interface TpYtPaperIconButtonProps {
34 | icon: Icons;
35 | }
36 |
37 | interface IntrinsicElements {
38 | center: ComponentProps<'div'>;
39 | 'yt-formatted-string': ComponentProps<'span'> & YtFormattedStringProps;
40 | 'yt-button-renderer': ComponentProps<'button'> & YtButtonRendererProps;
41 | 'tp-yt-paper-spinner-lite': ComponentProps<'div'> &
42 | YpYtPaperSpinnerLiteProps;
43 | 'tp-yt-paper-icon-button': ComponentProps<'div'> &
44 | TpYtPaperIconButtonProps;
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/tests/index.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 |
3 | const path = require('node:path');
4 |
5 | const { _electron: electron } = require('playwright');
6 | const { test, expect } = require('@playwright/test');
7 |
8 | process.env.NODE_ENV = 'test';
9 |
10 | const appPath = path.resolve(__dirname, '..');
11 |
12 | test('YouTube Music App - With default settings, app is launched and visible', async () => {
13 | const app = await electron.launch({
14 | cwd: appPath,
15 | args: [
16 | appPath,
17 | '--no-sandbox',
18 | '--disable-gpu',
19 | '--whitelisted-ips=',
20 | '--disable-dev-shm-usage',
21 | ],
22 | });
23 |
24 | const window = await app.firstWindow();
25 |
26 | const consentForm = await window.$(
27 | "form[action='https://consent.youtube.com/save']",
28 | );
29 | if (consentForm) {
30 | await consentForm.click('button');
31 | }
32 |
33 | // const title = await window.title();
34 | // expect(title.replaceAll(/\s/g, ' ')).toEqual('YouTube Music');
35 |
36 | const url = window.url();
37 | expect(url.startsWith('https://music.youtube.com')).toBe(true);
38 |
39 | await app.close();
40 | });
41 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@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": "node",
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 snakeToCamel = (text: string) =>
8 | text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase());
9 |
10 | const __dirname = dirname(fileURLToPath(import.meta.url));
11 | const globalProject = new Project({
12 | tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'),
13 | skipAddingFilesFromTsConfig: true,
14 | skipLoadingLibFiles: true,
15 | skipFileDependencyResolution: true,
16 | });
17 |
18 | export const i18nImporter = () => {
19 | const srcPath = resolve(__dirname, '..', 'src');
20 | const plugins = globSync(['src/i18n/resources/*.json']).map((path) => {
21 | const nameWithExt = basename(path);
22 | const name = nameWithExt.replace(extname(nameWithExt), '');
23 |
24 | return { name, path };
25 | });
26 |
27 | const src = globalProject.createSourceFile(
28 | 'vm:i18n',
29 | (writer) => {
30 | // prettier-ignore
31 | for (const { name, path } of plugins) {
32 | const relativePath = relative(resolve(srcPath, '..'), path).replace(/\\/g, '/');
33 | writer.writeLine(`import ${snakeToCamel(name)}Json from "./${relativePath}";`);
34 | }
35 |
36 | writer.blankLine();
37 |
38 | writer.writeLine('export const languageResources = {');
39 | for (const { name } of plugins) {
40 | writer.writeLine(` "${name}": {`);
41 | writer.writeLine(` translation: ${snakeToCamel(name)}Json,`);
42 | writer.writeLine(' },');
43 | }
44 | writer.writeLine('};');
45 | writer.blankLine();
46 | },
47 | { overwrite: true },
48 | );
49 |
50 | return src.getText();
51 | };
52 |
--------------------------------------------------------------------------------
/vite-plugins/plugin-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 snakeToCamel = (text: string) =>
8 | text.replace(/-(\w)/g, (_, letter: string) => letter.toUpperCase());
9 |
10 | const __dirname = dirname(fileURLToPath(import.meta.url));
11 | const globalProject = new Project({
12 | tsConfigFilePath: resolve(__dirname, '..', 'tsconfig.json'),
13 | skipAddingFilesFromTsConfig: true,
14 | skipLoadingLibFiles: true,
15 | skipFileDependencyResolution: true,
16 | });
17 |
18 | export const pluginVirtualModuleGenerator = (
19 | mode: 'main' | 'preload' | 'renderer',
20 | ) => {
21 | const srcPath = resolve(__dirname, '..', 'src');
22 | const plugins = globSync([
23 | 'src/plugins/*/index.{js,ts}',
24 | 'src/plugins/*.{js,ts}',
25 | '!src/plugins/utils/**/*',
26 | '!src/plugins/utils/*',
27 | ]).map((path) => {
28 | let name = basename(path);
29 | if (name === 'index.ts' || name === 'index.js') {
30 | name = basename(resolve(path, '..'));
31 | }
32 |
33 | name = name.replace(extname(name), '');
34 |
35 | return { name, path };
36 | });
37 |
38 | const src = globalProject.createSourceFile(
39 | 'vm:pluginIndexes',
40 | (writer) => {
41 | // prettier-ignore
42 | for (const { name, path } of plugins) {
43 | const relativePath = relative(resolve(srcPath, '..'), path).replace(/\\/g, '/');
44 | writer.writeLine(`import ${snakeToCamel(name)}Plugin, { pluginStub as ${snakeToCamel(name)}PluginStub } from "./${relativePath}";`);
45 | }
46 |
47 | writer.blankLine();
48 |
49 | // Context-specific exports
50 | writer.writeLine(`export const ${mode}Plugins = {`);
51 | for (const { name } of plugins) {
52 | const checkMode = mode === 'main' ? 'backend' : mode;
53 | // HACK: To avoid situation like importing renderer plugins in main
54 | writer.writeLine(
55 | ` ...(${snakeToCamel(name)}Plugin['${checkMode}'] ? { "${name}": ${snakeToCamel(name)}Plugin } : {}),`,
56 | );
57 | }
58 | writer.writeLine('};');
59 | writer.blankLine();
60 |
61 | // All plugins export (stub only) // Omit
62 | writer.writeLine('export const allPlugins = {');
63 | for (const { name } of plugins) {
64 | writer.writeLine(` "${name}": ${snakeToCamel(name)}PluginStub,`);
65 | }
66 | writer.writeLine('};');
67 | writer.blankLine();
68 | },
69 | { overwrite: true },
70 | );
71 |
72 | return src.getText();
73 | };
74 |
--------------------------------------------------------------------------------
/web/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/th-ch/youtube-music/60ca5ec17e9e435789c143b1adb03b6b682b97a4/web/screenshot.png
--------------------------------------------------------------------------------