Compare commits

..

1 Commits

Author SHA1 Message Date
4c46886258 chore(deps): update dependency typescript-eslint to v8.52.0 2026-01-08 17:54:02 +00:00
41 changed files with 2296 additions and 2327 deletions

View File

@ -1,16 +0,0 @@
// 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-26, ubuntu-latest, windows-latest ]
os: [ macos-latest, 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@v6
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
@ -104,14 +104,14 @@ jobs:
- name: Setup NodeJS
if: startsWith(matrix.os, 'macOS') != true
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v6
uses: actions/setup-node@v5
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@v6
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Setup NodeJS for macOS
if: startsWith(matrix.os, 'macOS')
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
@ -103,7 +103,7 @@ jobs:
pull-requests: write
steps:
- name: Create comment
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const runId = context.runId;

View File

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

1
.gitignore vendored
View File

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

15
.vscode/launch.json vendored
View File

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

View File

@ -69,7 +69,7 @@
You can help with translation on [Hosted Weblate](https://bit.ly/48n5YF7).
<a href="https://bit.ly/48n5YF7">
<a href="https://bit.ly/48n5YF7/">
<img src="https://bit.ly/4q83L6S" alt="translation status" />
<img src="https://bit.ly/4h3zBxo" alt="translation status 2" />
</a>
@ -86,10 +86,10 @@ this [wiki page](https://wiki.archlinux.org/index.php/Arch_User_Repository#Insta
### macOS
You can install the app using Homebrew (see the [cask definition](https://github.com/pear-devs/homebrew-pear)):
You can install the app using Homebrew (see the [cask definition](https://github.com/pear-devs/pear-desktop-homebrew)):
```bash
brew install pear-devs/pear/pear-desktop
brew install pear-devs/pear-desktop
```
If you install the app manually and get an error "is damaged and cant be opened." when launching the app, run the following in the Terminal:
@ -144,10 +144,6 @@ 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:
@ -283,16 +279,6 @@ export default createPlugin({
Builds the app for macOS, Linux, and Windows,
using [electron-builder](https://github.com/electron-userland/electron-builder).
### Building in devcontainer
1. Clone the repo;
2. Open the folder in VS Code;
3. Reopen in container when prompted;
4. Run `pnpm build` as above (choosing the desired target);
5. Collect the built files from the `dist` folder.
Since devcontainer uses a mount for the workspace, the built files will be available on the host system as well.
## Production Preview
```bash

View File

@ -109,7 +109,7 @@ deb:
- libgbm1
rpm:
depends:
- libuuid
- /usr/lib64/libuuid.so.1
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 } from 'electron-vite';
import { defineConfig, defineViteConfig } from 'electron-vite';
import builtinModules from 'builtin-modules';
import Inspect from 'vite-plugin-inspect';
@ -21,10 +21,9 @@ const resolveAlias = {
'@assets': resolve(__dirname, './assets'),
};
export default defineConfig(({ mode }) => {
const isDev = mode === 'development';
const mainConfig: UserConfig = {
export default defineConfig({
main: defineViteConfig(({ mode }) => {
const commonConfig: UserConfig = {
experimental: {
enableNativePlugin: true,
},
@ -37,8 +36,8 @@ export default defineConfig(({ mode }) => {
],
publicDir: 'assets',
define: {
__dirname: 'import.meta.dirname',
__filename: 'import.meta.filename',
'__dirname': 'import.meta.dirname',
'__filename': 'import.meta.filename',
},
build: {
lib: {
@ -50,16 +49,34 @@ export default defineConfig(({ mode }) => {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/index.ts',
},
minify: !isDev,
cssMinify: !isDev,
sourcemap: isDev ? 'inline' : undefined,
},
resolve: {
alias: resolveAlias,
},
};
const preloadConfig: UserConfig = {
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 = {
experimental: {
enableNativePlugin: true,
},
@ -83,18 +100,36 @@ export default defineConfig(({ mode }) => {
external: ['electron', 'custom-electron-prompt', ...builtinModules],
input: './src/preload.ts',
},
minify: !isDev,
cssMinify: !isDev,
sourcemap: isDev ? 'inline' : undefined,
},
resolve: {
alias: resolveAlias,
},
};
const rendererConfig: UserConfig = {
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 = {
experimental: {
enableNativePlugin: !isDev, // Disable native plugin in development mode to avoid issues with HMR (bug in rolldown-vite)
enableNativePlugin: mode !== 'development', // Disable native plugin in development mode to avoid issues with HMR (bug in rolldown-vite)
},
plugins: [
pluginLoader('renderer'),
@ -118,44 +153,36 @@ export default defineConfig(({ mode }) => {
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 (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(
if (mode === 'development') {
commonConfig.build!.sourcemap = 'inline';
commonConfig.plugins?.push(
Inspect({
build: true,
outputDir: join(__dirname, '.vite-inspect/renderer'),
}),
);
return commonConfig;
}
return {
main: mainConfig,
preload: preloadConfig,
renderer: rendererConfig,
...commonConfig,
build: {
...commonConfig.build,
minify: true,
cssMinify: true,
},
};
}),
});

View File

@ -45,12 +45,12 @@
},
"pnpm": {
"overrides": {
"vite": "npm:rolldown-vite@7.3.1",
"node-gyp": "12.2.0",
"vite": "npm:rolldown-vite@7.3.0",
"node-gyp": "11.4.2",
"xml2js": "0.6.2",
"node-fetch": "3.3.2",
"@electron/universal": "3.0.2",
"@babel/runtime": "7.28.6"
"@babel/runtime": "7.28.4"
},
"patchedDependencies": {
"vudio@2.1.1": "patches/vudio@2.1.1.patch",
@ -64,24 +64,24 @@
},
"dependencies": {
"@dehoist/romanize-thai": "1.0.0",
"@electron-toolkit/tsconfig": "2.0.0",
"@electron-toolkit/tsconfig": "1.0.1",
"@electron/remote": "2.1.3",
"@ffmpeg.wasm/core-mt": "0.12.0",
"@ffmpeg.wasm/main": "0.12.0",
"@floating-ui/dom": "1.7.5",
"@floating-ui/dom": "1.7.4",
"@foobar404/wave": "2.0.5",
"@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.1",
"@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",
"@hono/zod-openapi": "1.2.0",
"@hono/zod-validator": "0.7.6",
"@jellybrick/dbus-next": "0.10.3",
"@jellybrick/electron-better-web-request": "2.0.0",
"@jellybrick/mpris-service": "2.1.5",
"@jimp/plugin-color": "1.6.0",
"@mdui/icons": "1.0.3",
"@mdui/icons": "^1.0.3",
"@skyra/jaro-winkler": "1.1.1",
"@xhayper/discord-rpc": "1.3.0",
"async-mutex": "0.5.0",
@ -89,26 +89,27 @@
"butterchurn": "3.0.0-beta.5",
"butterchurn-presets": "3.0.0-beta.4",
"color": "5.0.3",
"conf": "15.0.2",
"custom-electron-prompt": "1.6.1",
"conf": "14.0.0",
"custom-electron-prompt": "1.5.8",
"deepmerge-ts": "7.1.5",
"delay": "6.0.0",
"electron-debug": "4.1.0",
"electron-is": "3.0.0",
"electron-localshortcut": "3.2.1",
"electron-store": "11.0.2",
"electron-store": "10.1.0",
"electron-unhandled": "5.0.0",
"electron-updater": "6.7.3",
"electron-updater": "6.6.2",
"es-hangul": "2.3.8",
"fast-average-color": "9.5.0",
"fast-equals": "6.0.0",
"fast-equals": "5.2.2",
"fflate": "0.8.2",
"filenamify": "7.0.1",
"filenamify": "6.0.0",
"hanja": "1.1.5",
"happy-dom": "20.5.0",
"hono": "4.11.7",
"happy-dom": "20.0.11",
"hono": "4.10.3",
"howler": "2.2.4",
"html-to-text": "9.0.5",
"i18next": "25.8.0",
"i18next": "25.5.2",
"jimp": "1.6.0",
"keyboardevent-from-electron-accelerator": "2.0.0",
"keyboardevents-areequal": "0.2.2",
@ -117,7 +118,7 @@
"kuroshiro-analyzer-kuromoji": "1.1.0",
"lazy-var": "2.2.2",
"mdui": "2.1.4",
"node-html-parser": "7.0.2",
"node-html-parser": "7.0.1",
"node-id3": "0.2.9",
"peerjs": "1.5.5",
"semver": "7.7.3",
@ -125,57 +126,57 @@
"socks": "2.8.7",
"solid-element": "1.9.1",
"solid-floating-ui": "0.3.1",
"solid-js": "1.9.11",
"solid-js": "1.9.9",
"solid-styled-components": "0.28.5",
"solid-transition-group": "0.3.0",
"tiny-pinyin": "1.3.2",
"tinyld": "1.3.4",
"virtua": "0.48.5",
"virtua": "0.48.2",
"vudio": "2.1.1",
"x11": "2.3.0",
"youtubei.js": "16.0.1",
"zod": "4.3.6"
"youtubei.js": "^16.0.1",
"zod": "4.2.1"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "1.0.1",
"@eslint/js": "9.39.2",
"@eslint/js": "9.35.0",
"@malept/flatpak-bundler": "0.4.0",
"@playwright/test": "1.58.1",
"@stylistic/eslint-plugin": "5.7.1",
"@playwright/test": "1.55.0",
"@stylistic/eslint-plugin": "5.3.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.1.0",
"bufferutil": "4.0.9",
"builtin-modules": "5.0.0",
"cross-env": "10.1.0",
"del-cli": "7.0.0",
"discord-api-types": "0.38.38",
"electron": "40.1.0",
"electron-builder": "26.7.0",
"electron-builder-squirrel-windows": "26.7.0",
"cross-env": "10.0.0",
"del-cli": "6.0.0",
"discord-api-types": "0.38.37",
"electron": "38.7.2",
"electron-builder": "26.4.0",
"electron-builder-squirrel-windows": "26.0.12",
"electron-devtools-installer": "4.0.0",
"electron-vite": "5.0.0",
"eslint": "9.39.2",
"electron-vite": "4.0.1",
"eslint": "9.35.0",
"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.5",
"eslint-plugin-prettier": "5.5.4",
"eslint-plugin-solid": "0.14.5",
"glob": "13.0.0",
"node-gyp": "12.2.0",
"glob": "11.1.0",
"node-gyp": "11.4.2",
"ts-morph": "27.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.54.0",
"typescript-eslint": "8.52.0",
"utf-8-validate": "6.0.6",
"vite": "npm:rolldown-vite@7.3.1",
"vite": "npm:rolldown-vite@7.3.0",
"vite-plugin-inspect": "11.3.3",
"vite-plugin-resolve": "2.5.2",
"vite-plugin-solid": "2.11.10",
"ws": "8.19.0"
"ws": "8.18.3"
},
"auto-changelog": {
"hideCredit": true,

3140
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -321,22 +321,6 @@
"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"
}
@ -478,8 +462,8 @@
"set-status-display-type": {
"label": "Text d'estat",
"submenu": {
"application": "Escoltant {{applicationName}}",
"artist": "Escoltant {artist}",
"application": "Escoltant {{applicationName}}",
"title": "Escoltant {song title}"
}
}

View File

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

View File

@ -128,7 +128,7 @@
},
"label": "Keel",
"submenu": {
"to-help-translate": "Soovid aidata tõlkimisel? Klõpsa siin"
"to-help-translate": "Soovid aidata tõlkimisel? Klõpsi siin"
}
},
"resume-on-start": "Rakenduse käivitamisel jätka viimatiesitatud loo esitamist",
@ -139,7 +139,7 @@
"unset": "Määramata"
},
"tray": {
"label": "Tasku",
"label": "Trey",
"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 toimingud"
"name": "Albumi aktsioonid"
},
"album-color-theme": {
"description": "Rakendab dünaamilist teemat ja visuaalseid efekte, mis põhinevad albumi värvipalettil",
@ -237,8 +237,7 @@
"submenu": {
"percent": "{{suhe}}%"
}
},
"enable-seekbar": "Luba kerimisriba kujundamine"
}
},
"name": "Albumi värviteema"
},
@ -246,19 +245,9 @@
"description": "Rakendab valgusefekti, projitseerides videost õrnad värvid ekraani taustale",
"menu": {
"blur-amount": {
"label": "Hägusus",
"submenu": {
"pixels": "{{blurAmount}} pikslit"
}
},
"buffer": {
"label": "Puhver",
"submenu": {
"buffer": "{{buffer}}"
}
"label": "Hägusus"
},
"opacity": {
"label": "Läbipaistmatus",
"submenu": {
"percent": "{{opacity}}%"
}
@ -274,15 +263,8 @@
"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

@ -161,8 +161,7 @@
"default": "Par défaut",
"force-show": "Forcer à apparaître",
"hide": "Cacher",
"label": "Boutons « J'aime »",
"swap": "Inverser l'order des boutons like"
"label": "Boutons « J'aime »"
},
"remove-upgrade-button": "Supprimer le bouton de mise à niveau",
"theme": {
@ -413,17 +412,6 @@
"no-captions": "Aucun sous-titres disponibles pour cette chanson"
}
},
"clock": {
"description": "Ajoute une horloge a la barre de navigation",
"menu": {
"format": {
"24-hour-format": "Format 24 heures",
"display-seconds": "Afficher les secondes",
"label": "Format"
}
},
"name": "Horloge"
},
"compact-sidebar": {
"description": "Toujours définir la barre latérale en mode compact",
"name": "Barre latérale compacte"

View File

@ -2,13 +2,13 @@
"common": {
"console": {
"plugins": {
"execute-failed": "확장 {{pluginName}}::{{contextName}}을(를) 실행 실패함",
"execute-failed": "확장 {{pluginName}}::{{contextName}}을(를) 실행하지 못했습니다",
"executed-at-ms": "확장 {{pluginName}}::{{contextName}}이 {{ms}}ms 만에 실행됨",
"initialize-failed": "확장 \"{{pluginName}}\"을(를) 초기화 실패함",
"initialize-failed": "확장 \"{{pluginName}}\"을(를) 초기화하지 못했습니다",
"load-all": "모든 확장 로드 중",
"load-failed": "확장 \"{{pluginName}}\"을(를) 로드하지 못했습니다",
"loaded": "확장 \"{{pluginName}}\" 로드됨",
"unload-failed": "확장 \"{{pluginName}}\"을(를) 언로드 실패함",
"unload-failed": "확장 \"{{pluginName}}\"을(를) 언로드하지 못했습니다",
"unloaded": "확장 \"{{pluginName}}\" 언로드 됨"
}
}
@ -21,7 +21,7 @@
"main": {
"console": {
"did-finish-load": {
"dev-tools": "로드 완료. 개발자 도구 실행됨"
"dev-tools": "로드 완료되었습니다. 개발자 도구가 열렸습니다"
},
"i18n": {
"loaded": "국제화 로드됨"
@ -30,32 +30,32 @@
"receive-command": "프로토콜을 통해 명령을 받았습니다: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS 파일 \"{{cssFile}}\"이(가) 존재하지 않. 무시"
"css-file-not-found": "CSS 파일 \"{{cssFile}}\"이(가) 존재하지 않습니다. 무시합니다"
},
"unresponsive": {
"details": "응답 없음 오류!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "앱 캐시 지우는 중"
"clearing-cache-after-20s": "앱 캐시 지우"
},
"window": {
"tried-to-render-offscreen": "창이 오프스크린 렌더링을 시도했습니다, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
"tried-to-render-offscreen": "창이 오프스크린 렌더링을 시도했습니다. windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "'Alt' 키를 눌러 숨겨진 메뉴를 표시할 수 있습니다 (인앱 메뉴를 사용하는 경우 'Esc' 키를 사용)",
"message": "메뉴 숨기기가 활성화되어 있",
"message": "메뉴 숨기기가 활성화되어 있습니다",
"title": "메뉴 숨기기 활성화됨"
},
"need-to-restart": {
"buttons": {
"later": "나중에",
"later": "나중에 하기",
"restart-now": "지금 재시작하기"
},
"detail": "\"{{pluginName}}\" 확장을 적용하려면 재시작해야 합니다",
"message": "\"{{pluginName}}\"은(는) 재시작이 필요합니다",
"title": "재시작 필요"
"title": "재시작 필요"
},
"unresponsive": {
"buttons": {
@ -63,17 +63,17 @@
"relaunch": "재시작",
"wait": "기다리기"
},
"detail": "불편을 드려 죄송합니다! 다음 중 하나를 선택해 주세요.",
"detail": "불편을 드려 죄송합니다! 방법을 선택해 주세요:",
"message": "애플리케이션이 응답하지 않습니다",
"title": "창이 응답하지 않음"
},
"update-available": {
"buttons": {
"disable": "업데이트 비활성화하기",
"disable": "업데이트 비활성화",
"download": "다운로드",
"ok": "확인"
},
"detail": "새 버전 {{downloadLink}}에서 설치할 수 있습니다",
"detail": "새 버전이 출시되었습니다. {{downloadLink}}에서 다운로드할 수 있습니다",
"message": "새 버전을 사용할 수 있습니다",
"title": "업데이트 사용 가능"
}
@ -96,23 +96,23 @@
"advanced-options": {
"label": "고급 설정",
"submenu": {
"auto-reset-app-cache": "앱 시작 시 앱 캐시 초기화하기",
"auto-reset-app-cache": "앱 시작 시 앱 캐시 초기화",
"disable-hardware-acceleration": "하드웨어 가속 비활성화",
"edit-config-json": "config.json 편집",
"override-user-agent": "User-Agent 재정의하기",
"restart-on-config-changes": "설정 변경 시 재시작하기",
"override-user-agent": "User-Agent 재정의",
"restart-on-config-changes": "설정 변경 시 재시작",
"set-proxy": {
"label": "프록시 설정하기",
"label": "프록시 설정",
"prompt": {
"label": "프록시 주소를 입력하세요: (비어있을 시 비활성화됨)",
"label": "프록시 주소를 입력하세요: (비워두면 비활성화됨)",
"placeholder": "예제: SOCKS5://127.0.0.1:9999",
"title": "프록시 설정하기"
"title": "프록시 설정"
}
},
"toggle-dev-tools": "DevTools 열기"
}
},
"always-on-top": "항상 최상단에 표시하기",
"always-on-top": "항상 최상단에 표시",
"auto-update": "자동 업데이트",
"hide-menu": {
"dialog": {
@ -153,16 +153,15 @@
"custom-window-title": {
"label": "사용자 정의 앱 제목",
"prompt": {
"label": "앱 제목으로 표시할 내용: (빈칸일 시 비활성화)",
"label": "앱 제목으로 표시할 내용 : (빈 칸 일시 비활성화)",
"placeholder": "예: {{applicationName}}"
}
},
"like-buttons": {
"default": "기본",
"force-show": "강제로 표시하기",
"force-show": "강제로 표시",
"hide": "숨기기",
"label": "좋아요 버튼",
"swap": "\"좋아요\" 버튼 순서 변경하기"
"label": "좋아요 버튼"
},
"remove-upgrade-button": "업그레이드 버튼 제거",
"theme": {
@ -187,7 +186,7 @@
"plugins": {
"enabled": "활성화",
"label": "확장",
"new": "새 플러그인"
"new": "NEW"
},
"view": {
"label": "보기",
@ -216,7 +215,7 @@
},
"plugins": {
"ad-speedup": {
"description": "광고가 재생될 때 소리를 음소거고 재생 속도 16배로 설정니다",
"description": "광고가 재생될 때, 오디오가 음소거고 재생 속도 16배로 설정니다",
"name": "광고 배속"
},
"adblocker": {
@ -244,7 +243,7 @@
"name": "앨범 컬러 기반 테마"
},
"ambient-mode": {
"description": "영상의 간접 조명을 화면 배경에 투사하여 조명 효과를 적용합니다",
"description": "영상의 간접 조명을 화면 배경에 투사합니다",
"menu": {
"blur-amount": {
"label": "흐림 효과 강도",
@ -253,7 +252,7 @@
}
},
"buffer": {
"label": "버퍼",
"label": "버퍼",
"submenu": {
"buffer": "{{buffer}}"
}
@ -290,7 +289,7 @@
},
"amuse": {
"description": "6K Labs Amuse의 'now playing' 위젯에 {{applicationName}} 지원 추가",
"name": "Amuse (어뮤즈)",
"name": "Amuseio AB",
"response": {
"query": "Amuse API 서버가 실행 중입니다. GET /query로 노래 정보를 가져오세요."
}
@ -359,10 +358,10 @@
"name": "오디오 컴프레서"
},
"auth-proxy-adapter": {
"description": "인증이 필요한 프록시를 지원합니다",
"description": "아이디/패스워드가 필요한 프록시를 지원합니다",
"menu": {
"disable": "프록시 어댑터 비활성화",
"enable": "프록시 어댑터 활성화",
"disable": "프록시 어댑터 차단",
"enable": "프록시 어댑터 허용",
"hostname": {
"label": "호스트 명"
},
@ -373,11 +372,11 @@
"name": "권한 프록시 어댑터",
"prompt": {
"hostname": {
"label": "로컬 프록시 서버의 호스트명을 입력해주세요 (재시이 필요합니다):",
"label": "로컬 프록시 서버의 호스트명 을 입력 해주세요 (재시이 필요합니다):",
"title": "프록시 호스트명"
},
"port": {
"label": "로컬 프록시 서버의 포트를 입력 해주세요 (재시이 필요합니다):",
"label": "로컬 프록시 서버의 포트를 입력 해주세요 (재시이 필요합니다):",
"title": "프록시 포트"
}
}
@ -387,7 +386,7 @@
"name": "탐색 바 흐림 효과"
},
"bypass-age-restrictions": {
"description": "플레이어의 연령 확인 인증 우회",
"description": "음악 플레이어의 연령 확인 우회합니다",
"name": "나이 제한 우회"
},
"captions-selector": {
@ -409,21 +408,10 @@
},
"toast": {
"caption-changed": "자막 언어가 {{language}}(으)로 변경되었습니다",
"caption-disabled": "자막 비활성화",
"caption-disabled": "자막 비활성화 되었습니다",
"no-captions": "이 곡에는 자막이 없습니다"
}
},
"clock": {
"description": "네비게이션 바 옆 시계 추가하기",
"menu": {
"format": {
"24-hour-format": "24시간 형식",
"display-seconds": "초 표시하기",
"label": "형식"
}
},
"name": "시계"
},
"compact-sidebar": {
"description": "사이드바를 항상 컴팩트 모드로 설정합니다",
"name": "컴팩트 사이드바"
@ -451,9 +439,9 @@
}
},
"custom-output-device": {
"description": "미디어 출력할­ 장치 구성하기",
"description": "미디어 출력 장치 구성",
"menu": {
"device-selector": "장치 선택하세요"
"device-selector": "장치 선택"
},
"name": "출력 장치 커스텀",
"prompt": {
@ -473,16 +461,16 @@
"discord": {
"backend": {
"already-connected": "활성화 된 연결에 연결을 시도했습니다",
"connected": "Discord에 연결됨",
"disconnected": "Discord에서 연결이 끊김"
"connected": "디스코드에 연결됨",
"disconnected": "디스코드에서 연결이 끊김"
},
"description": "Rich Presence를 사용하여 친구들에게 내가 듣는 음악을 보여주세요",
"description": "활동 상태를 사용하여 친구들에게 내가 듣는 음악을 보여주세요",
"menu": {
"auto-reconnect": "자동 연결",
"auto-reconnect": "자동 연결",
"clear-activity": "활동 제거",
"clear-activity-after-timeout": "시간 초과 시 활동 제거",
"connected": "연결됨",
"disconnected": "연결 해제됨",
"connected": "연결 됨",
"disconnected": "연결 해제 됨",
"hide-duration-left": "남은 재생 시간 숨기기",
"hide-github-button": "GitHub 링크 버튼 숨기기",
"play-on-application": "{{applicationName}} 에서 재생",
@ -496,7 +484,7 @@
}
}
},
"name": "Discord 활동 상태",
"name": "디스코드 활동 상태",
"prompt": {
"set-inactivity-timeout": {
"label": "비활성 시간 제한을 초 단위로 입력하세요:",
@ -511,7 +499,7 @@
"buttons": {
"ok": "확인"
},
"message": "앗! 죄송합니다. 다운로드가 실패했습니다…",
"message": "죄송합니다. 다운로드가 실패했습니다…",
"title": "다운로드 중 오류 발생!"
},
"start-download-playlist": {
@ -524,7 +512,7 @@
}
},
"feedback": {
"conversion-progress": "변환: {{percent}}%",
"conversion-progress": "변환: {{percent}}%",
"converting": "변환 중…",
"done": "완료: {{filePath}}",
"download-info": "{{artist}} - {{title}} [{{videoId}} 다운로드 중",
@ -539,7 +527,7 @@
"playlist-has-only-one-song": "재생목록에 한 항목만 존재합니다. 직접 다운로드합니다",
"playlist-id-not-found": "재생목록 ID를 찾을 수 없습니다",
"playlist-is-empty": "재생목록이 비어있습니다",
"playlist-is-mix-or-private": "재생목록 정보 가져오는 중 오류 발생: 비공개 재생목록 또는 \"나만을 위한 맞춤 믹스\" 재생목록이 아닌지 확인하세요\n\n{{error}}",
"playlist-is-mix-or-private": "재생목록 정보 가져오는 중 오류 발생: 비공개 재생목록 또는 '유튜브 Mix' 재생목록이 아닌지 확인하세요\n\n{{error}}",
"preparing-file": "파일 준비 중…",
"saving": "저장 중…",
"trying-to-get-playlist-id": "재생목록 ID를 가져오는 중: {{playlistId}}",

View File

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

View File

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

View File

@ -161,8 +161,7 @@
"default": "Padrão",
"force-show": "Forçar exibir",
"hide": "Ocultar",
"label": "Botões de 'Curtir'",
"swap": "Inverter ordem dos botões de curtir"
"label": "Botões de 'Curtir'"
},
"remove-upgrade-button": "Remover botão de atualização",
"theme": {
@ -413,17 +412,6 @@
"no-captions": "Sem legendas disponíveis para essa música"
}
},
"clock": {
"description": "Adicionar relógio na barra de navegação",
"menu": {
"format": {
"24-hour-format": "Formato 24 horas",
"display-seconds": "Mostrar segundos",
"label": "Formato"
}
},
"name": "Relógio"
},
"compact-sidebar": {
"description": "Sempre definir a barra lateral no modo compacto",
"name": "Barra lateral compacta"

View File

@ -1,12 +0,0 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "Плагины толорор кыаҕа суох {{pluginName}}::{{contextName}}",
"executed-at-ms": "Плагин {{pluginName}}::{{contextName}} толоруллубут {{ms}}мс",
"initialize-failed": "\"{{pluginName}}\" плагины инициализациялааһын кыаллыбата",
"load-all": "Плагиннары загрузкалыбыт"
}
}
}
}

View File

@ -396,17 +396,6 @@
"no-captions": "Pre túto skladbu nie sú dostupné žiadne titulky"
}
},
"clock": {
"description": "Pridať hodiny do navigačnej lišty",
"menu": {
"format": {
"24-hour-format": "24-hodinový formát",
"display-seconds": "Zobraz sekundy",
"label": "Formát"
}
},
"name": "Hodiny"
},
"compact-sidebar": {
"description": "Vždy nastaviť bočný panel do kompaktného režimu",
"name": "Kompaktný bočný panel"
@ -473,8 +462,8 @@
"set-status-display-type": {
"label": "Text stavu",
"submenu": {
"application": "Počúvať {{applicationName}}",
"artist": "Aktuálne si prehráva {artist}",
"application": "Počúvať {{applicationName}}",
"title": "Aktuálne si prehráva {song title}"
}
}

View File

@ -321,22 +321,6 @@
"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"
}
@ -478,8 +462,8 @@
"set-status-display-type": {
"label": "Durum metni",
"submenu": {
"application": "{{applicationName}} Dinleniyor",
"artist": "{artist} Dinleniyor",
"application": "{{applicationName}} Dinleniyor",
"title": "{song title} Dinleniyor"
}
}

View File

@ -161,8 +161,7 @@
"default": "默认",
"force-show": "强制显示",
"hide": "隐藏",
"label": "点赞按钮",
"swap": "交换“点赞”按钮顺序"
"label": "点赞按钮"
},
"remove-upgrade-button": "移除升级按钮",
"theme": {
@ -413,17 +412,6 @@
"no-captions": "这首歌没有字幕"
}
},
"clock": {
"description": "添加时钟到导航栏",
"menu": {
"format": {
"24-hour-format": "24 小时格式",
"display-seconds": "显示秒数",
"label": "格式"
}
},
"name": "时钟"
},
"compact-sidebar": {
"description": "始终将侧边栏设为紧凑模式",
"name": "紧凑式侧边栏"

View File

@ -285,19 +285,6 @@ 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,
);
},
},
],
},
{

View File

@ -1,109 +0,0 @@
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

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

View File

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

View File

@ -9,7 +9,7 @@ import { TimerManager } from './timer-manager';
import {
buildDiscordButtons,
padHangulFields,
sanitizeActivityText,
truncateString,
isSeek,
} from './utils';
@ -22,7 +22,7 @@ export class DiscordService {
/**
* Discord RPC client instance.
*/
rpc!: DiscordClient;
rpc = new DiscordClient({ clientId });
/**
* Indicates if the service is ready to send activity updates.
*/
@ -62,21 +62,6 @@ 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'));
@ -114,17 +99,13 @@ export class DiscordService {
const activityInfo: SetActivity = {
type: ActivityType.Listening,
statusDisplayType: config.statusDisplayType,
details: sanitizeActivityText(
songInfo.alternativeTitle ?? songInfo.title
), // Song title
details: truncateString(songInfo.alternativeTitle ?? songInfo.title, 128), // Song title
detailsUrl: songInfo.url ?? undefined,
state: sanitizeActivityText(
songInfo.tags?.at(0) ?? songInfo.artist
), // Artist name
state: truncateString(songInfo.tags?.at(0) ?? songInfo.artist, 128), // Artist name
stateUrl: songInfo.artistUrl,
largeImageKey: songInfo.imageSrc ?? undefined,
largeImageText: songInfo.album
? sanitizeActivityText(songInfo.album)
? truncateString(songInfo.album, 128)
: undefined,
buttons: buildDiscordButtons(config, songInfo),
};
@ -211,7 +192,6 @@ export class DiscordService {
resolve();
})
.catch(() => {
this.initializeRpc();
this.connectRecursive();
});
},
@ -256,9 +236,6 @@ 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
@ -273,12 +250,13 @@ 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,27 +19,6 @@ 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

@ -1,9 +1,15 @@
import { type DataConnection, Peer, type PeerError } from 'peerjs';
import {
type DataConnection,
Peer,
type PeerError,
PeerErrorType,
} from 'peerjs';
import delay from 'delay';
import type { Permission, Profile, VideoData } from './types';
export type ConnectionEventMap = {
CLEAR_QUEUE: null;
CLEAR_QUEUE: {};
ADD_SONGS: { videoList: VideoData[]; index?: number };
REMOVE_SONG: { index: number };
MOVE_SONG: { fromIndex: number; toIndex: number };
@ -98,14 +104,16 @@ export class Connection {
this.peer.disconnect();
this.peer.destroy();
});
this.peer.on('error', (err) => {
if (err.type === 'network') {
setTimeout(() => {
this.peer.on('error', async (err) => {
if (err.type === PeerErrorType.Network) {
// retrying after 10 seconds
await delay(10000);
try {
this.peer.reconnect();
} catch {}
}, 10000);
return;
} catch {
//ignored
}
}
this.waitOpen.reject(err);
@ -168,9 +176,7 @@ export class Connection {
after?: ConnectionEventUnion[],
) {
await Promise.all(
this.getConnections().map(
(conn) => conn.send({ type, payload, after }) ?? Promise.resolve(),
),
this.getConnections().map((conn) => conn.send({ type, payload, after })),
);
}

View File

@ -224,7 +224,7 @@ export default createPlugin<
}
this.queue?.clear();
await this.connection?.broadcast('CLEAR_QUEUE', null);
await this.connection?.broadcast('CLEAR_QUEUE', {});
break;
}
case 'SET_INDEX': {
@ -413,7 +413,7 @@ export default createPlugin<
this.ignoreChange = true;
switch (event.type) {
case 'CLEAR_QUEUE': {
await this.connection?.broadcast('CLEAR_QUEUE', null);
await this.connection?.broadcast('CLEAR_QUEUE', {});
break;
}
case 'SET_INDEX': {

View File

@ -316,7 +316,7 @@ export class Queue {
this.ignoreFlag = true;
this.broadcast({
type: 'CLEAR_QUEUE',
payload: null,
payload: {},
});
return;
}

View File

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

View File

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

View File

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

View File

@ -1,15 +1,22 @@
export abstract class Visualizer {
protected audioNode: GainNode;
protected audioSource: MediaElementAudioSourceNode;
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;
protected constructor(
_audioContext: AudioContext,
_audioSource: MediaElementAudioSourceNode,
_visualizerContainer: HTMLElement,
_canvas: HTMLCanvasElement,
_audioNode: GainNode,
) {
this.audioNode = _audioNode;
this.audioSource = _audioSource;
}
_stream: MediaStream,
_options: VisualizerPluginConfig,
) {}
abstract resize(width: number, height: number): void;
abstract destroy(): void;
abstract render(): void;
}

View File

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

View File

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

View File

@ -406,18 +406,6 @@ 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);
}
}
/**