Compare commits

...

89 Commits

Author SHA1 Message Date
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
82b7b24ef7 chore(i18n): Translated using Weblate (Portuguese (Brazil))
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/pt_BR/
2026-01-06 00:01:50 +01:00
732bbc17f4 chore(i18n): Translated using Weblate (Italian)
Currently translated at 99.7% (462 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/it/
2026-01-06 00:01:48 +01:00
29da42d9ff chore(i18n): Translated using Weblate (Italian)
Currently translated at 99.7% (462 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/it/
2026-01-06 00:01:47 +01:00
269d0cd638 chore(i18n): Translated using Weblate (Russian)
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/ru/
2026-01-04 05:01:48 +00:00
69e15165e3 chore(i18n): Translated using Weblate (Georgian)
Currently translated at 32.3% (150 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ka/
2026-01-03 00:02:04 +01:00
Fin
7663a12ee4 chore(i18n): Translated using Weblate (German)
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/de/
2026-01-03 00:02:03 +01:00
dcda0b3561 chore(i18n): Translated using Weblate (Korean)
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/ko/
2026-01-03 00:02:02 +01:00
5f82fd9e5a chore: Update README with Warp details 2026-01-02 14:59:50 +09:00
af11fa31d3 chore(i18n): Translated using Weblate (Lithuanian)
Currently translated at 78.4% (363 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/lt/
2026-01-01 12:02:01 +01:00
a049d618e5 chore(i18n): Translated using Weblate (Vietnamese)
Currently translated at 98.7% (457 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/vi/
2026-01-01 12:02:01 +01:00
41bc03a737 Revert "fix macos build (?)"
This reverts commit 2ab6eff761.
2025-12-30 22:23:14 +02:00
2ab6eff761 fix macos build (?) 2025-12-30 22:17:18 +02:00
6c881a265a fix tests 2025-12-30 22:02:23 +02:00
2b15f0a87c chore(i18n): Translated using Weblate (Persian)
Currently translated at 99.5% (461 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fa/
2025-12-30 10:00:31 +01:00
1d6ab2a82b update eslint config 2025-12-29 17:04:25 +02:00
24e82e69d1 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/
2025-12-28 21:00:38 +00:00
b079d411de feat: apply new icon (#4215) 2025-12-29 01:39:53 +09:00
e04c8eb99f chore(i18n): Translated using Weblate (German)
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/de/
2025-12-26 12:00:20 +00:00
3a6b793948 chore(i18n): Translated using Weblate (Chinese (Simplified Han script))
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/zh_Hans/
2025-12-24 02:00:23 +01:00
f5361de78d chore(i18n): Translated using Weblate (Dutch)
Currently translated at 98.4% (456 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/nl/
2025-12-24 02:00:21 +01:00
e0759afc70 chore(i18n): Translated using Weblate (Spanish)
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/es/
2025-12-24 02:00:20 +01:00
700e4ba38e chore(i18n): Translated using Weblate (Croatian)
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/hr/
2025-12-22 19:00:35 +00:00
cc82b232ce chore(i18n): Translated using Weblate (Hindi)
Currently translated at 85.0% (394 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/hi/
2025-12-22 19:00:34 +00:00
c1f422e325 chore(i18n): Translated using Weblate (Persian)
Currently translated at 98.2% (455 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/fa/
2025-12-22 19:00:33 +00:00
ffaaba29d7 chore(i18n): Translated using Weblate (Tamil)
Currently translated at 98.2% (455 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ta/
2025-12-22 19:00:31 +00:00
9cad841efb chore(i18n): Translated using Weblate (Bosnian)
Currently translated at 27.8% (129 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/bs/
2025-12-22 19:00:30 +00:00
324ab09b5d chore(i18n): Translated using Weblate (Portuguese)
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/pt/
2025-12-22 19:00:28 +00:00
0b5e9e923b chore(i18n): Translated using Weblate (Serbian)
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/sr/
2025-12-22 19:00:27 +00:00
f2308e567d chore(i18n): Translated using Weblate (French)
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/fr/
2025-12-22 19:00:26 +00:00
1466c6451b chore(i18n): Translated using Weblate (Swedish)
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/sv/
2025-12-22 19:00:23 +00:00
598c708e92 chore(i18n): Translated using Weblate (German)
Currently translated at 99.7% (462 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/de/
2025-12-22 19:00:22 +00:00
1f903b4f75 chore(i18n): Translated using Weblate (Russian)
Currently translated at 98.2% (455 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ru/
2025-12-22 19:00:20 +00:00
7951b17877 chore(i18n): Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 98.7% (457 of 463 strings)

Translation: pear-devs/pear-desktop/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hans/
2025-12-21 18:00:36 +00:00
2a39b7315b chore(i18n): Translated using Weblate (German)
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/de/
2025-12-21 18:00:35 +00:00
65 changed files with 2793 additions and 2059 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

@ -1,3 +1,17 @@
<div align="center" markdown="1">
<sup>Special thanks to:</sup>
<br>
<br>
<a href="https://go.warp.dev/pear-desktop">
<img alt="Warp sponsorship" width="400" src="https://github.com/user-attachments/assets/8307ea56-e872-494a-8a9c-de0e296a06ed" />
</a>
### [Warp, built for coding with multiple AI agents](https://go.warp.dev/pear-desktop)
[Available for macOS, Linux, & Windows](https://go.warp.dev/pear-desktop)<br>
</div>
<hr>
<div align="center">
# :pear: Pear Desktop
@ -55,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>
@ -72,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:
@ -130,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:
@ -265,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

@ -0,0 +1,35 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_18_2)">
<circle cx="512" cy="512" r="410" fill="url(#paint0_linear_18_2)"/>
<circle cx="512" cy="512" r="402" stroke="url(#paint1_radial_18_2)" stroke-opacity="0.5" stroke-width="16"/>
</g>
<path d="M675 505.072C680.333 508.152 680.333 515.849 675 518.928L436.5 656.626C431.167 659.705 424.5 655.857 424.5 649.698V374.302C424.5 368.24 430.96 364.415 436.249 367.234L436.5 367.374L675 505.072Z" fill="url(#paint2_linear_18_2)" stroke="url(#paint3_linear_18_2)" stroke-width="8" stroke-linejoin="round"/>
<defs>
<filter id="filter0_d_18_2" x="78" y="90" width="868" height="868" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="12"/>
<feGaussianBlur stdDeviation="12"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.866667 0 0 0 0 0.141176 0 0 0 0 0.462745 0 0 0 0.4 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_18_2"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_18_2" result="shape"/>
</filter>
<linearGradient id="paint0_linear_18_2" x1="102" y1="102" x2="922" y2="922" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF632F"/>
<stop offset="1" stop-color="#DC148C"/>
</linearGradient>
<radialGradient id="paint1_radial_18_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(512 512) rotate(45) scale(579.828)">
<stop offset="0.68" stop-color="white" stop-opacity="0"/>
<stop offset="0.72" stop-color="white"/>
</radialGradient>
<linearGradient id="paint2_linear_18_2" x1="512" y1="329" x2="512" y2="695" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="white" stop-opacity="0.4"/>
</linearGradient>
<linearGradient id="paint3_linear_18_2" x1="512" y1="329" x2="512" y2="695" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white" stop-opacity="0.5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1,67 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:1.00000,1.00000,1.00000,1.00000",
"srgb:0.84314,0.84314,0.84314,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"blur-material" : null,
"hidden" : false,
"layers" : [
{
"blend-mode-specializations" : [
{
"appearance" : "dark",
"value" : "normal"
}
],
"image-name" : "SVG Image.svg",
"name" : "transparent-icon",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "dark",
"value" : 1
}
]
}
],
"name" : "group",
"opacity-specializations" : [
{
"appearance" : "dark",
"value" : 0.8
}
],
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 790 KiB

View File

@ -1,10 +1,40 @@
<svg width="180" height="180" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-play">
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_18_2)">
<circle cx="512" cy="512" r="410" fill="url(#paint0_linear_18_2)" />
<circle cx="512" cy="512" r="402" stroke="url(#paint1_radial_18_2)" stroke-opacity="0.5" stroke-width="16" />
</g>
<path
d="M675 505.072C680.333 508.152 680.333 515.849 675 518.928L436.5 656.626C431.167 659.705 424.5 655.857 424.5 649.698V374.302C424.5 368.24 430.96 364.415 436.249 367.234L436.5 367.374L675 505.072Z"
fill="url(#paint2_linear_18_2)" stroke="url(#paint3_linear_18_2)" stroke-width="8" stroke-linejoin="round" />
<defs>
<linearGradient id="grad-play" x1="10%" y1="10%" x2="90%" y2="90%">
<stop offset="0%" style="stop-color:#FF512F; stop-opacity:1"/>
<stop offset="100%" style="stop-color:#DD2476; stop-opacity:1"/>
<filter id="filter0_d_18_2" x="78" y="90" width="868" height="868" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha" />
<feOffset dy="12" />
<feGaussianBlur stdDeviation="12" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.866667 0 0 0 0 0.141176 0 0 0 0 0.462745 0 0 0 0.4 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_18_2" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_18_2" result="shape" />
</filter>
<linearGradient id="paint0_linear_18_2" x1="102" y1="102" x2="922" y2="922" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF632F" />
<stop offset="1" stop-color="#DC148C" />
</linearGradient>
<radialGradient id="paint1_radial_18_2" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="translate(512 512) rotate(45) scale(579.828)">
<stop offset="0.68" stop-color="white" stop-opacity="0" />
<stop offset="0.72" stop-color="white" />
</radialGradient>
<linearGradient id="paint2_linear_18_2" x1="512" y1="329" x2="512" y2="695" gradientUnits="userSpaceOnUse">
<stop stop-color="white" />
<stop offset="1" stop-color="white" stop-opacity="0.4" />
</linearGradient>
<linearGradient id="paint3_linear_18_2" x1="512" y1="329" x2="512" y2="695" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0" />
<stop offset="1" stop-color="white" stop-opacity="0.5" />
</linearGradient>
</defs>
<circle cx="100" cy="100" r="90" fill="url(#grad-play)"/>
<path d="M85 70 L130 100 L85 130 Z" fill="white" stroke="white" stroke-width="4" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/tray-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -20,7 +20,7 @@ mac:
arch:
- x64
- arm64
icon: assets/generated/icons/mac/icon.icns
icon: assets/generated/icons/mac/icon.icon
compression: maximum
win:
icon: assets/generated/icons/win/icon.ico
@ -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

@ -18,51 +18,89 @@ export default tsEslint.config(
{
plugins: {
stylistic,
importPlugin
importPlugin,
},
languageOptions: {
parser: tsEslint.parser,
parserOptions: {
project: true,
project: ['tsconfig.json', 'tsconfig.test.json'],
sourceType: 'module',
ecmaVersion: 'latest'
}
ecmaVersion: 'latest',
},
},
rules: {
'stylistic/arrow-parens': ['error', 'always'],
'stylistic/object-curly-spacing': ['error', 'always'],
'stylistic/jsx-pascal-case': 'error',
'stylistic/jsx-curly-spacing': ['error', { when: 'never', children: true }],
'stylistic/jsx-curly-spacing': [
'error',
{ when: 'never', children: true },
],
'stylistic/jsx-sort-props': 'error',
'prettier/prettier': ['error', { singleQuote: true, semi: true, tabWidth: 2, trailingComma: 'all', quoteProps: 'preserve' }],
'prettier/prettier': [
'error',
{
singleQuote: true,
semi: true,
tabWidth: 2,
trailingComma: 'all',
quoteProps: 'preserve',
},
],
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-misused-promises': ['off', { checksVoidReturn: false }],
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-misused-promises': [
'off',
{ checksVoidReturn: false },
],
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/consistent-type-imports': ['error', {
'@typescript-eslint/consistent-type-imports': [
'error',
{
fixStyle: 'inline-type-imports',
prefer: 'type-imports',
disallowTypeAnnotations: false,
}],
},
],
'importPlugin/first': 'error',
'importPlugin/newline-after-import': 'off',
'importPlugin/no-default-export': 'off',
'importPlugin/no-duplicates': 'error',
'importPlugin/no-unresolved': ['error', { ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'] }],
'importPlugin/order': ['error', {
'groups': ['builtin', 'external', ['internal', 'index', 'sibling'], 'parent', 'type'],
'importPlugin/no-unresolved': [
'error',
{
ignore: ['^virtual:', '\\?inline$', '\\?raw$', '\\?asset&asarUnpack'],
},
],
'importPlugin/order': [
'error',
{
'groups': [
'builtin',
'external',
['internal', 'index', 'sibling'],
'parent',
'type',
],
'newlines-between': 'always-and-inside-groups',
'alphabetize': { order: 'ignore', caseInsensitive: false }
}],
'alphabetize': { order: 'ignore', caseInsensitive: false },
},
],
'importPlugin/prefer-default-export': 'off',
'camelcase': ['error', { properties: 'never' }],
'class-methods-use-this': 'off',
'stylistic/lines-around-comment': ['error', {
'stylistic/lines-around-comment': [
'error',
{
beforeBlockComment: false,
afterBlockComment: false,
beforeLineComment: false,
afterLineComment: false,
}],
},
],
'stylistic/max-len': 'off',
'stylistic/no-mixed-operators': 'warn', // prettier does not support no-mixed-operators
'stylistic/no-multi-spaces': ['error', { ignoreEOLComments: true }],
@ -70,16 +108,20 @@ export default tsEslint.config(
'no-void': 'error',
'no-empty': 'off',
'prefer-promise-reject-errors': 'off',
'stylistic/quotes': ['error', 'single', {
'stylistic/quotes': [
'error',
'single',
{
avoidEscape: true,
allowTemplateLiterals: false,
}],
allowTemplateLiterals: 'never',
},
],
'stylistic/quote-props': ['error', 'consistent'],
'stylistic/semi': ['error', 'always'],
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts']
'@typescript-eslint/parser': ['.ts'],
},
'import/resolver': {
typescript: {},

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",
@ -68,13 +68,13 @@
"@electron/remote": "2.1.3",
"@ffmpeg.wasm/core-mt": "0.12.0",
"@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,45 +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-builder": "26.0.12",
"electron-builder-squirrel-windows": "26.0.12",
"electron": "38.8.0",
"electron-builder": "26.4.0",
"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",
"playwright": "1.55.1",
"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,

2807
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

@ -184,9 +184,31 @@
}
},
"plugins": {
"enabled": "Omogući",
"enabled": "Omogućeno",
"label": "Dodaci",
"new": "Novo"
},
"view": {
"label": "Pogled",
"submenu": {
"force-reload": "Silom Ponovo Učitaj",
"reload": "Ponovo Učitaj",
"reset-zoom": "Stvarna Veličina",
"toggle-fullscreen": "Uključi/Isključi Prikaz Cijelog Ekrana",
"zoom-in": "Uvećaj",
"zoom-out": "Umanji"
}
}
},
"tray": {
"next": "Slijedeće",
"play-pause": "Plej/Pauza",
"previous": "Prethodno",
"quit": "Izlaz",
"restart": "Ponovo Pokreni Aplikaciju",
"show": "Pokaži prozor",
"tooltip": {
"default": "{{applicationName}}"
}
}
},
@ -197,6 +219,53 @@
"label": "Kvalitet"
}
}
},
"discord": {
"menu": {
"set-status-display-type": {
"submenu": {
"application": "Slušate {{applicationName}}",
"artist": "Slušate {muzičar}",
"title": "Slušate {naziv pesme}"
}
}
},
"name": "Diskord Rich Presence",
"prompt": {
"set-inactivity-timeout": {
"label": "Unesi ograničenje neaktivnosti u sekundama:",
"title": "Postavi ograničenje neaktivnosti"
}
}
},
"downloader": {
"backend": {
"dialog": {
"error": {
"buttons": {
"ok": "OK"
},
"message": "Ufff! Izvinite, preuzimanje nije uspelo…",
"title": "Greška u preuzimanju!"
},
"start-download-playlist": {
"buttons": {
"ok": "OK"
},
"detail": "({{Playlist Size}} pjesme)",
"message": "Preuzimanje Plejliste {{playlist Title}}",
"title": "Preuzimanje počelo"
}
},
"feedback": {
"conversion-progress": "Pretvaranje: {{percent}}%",
"converting": "Pretvaranje…",
"done": "Gotovo: {{file Path}}",
"download-info": "Preuzimanje {{artist}} - {{title}} [{{videoId}}",
"download-progress": "Preuzimanje: {{percent}}%",
"downloading": "Preuzimanje…"
}
}
}
}
}

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

@ -288,7 +288,7 @@
"name": "Ambiente-Modus"
},
"amuse": {
"description": "Fügt Unterstützung für das Amuse \"Spielt gerade\"-Widget von 6K Labs hinzu",
"description": "Fügt {{applicationName}} Unterstützung für das Amuse \"Spielt gerade\"-Widget von 6K Labs hinzu",
"name": "Amuse",
"response": {
"query": "Amuse API-Server läuft. /query für Liedinformationen."
@ -321,6 +321,22 @@
"hostname": {
"label": "Hostname"
},
"https": {
"label": "HTTPS & Zertifikate",
"submenu": {
"cert": {
"dialogTitle": "HTTPS Zertifikat Datei auswählen",
"label": "Zertifikate Datei (.crt/.pem)"
},
"enable-https": {
"label": "HTTPS aktivieren"
},
"key": {
"dialogTitle": "HTTPS privaten Schlüssel Datei auswählen",
"label": "Privater Schlüssel Datei (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Status Text",
"submenu": {
"artist": "Hört {artist} zu",
"application": "Hört {{applicationName}}",
"artist": "Hört {artist} zu",
"title": "Du hörst {song title}"
}
}
@ -867,12 +883,12 @@
},
"name": "Synchronisierte Texte",
"refetch-btn": {
"fetching": "Hole Songtext...",
"normal": "Songtext neu holen"
"fetching": "Laden...",
"normal": "Songtext neu laden"
},
"warnings": {
"duration-mismatch": "⚠️ - Es kann sein, dass die Synchronization nicht stimmt, da die Songdauer nicht übereinstimmt.",
"inexact": "⚠️ - Der Songtext stimmt möglicherweise nicht überein",
"inexact": "⚠️ - Es ist möglich, dass der Songtext für diesen Song nicht übereinstimmt.",
"instrumental": "⚠️ - Das ist ein instrumentales Lied"
}
},

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

@ -321,6 +321,22 @@
"hostname": {
"label": "Nombre del host"
},
"https": {
"label": "HTTPS & Certificados",
"submenu": {
"cert": {
"dialogTitle": "Selecciona el archivo de certificado HTTPS",
"label": "Archivo de certificado (.crt/.pem)"
},
"enable-https": {
"label": "Habilitar HTTPS"
},
"key": {
"dialogTitle": "Selecciona el archivo de clave privada HTTPS",
"label": "Archivo de clave privada (.key/.pem)"
}
}
},
"port": {
"label": "Puerto"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Texto de estado",
"submenu": {
"artist": "Escuchando a {artist}",
"application": "Escuchando {{applicationName}}",
"artist": "Escuchando a {artist}",
"title": "Escuchando {song title}"
}
}

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

@ -320,6 +320,22 @@
"hostname": {
"label": "نام میزبان"
},
"https": {
"label": "HTTPS و گواهینامه‌ها",
"submenu": {
"cert": {
"dialogTitle": "پرونده گواهینامه HTTPS را انتخاب کنید",
"label": "پرونده گواهینامه (crt/.pem.)"
},
"enable-https": {
"label": "فعال کردن HTTPS"
},
"key": {
"dialogTitle": "پرونده کلید خصوصی HTTPS را انتخاب کنید",
"label": "پرونده کلید خصوصی (key/.pem)"
}
}
},
"port": {
"label": "پورت"
}
@ -461,8 +477,8 @@
"set-status-display-type": {
"label": "متن وضعىت",
"submenu": {
"artist": "به {artist} گوش مىکند",
"application": "به پىر دسکتاپ گوش مىکند",
"artist": "به {artist} گوش مىکند",
"title": "به {song title} گوش مىکند"
}
}

View File

@ -209,7 +209,7 @@
"show": "Afficher la fenêtre",
"tooltip": {
"default": "{{applicationName}}",
"with-song-info": "{{applicationName}}: {{artist}} - {{title}}"
"with-song-info": "{{applicationName}} : {{artist}} - {{title}}"
}
}
},
@ -321,6 +321,22 @@
"hostname": {
"label": "Nom de l'hôte"
},
"https": {
"label": "HTTPS & Certificats",
"submenu": {
"cert": {
"dialogTitle": "Sélectionner le fichier de certificat HTTPS",
"label": "Fichier de certificat (.crt/.pem)"
},
"enable-https": {
"label": "Activer HTTPS"
},
"key": {
"dialogTitle": "Sélectionner le fichier de clé privée HTTPS",
"label": "Fichier de clé privée (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Texte d'état",
"submenu": {
"artist": "Écoute {artiste}",
"application": "Écoute {{applicationName}}",
"artist": "Écoute {artiste}",
"title": "Écoute {titre de la chanson}"
}
}
@ -483,8 +499,8 @@
"buttons": {
"ok": "Ok"
},
"message": "Argh ! Désolé, le téléchargement a échoué…",
"title": "Erreur de téléchargement !"
"message": "Argh! Désolé, le téléchargement a échoué…",
"title": "Erreur de téléchargement!"
},
"start-download-playlist": {
"buttons": {
@ -492,30 +508,30 @@
},
"detail": "({{playlistSize}} chansons)",
"message": "Téléchargement de la playlist {{playlistTitle}}",
"title": "Téléchargement a commencé"
"title": "Téléchargement commencé"
}
},
"feedback": {
"conversion-progress": "Conversion : {{percent}} %",
"conversion-progress": "Conversion : {{percent}}%",
"converting": "Conversion…",
"done": "Terminé : {{filePath}}",
"download-info": "Téléchargement {{artist}} - {{title}} [{{videoId}}",
"download-progress": "Téléchargé: {{percent}}%",
"download-progress": "Téléchargé : {{percent}}%",
"downloading": "Télécharge…",
"downloading-counter": "Télécharge {{current}}/{{total}}…",
"downloading-playlist": "Téléchargement de la playlist \"{{playlistTitle}}\"  {{playlistSize}} chansons ({{playlistId}})",
"downloading-playlist": "Téléchargement de la playlist \"{{playlistTitle}}\" - {{playlistSize}} chansons ({{playlistId}})",
"error-while-downloading": "Erreur lors du téléchargement de \"{{author}} - {{title}}\" : {{error}}",
"folder-already-exists": "Le dossier {{playlistFolder}} existe déjà",
"getting-playlist-info": "Obtention d'informations sur la liste de lecture…",
"getting-playlist-info": "Obtention des données de la playlist…",
"loading": "Chargement…",
"playlist-has-only-one-song": "La liste de lecture ne contient qu'un seul élément, téléchargement du morceau seul",
"playlist-id-not-found": "Aucun ID de liste de lecture trouvé",
"playlist-is-empty": "La liste de lecture est vide",
"playlist-is-mix-or-private": "Erreur lors de l'obtention des informations sur la liste de lecture: assurez-vous qu'il ne s'agit pas d'une liste privée ou \"Mixée pour vous\"\n\n{{error}}",
"preparing-file": "Péparer des fichier…",
"playlist-has-only-one-song": "La playlist ne contient qu'un seul élément, téléchargement du morceau seul",
"playlist-id-not-found": "Aucun ID de playlist trouvé",
"playlist-is-empty": "La playlist est vide",
"playlist-is-mix-or-private": "Erreur lors de l'obtention des informations sur la playlist : assurez-vous qu'il ne s'agit pas d'une playlist privée ou \"Mixée pour vous\"\n\n{{error}}",
"preparing-file": "Préparation des fichier…",
"saving": "Sauvegarde…",
"trying-to-get-playlist-id": "Obtention de l'ID de la liste de lecture: {{playlistId}}",
"video-id-not-found": "Vidéo non trouvée",
"trying-to-get-playlist-id": "Obtention de l'ID de la playlist : {{playlistId}}",
"video-id-not-found": "Vidéo introuvable",
"writing-id3": "Écriture des balises ID3…"
}
},
@ -525,19 +541,19 @@
"download-finish-settings": {
"label": "Télécharger une fois terminé",
"prompt": {
"last-percent": "Après x pour cent",
"last-percent": "Après x pourcents",
"last-seconds": "Dernières x secondes",
"title": "Configurer quand télécharger"
},
"submenu": {
"advanced": "Avancé",
"enabled": "Activé",
"mode": "Mode de temps",
"mode": "Unité de temps",
"percent": "Pourcent",
"seconds": "Secondes"
}
},
"download-playlist": "Télécharger la liste de lecture",
"download-playlist": "Télécharger la playlist",
"presets": "Préconfigurations",
"skip-existing": "Passer les fichiers existants"
},
@ -574,7 +590,7 @@
},
"lumiastream": {
"description": "Ajoute la prise en charge de Lumia Stream",
"name": "Lumia Stream [Bêta]"
"name": "Lumia Stream [Beta]"
},
"lyrics-genius": {
"description": "Ajoute la prise en charge des paroles pour la plupart des chansons",
@ -605,9 +621,9 @@
"host": "Hôte du Music Together",
"join": "Rejoindre le Music Together",
"permission": {
"all": "Autorisez les invités à contrôler la musique et le player",
"host-only": "Seulement l'hôte peut contrôler les playlists et le lecteur",
"playlist": "Autoriser les invités à contrôler les playlists"
"all": "Autorisez les invités à contrôler la playlist et le lecteur",
"host-only": "Seulement l'hôte peut contrôler la playlist et le lecteur",
"playlist": "Autoriser les invités à contrôler la playlist"
},
"set-permission": "Changer les permissions de contrôle",
"status": {
@ -625,7 +641,7 @@
"id-copied": "Identifiant de l'hôte copié dans le presse papier",
"id-copy-failed": "Echec de la copie de l'identifiant de l'hôte dans le presse papier",
"join-failed": "Echec en rejoignant le Music Together",
"joined": "Rejoint le Music Together",
"joined": "Music Together rejoint",
"permission-changed": "Permission du Music Together changé à \"{{permission}}\"",
"remove-song-failed": "Echec du retrait de la piste",
"user-connected": "{{name}} à rejoint le Music Together",
@ -657,7 +673,7 @@
"submenu": {
"hide-button-text": "Masquer le texte du bouton",
"refresh-on-play-pause": "Actualiser lors de la lecture/pause",
"tray-controls": "Ouvrir/Fermer sur le plateau, cliquez"
"tray-controls": "Ouvrir/Fermer au clic sur licône de la barre des tâches"
}
},
"priority": "Priorité des notifications",
@ -671,30 +687,30 @@
"name": "Amélioration des performances [Beta]"
},
"picture-in-picture": {
"description": "Permet de basculer lapplication en mode image dans image",
"description": "Permet de basculer lapplication en mode picture-in-picture",
"menu": {
"always-on-top": "Toujours en haut",
"always-on-top": "Toujours au dessus",
"hotkey": {
"label": "Raccourci clavier",
"prompt": {
"keybind-options": {
"hotkey": "Raccourci clavier"
},
"label": "Choisissez un raccourci clavier pour activer le mode Image dans l'image",
"title": "Touche de raccourci Image dans l'image"
"label": "Choisissez un raccourci clavier pour activer le mode picture-in-picture",
"title": "Touche de raccourci picture-in-picture"
}
},
"save-window-position": "Enregistrer la position de la fenêtre",
"save-window-size": "Enregistrer la taille de la fenêtre",
"use-native-pip": "Utiliser le mode image dans image natif du navigateur"
"use-native-pip": "Utiliser le mode PiP natif du navigateur"
},
"name": "Image dans l'image",
"name": "Picture-in-picture",
"templates": {
"button": "Image dans l'image"
"button": "Picture-in-picture"
}
},
"playback-speed": {
"description": "Écoutez vite, écoutez lentement ! Ajoute un curseur qui contrôle la vitesse de la chanson",
"description": "Écoutez vite, écoutez lentement! Ajoute un curseur qui contrôle la vitesse de la chanson",
"name": "Vitesse de lecture",
"templates": {
"button": "Vitesse"
@ -727,14 +743,14 @@
"backend": {
"dialog": {
"quality-changer": {
"detail": "Qualité actuelle: {{quality}}",
"detail": "Qualité actuelle : {{quality}}",
"message": "Choisissez la qualité vidéo :",
"title": "Choisissez la qualité vidéo"
}
}
},
"description": "Permet de changer la qualité vidéo avec un bouton sur la vidéo",
"name": "Changeur de qualité vidéo",
"description": "Permet de changer la qualité vidéo avec un bouton sur l'overlay vidéo",
"name": "Sélecteur de qualité vidéo",
"renderer": {
"quality-settings-button": {
"label": "Ouvrir le sélecteur de qualité du lecteur"
@ -746,7 +762,7 @@
"dialog": {
"lastfm": {
"auth-failed": {
"message": "Erreur lors de l'authetification avec Last.fm\nCachez la popup jusqu'au prochain redémarrage.",
"message": "Erreur lors de l'authetification avec Last.fm\nCacher la popup jusqu'au prochain redémarrage.",
"title": "Authentification échouée"
}
}
@ -762,7 +778,7 @@
"scrobble-alternative-title": "Utiliser des titres alternatifs",
"scrobble-other-media": "Scrobbler d'autres médias"
},
"name": "Scrobble",
"name": "Scrobbler",
"prompt": {
"lastfm": {
"api-key": "Clé API de Last.fm",
@ -770,7 +786,7 @@
},
"listenbrainz": {
"token": {
"label": "Entrez votre token utilisateur ListenBrainz:",
"label": "Entrez votre token utilisateur ListenBrainz :",
"title": "Token ListenBrainz"
}
}
@ -796,8 +812,8 @@
}
},
"skip-disliked-songs": {
"description": "Passer les musiques que je n'aime pas",
"name": "Passer les chansons « Je n'aime pas »"
"description": "Passe les titres \"Je n'aime pas\"",
"name": "Passer les titres \"Je n'aime pas\""
},
"skip-silences": {
"description": "Ignorer automatiquement les sections de silence dans les chansons",
@ -811,12 +827,12 @@
"description": "Ajoute des paroles synchronisées aux chansons, grâce à LRClib par exemple.",
"errors": {
"fetch": "⚠️\tUne erreur s'est produite en allant chercher les paroles.\n\tMerci de réessayer plus tard.",
"not-found": "⚠️ Aucune paroles trouvées pour cette musique."
"not-found": "⚠️ Aucune paroles trouvées pour ce titre."
},
"menu": {
"default-text-string": {
"label": "Caractère par défaut entre les paroles",
"tooltip": "Choisi le caractère par défaut à utiliser pour l'espace entre les paroles"
"tooltip": "Choisi le caractère par défaut à utiliser pour les blancs entre les paroles"
},
"line-effect": {
"label": "Effet de ligne",
@ -827,7 +843,7 @@
},
"focus": {
"label": "Focus",
"tooltip": "Rend seulement la ligne actuelle blanche"
"tooltip": "Rend blanche seulement la ligne actuelle"
},
"offset": {
"label": "Décalage",
@ -850,7 +866,7 @@
"label": "Aucun",
"tooltip": "Aucun fournisseur privilégié"
},
"tooltip": "Choisissez le fournisseur par défaut à utiliser"
"tooltip": "Choisissez le fournisseur à utiliser par défaut"
},
"romanization": {
"label": "Romaniser les paroles",
@ -862,7 +878,7 @@
},
"show-time-codes": {
"label": "Afficher les timecodes",
"tooltip": "Affiche à côté de chaque paroles son timecode"
"tooltip": "Affiche le timecode à côté de chaque paroles"
}
},
"name": "Paroles Synchronisées",
@ -873,7 +889,7 @@
"warnings": {
"duration-mismatch": "⚠️ - Les paroles peuvent ne pas être synchronisées à cause d'une différence de durée.",
"inexact": "⚠️ - Les paroles de cette chanson peuvent ne pas être exactes",
"instrumental": "⚠️ - Cette musique n'a pas de paroles"
"instrumental": "⚠️ - C'est un titre instrumental"
}
},
"taskbar-mediacontrol": {
@ -910,7 +926,7 @@
"name": "Tuna OBS"
},
"unobtrusive-player": {
"description": "Empêche le lecteur de s'afficher quand une chanson est en cours de lecture",
"description": "Empêche le lecteur de s'afficher quand un titre est en cours de lecture",
"name": "Lecteur Non-Intrusif"
},
"video-toggle": {
@ -934,7 +950,7 @@
}
}
},
"name": "Basculer la vidéo",
"name": "Bouton de bascule vidéo",
"templates": {
"button-song": "Musique",
"button-video": "Vidéo"

View File

@ -461,8 +461,8 @@
"set-status-display-type": {
"label": "स्टेटस टेक्स्ट",
"submenu": {
"artist": "{artist} को सुन रहे है",
"application": "{{applicationName}} सुन रहे है",
"artist": "{artist} को सुन रहे है",
"title": "{song title} सुन रहे है"
}
}

View File

@ -237,7 +237,8 @@
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"enable-seekbar": "Omogući postavljanje teme \"seekbar\"-a"
},
"name": "Boja teme albuma"
},
@ -320,6 +321,22 @@
"hostname": {
"label": "Naziv hosta"
},
"https": {
"label": "HTTPS i Sertifikati",
"submenu": {
"cert": {
"dialogTitle": "Biraj HTTPS sertifikat datoteku",
"label": "Sertifikat datoteka (.crt/.pem)"
},
"enable-https": {
"label": "Omogući HTTPS"
},
"key": {
"dialogTitle": "Biraj privatni ključ datoteku za HTTPS",
"label": "Privatni ključ datoteka (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -461,8 +478,8 @@
"set-status-display-type": {
"label": "Tekst statusa",
"submenu": {
"artist": "Slušate {glazbenika}",
"application": "Slušate {{applicationName}}",
"artist": "Slušate {glazbenika}",
"title": "Slušate {naslov pjesme}"
}
}

View File

@ -237,7 +237,8 @@
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"enable-seekbar": "Abilita tematizzazione della seekbar"
},
"name": "Tema abbinato a colore album"
},
@ -320,6 +321,22 @@
"hostname": {
"label": "Hostname"
},
"https": {
"label": "HTTPS & Certificati",
"submenu": {
"cert": {
"dialogTitle": "Seleziona file di certificato HTTPS",
"label": "File di certificato (.crt/.pem)"
},
"enable-https": {
"label": "Abilita HTTPS"
},
"key": {
"dialogTitle": "Seleziona il file della chiave privata HTTPS",
"label": "File della chiave privata (.key/.pem)"
}
}
},
"port": {
"label": "Porta"
}
@ -461,9 +478,9 @@
"set-status-display-type": {
"label": "Testo dello status",
"submenu": {
"application": "Ascoltando {{applicationName}}",
"artist": "Stai ascoltando {artist}",
"title": "Stai ascoltando {song title}",
"application": "Ascoltando {{applicationName}}"
"title": "Stai ascoltando {song title}"
}
}
},
@ -866,7 +883,7 @@
},
"name": "Testi sincronizzati",
"refetch-btn": {
"fetching": "Sto recuperando...",
"fetching": "Caricamento...",
"normal": "Recupera i testi"
},
"warnings": {

View File

@ -117,7 +117,7 @@
"hide-menu": {
"dialog": {
"message": "მენიუ შემდეგი გაშვებისას დაიმალება, მის საჩვენებლად გამოიყენეთ [Alt] (ან თუ აპლიკაციის მენიუს იყენებთ, უკან დააწკაპუნეთ [`])",
"title": "მენიუს დამალვა ჩართულია"
"title": "მენიუს დამალვა ჩართ"
},
"label": "მენიუს დამალვა"
},

View File

@ -171,7 +171,7 @@
"remove": "제거"
},
"remove-theme": "사용자 정의 테마를 제거하시겠습니까?",
"remove-theme-message": "사용자 정의 테마를 제거하시겠습니까?"
"remove-theme-message": "이 설정을 변경하면 커스텀 테마가 삭제됩니다"
},
"label": "테마",
"submenu": {
@ -289,7 +289,7 @@
},
"amuse": {
"description": "6K Labs Amuse의 'now playing' 위젯에 {{applicationName}} 지원 추가",
"name": "Amuse",
"name": "Amuseio AB",
"response": {
"query": "Amuse API 서버가 실행 중입니다. GET /query로 노래 정보를 가져오세요."
}
@ -321,6 +321,22 @@
"hostname": {
"label": "호스트 명"
},
"https": {
"label": "HTTPS 및 인증서",
"submenu": {
"cert": {
"dialogTitle": "HTTPS 인증서 파일을 선택해 주세요",
"label": "인증서 파일(.crt/.pem)"
},
"enable-https": {
"label": "HTTPS 활성화"
},
"key": {
"dialogTitle": "HTTPS 개인 키 파일을 선택해 주세요",
"label": "개인 키 파일(.key/.pem)"
}
}
},
"port": {
"label": "포트"
}
@ -457,13 +473,13 @@
"disconnected": "연결 해제 됨",
"hide-duration-left": "남은 재생 시간 숨기기",
"hide-github-button": "GitHub 링크 버튼 숨기기",
"play-on-application": "유튜브 뮤직에서 재생",
"play-on-application": "{{applicationName}} 에서 재생",
"set-inactivity-timeout": "비활성 시간 제한 설정",
"set-status-display-type": {
"label": "상태 텍스트",
"submenu": {
"artist": "{아티스트} 듣는 중",
"application": "{{applicationName}} 듣는 중",
"artist": "{아티스트} 듣는 중",
"title": "{곡 제목} 듣는 중"
}
}

View File

@ -151,7 +151,9 @@
"label": "Vizualiniai patobulinimai",
"submenu": {
"custom-window-title": {
"label": "Pasirinktinis lango pavadinimas",
"prompt": {
"label": "Įveskite pasirinktinį lango pavadinimą: (palikite tuščią, jei norite išjungti)",
"placeholder": "Pavyzdys: {{applicationName}}"
}
},
@ -431,9 +433,9 @@
"set-inactivity-timeout": "Nustatyti neveiklumo laiką",
"set-status-display-type": {
"submenu": {
"application": "Klausosi {{applicationName}}",
"artist": "Klausosi {artist]",
"title": "Klausosi {song title}",
"application": "Klausosi {{applicationName}}"
"title": "Klausosi {song title}"
}
}
},

View File

@ -5,10 +5,10 @@
"execute-failed": "Mislukt om plugin {{pluginName}}::{{contextName}} uit te voeren",
"executed-at-ms": "Plugin {{pluginName}}::{{contextName}} uitgevoerd in {{ms}}ms",
"initialize-failed": "Initialisatie van plugin \"{{pluginName}}\" mislukt",
"load-all": "Alle plugins laden",
"load-all": "Alle plugins aan het laden",
"load-failed": "Mislukt om plugin \"{{pluginName}}\" te laden",
"loaded": "Plugin \"{{pluginName}}\" geladen",
"unload-failed": "Mislukt om plugin \"{{pluginName}}\" te lossen",
"unload-failed": "Mislukt om plugin \"{{pluginName}}\" te ontladen",
"unloaded": "Plugin \"{{pluginName}}\" gelost"
}
}
@ -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"
}
@ -461,9 +478,9 @@
"set-status-display-type": {
"label": "Status tekst",
"submenu": {
"application": "Naar {{applicationName}} aan het luisteren",
"artist": "Naar {artist} aan het luisteren",
"title": "Naar {song title} aan het luisteren",
"application": "Naar {{applicationName}} aan het luisteren"
"title": "Naar {song title} aan het luisteren"
}
}
},

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}}"
}
}
},
@ -237,7 +237,8 @@
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"enable-seekbar": "Zezwól stylowanie paska wyszukiwań"
},
"name": "Motyw kolorów albumu"
},
@ -294,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": {
@ -320,6 +321,22 @@
"hostname": {
"label": "Nazwa hosta (IP)"
},
"https": {
"label": "HTTPS i Certyfikaty",
"submenu": {
"cert": {
"dialogTitle": "Wybierz plik certyfikatu HTTPS",
"label": "Certyfikat (.crt/.pem)"
},
"enable-https": {
"label": "Zezwól HTTPS"
},
"key": {
"dialogTitle": "Wybierz plik prywatnego klucza HTTPS",
"label": "Klucz prywatny (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -461,8 +478,8 @@
"set-status-display-type": {
"label": "Opis statusu",
"submenu": {
"artist": "Słucha {artist}",
"application": "Słucha {{applicationName}}",
"artist": "Słucha {artist}",
"title": "Słucha {song title}"
}
}

View File

@ -321,6 +321,22 @@
"hostname": {
"label": "Nome do anfitrião"
},
"https": {
"label": "HTTPS & Certificados",
"submenu": {
"cert": {
"dialogTitle": "Selecione o certificado do HTTPS",
"label": "Arquivo de certificado (.crt/.pem)"
},
"enable-https": {
"label": "Habilitar HTTPS"
},
"key": {
"dialogTitle": "Selecione a chave privada do HTTPS",
"label": "Arquivo de chave privada (.key/.pem)"
}
}
},
"port": {
"label": "Porta"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Texto de status",
"submenu": {
"artist": "Ouvindo {artist}",
"application": "Ouvindo {{applicationName}}",
"artist": "Ouvindo {artist}",
"title": "Ouvindo {song title}"
}
}

View File

@ -321,6 +321,22 @@
"hostname": {
"label": "Nome do anfitrião"
},
"https": {
"label": "HTTPS e Certificados",
"submenu": {
"cert": {
"dialogTitle": "Selecionar arquivo do certificado HTTPS",
"label": "Arquivo do certificado (.crt/.pem)"
},
"enable-https": {
"label": "Ativar HTTPS"
},
"key": {
"dialogTitle": "Selecionar arquivo da chave privada HTTPS",
"label": "Arquivo da chave privada (.key/.pen)"
}
}
},
"port": {
"label": "Porta"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Texto de estado",
"submenu": {
"artist": "A ouvir {artist}",
"application": "A reproduzir {{applicationName}}",
"artist": "A ouvir {artist}",
"title": "A ouvir {song title}"
}
}

View File

@ -2,13 +2,13 @@
"common": {
"console": {
"plugins": {
"execute-failed": "Ошибка загрузки плагина {{pluginName}}::{{contextName}}",
"execute-failed": "Ошибка при выполнении плагина {{pluginName}}::{{contextName}}",
"executed-at-ms": "Плагин {{pluginName}}::{{contextName}} загружен за {{ms}}мс",
"initialize-failed": "Ошибка инициализации плагина \"{{pluginName}}\"",
"load-all": "Загружаем все плагины",
"load-failed": "Ошибка загрузки плагина \"{{pluginName}}\"",
"loaded": "Плагин \"{{pluginName}}\" загружен",
"unload-failed": "Ошибка выгрузки плагина \"{{pluginName}}\"",
"unload-failed": "Ошибка при выгрузке плагина \"{{pluginName}}\"",
"unloaded": "Плагин \"{{pluginName}}\" выгружен"
}
}
@ -44,7 +44,7 @@
},
"dialog": {
"hide-menu-enabled": {
"detail": "Меню скрыто, 'Alt' чтобы показать его ('Escape' если используете внутреннее меню приложения)",
"detail": "Меню скрыто, используйте 'Alt' чтобы показать его ('Escape' если используете внутреннее меню приложения)",
"message": "Скрытие меню включено",
"title": "Включено скрытие меню"
},
@ -53,8 +53,8 @@
"later": "Позже",
"restart-now": "Перезапустить сейчас"
},
"detail": "Перезапустите приложение для включения плагина {{pluginName}}",
"message": "Перезапуск для применения плагина {{pluginName}}",
"detail": "Для вступления изменений в силу плагину \"{{pluginName}}\" требуется перезапуск",
"message": "Требуется перезапуск плагина \"{{pluginName}}\"",
"title": "Нужен перезапуск"
},
"unresponsive": {
@ -100,7 +100,7 @@
"disable-hardware-acceleration": "Отключить аппаратное ускорение",
"edit-config-json": "Редактировать config.json",
"override-user-agent": "Переопределить User-Agent",
"restart-on-config-changes": "Перезапускать при изменениях конфига",
"restart-on-config-changes": "Перезапускать при изменениях конфигурации",
"set-proxy": {
"label": "Задать прокси",
"prompt": {
@ -237,7 +237,8 @@
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"enable-seekbar": "Включить тематическое оформление полосы прокрутки"
},
"name": "Цветовая тема альбома"
},
@ -287,7 +288,7 @@
"name": "Режим Ambient"
},
"amuse": {
"description": "Добавляет поддержку виджета Amuse „сейчас играет“ от 6K Labs",
"description": "Добавляет {{applicationName}} поддержку виджета Amuse „сейчас играет“ от 6K Labs",
"name": "Amuse",
"response": {
"query": "Сервер Amuse API запущен. GET /query чтобы получить информацию о треке."
@ -320,6 +321,22 @@
"hostname": {
"label": "Имя хоста"
},
"https": {
"label": "HTTPS и сертификаты",
"submenu": {
"cert": {
"dialogTitle": "Выберите файл сертификата HTTPS",
"label": "Файл сертификата (.crt/.pem)"
},
"enable-https": {
"label": "Включить HTTPS"
},
"key": {
"dialogTitle": "Выберите файл приватного ключа HTTPS",
"label": "Файл приватного ключа (.key/.pem)"
}
}
},
"port": {
"label": "Порт"
}
@ -461,8 +478,8 @@
"set-status-display-type": {
"label": "Текст статуса",
"submenu": {
"artist": "Слушает {исполнитель}",
"application": "Слушает {{applicationName}}",
"artist": "Слушает {исполнитель}",
"title": "Слушает {название трека}"
}
}

View File

@ -237,7 +237,8 @@
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"enable-seekbar": "Omogući postavljanje teme \"seekbar\"-a"
},
"name": "Paleta boja albuma"
},
@ -320,6 +321,22 @@
"hostname": {
"label": "Ime host-a"
},
"https": {
"label": "HTTPS i Sertifikati",
"submenu": {
"cert": {
"dialogTitle": "Izaberi HTTPS datoteku sertifikata",
"label": "Datoteka sertifikata (.crt/.pem)"
},
"enable-https": {
"label": "Omogući HTTPS"
},
"key": {
"dialogTitle": "Izaberi HTTPS datoteku privatnog ključa",
"label": "Datoteka privatnog ključa (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -461,9 +478,9 @@
"set-status-display-type": {
"label": "Tekst statusa",
"submenu": {
"application": "Slušanje {{applicationName}}",
"artist": "Slušanje {artist}",
"title": "Slušanje {song title}",
"application": "Slušanje {{applicationName}}"
"title": "Slušanje {song title}"
}
}
},

View File

@ -321,6 +321,22 @@
"hostname": {
"label": "Värdnamn"
},
"https": {
"label": "HTTPS och certifikat",
"submenu": {
"cert": {
"dialogTitle": "Välj HTTPS-certifikatfil",
"label": "Certifikatfil (.crt/.pem)"
},
"enable-https": {
"label": "Aktivera HTTPS"
},
"key": {
"dialogTitle": "Välj privat nyckelfil (.key/.pem)",
"label": "Privat nyckelfil (.key/.pem)"
}
}
},
"port": {
"label": "Port"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "Statusmeddelande",
"submenu": {
"artist": "Lyssnar på {artist}",
"application": "Lyssnar på {{applicationName}}",
"artist": "Lyssnar på {artist}",
"title": "Lyssnar på {song title}"
}
}

View File

@ -461,8 +461,8 @@
"set-status-display-type": {
"label": "நிலை உரை",
"submenu": {
"artist": "{கலைஞர்} பாடலைக் கேட்கிறேன்",
"application": "வலையொளி இசையில் கேட்கிறது",
"artist": "{கலைஞர்} பாடலைக் கேட்கிறேன்",
"title": "பாடலைக் கேட்கிறேன்{பாடல் தலைப்பு}"
}
}

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

@ -320,6 +320,13 @@
"hostname": {
"label": "Tên máy chủ"
},
"https": {
"submenu": {
"enable-https": {
"label": "Bật HTTPS"
}
}
},
"port": {
"label": "Cổng"
}
@ -461,9 +468,9 @@
"set-status-display-type": {
"label": "Văn bản trạng thái",
"submenu": {
"application": "Đang nghe {{applicationName}}",
"artist": "Đang nghe nhạc của {artist}",
"title": "Đang nghe nhạc {song title}",
"application": "Đang nghe {{applicationName}}"
"title": "Đang nghe nhạc {song title}"
}
}
},

View File

@ -321,6 +321,22 @@
"hostname": {
"label": "主机名"
},
"https": {
"label": "HTTPS & 数字证书",
"submenu": {
"cert": {
"dialogTitle": "选择 HTTPS 证书文件",
"label": "证书文件(.crt/.pem"
},
"enable-https": {
"label": "启用 HTTPS"
},
"key": {
"dialogTitle": "选择 HTTPS 私钥文件",
"label": "私钥文件(.key/.pem"
}
}
},
"port": {
"label": "端口号"
}
@ -462,8 +478,8 @@
"set-status-display-type": {
"label": "状态文本",
"submenu": {
"artist": "在听 {artist}",
"application": "在听 {{applicationName}}",
"artist": "在听 {artist}",
"title": "在听 {song title}"
}
}

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);
}
}
/**

View File

@ -1,8 +1,10 @@
import { Menu, nativeImage, screen, Tray } from 'electron';
import is from 'electron-is';
import defaultTrayIconAsset from '@assets/tray.png?asset&asarUnpack';
import pausedTrayIconAsset from '@assets/tray-paused.png?asset&asarUnpack';
import TrayIcon from '@assets/tray.png?asset&asarUnpack';
import PausedTrayIcon from '@assets/tray-paused.png?asset&asarUnpack';
import TrayIconWhite from '@assets/tray-white.png?asset&asarUnpack';
import PausedTrayIconWhite from '@assets/tray-paused-white.png?asset&asarUnpack';
import * as config from './config';
@ -52,14 +54,15 @@ export const setUpTray = (app: Electron.App, win: Electron.BrowserWindow) => {
const pixelRatio = is.windows()
? screen.getPrimaryDisplay().scaleFactor || 1
: 1;
const defaultTrayIcon = nativeImage
.createFromPath(defaultTrayIconAsset)
.createFromPath(is.macOS() ? TrayIconWhite : TrayIcon)
.resize({
width: 16 * pixelRatio,
height: 16 * pixelRatio,
});
const pausedTrayIcon = nativeImage
.createFromPath(pausedTrayIconAsset)
.createFromPath(is.macOS() ? PausedTrayIconWhite : PausedTrayIcon)
.resize({
width: 16 * pixelRatio,
height: 16 * pixelRatio,

View File

@ -1,7 +1,7 @@
import path from 'node:path';
import process from 'node:process';
import { _electron as electron } from 'playwright';
import { test, expect } from '@playwright/test';
import { test, expect, _electron as electron } from '@playwright/test';
process.env.NODE_ENV = 'test';
@ -32,7 +32,11 @@ test('Pear Desktop App - With default settings, app is launched and visible', as
// expect(title.replaceAll(/\s/g, ' ')).toEqual('Pear Desktop');
const url = window.url();
expect(url.startsWith('https://music.\u0079\u006f\u0075\u0074\u0075\u0062\u0065.com')).toBe(true);
expect(
url.startsWith(
'https://music.\u0079\u006f\u0075\u0074\u0075\u0062\u0065.com',
),
).toBe(true);
await app.close();
});

View File

@ -25,7 +25,8 @@
"exclude": ["./dist"],
"include": [
"electron.vite.config.mts",
"playwright.config.ts",
"./src/**/*",
"*.config.*js",
"*.config.*js"
]
}

8
tsconfig.test.json Normal file
View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"exclude": ["./dist"],
"include": [
"playwright.config.ts",
"./tests/**/*"
]
}