├── .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 | 2 | 3 | 5 | 6 | 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 | 2 | 3 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /docs/img/bg-bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 16 | 19 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /docs/img/bg-top.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 25 | 28 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/img/code.svg: -------------------------------------------------------------------------------- 1 | 2 | </> 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/img/download.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/img/footer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /docs/img/plugins.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 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 | 2 | 3 | 5 | 6 | 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 | 18 | 19 | 23 | 24 | 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 |
  • 43 | {local.text} 44 |
  • 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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/plugins/music-together/icons/key.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/plugins/music-together/icons/music-cast.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/plugins/music-together/icons/off.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | -------------------------------------------------------------------------------- /src/plugins/music-together/icons/tune.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 |
    3 | 4 |
    5 |
    6 | 7 |
    8 |
    9 | -------------------------------------------------------------------------------- /src/plugins/music-together/templates/popup.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 |
    6 | -------------------------------------------------------------------------------- /src/plugins/music-together/templates/setting.html: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 4 | 5 | 6 |
    7 |
    8 | -------------------------------------------------------------------------------- /src/plugins/music-together/templates/status.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | Profile Image 4 |
    5 | 6 | 7 | 8 | 9 | 10 | 11 | 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 | 18 | 19 | 23 | 24 | 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 --------------------------------------------------------------------------------