Compare commits

..

56 Commits

Author SHA1 Message Date
018931c8f3 fix(deps): update dependency @ffmpeg.wasm/core-mt to v0.13.2 2026-01-29 09:14:35 +00:00
8f4dc17774 fix(deps): update dependency happy-dom to v20.4.0 (#4287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 18:12:24 +09:00
Tix
bc890943ef Fix: discord activity sanitizer (#4119)
Co-authored-by: Angelos Bouklis <me@arjix.dev>
2026-01-29 18:04:31 +09:00
12fcb92a8a plugin: clock widget (#4161) 2026-01-29 17:55:01 +09:00
728be6633a dep: update libuuid for rpm builds (#4139)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2026-01-29 17:51:34 +09:00
782204cc63 Add devcontainer setup (#4143)
Co-authored-by: Angelos Bouklis <me@arjix.dev>
Co-authored-by: JellyBrick <shlee1503@naver.com>
2026-01-29 17:48:47 +09:00
f3d988746a feat: Add toggle to swap the order of like/dislike buttons (#4221) 2026-01-29 17:41:52 +09:00
e3be1e7777 Merge branch 'master' of https://github.com/pear-devs/pear-desktop 2026-01-29 17:36:28 +09:00
93c1f411d0 fix: macOS runner 2026-01-29 17:35:59 +09:00
0c43ee1310 Update Homebrew installation command (#4228) 2026-01-29 17:27:17 +09:00
55ad129f57 Merge branch 'master' of https://github.com/pear-devs/pear-desktop 2026-01-29 17:25:53 +09:00
442dd51d3d refactor(visualizer): Removed restart requirement and refactored impls (#4200) 2026-01-29 17:24:00 +09:00
2180a2a810 chore(deps): update dependency @playwright/test to v1.58.0 (#4284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 17:21:41 +09:00
2594d96272 fix(deps): update dependency solid-js to v1.9.11 (#4282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 17:21:34 +09:00
ff80b6bca8 chore(deps): update dependency electron to v38.8.0 (#4285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 17:21:25 +09:00
3f09a826ac chore: update pnpm-lock.yaml 2026-01-29 17:20:56 +09:00
e6555f215d chore: Updated electron-vite to 5.0.0 (#4203)
Co-authored-by: JellyBrick <shlee1503@naver.com>
2026-01-29 17:20:31 +09:00
c3852d17fe fix(deps): update dependency virtua to v0.48.5 (#4283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 17:05:30 +09:00
7e4d1ab681 fix(discord): Fixed memory leak by repeated RPC failures (#4197) 2026-01-29 17:05:19 +09:00
d2d6db192e fix(deps): update dependency custom-electron-prompt to v1.6.1 (#4259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:56:43 +09:00
d9f3c97bec fix(deps): update dependency electron-updater to v6.7.3 (#4260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:56:36 +09:00
9ace05c511 fix(deps): update dependency fast-equals to v5.4.0 (#4261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:56:27 +09:00
6c957a5563 chore(deps): update actions/github-script action to v8 (#4263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:56:21 +09:00
2d40bc77b3 chore(deps): update actions/setup-node action to v6 (#4264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:56:13 +09:00
62080c277e chore(deps): update dependency @stylistic/eslint-plugin to v5.7.1 (#4274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:55:55 +09:00
9687e85699 fix(deps): update dependency @floating-ui/dom to v1.7.5 (#4275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:55:48 +09:00
868ad322cf fix(deps): update dependency @hono/node-ws to v1.3.0 (#4258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 16:55:41 +09:00
666f59f4c8 fix(deps): update dependency @ghostery/adblocker-electron-preload to v2.13.4 (#4257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 00:10:36 +09:00
9f05926452 fix(deps): update dependency hono to v4.11.7 [security] (#4273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 00:10:22 +09:00
ed7ef30aaa feat(synced-lyrics): Improve LRC parsing (#4269) 2026-01-26 11:41:53 +02:00
5bbf7f964c chore(i18n): Translated using Weblate (Polish)
Currently translated at 100.0% (463 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/pl/
2026-01-25 21:28:00 +01:00
428151ad6e chore(i18n): Translated using Weblate (Estonian)
Currently translated at 34.5% (160 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/et/
2026-01-24 01:09:19 +01:00
1d72d1260c chore(deps): update dependency cross-env to v10.1.0 (#4187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 06:17:35 +09:00
20836d9e62 chore(deps): update dependency ws to v8.19.0 (#4254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 06:17:26 +09:00
fc092177e1 fix(deps): update dependency @ghostery/adblocker-electron to v2.13.4 (#4255)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 06:17:15 +09:00
b3fe19c136 fix(deps): update dependency @hono/node-server to v1.19.9 (#4249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 06:13:23 +09:00
4231289bbb fix(deps): update dependency virtua to v0.48.3 (#4252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 06:13:08 +09:00
746e0ba584 chore(deps): update dependency electron-builder-squirrel-windows to v26.4.0 (#4253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 06:13:00 +09:00
803e2b3312 chore(deps): update dependency node-gyp to v11.5.0 (#4189)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 04:47:48 +09:00
320a166f59 chore(deps): update dependency @stylistic/eslint-plugin to v5.7.0 (#4185)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 04:47:38 +09:00
c6c8899af8 fix(deps): update dependency @hono/swagger-ui to v0.5.3 (#4250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 03:34:50 +09:00
7a63fc45c7 fix(deps): update dependency node-html-parser to v7.0.2 (#4251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 03:34:44 +09:00
21533ee461 chore(deps): update eslint monorepo to v9.39.2 (#4191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 02:17:00 +09:00
9e3d3662ce chore(deps): update dependency vite to v7.3.1 (#4248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 02:14:02 +09:00
9f56befc3c chore(deps): update dependency bufferutil to v4.1.0 (#4186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 02:13:50 +09:00
7b7c4a4153 chore(deps): update dependency typescript-eslint to v8.53.1 (#4190)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 02:13:27 +09:00
c07f1ef584 chore(deps): update dependency eslint-plugin-prettier to v5.5.5 (#4247)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 02:13:02 +09:00
c506bf21a9 chore(deps): update dependency @babel/runtime to v7.28.6 (#4246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 02:00:51 +09:00
4e697f250a chore(deps): update dependency @playwright/test to v1.57.0 (#4192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 01:17:53 +09:00
0e80e09313 fix(deps): update dependency solid-js to v1.9.10 (#4184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 01:17:44 +09:00
70bede3f07 fix(deps): update dependency hono to v4.11.4 [security] (#4239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-22 01:17:36 +09:00
b37db09e0c chore(i18n): Translated using Weblate (Catalan)
Currently translated at 100.0% (463 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ca/
2026-01-19 13:01:57 +00:00
7a4def8acc Fix weblate link (#4204) 2026-01-16 17:24:34 +09:00
f4bbd53e1a chore(i18n): Translated using Weblate (Turkish)
Currently translated at 100.0% (463 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/tr/
2026-01-13 22:01:47 +00:00
272ee7bdb1 chore(i18n): Translated using Weblate (Estonian)
Currently translated at 32.8% (152 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/et/
2026-01-12 19:01:50 +00:00
cece515696 chore(i18n): Translated using Weblate (Dutch)
Currently translated at 100.0% (463 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/nl/
2026-01-12 19:01:48 +00:00
32 changed files with 2090 additions and 2004 deletions

View File

@ -0,0 +1,16 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Pear Desktop - Dev Container",
// Keep in sync with `.github/workflows/build.yml`
"image": "mcr.microsoft.com/devcontainers/typescript-node:24",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {},
"postCreateCommand": "pnpm install --frozen-lockfile"
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ macos-latest, ubuntu-latest, windows-latest ]
os: [ macos-26, ubuntu-latest, windows-latest ]
steps:
- uses: actions/checkout@v5
@ -29,14 +29,14 @@ jobs:
- name: Setup NodeJS
if: startsWith(matrix.os, 'macOS') != true
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
@ -104,14 +104,14 @@ jobs:
- name: Setup NodeJS
if: startsWith(matrix.os, 'macOS') != true
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}

View File

@ -43,14 +43,14 @@ jobs:
- name: Setup NodeJS
if: startsWith(matrix.os, 'macOS') != true
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
@ -103,7 +103,7 @@ jobs:
pull-requests: write
steps:
- name: Create comment
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const runId = context.runId;

View File

@ -26,7 +26,7 @@ jobs:
run_install: false
- name: Setup NodeJS
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'

1
.gitignore vendored
View File

@ -5,6 +5,7 @@ node_modules
.idea
.pnp.*
.pnpm-store
.yarn/*
!.yarn/patches
!.yarn/plugins

15
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node-terminal",
"name": "Run Script: dev (pear-desktop)",
"request": "launch",
"command": "pnpm run dev",
"cwd": "${workspaceFolder}"
}
]
}

View File

@ -69,7 +69,7 @@
You can help with translation on [Hosted Weblate](https://bit.ly/48n5YF7).
<a href="https://bit.ly/48n5YF7/">
<a href="https://bit.ly/48n5YF7">
<img src="https://bit.ly/4q83L6S" alt="translation status" />
<img src="https://bit.ly/4h3zBxo" alt="translation status 2" />
</a>
@ -86,10 +86,10 @@ this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Insta
### macOS
You can install the app using Homebrew (see the [cask definition](https://github.com/pear-devs/pear-desktop-homebrew)):
You can install the app using Homebrew (see the [cask definition](https://github.com/pear-devs/homebrew-pear)):
```bash
brew install pear-devs/pear-desktop
brew install pear-devs/pear/pear-desktop
```
If you install the app manually and get an error "is damaged and cant be opened." when launching the app, run the following in the Terminal:
@ -144,6 +144,10 @@ pnpm install --frozen-lockfile
pnpm dev
```
Instead of installing pnpm on your system, you can also use [devcontainers](https://containers.dev/). You can use devcontainers either as a development environment in VS Code, or as a way to easily build the project without installing dependencies on your host system.
Note that this has it's own limitations (for example, GUI doesn't work on, at least some, Linux hosts).
## Build your own plugins
Using plugins, you can:
@ -279,6 +283,16 @@ export default createPlugin({
Builds the app for macOS, Linux, and Windows,
using [electron-builder](https://github.com/electron-userland/electron-builder).
### Building in devcontainer
1. Clone the repo;
2. Open the folder in VS Code;
3. Reopen in container when prompted;
4. Run `pnpm build` as above (choosing the desired target);
5. Collect the built files from the `dist` folder.
Since devcontainer uses a mount for the workspace, the built files will be available on the host system as well.
## Production Preview
```bash

View File

@ -109,7 +109,7 @@ deb:
- libgbm1
rpm:
depends:
- /usr/lib64/libuuid.so.1
- libuuid
fpm:
- '--rpm-rpmbuild-define'
- _build_id_links none

View File

@ -1,7 +1,7 @@
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig, defineViteConfig } from 'electron-vite';
import { defineConfig } from 'electron-vite';
import builtinModules from 'builtin-modules';
import Inspect from 'vite-plugin-inspect';
@ -21,9 +21,10 @@ const resolveAlias = {
'@assets': resolve(__dirname, './assets'),
};
export default defineConfig({
main: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
export default defineConfig(({ mode }) => {
const isDev = mode === 'development';
const mainConfig: UserConfig = {
experimental: {
enableNativePlugin: true,
},
@ -36,8 +37,8 @@ export default defineConfig({
],
publicDir: 'assets',
define: {
'__dirname': 'import.meta.dirname',
'__filename': 'import.meta.filename',
__dirname: 'import.meta.dirname',
__filename: 'import.meta.filename',
},
build: {
lib: {
@ -49,34 +50,16 @@ export default defineConfig({
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/index.ts',
},
minify: !isDev,
cssMinify: !isDev,
sourcemap: isDev ? 'inline' : undefined,
},
resolve: {
alias: resolveAlias,
},
};
if (mode === 'development') {
commonConfig.build!.sourcemap = 'inline';
commonConfig.plugins?.push(
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/backend'),
}),
);
return commonConfig;
}
return {
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
};
}),
preload: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
const preloadConfig: UserConfig = {
experimental: {
enableNativePlugin: true,
},
@ -100,36 +83,18 @@ export default defineConfig({
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/preload.ts',
},
minify: !isDev,
cssMinify: !isDev,
sourcemap: isDev ? 'inline' : undefined,
},
resolve: {
alias: resolveAlias,
},
};
if (mode === 'development') {
commonConfig.build!.sourcemap = 'inline';
commonConfig.plugins?.push(
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/preload'),
}),
);
return commonConfig;
}
return {
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
};
}),
renderer: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
const rendererConfig: UserConfig = {
experimental: {
enableNativePlugin: mode !== 'development', // Disable native plugin in development mode to avoid issues with HMR (bug in rolldown-vite)
enableNativePlugin: !isDev, // Disable native plugin in development mode to avoid issues with HMR (bug in rolldown-vite)
},
plugins: [
pluginLoader('renderer'),
@ -153,36 +118,44 @@ export default defineConfig({
external: ['electron', ...builtinModules],
input: './src/index.html',
},
minify: !isDev,
cssMinify: !isDev,
sourcemap: isDev ? 'inline' : undefined,
},
resolve: {
alias: resolveAlias,
},
server: {
cors: {
origin:
'https://music.\u0079\u006f\u0075\u0074\u0075\u0062\u0065.com',
origin: 'https://music.\u0079\u006f\u0075\u0074\u0075\u0062\u0065.com',
},
},
};
if (mode === 'development') {
commonConfig.build!.sourcemap = 'inline';
commonConfig.plugins?.push(
if (isDev) {
mainConfig.plugins?.push(
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/backend'),
}),
);
preloadConfig.plugins?.push(
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/preload'),
}),
);
rendererConfig.plugins?.push(
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/renderer'),
}),
);
return commonConfig;
}
return {
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
main: mainConfig,
preload: preloadConfig,
renderer: rendererConfig,
};
}),
});

View File

@ -45,12 +45,12 @@
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.3.0",
"node-gyp": "11.4.2",
"vite": "npm:rolldown-vite@7.3.1",
"node-gyp": "11.5.0",
"xml2js": "0.6.2",
"node-fetch": "3.3.2",
"@electron/universal": "3.0.2",
"@babel/runtime": "7.28.4"
"@babel/runtime": "7.28.6"
},
"patchedDependencies": {
"vudio@2.1.1": "patches/vudio@2.1.1.patch",
@ -66,15 +66,15 @@
"@dehoist/romanize-thai": "1.0.0",
"@electron-toolkit/tsconfig": "1.0.1",
"@electron/remote": "2.1.3",
"@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/core-mt": "0.13.2",
"@ffmpeg.wasm/main": "0.12.0",
"@floating-ui/dom": "1.7.4",
"@floating-ui/dom": "1.7.5",
"@foobar404/wave": "2.0.5",
"@ghostery/adblocker-electron": "2.11.6",
"@ghostery/adblocker-electron-preload": "2.11.6",
"@hono/node-server": "1.19.7",
"@hono/node-ws": "1.2.0",
"@hono/swagger-ui": "0.5.2",
"@ghostery/adblocker-electron": "2.13.4",
"@ghostery/adblocker-electron-preload": "2.13.4",
"@hono/node-server": "1.19.9",
"@hono/node-ws": "1.3.0",
"@hono/swagger-ui": "0.5.3",
"@hono/zod-openapi": "1.2.0",
"@hono/zod-validator": "0.7.6",
"@jellybrick/dbus-next": "0.10.3",
@ -90,7 +90,7 @@
"butterchurn-presets": "3.0.0-beta.4",
"color": "5.0.3",
"conf": "14.0.0",
"custom-electron-prompt": "1.5.8",
"custom-electron-prompt": "1.6.1",
"deepmerge-ts": "7.1.5",
"delay": "6.0.0",
"electron-debug": "4.1.0",
@ -98,15 +98,15 @@
"electron-localshortcut": "3.2.1",
"electron-store": "10.1.0",
"electron-unhandled": "5.0.0",
"electron-updater": "6.6.2",
"electron-updater": "6.7.3",
"es-hangul": "2.3.8",
"fast-average-color": "9.5.0",
"fast-equals": "5.2.2",
"fast-equals": "5.4.0",
"fflate": "0.8.2",
"filenamify": "6.0.0",
"hanja": "1.1.5",
"happy-dom": "20.0.11",
"hono": "4.10.3",
"happy-dom": "20.4.0",
"hono": "4.11.7",
"howler": "2.2.4",
"html-to-text": "9.0.5",
"i18next": "25.5.2",
@ -118,7 +118,7 @@
"kuroshiro-analyzer-kuromoji": "1.1.0",
"lazy-var": "2.2.2",
"mdui": "2.1.4",
"node-html-parser": "7.0.1",
"node-html-parser": "7.0.2",
"node-id3": "0.2.9",
"peerjs": "1.5.5",
"semver": "7.7.3",
@ -126,12 +126,12 @@
"socks": "2.8.7",
"solid-element": "1.9.1",
"solid-floating-ui": "0.3.1",
"solid-js": "1.9.9",
"solid-js": "1.9.11",
"solid-styled-components": "0.28.5",
"solid-transition-group": "0.3.0",
"tiny-pinyin": "1.3.2",
"tinyld": "1.3.4",
"virtua": "0.48.2",
"virtua": "0.48.5",
"vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "^16.0.1",
@ -139,44 +139,44 @@
},
"devDependencies": {
"@electron-toolkit/tsconfig": "1.0.1",
"@eslint/js": "9.35.0",
"@eslint/js": "9.39.2",
"@malept/flatpak-bundler": "0.4.0",
"@playwright/test": "1.55.0",
"@stylistic/eslint-plugin": "5.3.1",
"@playwright/test": "1.58.0",
"@stylistic/eslint-plugin": "5.7.1",
"@total-typescript/ts-reset": "0.6.1",
"@types/electron-localshortcut": "3.1.3",
"@types/howler": "2.2.12",
"@types/html-to-text": "9.0.4",
"@types/semver": "7.7.1",
"@types/trusted-types": "2.0.7",
"bufferutil": "4.0.9",
"bufferutil": "4.1.0",
"builtin-modules": "5.0.0",
"cross-env": "10.0.0",
"cross-env": "10.1.0",
"del-cli": "6.0.0",
"discord-api-types": "0.38.37",
"electron": "38.7.2",
"electron": "38.8.0",
"electron-builder": "26.4.0",
"electron-builder-squirrel-windows": "26.0.12",
"electron-builder-squirrel-windows": "26.4.0",
"electron-devtools-installer": "4.0.0",
"electron-vite": "4.0.1",
"eslint": "9.35.0",
"electron-vite": "5.0.0",
"eslint": "9.39.2",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-exports": "1.0.0-beta.5",
"eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-prettier": "5.5.5",
"eslint-plugin-solid": "0.14.5",
"glob": "11.1.0",
"node-gyp": "11.4.2",
"node-gyp": "11.5.0",
"ts-morph": "27.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.43.0",
"typescript-eslint": "8.53.1",
"utf-8-validate": "6.0.6",
"vite": "npm:rolldown-vite@7.3.0",
"vite": "npm:rolldown-vite@7.3.1",
"vite-plugin-inspect": "11.3.3",
"vite-plugin-resolve": "2.5.2",
"vite-plugin-solid": "2.11.10",
"ws": "8.18.3"
"ws": "8.19.0"
},
"auto-changelog": {
"hideCredit": true,

2845
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@ export interface DefaultConfig {
autoResetAppCache: boolean;
resumeOnStart: boolean;
likeButtons: string;
swapLikeButtonsOrder: boolean;
proxy: string;
startingPage: string;
backgroundMaterial?: 'none' | 'mica' | 'acrylic' | 'tabbed';
@ -66,6 +67,7 @@ export const defaultConfig: DefaultConfig = {
autoResetAppCache: false,
resumeOnStart: true,
likeButtons: '',
swapLikeButtonsOrder: false,
proxy: '',
startingPage: '',
overrideUserAgent: false,

View File

@ -321,6 +321,22 @@
"hostname": {
"label": "Nom del host"
},
"https": {
"label": "HTTPS i Certificats",
"submenu": {
"cert": {
"dialogTitle": "Seleccionar arxiu de certificat HTTPS",
"label": "Arxiu de certificat (.crt/.pem)"
},
"enable-https": {
"label": "Activa HTTPS"
},
"key": {
"dialogTitle": "Selecciona arxiu de clau HTTPS privada",
"label": "Arxiu de clau privada (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Text d'estat",
"submenu": {
"artist": "Escoltant {artist}",
"application": "Escoltant {{applicationName}}",
"artist": "Escoltant {artist}",
"title": "Escoltant {song title}"
}
}

View File

@ -154,6 +154,7 @@
"default": "Default",
"force-show": "Force show",
"hide": "Hide",
"swap": "Swap like buttons order",
"label": "Like buttons"
},
"custom-window-title": {
@ -412,6 +413,17 @@
"no-captions": "No captions available for this song"
}
},
"clock": {
"description": "Add a clock to the navigation bar",
"name": "Clock",
"menu": {
"format": {
"label": "Format",
"display-seconds": "Display Seconds",
"24-hour-format": "24-Hour Format"
}
}
},
"compact-sidebar": {
"description": "Always set the sidebar in compact mode",
"name": "Compact Sidebar"

View File

@ -128,7 +128,7 @@
},
"label": "Keel",
"submenu": {
"to-help-translate": "Soovid aidata tõlkimisel? Klõpsi siin"
"to-help-translate": "Soovid aidata tõlkimisel? Klõpsa siin"
}
},
"resume-on-start": "Rakenduse käivitamisel jätka viimatiesitatud loo esitamist",
@ -139,7 +139,7 @@
"unset": "Määramata"
},
"tray": {
"label": "Trey",
"label": "Tasku",
"submenu": {
"disabled": "Välja lülitatud",
"enabled-and-hide-app": "Sisse lülitatud ja rakendus peidetud",
@ -227,7 +227,7 @@
},
"album-actions": {
"description": "Lisab Undislike, Ebameeldiv, Meeldiv ja Unlike nupud selle rakendamiseks kõikidele loendisse või albumisse kuuluvatele lauludele.",
"name": "Albumi aktsioonid"
"name": "Albumi toimingud"
},
"album-color-theme": {
"description": "Rakendab dünaamilist teemat ja visuaalseid efekte, mis põhinevad albumi värvipalettil",
@ -237,7 +237,8 @@
"submenu": {
"percent": "{{suhe}}%"
}
}
},
"enable-seekbar": "Luba kerimisriba kujundamine"
},
"name": "Albumi värviteema"
},
@ -245,9 +246,19 @@
"description": "Rakendab valgusefekti, projitseerides videost õrnad värvid ekraani taustale",
"menu": {
"blur-amount": {
"label": "Hägusus"
"label": "Hägusus",
"submenu": {
"pixels": "{{blurAmount}} pikslit"
}
},
"buffer": {
"label": "Puhver",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Läbipaistmatus",
"submenu": {
"percent": "{{opacity}}%"
}
@ -263,8 +274,15 @@
"submenu": {
"percent": "{{size}}%"
}
},
"smoothness-transition": {
"label": "Sujuv üleminek"
},
"use-fullscreen": {
"label": "Kasutamas täisekraani"
}
}
},
"name": "Ümbritsev režiim"
},
"blur-nav-bar": {
"description": "Muudab navigatsiooniriba läbipaistavaks ja hägusaks",

View File

@ -237,7 +237,8 @@
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"enable-seekbar": "Schakel thema's voor de schuifbalk in"
},
"name": "Albumkleurthema"
},
@ -320,6 +321,22 @@
"hostname": {
"label": "Hostnaam"
},
"https": {
"label": "HTTPS & Certificaten",
"submenu": {
"cert": {
"dialogTitle": "Selecteer HTTPS certificaat",
"label": "Certificaatbestand (.crt/.pem)"
},
"enable-https": {
"label": "HTTPS aanzetten"
},
"key": {
"dialogTitle": "Selecteer HTTPS privésleutel",
"label": "Privésleutelbestand (.key/.pem)"
}
}
},
"port": {
"label": "Poort"
}

View File

@ -209,7 +209,7 @@
"show": "Pokaż okno",
"tooltip": {
"default": "{{applicationName}}",
"with-song-info": "{{artist}} - (autorstwa {{artist}}) - {{applicationName}}"
"with-song-info": "{{title}} (autorstwa {{artist}}) - {{applicationName}}"
}
}
},
@ -295,7 +295,7 @@
}
},
"api-server": {
"description": "Pozwala na kontrolowanie {{applicationName}} poprzez podłączenie specjalnego serwera API",
"description": "Steruj odtwarzaczem przez specjalny serwer API",
"dialog": {
"request": {
"buttons": {

View File

@ -321,6 +321,22 @@
"hostname": {
"label": "Ana bilgisayar adı"
},
"https": {
"label": "HTTPS & Sertifikalar",
"submenu": {
"cert": {
"dialogTitle": "HTTPS sertifika dosyası seç",
"label": "Sertifika dosyası (.crt/.pem)"
},
"enable-https": {
"label": "HTTPS'i aktifleştir"
},
"key": {
"dialogTitle": "HTTPS özel anahtar dosyası seç",
"label": "Özel anahtar dosyası (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Durum metni",
"submenu": {
"artist": "{artist} Dinleniyor",
"application": "{{applicationName}} Dinleniyor",
"artist": "{artist} Dinleniyor",
"title": "{song title} Dinleniyor"
}
}

View File

@ -285,6 +285,19 @@ export const mainMenuTemplate = async (
config.set('options.likeButtons', 'hide');
},
},
{
label: t(
'main.menu.options.submenu.visual-tweaks.submenu.like-buttons.swap',
),
type: 'checkbox',
checked: config.get('options.swapLikeButtonsOrder'),
click(item: MenuItem) {
config.setMenuOption(
'options.swapLikeButtonsOrder',
item.checked,
);
},
},
],
},
{

109
src/plugins/clock/index.tsx Normal file
View File

@ -0,0 +1,109 @@
import { render } from 'solid-js/web';
import { createSignal, onMount } from 'solid-js';
import style from './style.css?inline';
import { createPlugin } from '@/utils';
import { type MenuTemplate } from '@/menu';
import { t } from '@/i18n';
import { type ClockPluginConfig } from './types';
const defaultConfig: ClockPluginConfig = {
enabled: false,
displaySeconds: false,
hour12: false,
};
export default createPlugin({
name: () => t('plugins.clock.name'),
description: () => t('plugins.clock.description'),
restartNeeded: false,
config: defaultConfig,
stylesheets: [style],
menu: async ({ getConfig, setConfig }): Promise<MenuTemplate> => {
const config = await getConfig();
return [
{
label: t('plugins.clock.menu.format.label'),
submenu: [
{
label: t('plugins.clock.menu.format.display-seconds'),
type: 'checkbox',
checked: config.displaySeconds,
click(item) {
setConfig({ displaySeconds: item.checked });
},
},
{
label: t('plugins.clock.menu.format.24-hour-format'),
type: 'checkbox',
checked: !config.hour12,
click(item) {
setConfig({ hour12: !item.checked });
},
},
],
},
];
},
renderer: {
displaySeconds: defaultConfig.displaySeconds,
hour12: defaultConfig.hour12,
interval: null as NodeJS.Timeout | null,
clockContainer: document.createElement('div'),
updateTime: null as unknown as () => void,
async start({ getConfig }) {
const config = await getConfig();
this.displaySeconds = config.displaySeconds;
this.hour12 = config.hour12;
if (!this.clockContainer) {
this.clockContainer = document.createElement('div');
}
const [time, setTime] = createSignal<string>();
const updateTime = () => {
const timeFormat: Intl.DateTimeFormatOptions = {
hour12: this.hour12,
hour: 'numeric',
minute: 'numeric',
second: this.displaySeconds ? 'numeric' : undefined,
};
const now = new Date();
setTime(now.toLocaleTimeString('en', timeFormat));
};
this.updateTime = updateTime;
onMount(() => {
this.interval = setInterval(updateTime, 1000);
});
render(
() => (
<>
<h1 class="clock"> {time()} </h1>
</>
),
this.clockContainer,
);
const menu = document.querySelector('.center-content');
menu?.append(this.clockContainer);
},
onConfigChange(newConfig) {
this.displaySeconds = newConfig.displaySeconds;
this.hour12 = newConfig.hour12;
this.updateTime();
},
stop() {
this.clockContainer.remove();
this.clockContainer.replaceChildren();
if (this.interval) {
clearInterval(this.interval);
}
},
},
});

View File

@ -0,0 +1,6 @@
.clock {
position: absolute;
left: 50%;
transform: translateX(-50%);
align-self: center;
}

View File

@ -0,0 +1,5 @@
export type ClockPluginConfig = {
enabled: boolean;
displaySeconds: boolean;
hour12: boolean;
};

View File

@ -9,7 +9,7 @@ import { TimerManager } from './timer-manager';
import {
buildDiscordButtons,
padHangulFields,
truncateString,
sanitizeActivityText,
isSeek,
} from './utils';
@ -22,7 +22,7 @@ export class DiscordService {
/**
* Discord RPC client instance.
*/
rpc = new DiscordClient({ clientId });
rpc!: DiscordClient;
/**
* Indicates if the service is ready to send activity updates.
*/
@ -62,6 +62,21 @@ export class DiscordService {
this.mainWindow = mainWindow;
this.autoReconnect = config?.autoReconnect ?? true; // Default autoReconnect to true
this.initializeRpc();
}
private initializeRpc() {
if (this.rpc) {
try {
this.rpc.destroy();
} catch {
// ignored
}
this.rpc.removeAllListeners();
}
this.rpc = new DiscordClient({ clientId });
this.rpc.on('connected', () => {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
@ -99,13 +114,17 @@ export class DiscordService {
const activityInfo: SetActivity = {
type: ActivityType.Listening,
statusDisplayType: config.statusDisplayType,
details: truncateString(songInfo.alternativeTitle ?? songInfo.title, 128), // Song title
details: sanitizeActivityText(
songInfo.alternativeTitle ?? songInfo.title
), // Song title
detailsUrl: songInfo.url ?? undefined,
state: truncateString(songInfo.tags?.at(0) ?? songInfo.artist, 128), // Artist name
state: sanitizeActivityText(
songInfo.tags?.at(0) ?? songInfo.artist
), // Artist name
stateUrl: songInfo.artistUrl,
largeImageKey: songInfo.imageSrc ?? undefined,
largeImageText: songInfo.album
? truncateString(songInfo.album, 128)
? sanitizeActivityText(songInfo.album)
: undefined,
buttons: buildDiscordButtons(config, songInfo),
};
@ -192,6 +211,7 @@ export class DiscordService {
resolve();
})
.catch(() => {
this.initializeRpc();
this.connectRecursive();
});
},
@ -236,6 +256,9 @@ export class DiscordService {
this.resetInfo();
if (this.autoReconnect) {
// For some reason @xhayper/discord-rpc leaves a dangling listener on connection failure
// so we destroy and recreate the RPC client before reconnecting.
this.initializeRpc();
this.connectRecursive();
} else if (showErrorDialog && this.mainWindow) {
// connection failed
@ -250,13 +273,12 @@ export class DiscordService {
this.autoReconnect = false;
this.timerManager.clear(TimerKey.DiscordConnectRetry);
this.timerManager.clear(TimerKey.ClearActivity);
if (this.rpc.isConnected) {
try {
this.rpc.removeAllListeners();
this.rpc.destroy();
} catch {
// ignored
}
}
this.resetInfo(); // Reset internal state
}

View File

@ -19,6 +19,27 @@ export const truncateString = (str: string, length: number): string => {
return str;
};
/**
* Sanitizes a string for Discord Rich Presence activity, ensuring it meets length requirements.
* @param input - The string to sanitize.
* @param fallback - A fallback string to use if the input is empty or whitespace. Defaults to 'undefined'.
* @returns The sanitized string, compliant with Discord's requirements.
*/
export function sanitizeActivityText(input: string | undefined, fallback: string = 'undefined'): string {
const text = (input && input.trim()) ? input.trim() : fallback.trim();
let safeString = truncateString(text, 128);
if (safeString.length === 0) {
return fallback;
}
if (safeString.length < 2) {
safeString = safeString.padEnd(2, ''); // change if necessary
}
return safeString;
}
/**
* Builds the array of buttons for the Discord Rich Presence activity.
* @param config - The plugin configuration.

View File

@ -0,0 +1,144 @@
import { test, expect } from '@playwright/test';
import { LRC } from './lrc';
test('empty string', () => {
const lrc = LRC.parse('');
expect(lrc).toStrictEqual({ lines: [], tags: [] });
});
test('chorus', () => {
const lrc = LRC.parse(`\
[00:12.00]Line 1 lyrics
[00:17.20]Line 2 lyrics
[00:21.10][00:45.10]Repeating lyrics (e.g. chorus)
[mm:ss.xx]Last lyrics line\
`);
expect(lrc).toStrictEqual({
lines: [
{ duration: 12000, text: '', words: [], time: '00:00:00', timeInMs: 0 },
{
duration: 5020,
text: 'Line 1 lyrics',
words: [],
time: '00:12:00',
timeInMs: 12000,
},
{
duration: 3990,
text: 'Line 2 lyrics',
words: [],
time: '00:17:20',
timeInMs: 17020,
},
{
duration: 24000,
text: 'Repeating lyrics (e.g. chorus)',
words: [],
time: '00:21:10',
timeInMs: 21010,
},
{
duration: Infinity,
text: 'Repeating lyrics (e.g. chorus)',
words: [],
time: '00:45:10',
timeInMs: 45010,
},
],
tags: [],
});
});
test('attributes', () => {
const lrc = LRC.parse(
`[ar:Chubby Checker oppure Beatles, The]
[al:Hits Of The 60's - Vol. 2 Oldies]
[ti:Let's Twist Again]
[au:Written by Kal Mann / Dave Appell, 1961]
[length: 2:23]
[00:12.00]Naku Penda Piya-Naku Taka Piya-Mpenziwe
[00:15.30]Some more lyrics ...`,
);
expect(lrc).toStrictEqual({
lines: [
{ duration: 12000, text: '', words: [], time: '00:00:00', timeInMs: 0 },
{
duration: 3030,
text: 'Naku Penda Piya-Naku Taka Piya-Mpenziwe',
words: [],
time: '00:12:00',
timeInMs: 12000,
},
{
duration: Infinity,
text: 'Some more lyrics ...',
words: [],
time: '00:15:30',
timeInMs: 15030,
},
],
tags: [
{ tag: 'ar', value: 'Chubby Checker oppure Beatles, The' },
{ tag: 'al', value: "Hits Of The 60's - Vol. 2 Oldies" },
{ tag: 'ti', value: "Let's Twist Again" },
{ tag: 'au', value: 'Written by Kal Mann / Dave Appell, 1961' },
{ tag: 'length', value: '2:23' },
],
});
});
test('karaoke', () => {
const lrc = LRC.parse(
'[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies',
);
expect(lrc).toStrictEqual({
lines: [
{
duration: Infinity,
text: 'When the truth is found to be lies',
time: '00:00:00',
timeInMs: 0,
words: [
{
timeInMs: 4,
word: 'When',
},
{
timeInMs: 16,
word: 'the',
},
{
timeInMs: 82,
word: 'truth',
},
{
timeInMs: 1029,
word: 'is',
},
{
timeInMs: 1063,
word: 'found',
},
{
timeInMs: 3009,
word: 'to',
},
{
timeInMs: 3037,
word: 'be',
},
{
timeInMs: 5092,
word: 'lies',
},
],
},
],
tags: [],
});
});

View File

@ -8,6 +8,7 @@ interface LRCLine {
timeInMs: number;
duration: number;
text: string;
words: { timeInMs: number; word: string }[];
}
interface LRC {
@ -17,7 +18,10 @@ interface LRC {
const tagRegex = /^\[(?<tag>\w+):\s*(?<value>.+?)\s*\]$/;
// prettier-ignore
const lyricRegex = /^\[(?<minutes>\d+):(?<seconds>\d+)\.(?<milliseconds>\d+)\](?<text>.+)$/;
const timestampRegex = /^\[(?<minutes>\d+):(?<seconds>\d+)\.(?<milliseconds>\d+)\]/m;
// prettier-ignore
const wordRegex = /<(?<minutes>\d+):(?<seconds>\d+)\.(?<milliseconds>\d+)> *(?<word>\w+)/g;
export const LRC = {
parse: (text: string): LRC => {
@ -27,13 +31,29 @@ export const LRC = {
};
let offset = 0;
let previousLine: LRCLine | null = null;
for (const line of text.split('\n')) {
if (!line.trim().startsWith('[')) continue;
for (let line of text.split('\n')) {
line = line.trim();
if (!line.startsWith('[')) continue;
const lyric = line.match(lyricRegex)?.groups;
if (!lyric) {
const timestamps = [];
let match: Record<string, string> | undefined;
while ((match = line.match(timestampRegex)?.groups)) {
const { minutes, seconds, milliseconds } = match;
const timeInMs =
parseInt(minutes) * 60 * 1000 +
parseInt(seconds) * 1000 +
parseInt(milliseconds);
timestamps.push({
time: `${minutes}:${seconds}:${milliseconds}`,
timeInMs,
});
line = line.replace(timestampRegex, '');
}
if (!timestamps.length) {
const tag = line.match(tagRegex)?.groups;
if (tag) {
if (tag.tag === 'offset') {
@ -49,38 +69,52 @@ export const LRC = {
continue;
}
const { minutes, seconds, milliseconds, text } = lyric;
let text = line.trim();
const words = Array.from(text.matchAll(wordRegex), ({ groups }) => {
const { minutes, seconds, milliseconds, word } = groups!;
const timeInMs =
parseInt(minutes) * 60 * 1000 +
parseInt(seconds) * 1000 +
parseInt(milliseconds);
const currentLine: LRCLine = {
time: `${minutes}:${seconds}:${milliseconds}`,
return { timeInMs, word };
});
if (words.length) {
text = words.map(({ word }) => word).join(' ');
}
for (const { time, timeInMs } of timestamps) {
lrc.lines.push({
time,
timeInMs,
text: text.trim(),
text,
words,
duration: Infinity,
};
if (previousLine) {
previousLine.duration = timeInMs - previousLine.timeInMs;
});
}
}
previousLine = currentLine;
lrc.lines.push(currentLine);
}
lrc.lines.sort(({ timeInMs: timeA }, { timeInMs: timeB }) => timeA - timeB);
for (let i = 0; i < lrc.lines.length; i++) {
const current = lrc.lines[i];
const next = lrc.lines[i + 1];
for (const line of lrc.lines) {
line.timeInMs += offset;
current.timeInMs += offset;
if (next) {
current.duration = next.timeInMs - current.timeInMs;
}
}
const first = lrc.lines.at(0);
if (first && first.timeInMs > 300) {
lrc.lines.unshift({
time: '0:0:0',
time: '00:00:00',
timeInMs: 0,
duration: first.timeInMs,
text: '',
words: [],
});
}

View File

@ -18,7 +18,6 @@ export type VisualizerPluginConfig = {
type: 'butterchurn' | 'vudio' | 'wave';
butterchurn: {
preset: string;
renderingFrequencyInMs: number;
blendTimeInSeconds: number;
};
vudio: {
@ -57,17 +56,23 @@ export type VisualizerPluginConfig = {
};
};
type RenderProps = {
visualizerInstance: Visualizer | null;
audioContext: AudioContext | null;
audioSource: MediaElementAudioSourceNode | null;
observer: ResizeObserver | null;
};
export default createPlugin({
name: () => t('plugins.visualizer.name'),
description: () => t('plugins.visualizer.description'),
restartNeeded: true,
restartNeeded: false,
config: {
enabled: false,
type: 'butterchurn',
// Config per visualizer
butterchurn: {
preset: 'martin [shadow harlequins shape code] - fata morgana',
renderingFrequencyInMs: 500,
blendTimeInSeconds: 2.7,
},
vudio: {
@ -148,21 +153,23 @@ export default createPlugin({
},
renderer: {
async onPlayerApiReady(_, { getConfig }) {
const config = await getConfig();
props: {
visualizerInstance: null,
audioContext: null,
audioSource: null,
observer: null,
} as RenderProps,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let visualizerType: { new (...args: any[]): Visualizer<unknown> } = vudio;
createVisualizer(
this: { props: RenderProps },
config: VisualizerPluginConfig,
) {
this.props.visualizerInstance?.destroy();
this.props.visualizerInstance = null;
if (config.type === 'wave') {
visualizerType = wave;
} else if (config.type === 'butterchurn') {
visualizerType = butterchurn;
}
if (!this.props.audioContext || !this.props.audioSource) return;
if (!config.enabled) return;
document.addEventListener(
'peard:audio-can-play',
(e) => {
const video = document.querySelector<
HTMLVideoElement & { captureStream(): MediaStream }
>('video');
@ -183,46 +190,54 @@ export default createPlugin({
visualizerContainer?.prepend(canvas);
}
const resizeCanvas = () => {
if (canvas) {
canvas.width = visualizerContainer.clientWidth;
canvas.height = visualizerContainer.clientHeight;
}
};
resizeCanvas();
const gainNode = e.detail.audioContext.createGain();
const gainNode = this.props.audioContext.createGain();
gainNode.gain.value = 1.25;
e.detail.audioSource.connect(gainNode);
this.props.audioSource.connect(gainNode);
const visualizer = new visualizerType(
e.detail.audioContext,
e.detail.audioSource,
visualizerContainer,
let visualizerType: {
new (...args: ConstructorParameters<typeof vudio>): Visualizer;
} = vudio;
if (config.type === 'wave') {
visualizerType = wave;
} else if (config.type === 'butterchurn') {
visualizerType = butterchurn;
}
this.props.visualizerInstance = new visualizerType(
this.props.audioContext,
this.props.audioSource,
canvas,
gainNode,
video.captureStream(),
config,
);
const resizeVisualizer = (width: number, height: number) => {
resizeCanvas();
visualizer.resize(width, height);
};
resizeVisualizer(canvas.width, canvas.height);
const visualizerContainerObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
resizeVisualizer(
entry.contentRect.width,
entry.contentRect.height,
);
const resizeVisualizer = () => {
if (canvas && visualizerContainer) {
const { width, height } =
window.getComputedStyle(visualizerContainer);
canvas.width = Math.ceil(parseFloat(width));
canvas.height = Math.ceil(parseFloat(height));
}
});
visualizerContainerObserver.observe(visualizerContainer);
this.props.visualizerInstance?.resize(canvas.width, canvas.height);
};
resizeVisualizer();
visualizer.render();
this.props.observer?.disconnect();
this.props.observer = new ResizeObserver(resizeVisualizer);
this.props.observer.observe(visualizerContainer);
},
onConfigChange(newConfig) {
this.createVisualizer(newConfig);
},
onPlayerApiReady(_, { getConfig }) {
document.addEventListener(
'peard:audio-can-play',
async (e) => {
this.props.audioContext = e.detail.audioContext;
this.props.audioSource = e.detail.audioSource;
this.createVisualizer(await getConfig());
},
{ passive: true },
);

View File

@ -5,54 +5,49 @@ import { Visualizer } from './visualizer';
import type { VisualizerPluginConfig } from '../index';
class ButterchurnVisualizer extends Visualizer<Butterchurn> {
name = 'butterchurn';
visualizer: ReturnType<typeof Butterchurn.createVisualizer>;
private readonly renderingFrequencyInMs: number;
class ButterchurnVisualizer extends Visualizer {
private readonly visualizer: ReturnType<typeof Butterchurn.createVisualizer>;
private destroyed: boolean = false;
private animFrameHandle: number | null;
constructor(
audioContext: AudioContext,
audioSource: MediaElementAudioSourceNode,
visualizerContainer: HTMLElement,
canvas: HTMLCanvasElement,
audioNode: GainNode,
stream: MediaStream,
options: VisualizerPluginConfig,
_stream: MediaStream,
config: VisualizerPluginConfig,
) {
super(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
);
super(audioSource, audioNode);
const preset = ButterchurnPresets[config.butterchurn.preset];
const renderVisualizer = () => {
if (this.destroyed) return;
this.visualizer.render();
this.animFrameHandle = requestAnimationFrame(renderVisualizer);
};
this.visualizer = Butterchurn.createVisualizer(audioContext, canvas, {
width: canvas.width,
height: canvas.height,
});
const preset = ButterchurnPresets[options.butterchurn.preset];
this.visualizer.loadPreset(preset, options.butterchurn.blendTimeInSeconds);
this.visualizer.loadPreset(preset, config.butterchurn.blendTimeInSeconds);
this.visualizer.connectAudio(audioNode);
this.renderingFrequencyInMs = options.butterchurn.renderingFrequencyInMs;
// Start animation request loop. Do not use setInterval!
this.animFrameHandle = requestAnimationFrame(renderVisualizer);
}
resize(width: number, height: number) {
this.visualizer.setRendererSize(width, height);
}
render() {
const renderVisualizer = () => {
requestAnimationFrame(renderVisualizer);
this.visualizer.render();
};
setTimeout(renderVisualizer, this.renderingFrequencyInMs);
destroy() {
if (this.animFrameHandle) cancelAnimationFrame(this.animFrameHandle);
this.destroyed = true;
try {
this.audioSource.disconnect(this.audioNode);
} catch {}
}
}

View File

@ -1,22 +1,15 @@
import type { VisualizerPluginConfig } from '../index';
export abstract class Visualizer<T> {
/**
* The name must be the same as the file name.
*/
abstract name: string;
abstract visualizer: T;
export abstract class Visualizer {
protected audioNode: GainNode;
protected audioSource: MediaElementAudioSourceNode;
protected constructor(
_audioContext: AudioContext,
_audioSource: MediaElementAudioSourceNode,
_visualizerContainer: HTMLElement,
_canvas: HTMLCanvasElement,
_audioNode: GainNode,
_stream: MediaStream,
_options: VisualizerPluginConfig,
) {}
) {
this.audioNode = _audioNode;
this.audioSource = _audioSource;
}
abstract resize(width: number, height: number): void;
abstract render(): void;
abstract destroy(): void;
}

View File

@ -4,35 +4,24 @@ import { Visualizer } from './visualizer';
import type { VisualizerPluginConfig } from '../index';
class VudioVisualizer extends Visualizer<Vudio> {
name = 'vudio';
visualizer: Vudio;
class VudioVisualizer extends Visualizer {
private readonly visualizer: Vudio;
constructor(
audioContext: AudioContext,
_audioContext: AudioContext,
audioSource: MediaElementAudioSourceNode,
visualizerContainer: HTMLElement,
canvas: HTMLCanvasElement,
audioNode: GainNode,
stream: MediaStream,
options: VisualizerPluginConfig,
config: VisualizerPluginConfig,
) {
super(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
);
super(audioSource, audioNode);
this.visualizer = new Vudio(stream, canvas, {
width: canvas.width,
height: canvas.height,
// Visualizer config
...options,
...config,
});
this.visualizer.dance();
@ -45,7 +34,12 @@ class VudioVisualizer extends Visualizer<Vudio> {
});
}
render() {}
destroy() {
this.visualizer.pause();
try {
this.audioSource.disconnect(this.audioNode);
} catch {}
}
}
export default VudioVisualizer;

View File

@ -4,35 +4,24 @@ import { Visualizer } from './visualizer';
import type { VisualizerPluginConfig } from '../index';
class WaveVisualizer extends Visualizer<Wave> {
name = 'wave';
visualizer: Wave;
class WaveVisualizer extends Visualizer {
private readonly visualizer: Wave;
constructor(
audioContext: AudioContext,
audioSource: MediaElementAudioSourceNode,
visualizerContainer: HTMLElement,
canvas: HTMLCanvasElement,
audioNode: GainNode,
stream: MediaStream,
options: VisualizerPluginConfig,
_stream: MediaStream,
config: VisualizerPluginConfig,
) {
super(
audioContext,
audioSource,
visualizerContainer,
canvas,
audioNode,
stream,
options,
);
super(audioSource, audioNode);
this.visualizer = new Wave(
{ context: audioContext, source: audioSource },
canvas,
);
for (const animation of options.wave.animations) {
for (const animation of config.wave.animations) {
const TargetVisualizer =
this.visualizer.animations[
animation.type as keyof typeof this.visualizer.animations
@ -46,7 +35,12 @@ class WaveVisualizer extends Visualizer<Wave> {
resize(_: number, __: number) {}
render() {}
destroy() {
this.visualizer.clearAnimations();
try {
this.audioSource.disconnect(this.audioNode);
} catch {}
}
}
export default WaveVisualizer;

View File

@ -406,6 +406,18 @@ async function onApiLoaded() {
document.head.appendChild(style);
}
// Swap like button order
if (window.mainConfig.get('options.swapLikeButtonsOrder')) {
const style = document.createElement('style');
style.textContent = `
#like-button-renderer {
display: inline-flex;
flex-direction: row-reverse;
}`;
document.head.appendChild(style);
}
}
/**