Compare commits

..

33 Commits

Author SHA1 Message Date
65bcf20f97 Bump version to 3.7.5 2025-03-01 00:13:14 +09:00
1a214140fb chore(deps): update dependency builtin-modules to v5 (#3038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-01 00:12:42 +09:00
ffdf7ac5a8 fix: remove unused deps (@types/eslint__js) 2025-03-01 00:07:08 +09:00
c8bb1f386d chore(deps): update deps 2025-03-01 00:04:00 +09:00
457e1bb48e chore(deps): update dependency electron-builder to v26.0.9 2025-03-01 00:01:57 +09:00
c1177adc08 Update package.json 2025-02-28 23:55:40 +09:00
cd976ee500 Delete patches/app-builder-lib@26.0.6.patch 2025-02-28 23:55:11 +09:00
b856884bba chore(deps): update dependency typescript-eslint to v8.25.0 (#2953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:45:25 +09:00
2df8e58773 fix(deps): update dependency happy-dom to v17.1.8 (#3001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:44:58 +09:00
da8e0106f6 chore(deps): update dependency eslint-config-prettier to v10.0.2 (#3035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:44:43 +09:00
b0b2005e1c chore(deps): update dependency @electron/universal to v2.0.2 (#3034)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:44:24 +09:00
5817d9c3ae chore(deps): update dependency @stylistic/eslint-plugin-js to v4 (#2950)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:43:13 +09:00
08f9187cdf chore(deps): update dependency electron-builder-squirrel-windows to v26.0.9 (#2930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:42:38 +09:00
49ac17a40e fix(no-google-login): Remove Library icon removal code (#3010) 2025-02-28 23:42:05 +09:00
Dvd
b1c4b04ebf fix: Updated tray pause icon for consistency (#3025) 2025-02-28 23:41:43 +09:00
10b8066126 chore(deps): update dependency eslint-import-resolver-typescript to v3.8.3 (#2992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:31:55 +09:00
d86e454d3f fix(deps): update dependency hono to v4.7.2 (#2999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-28 23:31:40 +09:00
4ea8fa2561 fix: Filter for only MusicResponsiveListItem in playlist items (#3022) 2025-02-28 23:25:01 +09:00
dbea4c5884 fix(deps): update dependency youtubei.js to v13.1.0 (#3015) 2025-02-28 23:20:46 +09:00
7d4e949f0c chore(i18n): Translated using Weblate (Danish)
Currently translated at 32.5% (132 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/da/
2025-02-27 10:03:56 +00:00
82917e7748 chore(i18n): Added translation using Weblate (Danish) 2025-02-26 10:15:21 +01:00
7c9544a528 chore(i18n): Translated using Weblate (Hebrew)
Currently translated at 16.0% (65 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/he/
2025-02-26 09:04:34 +01:00
960a20e899 chore(i18n): Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 99.2% (403 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/zh_Hans/
2025-02-22 14:28:49 +01:00
c9b7901681 chore(i18n): Translated using Weblate (Thai)
Currently translated at 100.0% (406 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/th/
2025-02-19 18:02:02 +01:00
95698aaf35 chore(i18n): Translated using Weblate (Italian)
Currently translated at 99.5% (404 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/it/
2025-02-19 18:02:00 +01:00
4dc87417ff chore(i18n): Translated using Weblate (Thai)
Currently translated at 69.4% (282 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/th/
2025-02-18 17:47:14 +01:00
7510f8eb08 chore(i18n): Translated using Weblate (Arabic)
Currently translated at 39.1% (159 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/ar/
2025-02-18 13:51:53 +01:00
59103df665 fix: Match engines.pnpm with the pnpm version used to create the lock files (#2995) 2025-02-18 12:14:53 +09:00
258d35e48d chore(i18n): Translated using Weblate (Czech)
Currently translated at 92.8% (377 of 406 strings)

Translation: th-ch/youtube-music/i18n
Translate-URL: https://hosted.weblate.org/projects/youtube-music/i18n/cs/
2025-02-17 20:29:57 +01:00
5e419489d5 Update changelog for v3.7.4 2025-02-17 15:54:12 +00:00
0d462ac3a2 HOTFIX: Bump version to 3.7.4 2025-02-18 00:37:57 +09:00
bcb94f6de8 chore(deps): update dependency rollup to v4.34.8 (#2982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 00:36:54 +09:00
fe925ec8ee fix(plugin-loader): update context filtering logic for backend mode (#2990) 2025-02-18 00:35:55 +09:00
67 changed files with 3398 additions and 1788 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -2,8 +2,17 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [v3.7.4](https://github.com/th-ch/youtube-music/compare/v3.7.3...v3.7.4)
- chore(deps): update dependency rollup to v4.34.8 [`#2982`](https://github.com/th-ch/youtube-music/pull/2982)
- fix(plugin-loader): update context filtering logic for backend mode [`#2990`](https://github.com/th-ch/youtube-music/pull/2990)
- Update changelog for v3.7.3 [`86c77d1`](https://github.com/th-ch/youtube-music/commit/86c77d141f2bec62a4a578fff96d51ed388c05a5)
- HOTFIX: Bump version to 3.7.4 [`0d462ac`](https://github.com/th-ch/youtube-music/commit/0d462ac3a26caee23854014cbf5e4b91e2d385e2)
#### [v3.7.3](https://github.com/th-ch/youtube-music/compare/v3.7.2...v3.7.3) #### [v3.7.3](https://github.com/th-ch/youtube-music/compare/v3.7.2...v3.7.3)
> 17 February 2025
- fix(downloader): use the upgrade button to check for premium status [`#2987`](https://github.com/th-ch/youtube-music/pull/2987) - fix(downloader): use the upgrade button to check for premium status [`#2987`](https://github.com/th-ch/youtube-music/pull/2987)
- chore(deps): update dependency electron-vite to v3 [`#2986`](https://github.com/th-ch/youtube-music/pull/2986) - chore(deps): update dependency electron-vite to v3 [`#2986`](https://github.com/th-ch/youtube-music/pull/2986)
- chore(deps): update dependency @babel/runtime to v7.26.9 [`#2980`](https://github.com/th-ch/youtube-music/pull/2980) - chore(deps): update dependency @babel/runtime to v7.26.9 [`#2980`](https://github.com/th-ch/youtube-music/pull/2980)

View File

@ -2,7 +2,7 @@
"name": "youtube-music", "name": "youtube-music",
"desktopName": "com.github.th_ch.youtube_music", "desktopName": "com.github.th_ch.youtube_music",
"productName": "YouTube Music", "productName": "YouTube Music",
"version": "3.7.3", "version": "3.7.5",
"description": "YouTube Music Desktop App - including custom plugins", "description": "YouTube Music Desktop App - including custom plugins",
"main": "./dist/main/index.js", "main": "./dist/main/index.js",
"license": "MIT", "license": "MIT",
@ -218,20 +218,19 @@
}, },
"engines": { "engines": {
"node": ">=18", "node": ">=18",
"pnpm": ">=8" "pnpm": ">=10"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"vite": "6.1.0", "vite": "6.2.0",
"node-gyp": "11.1.0", "node-gyp": "11.1.0",
"xml2js": "0.6.2", "xml2js": "0.6.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"@electron/universal": "2.0.1", "@electron/universal": "2.0.2",
"@babel/runtime": "7.26.9" "@babel/runtime": "7.26.9"
}, },
"patchedDependencies": { "patchedDependencies": {
"vudio@2.1.1": "patches/vudio@2.1.1.patch", "vudio@2.1.1": "patches/vudio@2.1.1.patch",
"app-builder-lib@26.0.6": "patches/app-builder-lib@26.0.6.patch",
"@malept/flatpak-bundler": "patches/@malept__flatpak-bundler.patch" "@malept/flatpak-bundler": "patches/@malept__flatpak-bundler.patch"
}, },
"neverBuiltDependencies": [] "neverBuiltDependencies": []
@ -247,7 +246,7 @@
"@ghostery/adblocker-electron-preload": "2.5.0", "@ghostery/adblocker-electron-preload": "2.5.0",
"@hono/node-server": "1.13.8", "@hono/node-server": "1.13.8",
"@hono/swagger-ui": "0.5.0", "@hono/swagger-ui": "0.5.0",
"@hono/zod-openapi": "0.18.4", "@hono/zod-openapi": "0.19.0",
"@hono/zod-validator": "0.4.3", "@hono/zod-validator": "0.4.3",
"@jellybrick/dbus-next": "0.10.3", "@jellybrick/dbus-next": "0.10.3",
"@jellybrick/electron-better-web-request": "1.0.4", "@jellybrick/electron-better-web-request": "1.0.4",
@ -262,7 +261,7 @@
"color": "5.0.0", "color": "5.0.0",
"conf": "13.1.0", "conf": "13.1.0",
"custom-electron-prompt": "1.5.8", "custom-electron-prompt": "1.5.8",
"deepmerge-ts": "7.1.4", "deepmerge-ts": "7.1.5",
"electron-debug": "4.1.0", "electron-debug": "4.1.0",
"electron-is": "3.0.0", "electron-is": "3.0.0",
"electron-localshortcut": "3.2.1", "electron-localshortcut": "3.2.1",
@ -272,8 +271,8 @@
"fast-average-color": "9.4.0", "fast-average-color": "9.4.0",
"fast-equals": "5.2.2", "fast-equals": "5.2.2",
"filenamify": "6.0.0", "filenamify": "6.0.0",
"happy-dom": "17.1.0", "happy-dom": "17.1.8",
"hono": "4.7.1", "hono": "4.7.2",
"howler": "2.2.4", "howler": "2.2.4",
"html-to-text": "9.0.5", "html-to-text": "9.0.5",
"i18next": "24.2.2", "i18next": "24.2.2",
@ -281,61 +280,60 @@
"keyboardevent-from-electron-accelerator": "2.0.0", "keyboardevent-from-electron-accelerator": "2.0.0",
"keyboardevents-areequal": "0.2.2", "keyboardevents-areequal": "0.2.2",
"node-html-parser": "7.0.1", "node-html-parser": "7.0.1",
"node-id3": "0.2.7", "node-id3": "0.2.8",
"peerjs": "1.5.4", "peerjs": "1.5.4",
"semver": "7.7.1", "semver": "7.7.1",
"serve": "14.2.4", "serve": "14.2.4",
"simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9", "simple-youtube-age-restriction-bypass": "github:organization/Simple-YouTube-Age-Restriction-Bypass#v2.5.9",
"solid-floating-ui": "0.3.1", "solid-floating-ui": "0.3.1",
"solid-js": "1.9.4", "solid-js": "1.9.5",
"solid-styled-components": "0.28.5", "solid-styled-components": "0.28.5",
"solid-transition-group": "0.3.0", "solid-transition-group": "0.3.0",
"ts-morph": "25.0.1", "ts-morph": "25.0.1",
"vudio": "2.1.1", "vudio": "2.1.1",
"x11": "2.3.0", "x11": "2.3.0",
"youtubei.js": "13.0.0", "youtubei.js": "13.1.0",
"zod": "3.24.2" "zod": "3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.20.0", "@eslint/js": "9.21.0",
"@playwright/test": "1.50.1", "@playwright/test": "1.50.1",
"@stylistic/eslint-plugin-js": "3.1.0", "@stylistic/eslint-plugin-js": "4.1.0",
"@total-typescript/ts-reset": "0.6.1", "@total-typescript/ts-reset": "0.6.1",
"@types/electron-localshortcut": "3.1.3", "@types/electron-localshortcut": "3.1.3",
"@types/eslint__js": "8.42.3",
"@types/howler": "2.2.12", "@types/howler": "2.2.12",
"@types/html-to-text": "9.0.4", "@types/html-to-text": "9.0.4",
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"@types/trusted-types": "2.0.7", "@types/trusted-types": "2.0.7",
"bufferutil": "4.0.9", "bufferutil": "4.0.9",
"builtin-modules": "4.0.0", "builtin-modules": "5.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"del-cli": "6.0.0", "del-cli": "6.0.0",
"discord-api-types": "0.37.119", "discord-api-types": "0.37.119",
"electron": "34.2.0", "electron": "34.3.0",
"electron-builder": "26.0.6", "electron-builder": "26.0.9",
"electron-builder-squirrel-windows": "26.0.6", "electron-builder-squirrel-windows": "26.0.9",
"electron-devtools-installer": "4.0.0", "electron-devtools-installer": "4.0.0",
"electron-vite": "3.0.0", "electron-vite": "3.0.0",
"esbuild": "0.25.0", "esbuild": "0.25.0",
"eslint": "9.20.1", "eslint": "9.21.0",
"eslint-config-prettier": "10.0.1", "eslint-config-prettier": "10.0.2",
"eslint-import-resolver-exports": "1.0.0-beta.5", "eslint-import-resolver-exports": "1.0.0-beta.5",
"eslint-import-resolver-typescript": "3.8.0", "eslint-import-resolver-typescript": "3.8.3",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "5.2.3", "eslint-plugin-prettier": "5.2.3",
"glob": "11.0.1", "glob": "11.0.1",
"node-gyp": "11.1.0", "node-gyp": "11.1.0",
"playwright": "1.50.1", "playwright": "1.50.1",
"rollup": "4.34.7", "rollup": "4.34.8",
"typescript": "5.7.3", "typescript": "5.7.3",
"typescript-eslint": "8.24.0", "typescript-eslint": "8.25.0",
"utf-8-validate": "6.0.5", "utf-8-validate": "6.0.5",
"vite": "6.1.0", "vite": "6.2.0",
"vite-plugin-inspect": "10.2.1", "vite-plugin-inspect": "11.0.0",
"vite-plugin-resolve": "2.5.2", "vite-plugin-resolve": "2.5.2",
"vite-plugin-solid": "2.11.1", "vite-plugin-solid": "2.11.6",
"ws": "8.18.0" "ws": "8.18.1"
}, },
"auto-changelog": { "auto-changelog": {
"hideCredit": true, "hideCredit": true,

View File

@ -1,21 +0,0 @@
diff --git a/out/targets/snap.js b/out/targets/snap.js
index f72c36355d27cd2d69fc5fdf2d8bb2451db0287f..baae112fe25ebb49ab8e25aaa48efd6bc43b598f 100644
--- a/out/targets/snap.js
+++ b/out/targets/snap.js
@@ -212,14 +212,14 @@ class SnapTarget extends core_1.Target {
args.push("--template-url", `electron4:${snapArch}`);
}
await (0, builder_util_1.executeAppBuilder)(args);
- const publishConfig = findSnapPublishConfig(this.packager.config);
+
await packager.info.callArtifactBuildCompleted({
file: artifactPath,
safeArtifactName: packager.computeSafeArtifactName(artifactName, "snap", arch, false),
target: this,
arch,
packager,
- publishConfig: publishConfig == null ? { provider: "snapStore" } : publishConfig,
+ publishConfig: options.publish == null ? { provider: "snapStore" } : null,
});
}
isElectronVersionGreaterOrEqualThan(version) {

1091
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -279,6 +279,13 @@
}, },
"name": "الوضع المحيطي" "name": "الوضع المحيطي"
}, },
"amuse": {
"description": "تكامل دعم YouTube Music مع ويدجت Amuse لعرض الأغنية قيد التشغيل، من إنتاج 6K Labs",
"name": "تلسيه",
"response": {
"query": "خادم Amuse API قيد التشغيل. استخدم GET /query للحصول على معلومات الأغنية."
}
},
"api-server": { "api-server": {
"description": "يضيف خادم للتحكم في المشغل", "description": "يضيف خادم للتحكم في المشغل",
"dialog": { "dialog": {
@ -286,17 +293,38 @@
"buttons": { "buttons": {
"allow": "سماح", "allow": "سماح",
"deny": "رفض" "deny": "رفض"
} },
"message": "السماح لـ {{ID}} ({{origin}}) بالوصول إلى واجهة برمجة التطبيقات (API)؟",
"title": "طلب السماح بالوصول إلى واجهة برمجة التطبيقات(API)"
} }
}, },
"menu": { "menu": {
"auth-strategy": {
"label": "نهج التفويض",
"submenu": {
"auth-at-first": {
"label": "التفويض المبدئي عند الطلب الأول"
},
"none": {
"label": "بدون تفويض"
}
}
},
"hostname": { "hostname": {
"label": "اسم المضيف" "label": "اسم المضيف"
},
"port": {
"label": "المنفذ"
} }
}, },
"name": "خادم API [تجريبي]",
"prompt": { "prompt": {
"hostname": { "hostname": {
"label": "أدخل اسم المضيف (مثل 0.0.0.0) لخادم API:",
"title": "اسم الخادم" "title": "اسم الخادم"
},
"port": {
"label": "أدخل المنفذ لخادم API:"
} }
} }
}, },

View File

@ -279,6 +279,9 @@
}, },
"name": "Ambientní režim" "name": "Ambientní režim"
}, },
"amuse": {
"description": "Přídá YouTube Music podporu pro Amuse právě těď hraje widget od 6k Labs"
},
"api-server": { "api-server": {
"description": "Vlož API server abys mohl ovládat přehrávač", "description": "Vlož API server abys mohl ovládat přehrávač",
"dialog": { "dialog": {

315
src/i18n/resources/da.json Normal file
View File

@ -0,0 +1,315 @@
{
"common": {
"console": {
"plugins": {
"execute-failed": "Fejl ved udføring af plugin {{pluginName}}::{{contextName}}",
"executed-at-ms": "Plugin {{pluginName}}::{{contextName}} udført på {{ms}}ms",
"initialize-failed": "Fejl ved igangsætning af plugin \"{{pluginName}}\"",
"load-all": "Indlæser alle plugins",
"load-failed": "Fejl ved indlæsning af plugin \"{{pluginName}}\"",
"loaded": "Plugin \"{{pluginName}}\" indlæst",
"unload-failed": "Fejl ved unload af plugin \"{{pluginName}}\""
}
}
},
"language": {
"code": "dk",
"local-name": "Dansk",
"name": "Danish"
},
"main": {
"console": {
"did-finish-load": {
"dev-tools": "Indlæsning færdig. DevTools åbnet"
},
"i18n": {
"loaded": "i18n indlæst"
},
"second-instance": {
"receive-command": "Modtog kommando over protokol: \"{{command}}\""
},
"theme": {
"css-file-not-found": "CSS fil \"{{cssFile}}\" eksisterer ikke, ignorere"
},
"unresponsive": {
"details": "Uresponsiv fejl!\n{{error}}"
},
"when-ready": {
"clearing-cache-after-20s": "Rydder op i appens cache"
},
"window": {
"tried-to-render-offscreen": "Windows forsøgte at indlæse uden for skærmen, windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}"
}
},
"dialog": {
"hide-menu-enabled": {
"detail": "Menuen er gemt, brug 'Alt' knappen for at vise den igen (eller 'Escape' hvis In-App menuen bruges)",
"message": "Skjul menuen er aktiveret",
"title": "Skjult menu aktiveret"
},
"need-to-restart": {
"buttons": {
"later": "Senere",
"restart-now": "Genstart nu"
},
"detail": "\"{{pluginName}}\" plugin kræver en genstart for at have en effekt",
"message": "\"{{pluginName}}\" skal genstarte",
"title": "Genstart krævet"
},
"unresponsive": {
"buttons": {
"quit": "Afslut",
"relaunch": "Genåben",
"wait": "Vent"
},
"detail": "Vi undskylder for ubelejligheden! Vælg næste handling:",
"message": "Appen svarer ikke",
"title": "Vindue svarer ikke"
},
"update-available": {
"buttons": {
"disable": "Slå opdateringer fra",
"download": "Hent",
"ok": "OK"
},
"detail": "En ny version er tilgængelig og kan downloades her: {{downloadLink}}",
"message": "En ny version er tilgængelig",
"title": "Opdatering tilgængelig"
}
},
"menu": {
"about": "Om",
"navigation": {
"label": "Navigering",
"submenu": {
"copy-current-url": "Kopier nuværende URL",
"go-back": "Tilbage",
"go-forward": "Frem",
"quit": "Afslut",
"restart": "Genstart Appen"
}
},
"options": {
"label": "Indstillinger",
"submenu": {
"advanced-options": {
"label": "Avancerede indstillinger",
"submenu": {
"auto-reset-app-cache": "Nulstil app cache når appen starter",
"disable-hardware-acceleration": "Deaktiver hardware acceleration",
"edit-config-json": "Rediger config.json",
"restart-on-config-changes": "Genstart ved config ændringer",
"set-proxy": {
"label": "Indstil proxy",
"prompt": {
"label": "Skriv proxy adresse: (Efterlad tom for at deaktivere)",
"placeholder": "Eksempel: SOCKS5://127.0.0.1:9999",
"title": "Sæt proxy"
}
},
"toggle-dev-tools": "Skift DevTools"
}
},
"always-on-top": "Altid øverst",
"auto-update": "Automatisk opdatering",
"hide-menu": {
"dialog": {
"message": "Menuen vil være lukket næste gang appen starter. Brug [Alt] for at vise den (Eller backtick [`] hvis in-app-menu bruges)",
"title": "Gemt menu aktiveret"
},
"label": "Skjul menu"
},
"language": {
"dialog": {
"message": "Sproget vil blive ændret efter genstart",
"title": "Sprog ændret"
},
"label": "Sprog",
"submenu": {
"to-help-translate": "Vil du hjælpe med at oversætte? Klik her"
}
},
"resume-on-start": "Genoptag sidste sang når appen starter",
"start-at-login": "Start ved login",
"starting-page": {
"label": "Startside",
"unset": "Ikke valgt"
},
"tray": {
"submenu": {
"disabled": "Deaktiveret",
"enabled-and-show-app": "Aktiver og vis app",
"play-pause-on-click": "Start/Stop ved klik"
}
},
"visual-tweaks": {
"submenu": {
"like-buttons": {
"default": "Standard",
"hide": "Skjul",
"label": "Like knapper"
},
"remove-upgrade-button": "Fjern opgrader knappen",
"theme": {
"dialog": {
"button": {
"remove": "Fjern"
}
},
"label": "Tema",
"submenu": {
"no-theme": "Intet tema"
}
}
}
}
}
},
"plugins": {
"enabled": "Aktiveret",
"label": "Plugins",
"new": "NY"
},
"view": {
"label": "Vis",
"submenu": {
"reload": "Genindlæs",
"zoom-in": "Zoom ind",
"zoom-out": "Zoom ud"
}
}
},
"tray": {
"next": "Næste",
"play-pause": "Afspil",
"previous": "Sidste",
"quit": "Luk",
"restart": "Genstart app",
"show": "Vis vindue",
"tooltip": {
"default": "YouTube Music",
"with-song-info": "YouTube Music: {{artist}} - {{title}}"
}
}
},
"plugins": {
"ad-speedup": {
"description": "Hvis en reklame afspilles, slår den lyden fra og sætter hastigheden til 16x",
"name": "Spol igennem reklamen"
},
"adblocker": {
"description": "Bloker alle reklamer og sporing fra starten af",
"menu": {
"blocker": "Bloker"
},
"name": "Bloker reklamer"
},
"album-color-theme": {
"menu": {
"color-mix-ratio": {
"submenu": {
"percent": "{{ratio}}%"
}
}
},
"name": "Albummets farve tema"
},
"ambient-mode": {
"menu": {
"blur-amount": {
"label": "Sløringsmængde",
"submenu": {
"pixels": "{{blurAmount}} pixel"
}
},
"buffer": {
"label": "Buffer",
"submenu": {
"buffer": "{{buffer}}"
}
},
"opacity": {
"label": "Gennemsigtighed",
"submenu": {
"percent": "{{opacity}}%"
}
},
"quality": {
"label": "Kvalitet",
"submenu": {
"pixels": "{{quality}} pixel"
}
},
"size": {
"label": "Størrelse",
"submenu": {
"percent": "{{size}}%"
}
},
"use-fullscreen": {
"label": "Bruger fuldskærm"
}
}
},
"api-server": {
"dialog": {
"request": {
"buttons": {
"allow": "Tillad",
"deny": "Afvis"
},
"message": "Tillad at {{ID}} ({{origin}}) får adgang til API'en?"
}
},
"menu": {
"auth-strategy": {
"label": "Godkendelsesstrategi"
},
"hostname": {
"label": "Hostname"
},
"port": {
"label": "Port"
}
},
"name": "API Server [Beta]",
"prompt": {
"hostname": {
"label": "Skriv API serverens hostname (f. eks. 0.0.0.0):",
"title": "Hostname"
},
"port": {
"label": "Skriv API serverens port:",
"title": "Port"
}
}
},
"audio-compressor": {
"name": "Lyd kompressor"
},
"blur-nav-bar": {
"description": "Gør navigationsbaren gennemsigtig og sløret",
"name": "Slør navigationsbar"
},
"captions-selector": {
"menu": {
"disable-captions": "Ingen undertekster som standard"
},
"name": "Vælg undertekster",
"prompt": {
"selector": {
"label": "Nuværende sprog på undertekster: {{language}}",
"none": "Ingen",
"title": "Vælg underteksternes sprog"
}
}
},
"crossfade": {
"description": "Fade imellem sange",
"menu": {
"advanced": "Avanceret"
},
"name": "Fade [Beta]"
}
}
}

View File

@ -86,7 +86,8 @@
"copy-current-url": "העתק את כתובת ה-URL", "copy-current-url": "העתק את כתובת ה-URL",
"go-back": "חזור אחורה", "go-back": "חזור אחורה",
"go-forward": "לך קדימה", "go-forward": "לך קדימה",
"quit": "יציאה" "quit": "יציאה",
"restart": "הפעל מחדש את היישום"
} }
}, },
"options": { "options": {
@ -96,9 +97,39 @@
"label": "אפשרויות מתקדמות", "label": "אפשרויות מתקדמות",
"submenu": { "submenu": {
"auto-reset-app-cache": "אפס את מטמון האפליקציה כאשר האפליקציה מתחילה", "auto-reset-app-cache": "אפס את מטמון האפליקציה כאשר האפליקציה מתחילה",
"disable-hardware-acceleration": "השבת האצת החומרה" "disable-hardware-acceleration": "השבת האצת החומרה",
"edit-config-json": "ערוך את config.json",
"restart-on-config-changes": "הפעל מחדש בשינויי תצורה",
"set-proxy": {
"label": "הגדר שרת proxy",
"prompt": {
"label": "הזן כתובת פרוקסי: (להשאיר ריק כדי להשבית)",
"placeholder": "דוגמה: SOCKS5://127.0.0.1:9999",
"title": "הגדר שרת proxy"
}
}
} }
} },
"auto-update": "עדכון אוטומטי",
"hide-menu": {
"dialog": {
"message": "התפריט יוסתר בהפעלה הבאה, השתמש ב-[Alt] כדי להציג אותו (או סמן את [`] אם אתה משתמש בתפריט בתוך האפליקציה)",
"title": "הסתר תפריט מופעל"
},
"label": "הסתר את התפריט"
},
"language": {
"dialog": {
"message": "השפה תשתנה לאחר הפעלת היישום מחדש",
"title": "השפה שונתה"
},
"label": "שפה",
"submenu": {
"to-help-translate": "רוצים לעזור לתרגם? לחץ כאן"
}
},
"resume-on-start": "המשך את השיר האחרון עם הפעלת האפליקציה",
"single-instance-lock": "נעילת מופע יחיד"
} }
} }
} }

View File

@ -744,6 +744,9 @@
"line-effect": { "line-effect": {
"label": "Effetto linea", "label": "Effetto linea",
"submenu": { "submenu": {
"fancy": {
"tooltip": "Usa effetti grandi, simili a quelli di un'app sulla riga attuale"
},
"focus": { "focus": {
"label": "Focus", "label": "Focus",
"tooltip": "Rendi bianca solo la riga corrente" "tooltip": "Rendi bianca solo la riga corrente"

View File

@ -541,7 +541,7 @@
"menu": { "menu": {
"click-to-copy-id": "호스트 아이디 복사", "click-to-copy-id": "호스트 아이디 복사",
"close": "Music Together 닫기", "close": "Music Together 닫기",
"connected-users": "연결된 사용자: {{count}}명", "connected-users": "연결된 사용자",
"disconnect": "Music Together 연결 끊기", "disconnect": "Music Together 연결 끊기",
"empty-user": "연결된 사용자 없음", "empty-user": "연결된 사용자 없음",
"host": "Music Together 호스트", "host": "Music Together 호스트",

View File

@ -39,29 +39,29 @@
"clearing-cache-after-20s": "กำลังล้างแคชแอป" "clearing-cache-after-20s": "กำลังล้างแคชแอป"
}, },
"window": { "window": {
"tried-to-render-offscreen": "หน้าต่างพยายามแสดงผลเกินขนาดหน้าจอ windowSize={{windowSize}}, displaySize={{displaySize}}, position={{position}}" "tried-to-render-offscreen": "หน้าต่างพยายามแสดงผลเกินขนาดหน้าจอ ขนาดหน้าต่าง={{windowSize}}, ขนาดหน้าจอ={{displaySize}}, ตำแหน่ง={{position}}"
} }
}, },
"dialog": { "dialog": {
"hide-menu-enabled": { "hide-menu-enabled": {
"detail": "เมนูถูกซ่อนไว้ กด'Alt' เพื่อแสดงเมนู (หรือ 'Escape' หากอยู่ในเมนูแอป)", "detail": "เมนูถูกซ่อนไว้ กด'Alt' เพื่อแสดงเมนู (หรือ 'Escape' หากกำลังใช้เมนูบนแอป)",
"message": "การซ่อนเมนูถูกเปิดใช้งาน", "message": "การซ่อนเมนูเปิดใช้งานอยู่",
"title": "เปิดใช้งานการซ่อนเมนู" "title": "เปิดใช้งานการซ่อนเมนู"
}, },
"need-to-restart": { "need-to-restart": {
"buttons": { "buttons": {
"later": "ภายหลัง", "later": "รีสตาร์ตภายหลัง",
"restart-now": "รีสตาร์ตอนนี้" "restart-now": "รีสตาร์ตอนนี้"
}, },
"detail": "\"{{pluginName}}\" ปลั๊กอินต้องการการรีสตาร์เพื่อแสดงผล", "detail": "ปลั๊กอิน \"{{pluginName}}\" ต้องการการรีสตาร์เพื่อจะทำงานได้",
"message": "\"{{pluginName}}\" ต้องการรีสตาร์", "message": "\"{{pluginName}}\" ต้องการรีสตาร์",
"title": "แนะนำให้รีสตาร์" "title": "แนะนำให้รีสตาร์"
}, },
"unresponsive": { "unresponsive": {
"buttons": { "buttons": {
"quit": "ออก", "quit": "ออก",
"relaunch": "เปิดใหม่", "relaunch": "ปิดแล้วเปิดใหม่",
"wait": "รอซักครู่" "wait": "รอให้ตอบสนอง"
}, },
"detail": "ขออภัยในความไม่สะดวก! โปรดเลือกสิ่งที่ต้องการจะทำ:", "detail": "ขออภัยในความไม่สะดวก! โปรดเลือกสิ่งที่ต้องการจะทำ:",
"message": "แอปพลิเคชันไม่ตอบสนอง", "message": "แอปพลิเคชันไม่ตอบสนอง",
@ -73,7 +73,7 @@
"download": "ดาวน์โหลด", "download": "ดาวน์โหลด",
"ok": "ตกลง" "ok": "ตกลง"
}, },
"detail": "มีเวอร์ชันใหม่ให้ดาวน์โหลดแล้วที่ {{downloadLink}}", "detail": "มีเวอร์ชันใหม่ให้ใช้งานได้สามารถดาวน์โหลดได้ที่ {{downloadLink}}",
"message": "มีเวอร์ชันใหม่ให้ใช้งานแล้ว", "message": "มีเวอร์ชันใหม่ให้ใช้งานแล้ว",
"title": "อัปเดตพร้อมใช้งาน" "title": "อัปเดตพร้อมใช้งาน"
} }
@ -87,7 +87,7 @@
"go-back": "ก่อนหน้า", "go-back": "ก่อนหน้า",
"go-forward": "ถัดไป", "go-forward": "ถัดไป",
"quit": "ออก", "quit": "ออก",
"restart": "รีสตาร์แอป" "restart": "รีสตาร์แอป"
} }
}, },
"options": { "options": {
@ -96,23 +96,23 @@
"advanced-options": { "advanced-options": {
"label": "ตัวเลือกขั้นสูง", "label": "ตัวเลือกขั้นสูง",
"submenu": { "submenu": {
"auto-reset-app-cache": "รีเซตแอปแคชเมื่อเริ่มแอป", "auto-reset-app-cache": "รีเซตแคชของแอปเมื่อเริ่มแอป",
"disable-hardware-acceleration": "ปิดการใช้งานตัวเร่งประสิทธิภาพด้วยฮาร์ดแวร์", "disable-hardware-acceleration": "ปิดการใช้งานตัวเร่งประสิทธิภาพด้วยฮาร์ดแวร์",
"edit-config-json": "แก้ไข config.json", "edit-config-json": "แก้ไข config.json",
"override-user-agent": "แทนที่ User-Agent", "override-user-agent": "แทนที่ User-Agent",
"restart-on-config-changes": "รีสตาร์เมื่อมีการเปลี่ยนแปลงคอนฟิก", "restart-on-config-changes": "รีสตาร์เมื่อมีการเปลี่ยนแปลงคอนฟิก",
"set-proxy": { "set-proxy": {
"label": "ตั้งค่าพร็อกซี่", "label": "ตั้งค่าพร็อกซี่",
"prompt": { "prompt": {
"label": "ใส่ที่อยู่พร็อกซี่: (ปล่อยให้ว่างเพื่อปิดใช้งาน)", "label": "ใส่ที่อยู่ของพร็อกซี่: (เว้นว่างเพื่อปิดใช้งาน)",
"placeholder": "ตัวอย่าง: SOCKS5://127.0.0.1:9999", "placeholder": "ตัวอย่าง: SOCKS5://127.0.0.1:9999",
"title": "ตั้งค่าพร็อกซี่" "title": "ตั้งค่าพร็อกซี่"
} }
}, },
"toggle-dev-tools": "เปิด-ปิด DevTools" "toggle-dev-tools": "เปิด/ปิดเครื่องมือสำหรับนักพัฒนา"
} }
}, },
"always-on-top": "อยู่ด้านบนตลอดเวลา", "always-on-top": "อยู่ด้านบนหน้าต่างอื่นตลอดเวลา",
"auto-update": "อัปเดตอัตโนมัติ", "auto-update": "อัปเดตอัตโนมัติ",
"hide-menu": { "hide-menu": {
"dialog": { "dialog": {
@ -123,17 +123,17 @@
}, },
"language": { "language": {
"dialog": { "dialog": {
"message": "ภาษาจะเปลี่ยนหลังจากทำการรีสตาร์", "message": "ภาษาจะเปลี่ยนหลังจากทำการรีสตาร์",
"title": "ภาษาถูกเปลี่ยนแล้ว" "title": "ภาษาถูกเปลี่ยนแล้ว"
}, },
"label": "ภาษา", "label": "ภาษา",
"submenu": { "submenu": {
"to-help-translate": "ต้องการช่วยแปล? คลิกที่นี่" "to-help-translate": "ต้องการช่วยแปลภาษา? คลิกที่นี่"
} }
}, },
"resume-on-start": "เล่นเพลงล่าสุดต่อ เมื่อแอปเริ่มต้น", "resume-on-start": "เล่นเพลงที่เล่นล่าสุดต่อ เมื่อเปิดแอป",
"single-instance-lock": "ล็อกการทำงานเพียงรายการเดียว", "single-instance-lock": "ล็อกไม่ให้เปิดซำ้ซ้อน",
"start-at-login": "เริ่มต้นที่การเข้าสู่ระบบ", "start-at-login": "เริ่มต้นเมื่อเข้าสู่ระบบ",
"starting-page": { "starting-page": {
"label": "หน้าเริ่มต้น", "label": "หน้าเริ่มต้น",
"unset": "ยกเลิกการตั้งค่า" "unset": "ยกเลิกการตั้งค่า"
@ -148,7 +148,7 @@
} }
}, },
"visual-tweaks": { "visual-tweaks": {
"label": "การปรับแต่งหน้าตาแอป", "label": "ปรับแต่งหน้าตาแอป",
"submenu": { "submenu": {
"like-buttons": { "like-buttons": {
"default": "ค่าเริ่มต้น", "default": "ค่าเริ่มต้น",
@ -169,7 +169,7 @@
"label": "ธีม", "label": "ธีม",
"submenu": { "submenu": {
"import-css-file": "นำเข้าไฟล์ CSS ที่กำหนดเอง", "import-css-file": "นำเข้าไฟล์ CSS ที่กำหนดเอง",
"no-theme": "ไม่มีธีม" "no-theme": "ไม่ใช้ธีม"
} }
} }
} }
@ -187,28 +187,28 @@
"force-reload": "บังคับโหลดใหม่", "force-reload": "บังคับโหลดใหม่",
"reload": "โหลดใหม่", "reload": "โหลดใหม่",
"reset-zoom": "ขนาดจริง", "reset-zoom": "ขนาดจริง",
"toggle-fullscreen": "สลับเต็มหน้าจอ", "toggle-fullscreen": "เปิด/ปิดโหมดเต็มหน้าจอ",
"zoom-in": "ซูมเข้า", "zoom-in": "ซูมเข้า",
"zoom-out": "ซูมออก" "zoom-out": "ซูมออก"
} }
} }
}, },
"tray": { "tray": {
"next": "่อไป", "next": "่อไป",
"play-pause": "เล่น/พัก", "play-pause": "เล่น/หยุดชั่วคราว",
"previous": "ก่อนหน้า", "previous": "ก่อนหน้า",
"quit": "ออก", "quit": "ออก",
"restart": "รีสตาร์แอป", "restart": "รีสตาร์แอป",
"show": "แสดงหน้าต่าง", "show": "แสดงหน้าต่าง",
"tooltip": { "tooltip": {
"default": "ยูทุปมิวสิค", "default": "YouTube Music",
"with-song-info": "ยูทูปมิวสิค: {{artist}} - {{title}}" "with-song-info": "YouTube Music: {{artist}} - {{title}}"
} }
} }
}, },
"plugins": { "plugins": {
"ad-speedup": { "ad-speedup": {
"description": "หากมีการเล่นโฆษณา เสียงจะถูกปิดและตั้งค่าความเร็วในการเล่นเป็น 16x", "description": "หากมีโฆษณาเล่นขึ้น เสียงจะถูกปิดและตั้งความเร็วในการเล่นเป็น 16 เท่า",
"name": "เพิ่มความเร็วโฆษณา" "name": "เพิ่มความเร็วโฆษณา"
}, },
"adblocker": { "adblocker": {
@ -219,7 +219,7 @@
"name": "ตัวบล็อกโฆษณา" "name": "ตัวบล็อกโฆษณา"
}, },
"album-actions": { "album-actions": {
"description": "เพิ่ม ยกเลิกไม่ชอบ, ไม่ชอบ, 'ถูกใจ', และ 'ยกเลิกถูกใจ' เพื่อให้สามารถใช้งานกับเพลงทั้งหมดในรายการเพลงหรืออัลบั้ม", "description": "เพิ่ม ยกเลิกไม่ชอบ, ไม่ชอบ, ถูกใจ, และ ยกเลิกถูกใจ ที่มีผลกับเพลงทั้งหมดในรายการเพลงหรืออัลบั้ม",
"name": "การกระทำที่เกี่ยวกับอัลบั้ม" "name": "การกระทำที่เกี่ยวกับอัลบั้ม"
}, },
"album-color-theme": { "album-color-theme": {
@ -228,14 +228,14 @@
"color-mix-ratio": { "color-mix-ratio": {
"label": "สัดส่วนการผสมสี", "label": "สัดส่วนการผสมสี",
"submenu": { "submenu": {
"percent": "{{ratio}}เปอร์เซ็น" "percent": "{{ratio}}%"
} }
} }
}, },
"name": "ธีมสีของอัลบั้ม" "name": "ธีมสีของอัลบั้ม"
}, },
"ambient-mode": { "ambient-mode": {
"description": "เอฟเฟกต์แสงจะใช้สีอ่อนๆจากวีดิโอมาเป็พื้นหลังสกรีน", "description": "เอฟเฟกต์แสงที่ดึงสีอ่อนๆจากวีดิโอมาบนพื้นหลังแอป",
"menu": { "menu": {
"blur-amount": { "blur-amount": {
"label": "ระดับความเบลอ", "label": "ระดับความเบลอ",
@ -268,9 +268,9 @@
} }
}, },
"smoothness-transition": { "smoothness-transition": {
"label": "การเปลี่ยนแบบสมูท", "label": "ความเรียบการเปลี่ยน",
"submenu": { "submenu": {
"during": "ระหว่าง {{interpolationTime}} วินาที" "during": "ในระยะเวลา {{interpolationTime}} วินาที"
} }
}, },
"use-fullscreen": { "use-fullscreen": {
@ -279,6 +279,13 @@
}, },
"name": "โหมดสภาพแวดล้อม" "name": "โหมดสภาพแวดล้อม"
}, },
"amuse": {
"description": "เพิ่มการรองรับ Youtube Music สำหรับ Widget Amuse Now Playing ของ 6K Labs",
"name": "Amuse",
"response": {
"query": "API Server ของ Amuse ทำงานอยู่ ส่ง GET ไปที่ /query เพื่อขอข้อมูลเกี่ยวกับเพลง"
}
},
"api-server": { "api-server": {
"description": "เพิ่มเซิร์ฟเวอร์ API เพื่อควบคุมการเล่น", "description": "เพิ่มเซิร์ฟเวอร์ API เพื่อควบคุมการเล่น",
"dialog": { "dialog": {
@ -287,7 +294,38 @@
"allow": "อนุญาต", "allow": "อนุญาต",
"deny": "ปฏิเสธ" "deny": "ปฏิเสธ"
}, },
"message": "อนุญาตให้ {{ID}} ({{origin}}) เข้าถึง API หรือไม่?" "message": "อนุญาตให้ {{ID}} ({{origin}}) เข้าถึง API หรือไม่?",
"title": "คำขอลงชื่อเข้าใช้งาน API"
}
},
"menu": {
"auth-strategy": {
"label": "วิธีการลงชื่อเข้าใช้งาน",
"submenu": {
"auth-at-first": {
"label": "ลงชื่อเข้าใช้งานในคำขอแรก"
},
"none": {
"label": "ไม่ต้องลงชื่อเข้าใช้งาน"
}
}
},
"hostname": {
"label": "ชื่อโฮสต์ (Hostname)"
},
"port": {
"label": "พอร์ต (Port) ของ Server"
}
},
"name": "เซิร์ฟเวอร์ API [เบต้า]",
"prompt": {
"hostname": {
"label": "ใส่ชื่อโฮสต์ (Hostname) เช่น 0.0.0.0 สำหรับเซิร์ฟเวอร์ API:",
"title": "ชื่อโฮสต์ (Hostname)"
},
"port": {
"label": "ใส่พอร์ต (Port) ในการเข้าถึงเซิร์ฟเวอร์ API:",
"title": "พอร์ต (Port) ของ Server"
} }
} }
}, },
@ -300,11 +338,11 @@
"name": "เบลอแถบนำทาง" "name": "เบลอแถบนำทาง"
}, },
"bypass-age-restrictions": { "bypass-age-restrictions": {
"description": "ข้ามการตรวจสอบอายุของยูทูป", "description": "ข้ามการตรวจสอบอายุของ YouTube",
"name": "ข้ามข้อจำกัดอายุ" "name": "ข้ามข้อจำกัดอายุ"
}, },
"captions-selector": { "captions-selector": {
"description": "ตัวเลือกคำบรรยายสำหรับเพลงในYoutube Music", "description": "ตัวเลือกคำบรรยายสำหรับเพลงใน YouTube Music",
"menu": { "menu": {
"autoload": "เลือกคำบรรยายที่ใช้ครั้งล่าสุดโดยอัตโนมัติ", "autoload": "เลือกคำบรรยายที่ใช้ครั้งล่าสุดโดยอัตโนมัติ",
"disable-captions": "ไม่มีคำบรรยายเป็นค่าเริ่มต้น" "disable-captions": "ไม่มีคำบรรยายเป็นค่าเริ่มต้น"
@ -348,19 +386,19 @@
} }
}, },
"disable-autoplay": { "disable-autoplay": {
"description": "เริ่มเพลงในโหมดหยุด", "description": "เริ่มเพลงในโหมดหยุดชั่วคราว",
"menu": { "menu": {
"apply-once": "ใช้เฉพาะเมื่อเริ่มต้น" "apply-once": "ใช้เฉพาะเมื่อเริ่มต้นแอป"
}, },
"name": "ปิดใช้งานการเล่นอัตโนมัติ" "name": "ปิดใช้งานการเล่นอัตโนมัติ"
}, },
"discord": { "discord": {
"backend": { "backend": {
"already-connected": "พยายามเชื่อมต่อกับการเชื่อมต่อที่ทำงานอยู่", "already-connected": "กำลังพยายามเชื่อมต่อด้วยการเชื่อมต่อที่มีอยู่",
"connected": "เชื่อมต่อกับดิสคอร์ดแล้ว", "connected": "เชื่อมต่อกับดิสคอร์ดแล้ว",
"disconnected": "ตัดการเชื่อมต่อออกจากดิสคอร์ด" "disconnected": "ไม่มีการเชื่อมต่อกับดิสคอร์ดอยู่"
}, },
"description": "แสดงให้เพื่อนเห็นว่าคุณกำลังฟังอะไรด้วย Rich Presence", "description": "แสดงให้เพื่อนเห็นว่าคุณกำลังฟังอะไรด้วย Rich Presence บนดิสคอร์ด",
"menu": { "menu": {
"auto-reconnect": "เชื่อมต่อใหม่โดยอัตโนมัติ", "auto-reconnect": "เชื่อมต่อใหม่โดยอัตโนมัติ",
"clear-activity": "ล้างกิจกรรม", "clear-activity": "ล้างกิจกรรม",
@ -369,10 +407,10 @@
"disconnected": "ตัดการเชื่อมต่อ", "disconnected": "ตัดการเชื่อมต่อ",
"hide-duration-left": "ซ่อนระยะเวลาที่เหลือ", "hide-duration-left": "ซ่อนระยะเวลาที่เหลือ",
"hide-github-button": "ซ่อนปุ่มลิงก์ GitHub", "hide-github-button": "ซ่อนปุ่มลิงก์ GitHub",
"play-on-youtube-music": "เล่นบนยูทูปมิวสุค", "play-on-youtube-music": "เล่นบน YouTube Music",
"set-inactivity-timeout": "ตั้งระยะเวลาไม่มีกิจกรรม" "set-inactivity-timeout": "ตั้งระยะเวลาไม่มีกิจกรรม"
}, },
"name": "Discord Rich Presence", "name": "แสดงกิจกรรมบนดิสคอร์ด",
"prompt": { "prompt": {
"set-inactivity-timeout": { "set-inactivity-timeout": {
"label": "ป้อนระยะเวลาไม่มีกิจกรรมเป็นวินาที:", "label": "ป้อนระยะเวลาไม่มีกิจกรรมเป็นวินาที:",
@ -426,6 +464,21 @@
"description": "ดาวน์โหลด MP3 / เสียงต้นฉบับโดยตรงจากอินเทอร์เฟซ", "description": "ดาวน์โหลด MP3 / เสียงต้นฉบับโดยตรงจากอินเทอร์เฟซ",
"menu": { "menu": {
"choose-download-folder": "เลือกโฟลเดอร์ดาวน์โหลด", "choose-download-folder": "เลือกโฟลเดอร์ดาวน์โหลด",
"download-finish-settings": {
"label": "ดาวน์โหลดเมื่อเล่นเสร็จ",
"prompt": {
"last-percent": "หลังจาก x เปอร์เซ็นต์",
"last-seconds": "x วินาทีล่าสุด",
"title": "ตั่งค่าเวลาที่จะดาวน์โหลด"
},
"submenu": {
"advanced": "การตั้งค่าขั้นสูง",
"enabled": "เปิดใช้งาน",
"mode": "โหมดเวลา",
"percent": "โหมดเปอร์เซ็นต์",
"seconds": "โหมดวินาที"
}
},
"download-playlist": "ดาวน์โหลดเพลย์ลิสต์", "download-playlist": "ดาวน์โหลดเพลย์ลิสต์",
"presets": "พรีเซ็ต", "presets": "พรีเซ็ต",
"skip-existing": "ข้ามไฟล์ที่มีอยู่แล้ว" "skip-existing": "ข้ามไฟล์ที่มีอยู่แล้ว"
@ -438,12 +491,24 @@
"button": "ดาวน์โหลด" "button": "ดาวน์โหลด"
} }
}, },
"equalizer": {
"description": "เพิ่มอีควอไลเซอร์ให้ที่เล่นเพลง",
"menu": {
"presets": {
"label": "การตั้งค่าล่วงหน้า",
"list": {
"bass-booster": "เพิ่มเบส"
}
}
},
"name": "อีควอไลเซอร์"
},
"exponential-volume": { "exponential-volume": {
"description": "ทำให้ตัวเลือกความดังมีลักษณะเอ็กซ์โปเนนเชียล เพื่อให้ง่ายต่อการเลือกระดับความดังที่ต่ำลง", "description": "ทำให้ตัวเลือกความดังมีลักษณะเอ็กซ์โปเนนเชียล เพื่อให้ง่ายต่อการเลือกระดับความดังที่ต่ำลง",
"name": "ระดับเสียงแบบเอ็กซโปเนนเชียล" "name": "ระดับเสียงแบบเอ็กซโปเนนเชียล"
}, },
"in-app-menu": { "in-app-menu": {
"description": "ให้เมนูบาร์ดูทันสมัย มืดหรือเป็นสีของอัลบั้มอย่างน่าสนใจ", "description": "ให้เแถบเมนูดูทันสมัย มืดหรือเป็นสีของอัลบั้มอย่างน่าสนใจ",
"menu": { "menu": {
"hide-dom-window-controls": "ซ่อนตัวควบคุมหน้าต่าง DOM" "hide-dom-window-controls": "ซ่อนตัวควบคุมหน้าต่าง DOM"
}, },
@ -530,6 +595,7 @@
} }
}, },
"priority": "ลำดับความสำคัญของการแจ้งเตือน", "priority": "ลำดับความสำคัญของการแจ้งเตือน",
"toast-style": "แบบไม่ถาวร",
"unpause-notification": "แสดงการแจ้งเตือนเมื่อหยุดพัก" "unpause-notification": "แสดงการแจ้งเตือนเมื่อหยุดพัก"
}, },
"name": "การแจ้งเตือน" "name": "การแจ้งเตือน"
@ -549,8 +615,222 @@
} }
}, },
"save-window-position": "บันทึกตำแหน่งหน้าต่าง", "save-window-position": "บันทึกตำแหน่งหน้าต่าง",
"save-window-size": "บันทึกขนาดหน้าต่าง" "save-window-size": "บันทึกขนาดหน้าต่าง",
"use-native-pip": "ใช้โหมดภาพซ้อนภาพของเบราเซอร์"
},
"name": "โหมดภาพซ้อนภาพ",
"templates": {
"button": "เปิดโหมดภาพซ้อนภาพ"
} }
},
"playback-speed": {
"description": "ฟังเพลงได้ทั้งช้าและเร็ว เพิ่มที่เลื่อนปรับความเร็วของเพลง",
"name": "ความเร็วเพลง",
"templates": {
"button": "ความเร็ว"
}
},
"precise-volume": {
"description": "ควบคุมระดับเสียงได้อย่างแม่นยำด้วยที่เลื่อนเมาส์หรือปุ่มลัด พร้อมด้วยหน้าจอแสดงข้อมูลและขั้นระดับเสียงที่ปรับแต่งได้",
"menu": {
"arrows-shortcuts": "ควบคุมด้วยปุ่มลูกศรเมื่ออยู่ในแอป",
"custom-volume-steps": "ตั้งขั้นการปรับระดับเสียง",
"global-shortcuts": "ตั้งปุ่มลัดที่ใช้ได้ทั้งระบบ"
},
"name": "ระดับเสียงแม่นยำ",
"prompt": {
"global-shortcuts": {
"keybind-options": {
"decrease": "ปุ่มลดระดับเสียง",
"increase": "ปุ่มเพิ่มระดับเสียง"
},
"label": "ตั้งค่าปุ่มลัดระดับเสียงที่ใช้ได้ทั้งระบบ:",
"title": "ปุ่มลัดระดับเสียงที่ใช้ได้ทั้งระบบ"
},
"volume-steps": {
"label": "ตั้งขั้นการเพิ่ม/ลดระดับเสียง",
"title": "ขั้นการปรับระดับเสียง"
}
}
},
"quality-changer": {
"backend": {
"dialog": {
"quality-changer": {
"detail": "คุณภาพปัจจุบัน: {{quality}}",
"message": "เลือกคุณภาพวิดีโอ:",
"title": "เลือกคุณภาพวิดีโอ"
}
}
},
"description": "อนุญาตให้เปลี่ยนคุณภาพของวิดีโอด้วยปุ่มที่แสดงเหนือวิดีโอ",
"name": "ที่เปลี่ยนคุณภาพวิดีโอ"
},
"scrobbler": {
"description": "รองรับการบันทึกการเล่นเพลง (เช่น last.fm, Listenbrainz)",
"dialog": {
"lastfm": {
"auth-failed": {
"message": "เกิดปัญหาระหว่างเข้าใช้งาน Last.fm\nซ่อนข้อความนี้จนการรีสตาร์ตครั้งถัดไป",
"title": "เกิดปัญหาในการเข้าใช้งาน"
}
}
},
"menu": {
"lastfm": {
"api-settings": "การตั้งค่า API Last.fm"
},
"listenbrainz": {
"token": "ใส่ user token ของ ListenBrainz"
},
"scrobble-other-media": "บันทึกการเล่นสื่ออื่นๆ"
},
"name": "บันทึกการเล่น (Scrobbler)",
"prompt": {
"lastfm": {
"api-key": "API Key ของ Last.fm",
"api-secret": "API secret ของ Last.fm"
},
"listenbrainz": {
"token": {
"label": "ใส่ user token ListenBrainz ของคุณ:",
"title": "token ListenBrainz"
}
}
}
},
"shortcuts": {
"description": "อนุญาตให้ตั้งปุ่มลัดทั่วระบบสำหรับการเล่น (เล่น/หยุดชั่วคราว/ถัดไป/ก่อนหน้า) และปิดการแสดงผลสื่อโดยทับปุ่มควบคุมสื่อ เปิด Ctrl/CMD + F เพื่อค้นหา เปิดการใช้งานการควบคุมเพลงผ่าน MPRIS สำหรับ Linux และปุ่มลัดที่กำหนดเองได้สำหรับผู้ใช้ขั้นสูง",
"menu": {
"override-media-keys": "เปลี่ยนการทำงานของปุ่มควบคุมสื่อ",
"set-keybinds": "ตั้งค่าปุ่มลัดควบคุมเพลง"
},
"name": "ปุ่มลัด (และ MPRIS)",
"prompt": {
"keybind": {
"keybind-options": {
"next": "เพลงถัดไป",
"play-pause": "เล่น/หยุดชั่วคราว",
"previous": "เพลงก่อนหน้า"
},
"label": "ตั้งค่าปุ่มลัดทั่วระบบสำหรับควบคุมเพลง:",
"title": "ปุ่มลัดทั่วระบบ"
}
}
},
"skip-disliked-songs": {
"description": "ข้ามเพลงที่ไม่ถูกใจ",
"name": "ข้ามเพลงที่ไม่ถูกใจ"
},
"skip-silences": {
"description": "ข้ามช่วงที่เงียบในเพลงโดยอัตโนมัติ",
"name": "ข้ามช่วงเงียบ"
},
"sponsorblock": {
"description": "ข้ามช่วงที่ไม่ใช่เพลงเช่น intro/outro หรือ ช่วงที่ไม่มีเพลงเล่นใน mv โดยอัตโนมัติ",
"name": "SponsorBlock"
},
"synced-lyrics": {
"description": "ให้เนื้อเพลงที่ตรงกับเวลาของเพลง ผ่านผู้ให้บริการเช่น LRClib",
"errors": {
"fetch": "⚠️\tเกิดปัญหาในการดึงเนื้อเพลง\n\tกรุณาลองใหม่ภายหลัง",
"not-found": "⚠️ ไม่เจอเนื้อเพลงสำหรับเพลงนี้"
},
"menu": {
"default-text-string": {
"label": "อักขระคั่นระหว่างเนื้อเพลง",
"tooltip": "เลือกอักขระที่คั่นในช่วงที่อยู่ระหว่างเนื้อเพลง"
},
"line-effect": {
"label": "เอฟเฟกต์บรรทัด",
"submenu": {
"fancy": {
"label": "อลังการ",
"tooltip": "ใช้เอฟเฟกต์ใหญ่คล้ายบนแอพ บนบรรทัดปัจจุบัน"
},
"focus": {
"label": "เด่น",
"tooltip": "ทำให้แค่บรรทัดปัจจุบันสีขาว"
},
"offset": {
"label": "เลื่อน",
"tooltip": "เลื่อนบรรทัดปัจจุบันไปทางขวา"
},
"scale": {
"label": "ขยายขนาด",
"tooltip": "ขยายบรรทัดปัจจุบันให้ใหญ่ขึ้น"
}
},
"tooltip": "เลือกเอฟเฟกต์ที่จะใช้กับบรรทัดปัจจุบัน"
},
"precise-timing": {
"label": "ให้เนื้อเพลงตรงกับเพลงเป๊ะๆ",
"tooltip": "คำนวณมิลิวินาทีในการแสดงบรรทัดถัดไป (มีผลเล็กน้อยกับประสิทธิภาพการทำงาน)"
},
"show-lyrics-even-if-inexact": {
"label": "แสดงเนื้อเพลงแม้ไม่ตรงเป๊ะ",
"tooltip": "ถ้าหาเนื้อเพลงไม่เจอจะลองหาด้วยคำค้นหาที่ต่างกัน\nอาจได้เนื้อเพลงไม่ตรง"
},
"show-time-codes": {
"label": "แสดงตำแหน่งเวลา",
"tooltip": "แสดงตำแหน่งเวลาข้างๆเนื้อเพลง"
}
},
"name": "เนื้อเพลงตรงกับเพลง",
"refetch-btn": {
"fetching": "กำลังดึงข้อมูล...",
"normal": "ดึงเนื้อเพลงใหม่"
},
"warnings": {
"duration-mismatch": "⚠️ - เนื้อเพลงอาจไม่ตรงกับเวลาเนื่องจากความยาวไม่ตรงกัน",
"inexact": "⚠️ - เนื้อเพลงอาจไม่ตรง",
"instrumental": "⚠️ - เพลงนี้เป็นเพลงบรรเลง"
}
},
"taskbar-mediacontrol": {
"description": "ควบคุมการเล่นผ่าน taskbar ของ Windows",
"name": "ควบคุมสื่อผ่าน Taskbar"
},
"touchbar": {
"description": "เพิ่ม Widget บน TouchBar สำหรับผู้ใช้ macOS",
"name": "TouchBar"
},
"tuna-obs": {
"description": "ใช้งานร่วมกันกับปลั้กอิน Tuna บน OBS",
"name": "Tuna OBS"
},
"video-toggle": {
"description": "เพิ่มปุ่มสลับระหว่างโหมดเพลงกับโหมดวิดีโอ หรือลบแถบวิดีโอออกทั้งแถบ",
"menu": {
"align": {
"label": "ตำแหน่งปุ่ม",
"submenu": {
"left": "ซ้าย",
"middle": "กลาง",
"right": "ขวา"
}
},
"force-hide": "บังคับลบแถบวิดีโอ",
"mode": {
"label": "โหมด",
"submenu": {
"custom": "ปุ่มกำหนดเอง",
"disabled": "ปิด",
"native": "ปุ่มเริ่มต้น"
}
}
},
"name": "ปุ่มวิดีโอ",
"templates": {
"button": "เพลง"
}
},
"visualizer": {
"description": "เพิ่มวิชวลไลเซอร์ให้ที่เล่นเพลง",
"menu": {
"visualizer-type": "ประเภทวิชวลไลเซอร์"
},
"name": "วิชวลไลเซอร์"
} }
} }
} }

View File

@ -201,8 +201,8 @@
"restart": "重启应用", "restart": "重启应用",
"show": "显示窗口", "show": "显示窗口",
"tooltip": { "tooltip": {
"default": "YouTube Music", "default": "优兔音乐",
"with-song-info": "YouTube Music: {{artist}} - {{title}}" "with-song-info": "优兔音乐: {{artist}} - {{title}}"
} }
} }
}, },
@ -281,7 +281,7 @@
}, },
"amuse": { "amuse": {
"description": "为 6K Labs 的 Amuse 正在播放小部件添加 YouTube Music 支持", "description": "为 6K Labs 的 Amuse 正在播放小部件添加 YouTube Music 支持",
"name": "Amuse", "name": "逗笑",
"response": { "response": {
"query": "Amuse API服务器已在运行。使用 /query 以获取歌曲信息。" "query": "Amuse API服务器已在运行。使用 /query 以获取歌曲信息。"
} }
@ -734,7 +734,7 @@
"description": "透过 LRClib 等服务提供滚动歌词显示。", "description": "透过 LRClib 等服务提供滚动歌词显示。",
"errors": { "errors": {
"fetch": "⚠️ - 获取歌词时发生错误。请稍后再试。", "fetch": "⚠️ - 获取歌词时发生错误。请稍后再试。",
"not-found": "⚠️ - 未找到此歌曲的歌词。" "not-found": "⚠️ 未找到此歌曲的歌词。"
}, },
"menu": { "menu": {
"default-text-string": { "default-text-string": {

View File

@ -720,7 +720,7 @@ export const register = (
app.openapi(routes.addSongToQueue, (ctx) => { app.openapi(routes.addSongToQueue, (ctx) => {
const { videoId, insertPosition } = ctx.req.valid('json'); const { videoId, insertPosition } = ctx.req.valid('json');
controller.addSongToQueue(videoId, { queueInsertPosition: insertPosition }); controller.addSongToQueue(videoId, insertPosition);
ctx.status(204); ctx.status(204);
return ctx.body(null); return ctx.body(null);

View File

@ -639,7 +639,12 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
try { try {
playlist = await yt.music.getPlaylist(playlistId); playlist = await yt.music.getPlaylist(playlistId);
if (playlist?.items) { if (playlist?.items) {
items.push(...playlist.items.as(YTNodes.MusicResponsiveListItem)); const filteredItems = playlist.items.filter(
(item): item is YTNodes.MusicResponsiveListItem =>
item instanceof YTNodes.MusicResponsiveListItem,
);
items.push(...filteredItems);
} }
} catch (error: unknown) { } catch (error: unknown) {
sendError( sendError(
@ -674,9 +679,13 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
while (playlist.has_continuation) { while (playlist.has_continuation) {
playlist = await playlist.getContinuation(); playlist = await playlist.getContinuation();
if (playlist?.items) {
items.push(...playlist.items.as(YTNodes.MusicResponsiveListItem)); const filteredItems = playlist.items.filter(
} (item): item is YTNodes.MusicResponsiveListItem =>
item instanceof YTNodes.MusicResponsiveListItem,
);
items.push(...filteredItems);
} }
if (items.length === 1) { if (items.length === 1) {

View File

@ -1,117 +0,0 @@
import { setQueue } from '../store/queue';
import { ConnectionEventUnion, MusicTogetherConfig, VideoData } from '../types';
import { setStatus } from '../store/status';
import { IPC } from '../constants';
import { Connection } from '../connection';
import type { AppElement } from '@/types/queue';
import type { RendererContext } from '@/types/contexts';
type BuildListenerOptions = {
ipc: RendererContext<MusicTogetherConfig>['ipc'];
app: AppElement;
};
export const Guest = {
buildListener: (_: Connection, { ipc, app }: BuildListenerOptions) => {
const listener = async (event: ConnectionEventUnion) => {
switch (event.type) {
case 'ADD_SONGS': {
await ipc.invoke(
IPC.addSongToQueue,
event.payload.videoList.map((v) => v.videoId),
{
index: event.payload.index,
},
);
setQueue('queue', (queue) => {
const result: VideoData[] = [...queue];
if (event.payload.index) {
result.splice(event.payload.index, 0, ...event.payload.videoList);
} else {
result.push(...event.payload.videoList);
}
return result;
});
break;
}
case 'REMOVE_SONG': {
await ipc.invoke(IPC.removeSongFromQueue, event.payload.index);
setQueue('queue', (queue) => {
const result: VideoData[] = [...queue];
result.splice(event.payload.index, 1);
return result;
});
break;
}
case 'MOVE_SONG': {
await ipc.invoke(
IPC.moveSongInQueue,
event.payload.fromIndex,
event.payload.toIndex,
);
setQueue('queue', (queue) => {
const result: VideoData[] = [...queue];
const [removed] = result.splice(event.payload.fromIndex, 1);
result.splice(event.payload.toIndex, 0, removed);
return result;
});
break;
}
case 'IDENTIFY': {
console.warn('Music Together [Guest]: Not allowed Event', event);
break;
}
case 'SYNC_USER': {
setStatus('users', event.payload?.users ?? []);
break;
}
case 'PERMISSION': {
const permission = event.payload;
if (!permission) break;
setStatus('permission', permission);
break;
}
case 'SYNC_QUEUE': {
await ipc.invoke(IPC.clearQueue);
await ipc.invoke(
IPC.addSongToQueue,
event.payload?.videoList.map((v) => v.videoId),
{
queueInsertPosition: 'INSERT_AT_END',
},
);
setQueue('queue', event.payload?.videoList ?? []);
break;
}
case 'SYNC_PROGRESS': {
if (typeof event.payload?.progress === 'number') {
app.playerApi?.seekTo(event.payload.progress);
}
if (app.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) app.playerApi?.pauseVideo();
if (event.payload?.state === 1) app.playerApi?.playVideo();
}
if (typeof event.payload?.index === 'number') {
await ipc.invoke(IPC.setQueueIndex, event.payload.index);
}
break;
}
default: {
console.warn('Music Together [Host]: Unknown Event', event);
break;
}
}
};
return listener;
},
};

View File

@ -1,140 +0,0 @@
import { DataConnection } from 'peerjs';
import { RendererContext } from '@/types/contexts';
import { queue } from '@/plugins/music-together/store/queue';
import { AppElement } from '@/types/queue';
import { ConnectionEventUnion, MusicTogetherConfig } from '../types';
import { setStatus, status } from '../store/status';
import { IPC } from '../constants';
import { Connection } from '../connection';
type BuildListenerOptions = {
ipc: RendererContext<MusicTogetherConfig>['ipc'];
app: AppElement;
};
export const Host = {
buildListener: (conn: Connection, { ipc, app }: BuildListenerOptions) => {
const listener = async (
event: ConnectionEventUnion,
dataConnection?: DataConnection,
) => {
switch (event.type) {
case 'ADD_SONGS': {
if (dataConnection && status.permission === 'host-only') return;
await ipc.invoke(
IPC.addSongToQueue,
event.payload.videoList.map((v) => v.videoId),
{
index: event.payload.index,
},
);
console.log('ADD_SONGS', event);
await conn?.broadcast(event.type, event.payload);
break;
}
case 'REMOVE_SONG': {
if (dataConnection && status.permission === 'host-only') return;
await ipc.invoke(IPC.removeSongFromQueue, event.payload.index);
await conn?.broadcast(event.type, event.payload);
break;
}
case 'MOVE_SONG': {
if (dataConnection && status.permission === 'host-only') {
// await conn.broadcast('SYNC_QUEUE', {
// videoList: queue?.videoList ?? [],
// });
break;
}
await ipc.invoke(
IPC.moveSongInQueue,
event.payload.fromIndex,
event.payload.toIndex,
);
await conn?.broadcast(event.type, event.payload);
break;
}
case 'IDENTIFY': {
const newUser = event.payload?.user;
if (!newUser) return;
// api?.toastService?.show(
// t('plugins.music-together.toast.user-connected', {
// name: event.payload.profile.name,
// }),
// );
setStatus('users', (users) => [...users, newUser]);
await conn?.broadcast('SYNC_USER', {
users: status.users,
});
break;
}
case 'SYNC_USER': {
await conn?.broadcast('SYNC_USER', {
users: status.users,
});
break;
}
case 'PERMISSION': {
await conn?.broadcast('PERMISSION', status.permission);
break;
}
case 'SYNC_QUEUE': {
await conn?.broadcast('SYNC_QUEUE', {
videoList: queue.queue,
});
break;
}
case 'SYNC_PROGRESS': {
let permissionLevel = 0;
if (status.permission === 'all') permissionLevel = 2;
if (status.permission === 'playlist') permissionLevel = 1;
if (status.permission === 'host-only') permissionLevel = 0;
if (!conn) permissionLevel = 3;
if (permissionLevel >= 2) {
if (typeof event.payload?.progress === 'number') {
const currentTime = app.playerApi?.getCurrentTime() ?? 0;
const offset = Math.abs(event.payload.progress - currentTime);
if (offset > 3)
app.playerApi?.seekTo(event.payload.progress + offset);
}
if (app.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) app.playerApi?.pauseVideo();
if (event.payload?.state === 1) app.playerApi?.playVideo();
}
}
if (permissionLevel >= 1) {
if (typeof event.payload?.index === 'number') {
await ipc.invoke(IPC.setQueueIndex, event.payload.index);
}
}
await conn?.broadcast('SYNC_PROGRESS', event.payload);
break;
}
default: {
console.warn('Music Together [Host]: Unknown Event', event);
break;
}
}
if (event.after) {
const now = event.after.shift();
if (now) {
now.after = event.after;
await listener(now, dataConnection);
}
}
};
return listener;
},
};

View File

@ -1,53 +0,0 @@
import prompt from 'custom-electron-prompt';
import { MusicTogetherConfig } from './types';
import promptOptions from '@/providers/prompt-options';
import getSongControls from '@/providers/song-controls';
import { IPC } from './constants';
import type { BackendContext } from '@/types/contexts';
export const onMainLoad = ({
ipc,
window,
}: BackendContext<MusicTogetherConfig>) => {
const controller = getSongControls(window);
ipc.handle(IPC.prompt, async (title: string, label: string) =>
prompt({
title,
label,
type: 'input',
...promptOptions(),
}),
);
ipc.handle(IPC.play, () => controller.play());
ipc.handle(IPC.pause, () => controller.pause());
ipc.handle(IPC.previous, () => controller.previous());
ipc.handle(IPC.next, () => controller.next());
ipc.handle(IPC.seekTo, (seconds: number) => controller.seekTo(seconds));
ipc.handle(
IPC.addSongToQueue,
(
ids: string | string[],
options: {
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
index?: number;
},
) => controller.addSongToQueue(ids, options),
);
ipc.handle(IPC.removeSongFromQueue, (index: number) =>
controller.removeSongFromQueue(index),
);
ipc.handle(IPC.moveSongInQueue, (fromIndex: number, toIndex: number) =>
controller.moveSongInQueue(fromIndex, toIndex),
);
ipc.handle(IPC.clearQueue, () => controller.clearQueue());
ipc.handle(IPC.setQueueIndex, (index: number) =>
controller.setQueueIndex(index),
);
};

View File

@ -1,6 +1,26 @@
import { DataConnection, Peer } from 'peerjs'; import { DataConnection, Peer } from 'peerjs';
import { ConnectedState, ConnectionEventMap, ConnectionEventUnion } from './types'; import type { Permission, Profile, VideoData } from './types';
export type ConnectionEventMap = {
ADD_SONGS: { videoList: VideoData[]; index?: number };
REMOVE_SONG: { index: number };
MOVE_SONG: { fromIndex: number; toIndex: number };
IDENTIFY: { profile: Profile } | undefined;
SYNC_PROFILE: { profiles: Record<string, Profile> } | undefined;
SYNC_QUEUE: { videoList: VideoData[] } | undefined;
SYNC_PROGRESS:
| { progress?: number; state?: number; index?: number }
| undefined;
PERMISSION: Permission | undefined;
};
export type ConnectionEventUnion = {
[Event in keyof ConnectionEventMap]: {
type: Event;
payload: ConnectionEventMap[Event];
after?: ConnectionEventUnion[];
};
}[keyof ConnectionEventMap];
type PromiseUtil<T> = { type PromiseUtil<T> = {
promise: Promise<T>; promise: Promise<T>;
@ -12,10 +32,10 @@ export type ConnectionListener = (
event: ConnectionEventUnion, event: ConnectionEventUnion,
conn: DataConnection, conn: DataConnection,
) => void; ) => void;
export type ConnectionMode = 'host' | 'guest' | 'disconnected';
export class Connection { export class Connection {
private peer: Peer; private peer: Peer;
private _state: ConnectedState = 'disconnected'; private _mode: ConnectionMode = 'disconnected';
private connections: Record<string, DataConnection> = {}; private connections: Record<string, DataConnection> = {};
private waitOpen: PromiseUtil<string> = {} as PromiseUtil<string>; private waitOpen: PromiseUtil<string> = {} as PromiseUtil<string>;
@ -31,15 +51,15 @@ export class Connection {
}); });
this.peer.on('open', (id) => { this.peer.on('open', (id) => {
this._state = 'connecting'; this._mode = 'host';
this.waitOpen.resolve(id); this.waitOpen.resolve(id);
}); });
this.peer.on('connection', (conn) => { this.peer.on('connection', (conn) => {
this._state = 'host'; this._mode = 'host';
this.registerConnection(conn); this.registerConnection(conn);
}); });
this.peer.on('error', (err) => { this.peer.on('error', (err) => {
this._state = 'disconnected'; this._mode = 'disconnected';
this.waitOpen.reject(err); this.waitOpen.reject(err);
this.connectionListeners.forEach((listener) => listener()); this.connectionListeners.forEach((listener) => listener());
@ -53,16 +73,16 @@ export class Connection {
} }
async connect(id: string) { async connect(id: string) {
this._state = 'guest'; this._mode = 'guest';
const conn = this.peer.connect(id); const conn = this.peer.connect(id);
await this.registerConnection(conn); await this.registerConnection(conn);
return conn; return conn;
} }
disconnect() { disconnect() {
if (this._state === 'disconnected') throw new Error('Already disconnected'); if (this._mode === 'disconnected') throw new Error('Already disconnected');
this._state = 'disconnected'; this._mode = 'disconnected';
this.connections = {}; this.connections = {};
this.peer.destroy(); this.peer.destroy();
} }
@ -72,8 +92,8 @@ export class Connection {
return this.peer.id; return this.peer.id;
} }
public get state() { public get mode() {
return this._state; return this._mode;
} }
public getConnections() { public getConnections() {
@ -101,7 +121,7 @@ export class Connection {
private async registerConnection(conn: DataConnection) { private async registerConnection(conn: DataConnection) {
return new Promise<DataConnection>((resolve, reject) => { return new Promise<DataConnection>((resolve, reject) => {
this.peer.once('error', (err) => { this.peer.once('error', (err) => {
this._state = 'disconnected'; this._mode = 'disconnected';
reject(err); reject(err);
this.connectionListeners.forEach((listener) => listener()); this.connectionListeners.forEach((listener) => listener());
@ -113,12 +133,6 @@ export class Connection {
this.connectionListeners.forEach((listener) => listener(conn)); this.connectionListeners.forEach((listener) => listener(conn));
conn.on('data', (data) => { conn.on('data', (data) => {
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch {}
}
if ( if (
!data || !data ||
typeof data !== 'object' || typeof data !== 'object' ||
@ -126,7 +140,7 @@ export class Connection {
!('payload' in data) || !('payload' in data) ||
!data.type !data.type
) { ) {
console.warn('Music Together: Invalid data', data, typeof data); console.warn('Music Together: Invalid data', data);
return; return;
} }

View File

@ -1,13 +0,0 @@
export const IPC = {
prompt: 'music-together:prompt',
play: 'music-together:play',
pause: 'music-together:pause',
previous: 'music-together:previous',
next: 'music-together:next',
seekTo: 'music-together:seekTo',
addSongToQueue: 'music-together:addSongToQueue',
removeSongFromQueue: 'music-together:removeSongFromQueue',
moveSongInQueue: 'music-together:moveSongInQueue',
clearQueue: 'music-together:clearQueue',
setQueueIndex: 'music-together:setQueueIndex',
};

View File

@ -1,31 +0,0 @@
import { createContext, JSX, splitProps, useContext } from 'solid-js';
import { MusicTogetherConfig } from '../types';
import { RendererContext } from '@/types/contexts';
export type RendererContextContextType = {
context: RendererContext<MusicTogetherConfig>;
};
export const RendererContextContext =
createContext<RendererContextContextType>();
export type RendererContextProviderProps = RendererContextContextType & {
children: JSX.Element;
};
export const RendererContextProvider = (
props: RendererContextProviderProps,
) => {
const [local, left] = splitProps(props, ['children']);
return (
<RendererContextContext.Provider value={left}>
{local.children}
</RendererContextContext.Provider>
);
};
export const useRendererContext = () => {
const context = useContext(RendererContextContext);
if (!context) throw Error('RendererContextProvider not found');
return context.context;
};

View File

@ -1,22 +0,0 @@
import { createContext, JSX, useContext } from 'solid-js';
import { ToastService } from '@/types/queue';
export type ToastContextType = {
service: ToastService;
};
export const ToastContext = createContext<ToastContextType>();
export type ToastProviderProps = ToastContextType & {
children: JSX.Element;
};
export const ToastProvider = (props: ToastProviderProps) => (
<ToastContext.Provider value={props}>{props.children}</ToastContext.Provider>
);
export const useToast = () => {
const context = useContext(ToastContext);
return (message: string) => {
context?.service.show(message);
};
};

View File

@ -0,0 +1,148 @@
import { ElementFromHtml } from '@/plugins/utils/renderer';
import itemHTML from './templates/item.html?raw';
import popupHTML from './templates/popup.html?raw';
type Placement =
| 'top'
| 'bottom'
| 'right'
| 'left'
| 'center'
| 'middle'
| 'center-middle'
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
type PopupItem =
| (ItemRendererProps & { type: 'item' })
| { type: 'divider' }
| { type: 'custom'; element: HTMLElement };
type PopupProps = {
data: PopupItem[];
anchorAt?: Placement;
popupAt?: Placement;
};
export const Popup = (props: PopupProps) => {
const popup = ElementFromHtml(popupHTML);
const container = popup.querySelector<HTMLElement>(
'.music-together-popup-container',
)!;
const items = props.data
.map((props) => {
if (props.type === 'item')
return {
type: 'item' as const,
...ItemRenderer(props),
};
if (props.type === 'divider')
return {
type: 'divider' as const,
element: ElementFromHtml(
'<div class="music-together-divider horizontal"></div>',
),
};
if (props.type === 'custom')
return {
type: 'custom' as const,
element: props.element,
};
return null;
})
.filter(Boolean);
container.append(...items.map(({ element }) => element));
popup.style.setProperty('opacity', '0');
popup.style.setProperty('pointer-events', 'none');
document.body.append(popup);
return {
element: popup,
container,
items,
show(x: number, y: number, anchor?: HTMLElement) {
let left = x;
let top = y;
if (anchor) {
if (props.anchorAt?.includes('right')) left += anchor.clientWidth;
if (props.anchorAt?.includes('bottom')) top += anchor.clientHeight;
if (props.anchorAt?.includes('center')) left += anchor.clientWidth / 2;
if (props.anchorAt?.includes('middle')) top += anchor.clientHeight / 2;
}
if (props.popupAt?.includes('right')) left -= popup.clientWidth;
if (props.popupAt?.includes('bottom')) top -= popup.clientHeight;
if (props.popupAt?.includes('center')) left -= popup.clientWidth / 2;
if (props.popupAt?.includes('middle')) top -= popup.clientHeight / 2;
popup.style.setProperty('left', `${left}px`);
popup.style.setProperty('top', `${top}px`);
popup.style.setProperty('opacity', '1');
popup.style.setProperty('pointer-events', 'unset');
setTimeout(() => {
const onClose = (event: MouseEvent) => {
const isPopupClick = event
.composedPath()
.some((element) => element === popup);
if (!isPopupClick) {
this.dismiss();
document.removeEventListener('click', onClose);
}
};
document.addEventListener('click', onClose);
}, 16);
},
showAtAnchor(anchor: HTMLElement) {
const { x, y } = anchor.getBoundingClientRect();
this.show(x, y, anchor);
},
isShowing() {
return popup.style.getPropertyValue('opacity') === '1';
},
dismiss() {
popup.style.setProperty('opacity', '0');
popup.style.setProperty('pointer-events', 'none');
},
};
};
type ItemRendererProps = {
id?: string;
icon?: Element;
text: string;
onClick?: () => void;
};
export const ItemRenderer = (props: ItemRendererProps) => {
const element = ElementFromHtml(itemHTML);
const iconContainer = element.querySelector<HTMLElement>('div.icon')!;
const textContainer = element.querySelector<HTMLElement>('div.text')!;
if (props.icon) iconContainer.appendChild(props.icon);
textContainer.append(props.text);
if (props.onClick) {
element.addEventListener('click', () => {
props.onClick?.();
});
}
if (props.id) element.id = props.id;
return {
element,
setIcon(icon: Element) {
iconContainer.replaceChildren(icon);
},
setText(text: string) {
textContainer.replaceChildren(text);
},
id: props.id,
};
};

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path d="M480-640 280-440l56 56 104-103v407h80v-407l104 103 56-56-200-200ZM146-260q-32-49-49-105T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 59-17 115t-49 105l-58-58q22-37 33-78t11-84q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 43 11 84t33 78l-58 58Z"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path
d="M280-400q-33 0-56.5-23.5T200-480q0-33 23.5-56.5T280-560q33 0 56.5 23.5T360-480q0 33-23.5 56.5T280-400Zm0 160q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/>
</svg>

After

Width:  |  Height:  |  Size: 480 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path d="M560-160q-66 0-113-47t-47-113q0-66 47-113t113-47q23 0 42.5 5.5T640-458v-342h240v120H720v360q0 66-47 113t-113 47ZM80-320q0-99 38-186.5T221-659q65-65 152.5-103T560-800v80q-82 0-155 31.5t-127.5 86q-54.5 54.5-86 127T160-320H80Zm160 0q0-66 25.5-124.5t69-102Q378-590 436-615t124-25v80q-100 0-170 70t-70 170h-80Z"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path
d="M792-56 686-160H260q-92 0-156-64T40-380q0-77 47.5-137T210-594q3-8 6-15.5t6-16.5L56-792l56-56 736 736-56 56ZM260-240h346L284-562q-2 11-3 21t-1 21h-20q-58 0-99 41t-41 99q0 58 41 99t99 41Zm185-161Zm419 191-58-56q17-14 25.5-32.5T840-340q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-27 0-52 6.5T380-693l-58-58q35-24 74.5-36.5T480-800q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 39-15 72.5T864-210ZM593-479Z"/>
</svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path d="M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@ -1,10 +1,84 @@
import prompt from 'custom-electron-prompt';
import { DataConnection } from 'peerjs';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { createPlugin } from '@/utils'; import { createPlugin } from '@/utils';
import promptOptions from '@/providers/prompt-options';
import { onMainLoad } from './backend'; import {
import { onRendererLoad } from './src'; getDefaultProfile,
type Permission,
type Profile,
type VideoData,
} from './types';
import { Queue } from './queue';
import { Connection, type ConnectionEventUnion } from './connection';
import { createHostPopup } from './ui/host';
import { createGuestPopup } from './ui/guest';
import { createSettingPopup } from './ui/setting';
export default createPlugin({ import settingHTML from './templates/setting.html?raw';
import style from './style.css?inline';
import type { YoutubePlayer } from '@/types/youtube-player';
import type { RendererContext } from '@/types/contexts';
import type { VideoDataChanged } from '@/types/video-data-changed';
import type { AppElement } from '@/types/queue';
type RawAccountData = {
accountName: {
runs: { text: string }[];
};
accountPhoto: {
thumbnails: { url: string; width: number; height: number }[];
};
settingsEndpoint: unknown;
manageAccountTitle: unknown;
trackingParams: string;
channelHandle: {
runs: { text: string }[];
};
};
export default createPlugin<
unknown,
unknown,
{
connection?: Connection;
ipc?: RendererContext<never>['ipc'];
api: AppElement | null;
queue?: Queue;
playerApi?: YoutubePlayer;
showPrompt: (title: string, label: string) => Promise<string>;
popups: {
host: ReturnType<typeof createHostPopup>;
guest: ReturnType<typeof createGuestPopup>;
setting: ReturnType<typeof createSettingPopup>;
};
elements: {
setting: HTMLElement;
icon: SVGElement;
spinner: HTMLElement;
};
stateInterval?: number;
updateNext: boolean;
ignoreChange: boolean;
rollbackInjector?: () => void;
me?: Omit<Profile, 'id'>;
profiles: Record<string, Profile>;
permission: Permission;
videoChangeListener: (event: CustomEvent<VideoDataChanged>) => void;
videoStateChangeListener: () => void;
onHost: () => Promise<boolean>;
onJoin: () => Promise<boolean>;
onStop: () => void;
putProfile: (id: string, profile?: Profile) => void;
showSpinner: () => void;
hideSpinner: () => void;
initMyProfile: () => void;
}
>({
name: () => t('plugins.music-together.name'), name: () => t('plugins.music-together.name'),
description: () => t('plugins.music-together.description'), description: () => t('plugins.music-together.description'),
restartNeeded: false, restartNeeded: false,
@ -12,9 +86,757 @@ export default createPlugin({
config: { config: {
enabled: false, enabled: false,
}, },
stylesheets: [], stylesheets: [style],
backend: onMainLoad, backend({ ipc }) {
ipc.handle('music-together:prompt', async (title: string, label: string) =>
prompt({
title,
label,
type: 'input',
...promptOptions(),
}),
);
},
renderer: { renderer: {
start: onRendererLoad, updateNext: false,
ignoreChange: false,
permission: 'playlist',
popups: {} as {
host: ReturnType<typeof createHostPopup>;
guest: ReturnType<typeof createGuestPopup>;
setting: ReturnType<typeof createSettingPopup>;
},
elements: {} as {
setting: HTMLElement;
icon: SVGElement;
spinner: HTMLElement;
},
profiles: {},
showPrompt: () => Promise.resolve(''),
api: null,
/* events */
videoChangeListener(event: CustomEvent<VideoDataChanged>) {
if (event.detail.name === 'dataloaded' || this.updateNext) {
if (this.connection?.mode === 'host') {
const videoList: VideoData[] =
this.queue?.flatItems.map(
(it) =>
({
videoId: it!.videoId,
ownerId: this.connection!.id,
}) satisfies VideoData,
) ?? [];
this.queue?.setVideoList(videoList, false);
this.queue?.syncQueueOwner();
this.connection.broadcast('SYNC_QUEUE', {
videoList,
});
this.updateNext = event.detail.name === 'dataloaded';
}
}
},
videoStateChangeListener() {
if (this.connection?.mode !== 'guest') return;
if (this.ignoreChange) return;
if (this.permission !== 'all') return;
const state = this.playerApi?.getPlayerState();
if (state !== 1 && state !== 2) return;
this.connection.broadcast('SYNC_PROGRESS', {
// progress: this.playerApi?.getCurrentTime(),
state: this.playerApi?.getPlayerState(),
// index: this.queue?.selectedIndex ?? 0,
});
},
/* connection */
async onHost() {
this.connection = new Connection();
const wait = await this.connection.waitForReady().catch(() => null);
if (!wait) return false;
if (!this.me) this.me = getDefaultProfile(this.connection.id);
const rawItems =
this.queue?.flatItems?.map(
(it) =>
({
videoId: it!.videoId,
ownerId: this.connection!.id,
}) satisfies VideoData,
) ?? [];
this.queue?.setOwner({
id: this.connection.id,
...this.me,
});
this.queue?.setVideoList(rawItems, false);
this.queue?.syncQueueOwner();
this.queue?.initQueue();
this.queue?.injection();
this.profiles = {};
this.connection.onConnections((connection) => {
if (!connection) {
this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
this.onStop();
return;
}
if (!connection.open) {
this.api?.toastService?.show(
t('plugins.music-together.toast.user-disconnected', {
name: this.profiles[connection.peer]?.name,
}),
);
this.putProfile(connection.peer, undefined);
}
});
this.putProfile(this.connection.id, {
id: this.connection.id,
...this.me,
});
const listener = async (
event: ConnectionEventUnion,
conn?: DataConnection,
) => {
this.ignoreChange = true;
switch (event.type) {
case 'ADD_SONGS': {
if (conn && this.permission === 'host-only') return;
await this.queue?.addVideos(
event.payload.videoList,
event.payload.index,
);
await this.connection?.broadcast('ADD_SONGS', event.payload);
break;
}
case 'REMOVE_SONG': {
if (conn && this.permission === 'host-only') return;
this.queue?.removeVideo(event.payload.index);
await this.connection?.broadcast('REMOVE_SONG', event.payload);
break;
}
case 'MOVE_SONG': {
if (conn && this.permission === 'host-only') {
await this.connection?.broadcast('SYNC_QUEUE', {
videoList: this.queue?.videoList ?? [],
});
break;
}
this.queue?.moveItem(
event.payload.fromIndex,
event.payload.toIndex,
);
await this.connection?.broadcast('MOVE_SONG', event.payload);
break;
}
case 'IDENTIFY': {
if (!event.payload || !conn) {
console.warn(
'Music Together [Host]: Received "IDENTIFY" event without payload or connection',
);
break;
}
this.api?.toastService?.show(
t('plugins.music-together.toast.user-connected', {
name: event.payload.profile.name,
}),
);
this.putProfile(conn.peer, event.payload.profile);
break;
}
case 'SYNC_PROFILE': {
await this.connection?.broadcast('SYNC_PROFILE', {
profiles: this.profiles,
});
break;
}
case 'PERMISSION': {
await this.connection?.broadcast('PERMISSION', this.permission);
this.popups.guest.setPermission(this.permission);
this.popups.host.setPermission(this.permission);
this.popups.setting.setPermission(this.permission);
break;
}
case 'SYNC_QUEUE': {
await this.connection?.broadcast('SYNC_QUEUE', {
videoList: this.queue?.videoList ?? [],
});
break;
}
case 'SYNC_PROGRESS': {
let permissionLevel = 0;
if (this.permission === 'all') permissionLevel = 2;
if (this.permission === 'playlist') permissionLevel = 1;
if (this.permission === 'host-only') permissionLevel = 0;
if (!conn) permissionLevel = 3;
if (permissionLevel >= 2) {
if (typeof event.payload?.progress === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(event.payload.progress - currentTime) > 3)
this.playerApi?.seekTo(event.payload.progress);
}
if (this.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) this.playerApi?.pauseVideo();
if (event.payload?.state === 1) this.playerApi?.playVideo();
}
}
if (permissionLevel >= 1) {
if (typeof event.payload?.index === 'number') {
const nowIndex = this.queue?.selectedIndex ?? 0;
if (nowIndex !== event.payload.index) {
this.queue?.setIndex(event.payload.index);
}
}
}
break;
}
default: {
console.warn('Music Together [Host]: Unknown Event', event);
break;
}
}
if (event.after) {
const now = event.after.shift();
if (now) {
now.after = event.after;
await listener(now, conn);
}
}
};
this.connection.on(listener);
this.queue?.on(listener);
setTimeout(() => {
this.ignoreChange = false;
}, 16); // wait 1 frame
return true;
},
async onJoin() {
this.connection = new Connection();
const wait = await this.connection.waitForReady().catch(() => null);
if (!wait) return false;
this.profiles = {};
const id = await this.showPrompt(
t('plugins.music-together.name'),
t('plugins.music-together.dialog.enter-host'),
);
if (typeof id !== 'string') return false;
const connection = await this.connection.connect(id).catch(() => false);
if (!connection) return false;
this.connection.onConnections((connection) => {
if (!connection?.open) {
this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
this.onStop();
}
});
let resolveIgnore: number | null = null;
const listener = async (event: ConnectionEventUnion) => {
this.ignoreChange = true;
switch (event.type) {
case 'ADD_SONGS': {
await this.queue?.addVideos(
event.payload.videoList,
event.payload.index,
);
break;
}
case 'REMOVE_SONG': {
this.queue?.removeVideo(event.payload.index);
break;
}
case 'MOVE_SONG': {
this.queue?.moveItem(
event.payload.fromIndex,
event.payload.toIndex,
);
break;
}
case 'IDENTIFY': {
console.warn(
'Music Together [Guest]: Received "IDENTIFY" event from guest',
);
break;
}
case 'SYNC_QUEUE': {
if (Array.isArray(event.payload?.videoList)) {
await this.queue?.setVideoList(event.payload.videoList);
}
break;
}
case 'SYNC_PROFILE': {
if (!event.payload) {
console.warn(
'Music Together [Guest]: Received "SYNC_PROFILE" event without payload',
);
break;
}
Object.entries(event.payload.profiles).forEach(([id, profile]) => {
this.putProfile(id, profile);
});
break;
}
case 'SYNC_PROGRESS': {
if (typeof event.payload?.progress === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(event.payload.progress - currentTime) > 3)
this.playerApi?.seekTo(event.payload.progress);
}
if (this.playerApi?.getPlayerState() !== event.payload?.state) {
if (event.payload?.state === 2) this.playerApi?.pauseVideo();
if (event.payload?.state === 1) this.playerApi?.playVideo();
}
if (typeof event.payload?.index === 'number') {
const nowIndex = this.queue?.selectedIndex ?? 0;
if (nowIndex !== event.payload.index) {
this.queue?.setIndex(event.payload.index);
}
}
break;
}
case 'PERMISSION': {
if (!event.payload) {
console.warn(
'Music Together [Guest]: Received "PERMISSION" event without payload',
);
break;
}
this.permission = event.payload;
this.popups.guest.setPermission(this.permission);
this.popups.host.setPermission(this.permission);
this.popups.setting.setPermission(this.permission);
const permissionLabel = t(
`plugins.music-together.menu.permission.${this.permission}`,
);
this.api?.toastService?.show(
t('plugins.music-together.toast.permission-changed', {
permission: permissionLabel,
}),
);
break;
}
default: {
console.warn('Music Together [Guest]: Unknown Event', event);
break;
}
}
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
resolveIgnore = window.setTimeout(() => {
this.ignoreChange = false;
}, 16); // wait 1 frame
};
this.connection.on(listener);
this.queue?.on(async (event: ConnectionEventUnion) => {
this.ignoreChange = true;
switch (event.type) {
case 'ADD_SONGS': {
await this.connection?.broadcast('ADD_SONGS', event.payload);
await this.connection?.broadcast('SYNC_QUEUE', undefined);
break;
}
case 'REMOVE_SONG': {
await this.connection?.broadcast('REMOVE_SONG', event.payload);
break;
}
case 'MOVE_SONG': {
await this.connection?.broadcast('MOVE_SONG', event.payload);
await this.connection?.broadcast('SYNC_QUEUE', undefined);
break;
}
case 'SYNC_PROGRESS': {
if (this.permission === 'host-only')
await this.connection?.broadcast('SYNC_QUEUE', undefined);
else
await this.connection?.broadcast('SYNC_PROGRESS', event.payload);
break;
}
}
if (typeof resolveIgnore === 'number') clearTimeout(resolveIgnore);
resolveIgnore = window.setTimeout(() => {
this.ignoreChange = false;
}, 16); // wait 1 frame
});
if (!this.me) this.me = getDefaultProfile(this.connection.id);
this.queue?.injection();
this.queue?.setOwner({
id: this.connection.id,
...this.me,
});
const progress = Array.from(
document.querySelectorAll<
HTMLElement & {
_update: (...args: unknown[]) => void;
}
>('tp-yt-paper-progress'),
);
const rollbackList = progress.map((progress) => {
const original = progress._update;
progress._update = (...args) => {
const now = args[0];
if (this.permission === 'all' && typeof now === 'number') {
const currentTime = this.playerApi?.getCurrentTime() ?? 0;
if (Math.abs(now - currentTime) > 3)
this.connection?.broadcast('SYNC_PROGRESS', {
progress: now,
state: this.playerApi?.getPlayerState(),
});
}
original.call(progress, ...args);
};
return () => {
progress._update = original;
};
});
this.rollbackInjector = () => {
rollbackList.forEach((rollback) => rollback());
};
this.connection.broadcast('IDENTIFY', {
profile: {
id: this.connection.id,
handleId: this.me.handleId,
name: this.me.name,
thumbnail: this.me.thumbnail,
},
});
this.connection.broadcast('SYNC_PROFILE', undefined);
this.connection.broadcast('PERMISSION', undefined);
this.queue?.clear();
this.queue?.syncQueueOwner();
this.queue?.initQueue();
this.connection.broadcast('SYNC_QUEUE', undefined);
return true;
},
onStop() {
this.connection?.disconnect();
this.queue?.rollbackInjection();
this.queue?.removeQueueOwner();
if (this.rollbackInjector) {
this.rollbackInjector();
this.rollbackInjector = undefined;
}
this.profiles = {};
this.popups.host.setUsers(Object.values(this.profiles));
this.popups.guest.setUsers(Object.values(this.profiles));
this.popups.host.dismiss();
this.popups.guest.dismiss();
this.popups.setting.dismiss();
},
/* methods */
putProfile(id: string, profile?: Profile) {
if (profile === undefined) {
delete this.profiles[id];
} else {
this.profiles[id] = profile;
}
this.popups.host.setUsers(Object.values(this.profiles));
this.popups.guest.setUsers(Object.values(this.profiles));
},
showSpinner() {
this.elements.icon.style.setProperty('display', 'none');
this.elements.spinner.removeAttribute('hidden');
this.elements.spinner.setAttribute('active', '');
},
hideSpinner() {
this.elements.icon.style.removeProperty('display');
this.elements.spinner.removeAttribute('active');
this.elements.spinner.setAttribute('hidden', '');
},
initMyProfile() {
const accountButton = document.querySelector<
HTMLElement & {
onButtonTap: () => void;
}
>('ytmusic-settings-button');
accountButton?.onButtonTap();
setTimeout(() => {
accountButton?.onButtonTap();
const renderer = document.querySelector<
HTMLElement & { data: unknown }
>('ytd-active-account-header-renderer');
if (!accountButton || !renderer) {
console.warn('Music Together: Cannot find account');
this.me = getDefaultProfile(this.connection?.id ?? '');
return;
}
const accountData = renderer.data as RawAccountData;
this.me = {
handleId:
accountData.channelHandle.runs[0].text ??
accountData.accountName.runs[0].text,
name: accountData.accountName.runs[0].text,
thumbnail: accountData.accountPhoto.thumbnails[0].url,
};
if (this.me.thumbnail) {
this.popups.host.setProfile(this.me.thumbnail);
this.popups.guest.setProfile(this.me.thumbnail);
this.popups.setting.setProfile(this.me.thumbnail);
}
}, 0);
},
/* hooks */
start({ ipc }) {
this.ipc = ipc;
this.showPrompt = async (title: string, label: string) =>
ipc.invoke('music-together:prompt', title, label) as Promise<string>;
this.api = document.querySelector<AppElement>('ytmusic-app');
/* setup */
document
.querySelector('#right-content > ytmusic-settings-button')
?.insertAdjacentHTML('beforebegin', settingHTML);
const setting = document.querySelector<HTMLElement>(
'#music-together-setting-button',
);
const icon = document.querySelector<SVGElement>(
'#music-together-setting-button > svg',
);
const spinner = document.querySelector<HTMLElement>(
'#music-together-setting-button > tp-yt-paper-spinner-lite',
);
if (!setting || !icon || !spinner) {
console.warn('Music Together: Cannot inject html');
console.log(setting, icon, spinner);
return;
}
this.elements = {
setting,
icon,
spinner,
};
this.stateInterval = window.setInterval(() => {
if (this.connection?.mode !== 'host') return;
const index = this.queue?.selectedIndex ?? 0;
this.connection.broadcast('SYNC_PROGRESS', {
progress: this.playerApi?.getCurrentTime(),
state: this.playerApi?.getPlayerState(),
index,
});
}, 1000);
/* UI */
const hostPopup = createHostPopup({
onItemClick: (id) => {
if (id === 'music-together-close') {
this.onStop();
this.api?.toastService?.show(
t('plugins.music-together.toast.closed'),
);
hostPopup.dismiss();
}
if (id === 'music-together-copy-id') {
navigator.clipboard
.writeText(this.connection?.id ?? '')
.then(() => {
this.api?.toastService?.show(
t('plugins.music-together.toast.id-copied'),
);
hostPopup.dismiss();
})
.catch(() => {
this.api?.toastService?.show(
t('plugins.music-together.toast.id-copy-failed'),
);
hostPopup.dismiss();
});
}
if (id === 'music-together-permission') {
if (this.permission === 'all') this.permission = 'host-only';
else if (this.permission === 'playlist') this.permission = 'all';
else if (this.permission === 'host-only')
this.permission = 'playlist';
this.connection?.broadcast('PERMISSION', this.permission);
hostPopup.setPermission(this.permission);
guestPopup.setPermission(this.permission);
settingPopup.setPermission(this.permission);
const permissionLabel = t(
`plugins.music-together.menu.permission.${this.permission}`,
);
this.api?.toastService?.show(
t('plugins.music-together.toast.permission-changed', {
permission: permissionLabel,
}),
);
const item = hostPopup.items.find((it) => it?.element.id === id);
if (item?.type === 'item') {
item.setText(t('plugins.music-together.menu.set-permission'));
}
}
},
});
const guestPopup = createGuestPopup({
onItemClick: (id) => {
if (id === 'music-together-disconnect') {
this.onStop();
this.api?.toastService?.show(
t('plugins.music-together.toast.disconnected'),
);
guestPopup.dismiss();
}
},
});
const settingPopup = createSettingPopup({
onItemClick: async (id) => {
if (id === 'music-together-host') {
settingPopup.dismiss();
this.showSpinner();
const result = await this.onHost();
this.hideSpinner();
if (result) {
navigator.clipboard
.writeText(this.connection?.id ?? '')
.then(() => {
this.api?.toastService?.show(
t('plugins.music-together.toast.id-copied'),
);
hostPopup.showAtAnchor(setting);
})
.catch(() => {
this.api?.toastService?.show(
t('plugins.music-together.toast.id-copy-failed'),
);
hostPopup.showAtAnchor(setting);
});
} else {
this.api?.toastService?.show(
t('plugins.music-together.toast.host-failed'),
);
}
}
if (id === 'music-together-join') {
settingPopup.dismiss();
this.showSpinner();
const result = await this.onJoin();
this.hideSpinner();
if (result) {
this.api?.toastService?.show(
t('plugins.music-together.toast.joined'),
);
guestPopup.showAtAnchor(setting);
} else {
this.api?.toastService?.show(
t('plugins.music-together.toast.join-failed'),
);
}
}
},
});
this.popups = {
host: hostPopup,
guest: guestPopup,
setting: settingPopup,
};
setting.addEventListener('click', () => {
let popup = settingPopup;
if (this.connection?.mode === 'host') popup = hostPopup;
if (this.connection?.mode === 'guest') popup = guestPopup;
if (popup.isShowing()) popup.dismiss();
else popup.showAtAnchor(setting);
});
/* account data getter */
this.initMyProfile();
},
onPlayerApiReady(playerApi) {
this.queue = new Queue({
owner: {
id: this.connection?.id ?? '',
...this.me!,
},
getProfile: (id) => this.profiles[id],
});
this.playerApi = playerApi;
this.playerApi.addEventListener(
'onStateChange',
this.videoStateChangeListener,
);
document.addEventListener('videodatachange', this.videoChangeListener);
},
stop() {
const dividers = Array.from(
document.querySelectorAll('.music-together-divider'),
);
dividers.forEach((divider) => divider.remove());
this.elements.setting?.remove();
this.onStop();
if (typeof this.stateInterval === 'number')
clearInterval(this.stateInterval);
if (this.playerApi)
this.playerApi.removeEventListener(
'onStateChange',
this.videoStateChangeListener,
);
if (this.videoChangeListener)
document.removeEventListener(
'videodatachange',
this.videoChangeListener,
);
},
}, },
}); });

View File

@ -0,0 +1,48 @@
import { SHA1Hash } from './sha1hash';
export const extractToken = (cookie = document.cookie) =>
cookie.match(/SAPISID=([^;]+);/)?.[1] ??
cookie.match(/__Secure-3PAPISID=([^;]+);/)?.[1];
export const getHash = async (
papisid: string,
millis = Date.now(),
origin: string = 'https://music.youtube.com',
) => (await SHA1Hash(`${millis} ${papisid} ${origin}`)).toLowerCase();
export const getAuthorizationHeader = async (
papisid: string,
millis = Date.now(),
origin: string = 'https://music.youtube.com',
) => {
return `SAPISIDHASH ${millis}_${await getHash(papisid, millis, origin)}`;
};
export const getClient = () => {
return {
hl: navigator.language.split('-')[0] ?? 'en',
gl: navigator.language.split('-')[1] ?? 'US',
deviceMake: '',
deviceModel: '',
userAgent: navigator.userAgent,
clientName: 'WEB_REMIX',
clientVersion: '1.20231208.05.02',
osName: '',
osVersion: '',
platform: 'DESKTOP',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
locationInfo: {
locationPermissionAuthorizationStatus:
'LOCATION_PERMISSION_AUTHORIZATION_STATUS_UNSUPPORTED',
},
musicAppInfo: {
pwaInstallabilityStatus: 'PWA_INSTALLABILITY_STATUS_UNKNOWN',
webDisplayMode: 'WEB_DISPLAY_MODE_BROWSER',
storeDigitalGoodsApiSupportStatus: {
playStoreDigitalGoodsApiSupportStatus:
'DIGITAL_GOODS_API_SUPPORT_STATUS_UNSUPPORTED',
},
},
utcOffsetMinutes: -1 * new Date().getTimezoneOffset(),
};
};

View File

@ -0,0 +1 @@
export * from './queue';

View File

@ -0,0 +1,544 @@
import { getMusicQueueRenderer } from './song';
import { mapQueueItem } from './utils';
import { t } from '@/i18n';
import type { ConnectionEventUnion } from '@/plugins/music-together/connection';
import type { Profile, VideoData } from '../types';
import type { QueueItem } from '@/types/datahost-get-state';
import type { QueueElement, Store } from '@/types/queue';
const getHeaderPayload = (() => {
let payload: {
items?: QueueItem[] | undefined;
title: {
runs: {
text: string;
}[];
};
subtitle: {
runs: {
text: string;
}[];
};
buttons: {
chipCloudChipRenderer: {
style: {
styleType: string;
};
text: {
runs: {
text: string;
}[];
};
navigationEndpoint: {
saveQueueToPlaylistCommand: unknown;
};
icon: {
iconType: string;
};
accessibilityData: {
accessibilityData: {
label: string;
};
};
isSelected: boolean;
uniqueId: string;
};
}[];
} | null = null;
return () => {
if (!payload) {
payload = {
title: {
runs: [
{
text: t('plugins.music-together.internal.track-source'),
},
],
},
subtitle: {
runs: [
{
text: t('plugins.music-together.name'),
},
],
},
buttons: [
{
chipCloudChipRenderer: {
style: {
styleType: 'STYLE_TRANSPARENT',
},
text: {
runs: [
{
text: t('plugins.music-together.internal.save'),
},
],
},
navigationEndpoint: {
saveQueueToPlaylistCommand: {},
},
icon: {
iconType: 'ADD_TO_PLAYLIST',
},
accessibilityData: {
accessibilityData: {
label: t('plugins.music-together.internal.save'),
},
},
isSelected: false,
uniqueId: t('plugins.music-together.internal.save'),
},
},
],
};
}
return payload;
};
})();
export type QueueOptions = {
videoList?: VideoData[];
owner?: Profile;
queue?: QueueElement;
getProfile: (id: string) => Profile | undefined;
};
export type QueueEventListener = (event: ConnectionEventUnion) => void;
export class Queue {
private readonly queue: QueueElement;
private originalDispatch?: (obj: {
type: string;
payload?: { items?: QueueItem[] | undefined };
}) => void;
private internalDispatch = false;
private ignoreFlag = false;
private listeners: QueueEventListener[] = [];
private owner: Profile | null;
private readonly getProfile: (id: string) => Profile | undefined;
constructor(options: QueueOptions) {
this.getProfile = options.getProfile;
this.queue =
options.queue ?? document.querySelector<QueueElement>('#queue')!;
this.owner = options.owner ?? null;
this._videoList = options.videoList ?? [];
}
private _videoList: VideoData[] = [];
/* utils */
get videoList() {
return this._videoList;
}
get selectedIndex() {
return (
mapQueueItem(
(it) => it?.selected,
this.queue.queue.store.store.getState().queue.items,
).findIndex(Boolean) ?? 0
);
}
get rawItems() {
return this.queue?.queue.store.store.getState().queue.items;
}
get flatItems() {
return mapQueueItem((it) => it, this.rawItems);
}
setOwner(owner: Profile) {
this.owner = owner;
}
/* public */
async setVideoList(videoList: VideoData[], sync = true) {
this._videoList = videoList;
if (sync) await this.syncVideo();
}
async addVideos(videos: VideoData[], index?: number) {
const response = await getMusicQueueRenderer(
videos.map((it) => it.videoId),
);
if (!response) return false;
const items = response.queueDatas.map((it) => it?.content).filter(Boolean);
if (!items) return false;
this.internalDispatch = true;
this._videoList.push(...videos);
this.queue?.dispatch({
type: 'ADD_ITEMS',
payload: {
nextQueueItemId:
this.queue.queue.store.store.getState().queue.nextQueueItemId,
index:
index ??
this.queue.queue.store.store.getState().queue.items.length ??
0,
items,
shuffleEnabled: false,
shouldAssignIds: true,
},
});
this.internalDispatch = false;
setTimeout(() => {
this.initQueue();
this.syncQueueOwner();
}, 0);
return true;
}
removeVideo(index: number) {
this.internalDispatch = true;
this._videoList.splice(index, 1);
this.queue?.dispatch({
type: 'REMOVE_ITEM',
payload: index,
});
this.internalDispatch = false;
setTimeout(() => {
this.initQueue();
this.syncQueueOwner();
}, 0);
}
setIndex(index: number) {
this.internalDispatch = true;
this.queue?.dispatch({
type: 'SET_INDEX',
payload: index,
});
this.internalDispatch = false;
}
moveItem(fromIndex: number, toIndex: number) {
this.internalDispatch = true;
const data = this._videoList.splice(fromIndex, 1)[0];
this._videoList.splice(toIndex, 0, data);
this.queue?.dispatch({
type: 'MOVE_ITEM',
payload: {
fromIndex,
toIndex,
},
});
this.internalDispatch = false;
setTimeout(() => {
this.initQueue();
this.syncQueueOwner();
}, 0);
}
clear() {
this.internalDispatch = true;
this._videoList = [];
this.queue?.dispatch({
type: 'CLEAR',
});
this.internalDispatch = false;
}
on(listener: QueueEventListener) {
this.listeners.push(listener);
}
off(listener: QueueEventListener) {
this.listeners = this.listeners.filter((it) => it !== listener);
}
rollbackInjection() {
if (!this.queue) {
console.error('Queue is not initialized!');
return;
}
if (this.originalDispatch)
this.queue.queue.store.store.dispatch = this
.originalDispatch as Store['dispatch'];
}
injection() {
if (!this.queue) {
console.error('Queue is not initialized!');
return;
}
this.originalDispatch = this.queue.queue.store.store.dispatch;
this.queue.queue.store.store.dispatch = (event) => {
if (!this.queue || !this.owner) {
console.error('Queue is not initialized!');
return;
}
if (!this.internalDispatch) {
if (event.type === 'CLEAR') {
this.ignoreFlag = true;
}
if (event.type === 'ADD_ITEMS') {
if (this.ignoreFlag) {
this.ignoreFlag = false;
const videoList = mapQueueItem(
(it) =>
({
videoId: it!.videoId,
ownerId: this.owner!.id,
}) satisfies VideoData,
(
event.payload! as {
items: QueueItem[];
}
).items,
);
const index = this._videoList.length + videoList.length - 1;
if (videoList.length > 0) {
this.broadcast({
// play
type: 'ADD_SONGS',
payload: {
videoList,
},
after: [
{
type: 'SYNC_PROGRESS',
payload: {
index,
},
},
],
});
}
} else if (
(
event.payload as {
items: unknown[];
}
).items.length === 1
) {
this.broadcast({
// add playlist
type: 'ADD_SONGS',
payload: {
// index: (event.payload as any).index,
videoList: mapQueueItem(
(it) =>
({
videoId: it!.videoId,
ownerId: this.owner!.id,
}) satisfies VideoData,
(
event.payload! as {
items: QueueItem[];
}
).items,
),
},
});
}
return;
}
if (event.type === 'MOVE_ITEM') {
this.broadcast({
type: 'MOVE_SONG',
payload: {
fromIndex: (
event.payload as {
fromIndex: number;
}
).fromIndex,
toIndex: (
event.payload as {
toIndex: number;
}
).toIndex,
},
});
return;
}
if (event.type === 'REMOVE_ITEM') {
this.broadcast({
type: 'REMOVE_SONG',
payload: {
index: event.payload as number,
},
});
return;
}
if (event.type === 'SET_INDEX') {
this.broadcast({
type: 'SYNC_PROGRESS',
payload: {
index: event.payload as number,
},
});
return;
}
if (event.type === 'SET_HEADER') event.payload = getHeaderPayload();
if (event.type === 'ADD_STEERING_CHIPS') {
event.type = 'CLEAR_STEERING_CHIPS';
event.payload = undefined;
}
if (event.type === 'SET_PLAYER_UI_STATE') {
if (
(event.payload as string) === 'INACTIVE' &&
this.videoList.length > 0
) {
return;
}
}
if (event.type === 'HAS_SHOWN_AUTOPLAY') return;
if (event.type === 'ADD_AUTOMIX_ITEMS') return;
}
const fakeContext = {
...this.queue,
queue: {
...this.queue.queue,
store: {
...this.queue.queue.store,
dispatch: this.originalDispatch,
},
},
};
this.originalDispatch?.call(
fakeContext,
event as {
type: string;
payload?: { items?: QueueItem[] | undefined } | undefined;
},
);
};
}
/* sync */
initQueue() {
if (!this.queue) return;
this.internalDispatch = true;
this.queue.dispatch({
type: 'HAS_SHOWN_AUTOPLAY',
payload: false,
});
this.queue.dispatch({
type: 'SET_HEADER',
payload: getHeaderPayload(),
});
this.queue.dispatch({
type: 'CLEAR_STEERING_CHIPS',
});
this.internalDispatch = false;
}
async syncVideo() {
const response = await getMusicQueueRenderer(
this._videoList.map((it) => it.videoId),
);
if (!response) return false;
const items = response.queueDatas.map((it) => it.content);
this.internalDispatch = true;
this.queue?.dispatch({
type: 'UPDATE_ITEMS',
payload: {
items: items,
nextQueueItemId:
this.queue.queue.store.store.getState().queue.nextQueueItemId,
shouldAssignIds: true,
currentIndex: -1,
},
});
this.internalDispatch = false;
setTimeout(() => {
this.initQueue();
this.syncQueueOwner();
}, 0);
return true;
}
syncQueueOwner() {
const allQueue = document.querySelectorAll('#queue');
allQueue.forEach((queue) => {
const list = Array.from(
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
);
list.forEach((item, index: number | undefined) => {
if (typeof index !== 'number') return;
const id = this._videoList[index]?.ownerId;
const data = this.getProfile(id);
const profile =
item.querySelector<HTMLImageElement>('.music-together-owner') ??
document.createElement('img');
profile.classList.add('music-together-owner');
profile.dataset.id = id;
profile.dataset.index = index.toString();
const name =
item.querySelector<HTMLElement>('.music-together-name') ??
document.createElement('div');
name.classList.add('music-together-name');
name.textContent =
data?.name ?? t('plugins.music-together.internal.unknown-user');
if (data) {
profile.dataset.thumbnail = data.thumbnail ?? '';
profile.dataset.name = data.name ?? '';
profile.dataset.handleId = data.handleId ?? '';
profile.dataset.id = data.id ?? '';
profile.src = data.thumbnail ?? '';
profile.title = data.name ?? '';
profile.alt = data.handleId ?? '';
}
if (!profile.isConnected) item.append(profile);
if (!name.isConnected) item.append(name);
});
});
}
removeQueueOwner() {
const allQueue = document.querySelectorAll('#queue');
allQueue.forEach((queue) => {
const list = Array.from(
queue?.querySelectorAll<HTMLElement>('ytmusic-player-queue-item') ?? [],
);
list.forEach((item) => {
const profile = item.querySelector<HTMLImageElement>(
'.music-together-owner',
);
const name = item.querySelector<HTMLElement>('.music-together-name');
profile?.remove();
name?.remove();
});
});
}
/* private */
private broadcast(event: ConnectionEventUnion) {
this.listeners.forEach((listener) => listener(event));
}
}

View File

@ -0,0 +1,7 @@
export const SHA1Hash = async (str: string) => {
const enc = new TextEncoder();
const hash = await crypto.subtle.digest('SHA-1', enc.encode(str));
return Array.from(new Uint8Array(hash))
.map((v) => v.toString(16).padStart(2, '0'))
.join('');
};

View File

@ -0,0 +1,50 @@
import { extractToken, getAuthorizationHeader, getClient } from './client';
type QueueRendererResponse = {
queueDatas: {
content: unknown;
}[];
responseContext: unknown;
trackingParams: string;
};
export const getMusicQueueRenderer = async (
videoIds: string[],
): Promise<QueueRendererResponse | null> => {
const token = extractToken();
if (!token) return null;
const response = await fetch(
'https://music.youtube.com/youtubei/v1/music/get_queue?key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30&prettyPrint=false',
{
method: 'POST',
credentials: 'include',
body: JSON.stringify({
context: {
client: getClient(),
request: {
useSsl: true,
internalExperimentFlags: [],
consistencyTokenJars: [],
},
user: {
lockedSafetyMode: false,
},
},
videoIds,
}),
headers: {
'Content-Type': 'application/json',
'Origin': 'https://music.youtube.com',
'Authorization': await getAuthorizationHeader(token),
},
},
);
const text = await response.text();
try {
return JSON.parse(text) as QueueRendererResponse;
} catch {}
return null;
};

View File

@ -0,0 +1,26 @@
import {
ItemPlaylistPanelVideoRenderer,
PlaylistPanelVideoWrapperRenderer,
QueueItem,
} from '@/types/datahost-get-state';
export const mapQueueItem = <T>(
map: (item?: ItemPlaylistPanelVideoRenderer) => T,
array: QueueItem[],
): T[] =>
array
.map((item) => {
if ('playlistPanelVideoWrapperRenderer' in item) {
const keys = Object.keys(
item.playlistPanelVideoWrapperRenderer!.primaryRenderer,
) as (keyof PlaylistPanelVideoWrapperRenderer['primaryRenderer'])[];
return item.playlistPanelVideoWrapperRenderer!.primaryRenderer[keys[0]];
}
if ('playlistPanelVideoRenderer' in item) {
return item.playlistPanelVideoRenderer;
}
console.error('Music Together: Unknown item', item);
return undefined;
})
.map(map);

View File

@ -1,93 +0,0 @@
import { createSignal, Show } from 'solid-js';
import { css } from 'solid-styled-components';
import { useFloating } from 'solid-floating-ui';
import { autoUpdate, flip, offset } from '@floating-ui/dom';
import { Portal } from 'solid-js/web';
import { cacheNoArgs } from '@/providers/decorators';
import { MusicTogetherPanel } from '@/plugins/music-together/src/Panel';
const buttonStyle = cacheNoArgs(
() => css`
display: inline-flex;
cursor: pointer;
margin-left: 8px;
margin-right: 16px;
& svg {
width: 24px;
height: 24px;
fill: rgba(255, 255, 255, 0.5);
}
&:hover svg:hover {
fill: #fff;
}
`,
);
const popupStyle = cacheNoArgs(
() => css`
position: fixed;
top: var(--offset-y, 0);
left: var(--offset-x, 0);
z-index: 1000;
`,
);
export const MusicTogetherButton = () => {
const [enabled, setEnabled] = createSignal(false);
const [anchor, setAnchor] = createSignal<HTMLElement | null>(null);
const [panel, setPanel] = createSignal<HTMLElement | null>(null);
const position = useFloating(anchor, panel, {
whileElementsMounted: autoUpdate,
strategy: 'fixed',
placement: 'bottom-end',
middleware: [
offset({
mainAxis: 4,
crossAxis: 0,
}),
flip({ fallbackStrategy: 'bestFit' }),
],
});
return (
<div
id="music-together-setting-button"
class={`${buttonStyle()} style-scope ytmusic-nav-bar`}
>
<svg
ref={setAnchor}
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 -960 960 960"
width="24"
onClick={() => setEnabled(!enabled())}
>
<path d="M0-240v-63q0-43 44-70t116-27q13 0 25 .5t23 2.5q-14 21-21 44t-7 48v65H0Zm240 0v-65q0-32 17.5-58.5T307-410q32-20 76.5-30t96.5-10q53 0 97.5 10t76.5 30q32 20 49 46.5t17 58.5v65H240Zm540 0v-65q0-26-6.5-49T754-397q11-2 22.5-2.5t23.5-.5q72 0 116 26.5t44 70.5v63H780Zm-455-80h311q-10-20-55.5-35T480-370q-55 0-100.5 15T325-320ZM160-440q-33 0-56.5-23.5T80-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T160-440Zm640 0q-33 0-56.5-23.5T720-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T800-440Zm-320-40q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-600q0 50-34.5 85T480-480Zm0-80q17 0 28.5-11.5T520-600q0-17-11.5-28.5T480-640q-17 0-28.5 11.5T440-600q0 17 11.5 28.5T480-560Zm1 240Zm-1-280Z" />
</svg>
<Show when={enabled()}>
<Portal>
<div
ref={setPanel}
class={popupStyle()}
style={{
'--offset-x': `${position.x}px`,
'--offset-y': `${position.y}px`,
}}
>
<MusicTogetherPanel />
</div>
</Portal>
</Show>
</div>
);
};

View File

@ -1,154 +0,0 @@
import { css } from 'solid-styled-components';
import { createEffect, Match, Switch } from 'solid-js';
import { PanelItem } from './PanelItem';
import { MusicTogetherStatus } from './Status';
import {
IconConnect,
IconKey,
IconMusicCast,
IconOff,
IconTune,
} from './icons';
import { cacheNoArgs } from '@/providers/decorators';
import { t } from '@/i18n';
import { AppElement } from '@/types/queue';
import { Host } from '../api/host';
import { Guest } from '../api/guest';
import { Connection } from '../connection';
import { useToast } from '../context/ToastContext';
import { setStatus, status } from '../store/status';
import { connection, setConnection } from '../store/connection';
import { useRendererContext } from '../context/RendererContext';
const panelStyle = cacheNoArgs(
() => css`
border-radius: 10px !important;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
`,
);
const horizontalDividerStyle = cacheNoArgs(
() => css`
background-color: rgba(255, 255, 255, 0.15);
width: 100%;
height: 1px;
`,
);
export const MusicTogetherPanel = () => {
const show = useToast();
const { ipc } = useRendererContext();
const onHost = async () => {
setStatus('mode', 'connecting');
const result = new Connection();
await result.waitForReady();
setStatus('mode', 'host');
setConnection(result);
await onHostCopy();
};
const onHostCopy = async () => {
const id = connection()?.id;
if (!id) {
show(t('plugins.music-together.toast.id-copy-failed'));
return;
}
const success = await navigator.clipboard
.writeText(id)
.then(() => true)
.catch(() => false);
if (!success) {
show(t('plugins.music-together.toast.id-copy-failed'));
return;
}
show(t('plugins.music-together.toast.id-copied', { id }));
};
const onClose = () => {
setStatus('mode', 'disconnected');
connection()?.disconnect();
setConnection(null);
show(t('plugins.music-together.toast.closed'));
};
createEffect(() => {
const conn = connection();
const mode = status.mode;
const app = document.querySelector<AppElement>('ytmusic-app');
if (conn && app) {
if (mode === 'host') {
const listener = Host.buildListener(conn, {
ipc,
app,
});
conn.on(listener);
}
if (mode === 'guest') {
const listener = Guest.buildListener(conn, {
ipc,
app,
});
conn.on(listener);
}
}
});
return (
<tp-yt-paper-listbox
class={`style-scope ytmusic-menu-popup-renderer ${panelStyle()}`}
>
<MusicTogetherStatus />
<Switch>
<Match when={status.mode === 'disconnected'}>
<div class={horizontalDividerStyle()} />
<PanelItem
text={t('plugins.music-together.menu.host')}
icon={<IconMusicCast width={24} height={24} />}
onClick={onHost}
/>
<PanelItem
text={t('plugins.music-together.menu.join')}
icon={<IconConnect width={24} height={24} />}
/>
</Match>
<Match when={status.mode === 'host'}>
<div class={horizontalDividerStyle()} />
<PanelItem
text={t('plugins.music-together.menu.click-to-copy-id')}
icon={<IconKey width={24} height={24} />}
onClick={onHostCopy}
/>
<PanelItem
text={t('plugins.music-together.menu.set-permission', {
permission: t('plugins.music-together.menu.permission.host-only'),
})}
icon={<IconTune width={24} height={24} />}
/>
<div class={horizontalDividerStyle()} />
<PanelItem
text={t('plugins.music-together.menu.close')}
icon={<IconOff width={24} height={24} />}
onClick={onClose}
/>
</Match>
<Match when={status.mode === 'guest'}>
<div class={horizontalDividerStyle()} />
<PanelItem
text={t('plugins.music-together.menu.close')}
icon={<IconOff width={24} height={24} />}
onClick={onClose}
/>
</Match>
</Switch>
</tp-yt-paper-listbox>
);
};

View File

@ -1,43 +0,0 @@
import { JSX } from 'solid-js';
import { css } from 'solid-styled-components';
import { cacheNoArgs } from '@/providers/decorators';
const itemStyle = cacheNoArgs(
() => css`
display: flex;
height: 48px;
align-items: center;
padding: 0 8px;
--iron-icon-fill-color: #fff;
&:not([is-disabled]) {
cursor: pointer;
}
&:hover {
background-color: var(
--ytmusic-menu-item-hover-background-color,
rgba(255, 255, 255, 0.05)
);
}
`,
);
export type PanelItemProps = {
icon: JSX.Element;
text: string;
onClick?: () => void;
};
export const PanelItem = (props: PanelItemProps) => {
return (
<div class={`style-scope ${itemStyle()}`} onClick={props.onClick}>
<div class="icon style-scope ytmusic-menu-service-item-renderer">
{props.icon}
</div>
<div class="text style-scope ytmusic-menu-service-item-renderer">
{props.text}
</div>
</div>
);
};

View File

@ -1,211 +0,0 @@
import { For, Match, Show, Switch } from 'solid-js';
import { css } from 'solid-styled-components';
import { status } from '../store/status';
import { cacheNoArgs } from '@/providers/decorators';
import { user } from '@/plugins/music-together/store/user';
const panelStyle = cacheNoArgs(
() => css`
display: flex;
flex-direction: column;
align-items: stretch;
padding: 16px;
`,
);
const containerStyle = cacheNoArgs(
() => css`
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 16px;
`,
);
const profileStyle = cacheNoArgs(
() => css`
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
`,
);
const itemStyle = cacheNoArgs(
() => css`
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 400;
`,
);
const userContainerStyle = cacheNoArgs(
() => css`
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
overflow: auto;
gap: 8px;
padding-top: 16px;
font-size: 14px;
`,
);
const emptyStyle = cacheNoArgs(
() => css`
width: 100%;
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
text-align: center;
`,
);
const spinnerContainerStyle = cacheNoArgs(
() => css`
display: flex;
justify-content: center;
align-items: center;
`,
);
const horizontalDividerStyle = cacheNoArgs(
() => css`
background-color: rgba(255, 255, 255, 0.15);
width: 100%;
height: 1px;
`,
);
export const MusicTogetherStatus = () => {
return (
<div class={panelStyle()}>
<div class={containerStyle()}>
<img
class={profileStyle()}
style={{
width: '24px',
height: '24px',
}}
src={user.thumbnail}
alt="Profile Image"
/>
<div class={itemStyle()}>
<ytmd-trans key="plugins.music-together.name" />
<span id="music-together-status-label">
<Switch>
<Match when={status.mode === 'disconnected'}>
<ytmd-trans
key="plugins.music-together.menu.status.disconnected"
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
/>
</Match>
<Match when={status.mode === 'host'}>
<ytmd-trans
key="plugins.music-together.menu.status.host"
style={{ color: 'rgba(255, 0, 0, 1)' }}
/>
</Match>
<Match when={status.mode === 'guest'}>
<ytmd-trans
key="plugins.music-together.menu.status.guest"
style={{ color: 'rgba(255, 255, 255, 1)' }}
/>
</Match>
<Match when={status.mode === 'connecting'}>
<ytmd-trans
key="plugins.music-together.menu.status.connecting"
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
/>
</Match>
</Switch>
</span>
<Show
when={
status.mode !== 'connecting' && status.mode !== 'disconnected'
}
>
<marquee id="music-together-permission-label">
<Switch>
<Match when={status.permission === 'all'}>
<ytmd-trans
key="plugins.music-together.menu.permission.all"
style={{ color: 'rgba(255, 255, 255, 1)' }}
/>
</Match>
<Match when={status.permission === 'playlist'}>
<ytmd-trans
key="plugins.music-together.menu.permission.playlist"
style={{ color: 'rgba(255, 255, 255, 0.75)' }}
/>
</Match>
<Match when={status.permission === 'host-only'}>
<ytmd-trans
key="plugins.music-together.menu.permission.host-only"
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
/>
</Match>
</Switch>
</marquee>
</Show>
</div>
</div>
<Show
when={status.mode !== 'connecting' && status.mode !== 'disconnected'}
fallback={
<Show when={status.mode === 'connecting'}>
<div
class={horizontalDividerStyle()}
style={{
'margin-top': '16px',
'margin-bottom': '32px',
}}
/>
<div class={spinnerContainerStyle()}>
<tp-yt-paper-spinner-lite
active
id="music-together-host-spinner"
class="loading-indicator style-scope music-together-spinner"
/>
</div>
</Show>
}
>
<div class={horizontalDividerStyle()} style="margin: 16px 0;" />
<div class={itemStyle()}>
<ytmd-trans
key="plugins.music-together.menu.connected-users"
attr:count={status.users.length}
/>
</div>
<div class={userContainerStyle()}>
<For
each={status.users}
fallback={
<span class={emptyStyle()}>
<ytmd-trans key="plugins.music-together.menu.empty-user" />
</span>
}
>
{(user) => (
<img
class={profileStyle()}
src={user.thumbnail}
title={user.name}
alt={`${user.name} (${user.id})`}
/>
)}
</For>
</div>
</Show>
</div>
);
};

View File

@ -1,7 +0,0 @@
import { IconProps } from './types';
export const IconConnect = (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
<path d="M480-640 280-440l56 56 104-103v407h80v-407l104 103 56-56-200-200ZM146-260q-32-49-49-105T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 59-17 115t-49 105l-58-58q22-37 33-78t11-84q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 43 11 84t33 78l-58 58Z" />
</svg>
);

View File

@ -1,7 +0,0 @@
import { IconProps } from './types';
export const IconKey = (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
<path d="M280-400q-33 0-56.5-23.5T200-480q0-33 23.5-56.5T280-560q33 0 56.5 23.5T360-480q0 33-23.5 56.5T280-400Zm0 160q-100 0-170-70T40-480q0-100 70-170t170-70q67 0 121.5 33t86.5 87h352l120 120-180 180-80-60-80 60-85-60h-47q-32 54-86.5 87T280-240Zm0-80q56 0 98.5-34t56.5-86h125l58 41 82-61 71 55 75-75-40-40H435q-14-52-56.5-86T280-640q-66 0-113 47t-47 113q0 66 47 113t113 47Z"/>
</svg>
);

View File

@ -1,7 +0,0 @@
import { IconProps } from './types';
export const IconMusicCast = (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
<path d="M560-160q-66 0-113-47t-47-113q0-66 47-113t113-47q23 0 42.5 5.5T640-458v-342h240v120H720v360q0 66-47 113t-113 47ZM80-320q0-99 38-186.5T221-659q65-65 152.5-103T560-800v80q-82 0-155 31.5t-127.5 86q-54.5 54.5-86 127T160-320H80Zm160 0q0-66 25.5-124.5t69-102Q378-590 436-615t124-25v80q-100 0-170 70t-70 170h-80Z"/>
</svg>
);

View File

@ -1,7 +0,0 @@
import { IconProps } from './types';
export const IconOff = (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
<path d="M792-56 686-160H260q-92 0-156-64T40-380q0-77 47.5-137T210-594q3-8 6-15.5t6-16.5L56-792l56-56 736 736-56 56ZM260-240h346L284-562q-2 11-3 21t-1 21h-20q-58 0-99 41t-41 99q0 58 41 99t99 41Zm185-161Zm419 191-58-56q17-14 25.5-32.5T840-340q0-42-29-71t-71-29h-60v-80q0-83-58.5-141.5T480-720q-27 0-52 6.5T380-693l-58-58q35-24 74.5-36.5T480-800q117 0 198.5 81.5T760-520q69 8 114.5 59.5T920-340q0 39-15 72.5T864-210ZM593-479Z"/>
</svg>
);

View File

@ -1,7 +0,0 @@
import { IconProps } from './types';
export const IconTune = (props: IconProps) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" {...props}>
<path d="M440-120v-240h80v80h320v80H520v80h-80Zm-320-80v-80h240v80H120Zm160-160v-80H120v-80h160v-80h80v240h-80Zm160-80v-80h400v80H440Zm160-160v-240h80v80h160v80H680v80h-80Zm-480-80v-80h400v80H120Z"/>
</svg>
);

View File

@ -1,5 +0,0 @@
export * from './IconConnect';
export * from './IconKey';
export * from './IconMusicCast';
export * from './IconOff';
export * from './IconTune';

View File

@ -1,5 +0,0 @@
export type IconProps = {
width?: number;
height?: number;
fill?: string;
};

View File

@ -1,56 +0,0 @@
import { render } from 'solid-js/web';
import { MusicTogetherButton } from './Button';
import { AppElement } from '@/types/queue';
import { RendererContext } from '@/types/contexts';
import { MusicTogetherConfig } from '@/plugins/music-together/types';
import { ToastProvider } from '../context/ToastContext';
import { RendererContextProvider } from '../context/RendererContext';
import { setUser } from '../store/user';
export const onRendererLoad = (
context: RendererContext<MusicTogetherConfig>,
) => {
const container = document.createElement('div');
const target = document.querySelector<HTMLElement>(
'#right-content > ytmusic-settings-button',
);
const api = document.querySelector<AppElement>('ytmusic-app');
if (!target) {
console.warn('Music Together [renderer]: Cannot inject a button');
return;
}
const button = target.querySelector<HTMLElement>('tp-yt-paper-icon-button');
button?.click();
const interval = setInterval(() => {
const thumbnail = target?.querySelector<HTMLImageElement>('img')?.src;
const name = document.querySelector('#account-name')?.textContent;
if (name) {
setUser({ name, thumbnail });
clearInterval(interval);
setTimeout(() => {
button?.click();
target?.insertAdjacentElement('beforebegin', container);
render(
() => (
<RendererContextProvider context={context}>
<ToastProvider service={api!.toastService}>
<MusicTogetherButton />
</ToastProvider>
</RendererContextProvider>
),
container,
);
}, 0);
}
}, 1);
};

View File

@ -1,7 +0,0 @@
import { createSignal } from 'solid-js';
import { Connection } from '../connection';
export const [connection, setConnection] = createSignal<Connection | null>(
null,
);

View File

@ -1,12 +0,0 @@
import { createStore } from 'solid-js/store';
import { VideoData } from '../types';
export type QueueStoreType = {
queue: VideoData[];
title: string;
};
export const [queue, setQueue] = createStore<QueueStoreType>({
queue: [],
title: '',
});

View File

@ -1,28 +0,0 @@
import { createStore } from 'solid-js/store';
import { ConnectedState, Permission, User } from '../types';
// export const getDefaultProfile = (
// connectionID: string,
// id: string = Date.now().toString(36),
// ): User => {
// const name = `Guest ${id.slice(-6)}`;
//
// return {
// id: connectionID,
// handleId: `#music-together:${id}`,
// name,
// thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
// };
// };
export type StatusStoreType = {
mode: ConnectedState;
permission: Permission;
users: User[];
};
export const [status, setStatus] = createStore<StatusStoreType>({
mode: 'disconnected',
permission: 'all',
users: [],
});

View File

@ -1,14 +0,0 @@
import { createStore } from 'solid-js/store';
type ClinetUser = {
name: string;
thumbnail: string;
};
const id = Date.now().toString(36);
const name = `Guest ${id.slice(0, 4)}`;
const thumbnail = `https://ui-avatars.com/api/?name=${name}&background=random`;
export const [user, setUser] = createStore<ClinetUser>({
name,
thumbnail,
});

View File

@ -3,7 +3,6 @@
cursor: pointer; cursor: pointer;
margin-left: 8px; margin-left: 8px;
margin-right: 16px;
& svg { & svg {
width: 24px; width: 24px;
@ -84,15 +83,42 @@
} }
.music-together-status { .music-together-status {
display: flex;
flex-direction: column;
align-items: stretch;
padding: 16px;
} }
.music-together-profile { .music-together-profile {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
} }
.music-together-profile.big { .music-together-profile.big {
width: 32px;
height: 32px;
} }
.music-together-status-container { .music-together-status-container {
flex: 1;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 16px;
} }
.music-together-status-item { .music-together-status-item {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 400;
} }
.music-together-user-container { .music-together-user-container {
display: flex; display: flex;
@ -105,6 +131,11 @@
font-size: 14px; font-size: 14px;
} }
.music-together-empty { .music-together-empty {
width: 100%;
font-size: 14px;
color: rgba(255, 255, 255, .5);
text-align: center;
} }
.music-together-owner { .music-together-owner {

View File

@ -0,0 +1,8 @@
<div class="style-scope music-together-item">
<div class="icon style-scope ytmusic-menu-service-item-renderer">
<!-- icon -->
</div>
<div class="text style-scope ytmusic-menu-service-item-renderer">
<!-- text -->
</div>
</div>

View File

@ -0,0 +1,5 @@
<div class="music-together-popup">
<tp-yt-paper-listbox class="style-scope ytmusic-menu-popup-renderer music-together-popup-container">
</tp-yt-paper-listbox>
</div>

View File

@ -0,0 +1,7 @@
<div id="music-together-setting-button" class="music-together-button style-scope ytmusic-nav-bar">
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
<path d="M0-240v-63q0-43 44-70t116-27q13 0 25 .5t23 2.5q-14 21-21 44t-7 48v65H0Zm240 0v-65q0-32 17.5-58.5T307-410q32-20 76.5-30t96.5-10q53 0 97.5 10t76.5 30q32 20 49 46.5t17 58.5v65H240Zm540 0v-65q0-26-6.5-49T754-397q11-2 22.5-2.5t23.5-.5q72 0 116 26.5t44 70.5v63H780Zm-455-80h311q-10-20-55.5-35T480-370q-55 0-100.5 15T325-320ZM160-440q-33 0-56.5-23.5T80-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T160-440Zm640 0q-33 0-56.5-23.5T720-520q0-34 23.5-57t56.5-23q34 0 57 23t23 57q0 33-23 56.5T800-440Zm-320-40q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-600q0 50-34.5 85T480-480Zm0-80q17 0 28.5-11.5T520-600q0-17-11.5-28.5T480-640q-17 0-28.5 11.5T440-600q0 17 11.5 28.5T480-560Zm1 240Zm-1-280Z"/>
</svg>
<tp-yt-paper-spinner-lite id="music-together-host-spinner" hidden class="loading-indicator style-scope music-together-spinner"></tp-yt-paper-spinner-lite>
</div>
<div class="music-together-divider"></div>

View File

@ -0,0 +1,23 @@
<div class="music-together-status">
<div class="music-together-status-container">
<img class="music-together-profile big" alt="Profile Image">
<div class="music-together-status-item">
<ytmd-trans key="plugins.music-together.name"></ytmd-trans>
<span id="music-together-status-label">
<ytmd-trans key="plugins.music-together.menu.status.disconnected"></ytmd-trans>
</span>
<marquee id="music-together-permission-label">
<ytmd-trans key="plugins.music-together.menu.permission.playlist" style="color: rgba(255, 255, 255, 0.75)"></ytmd-trans>
</marquee>
</div>
</div>
<div class="music-together-divider horizontal" style="margin: 16px 0;"></div>
<div class="music-together-status-item">
<ytmd-trans key="plugins.music-together.menu.connected-users"></ytmd-trans>
</div>
<div class="music-together-user-container">
<span class="music-together-empty">
<ytmd-trans key="plugins.music-together.menu.empty-user"></ytmd-trans>
</span>
</div>
</div>

View File

@ -1,7 +1,4 @@
export type MusicTogetherConfig = { export type Profile = {
enabled: boolean;
};
export type User = {
id: string; id: string;
handleId: string; handleId: string;
name: string; name: string;
@ -11,25 +8,18 @@ export type VideoData = {
videoId: string; videoId: string;
ownerId: string; ownerId: string;
}; };
export type ConnectedState = 'disconnected' | 'host' | 'guest' | 'connecting';
export type Permission = 'host-only' | 'playlist' | 'all'; export type Permission = 'host-only' | 'playlist' | 'all';
export type ConnectionEventMap = { export const getDefaultProfile = (
ADD_SONGS: { videoList: VideoData[]; index?: number }; connectionID: string,
REMOVE_SONG: { index: number }; id: string = Date.now().toString(),
MOVE_SONG: { fromIndex: number; toIndex: number }; ): Profile => {
IDENTIFY: { user: User } | undefined; const name = `Guest ${id.slice(0, 4)}`;
SYNC_USER: { users: User[] } | undefined;
SYNC_QUEUE: { videoList: VideoData[] } | undefined; return {
SYNC_PROGRESS: id: connectionID,
| { progress?: number; state?: number; index?: number } handleId: `#music-together:${id}`,
| undefined; name,
PERMISSION: Permission | undefined; thumbnail: `https://ui-avatars.com/api/?name=${name}&background=random`,
};
export type ConnectionEventUnion = {
[Event in keyof ConnectionEventMap]: {
type: Event;
payload: ConnectionEventMap[Event];
after?: ConnectionEventUnion[];
}; };
}[keyof ConnectionEventMap]; };

View File

@ -0,0 +1,42 @@
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { t } from '@/i18n';
import { Popup } from '../element';
import { createStatus } from '../ui/status';
import IconOff from '../icons/off.svg?raw';
export type GuestPopupProps = {
onItemClick: (id: string) => void;
};
export const createGuestPopup = (props: GuestPopupProps) => {
const status = createStatus();
status.setStatus('guest');
const result = Popup({
data: [
{
type: 'custom',
element: status.element,
},
{
type: 'divider',
},
{
type: 'item',
id: 'music-together-disconnect',
icon: ElementFromHtml(IconOff),
text: t('plugins.music-together.menu.disconnect'),
onClick: () => props.onItemClick('music-together-disconnect'),
},
],
anchorAt: 'bottom-right',
popupAt: 'top-right',
});
return {
...status,
...result,
};
};

View File

@ -0,0 +1,62 @@
import { t } from '@/i18n';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { Popup } from '../element';
import { createStatus } from '../ui/status';
import IconKey from '../icons/key.svg?raw';
import IconOff from '../icons/off.svg?raw';
import IconTune from '../icons/tune.svg?raw';
export type HostPopupProps = {
onItemClick: (id: string) => void;
};
export const createHostPopup = (props: HostPopupProps) => {
const status = createStatus();
status.setStatus('host');
const result = Popup({
data: [
{
type: 'custom',
element: status.element,
},
{
type: 'divider',
},
{
id: 'music-together-copy-id',
type: 'item',
icon: ElementFromHtml(IconKey),
text: t('plugins.music-together.menu.click-to-copy-id'),
onClick: () => props.onItemClick('music-together-copy-id'),
},
{
id: 'music-together-permission',
type: 'item',
icon: ElementFromHtml(IconTune),
text: t('plugins.music-together.menu.set-permission', {
permission: t('plugins.music-together.menu.permission.host-only'),
}),
onClick: () => props.onItemClick('music-together-permission'),
},
{
type: 'divider',
},
{
type: 'item',
id: 'music-together-close',
icon: ElementFromHtml(IconOff),
text: t('plugins.music-together.menu.close'),
onClick: () => props.onItemClick('music-together-close'),
},
],
anchorAt: 'bottom-right',
popupAt: 'top-right',
});
return {
...status,
...result,
};
};

View File

@ -0,0 +1,49 @@
import { Popup } from '@/plugins/music-together/element';
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { createStatus } from './status';
import { t } from '@/i18n';
import IconMusicCast from '../icons/music-cast.svg?raw';
import IconConnect from '../icons/connect.svg?raw';
export type SettingPopupProps = {
onItemClick: (id: string) => void;
};
export const createSettingPopup = (props: SettingPopupProps) => {
const status = createStatus();
status.setStatus('disconnected');
const result = Popup({
data: [
{
type: 'custom',
element: status.element,
},
{
type: 'divider',
},
{
id: 'music-together-host',
type: 'item',
icon: ElementFromHtml(IconMusicCast),
text: t('plugins.music-together.menu.host'),
onClick: () => props.onItemClick('music-together-host'),
},
{
type: 'item',
icon: ElementFromHtml(IconConnect),
text: t('plugins.music-together.menu.join'),
onClick: () => props.onItemClick('music-together-join'),
},
],
anchorAt: 'bottom-right',
popupAt: 'top-right',
});
return {
...status,
...result,
};
};

View File

@ -0,0 +1,102 @@
import { ElementFromHtml } from '@/plugins/utils/renderer';
import { t } from '@/i18n';
import statusHTML from '../templates/status.html?raw';
import type { Permission, Profile } from '../types';
export const createStatus = () => {
const element = ElementFromHtml(statusHTML);
const icon = document.querySelector<HTMLImageElement>(
'ytmusic-settings-button > tp-yt-paper-icon-button > tp-yt-iron-icon#icon img',
);
const profile = element.querySelector<HTMLImageElement>(
'.music-together-profile',
)!;
const statusLabel = element.querySelector<HTMLSpanElement>(
'#music-together-status-label',
)!;
const permissionLabel = element.querySelector<HTMLMarqueeElement>(
'#music-together-permission-label',
)!;
profile.src = icon?.src ?? '';
const setStatus = (status: 'disconnected' | 'host' | 'guest') => {
if (status === 'disconnected') {
statusLabel.textContent = t(
'plugins.music-together.menu.status.disconnected',
);
statusLabel.style.color = 'rgba(255, 255, 255, 0.5)';
}
if (status === 'host') {
statusLabel.textContent = t('plugins.music-together.menu.status.host');
statusLabel.style.color = 'rgba(255, 0, 0, 1)';
}
if (status === 'guest') {
statusLabel.textContent = t('plugins.music-together.menu.status.guest');
statusLabel.style.color = 'rgba(255, 255, 255, 1)';
}
};
const setPermission = (permission: Permission) => {
if (permission === 'host-only') {
permissionLabel.textContent = t(
'plugins.music-together.menu.permission.host-only',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 0.5)';
}
if (permission === 'playlist') {
permissionLabel.textContent = t(
'plugins.music-together.menu.permission.playlist',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 0.75)';
}
if (permission === 'all') {
permissionLabel.textContent = t(
'plugins.music-together.menu.permission.all',
);
permissionLabel.style.color = 'rgba(255, 255, 255, 1)';
}
};
const setProfile = (src: string) => {
profile.src = src;
};
const setUsers = (users: Profile[]) => {
const container = element.querySelector<HTMLDivElement>(
'.music-together-user-container',
)!;
const empty = element.querySelector<HTMLElement>('.music-together-empty')!;
for (const child of Array.from(container.children)) {
if (child !== empty) child.remove();
}
if (users.length === 0) empty.style.display = 'block';
else empty.style.display = 'none';
for (const user of users) {
const img = document.createElement('img');
img.classList.add('music-together-profile');
img.src = user.thumbnail ?? '';
img.title = user.name;
img.alt = `${user.name} (${user.id})`;
container.append(img);
}
};
return {
element,
setStatus,
setUsers,
setProfile,
setPermission,
};
};

View File

@ -22,27 +22,5 @@ export default createPlugin({
node.remove(); node.remove();
} }
} }
// Remove the library button
const libraryIconPath =
'M16,6v2h-2v5c0,1.1-0.9,2-2,2s-2-0.9-2-2s0.9-2,2-2c0.37,0,0.7,0.11,1,0.28V6H16z M18,20H4V6H3v15h15V20z M21,3H6v15h15V3z M7,4h13v13H7V4z';
const observer = new MutationObserver(() => {
const menuEntries = document.querySelectorAll(
'#items ytmusic-guide-entry-renderer',
);
menuEntries.forEach((item) => {
const icon = item.querySelector('path');
if (icon) {
observer.disconnect();
if (icon.getAttribute('d') === libraryIconPath) {
item.remove();
}
}
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
}, },
}); });

View File

@ -24,6 +24,16 @@ const parseBooleanFromArgsType = (args: ArgsType<boolean>) => {
} }
}; };
const parseStringFromArgsType = (args: ArgsType<string>) => {
if (typeof args === 'string') {
return args;
} else if (Array.isArray(args)) {
return args[0];
} else {
return null;
}
};
export default (win: BrowserWindow) => { export default (win: BrowserWindow) => {
return { return {
// Playback // Playback
@ -90,14 +100,15 @@ export default (win: BrowserWindow) => {
}); });
}, },
// Queue // Queue
addSongToQueue: ( addSongToQueue: (videoId: string, queueInsertPosition: string) => {
videoIds: string | string[], const videoIdValue = parseStringFromArgsType(videoId);
options: { if (videoIdValue === null) return;
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
index?: number; win.webContents.send(
}, 'ytmd:add-to-queue',
) => { videoIdValue,
win.webContents.send('ytmd:add-to-queue', videoIds, options); queueInsertPosition,
);
}, },
moveSongInQueue: ( moveSongInQueue: (
fromIndex: ArgsType<number>, fromIndex: ArgsType<number>,

View File

@ -171,27 +171,21 @@ async function onApiLoaded() {
} satisfies QueueResponse); } satisfies QueueResponse);
}); });
type AddToQueueOptions = {
queueInsertPosition?: 'INSERT_AT_END' | 'INSERT_AFTER_CURRENT_VIDEO';
index?: number;
};
window.ipcRenderer.on( window.ipcRenderer.on(
'ytmd:add-to-queue', 'ytmd:add-to-queue',
(_, videoIds: string | string[], { queueInsertPosition = 'INSERT_AT_END', index }: AddToQueueOptions) => { (_, videoId: string, queueInsertPosition: string) => {
const ids = Array.isArray(videoIds) ? videoIds : [videoIds];
const queue = document.querySelector<QueueElement>('#queue'); const queue = document.querySelector<QueueElement>('#queue');
const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app'); const app = document.querySelector<YouTubeMusicAppElement>('ytmusic-app');
if (!app) return; if (!app) return;
const store = queue?.queue.store.store; const store = queue?.queue.store.store;
console.log('add-to-queue!', ids, queue, app, store);
if (!store) return; if (!store) return;
app.networkManager app.networkManager
.fetch('/music/get_queue', { .fetch('/music/get_queue', {
queueContextParams: store.getState().queue.queueContextParams, queueContextParams: store.getState().queue.queueContextParams,
queueInsertPosition, queueInsertPosition,
videoIds: ids, videoIds: [videoId],
}) })
.then((result) => { .then((result) => {
if ( if (
@ -207,8 +201,7 @@ async function onApiLoaded() {
payload: { payload: {
nextQueueItemId: store.getState().queue.nextQueueItemId, nextQueueItemId: store.getState().queue.nextQueueItemId,
index: index:
(index ?? queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO'
queueInsertPosition === 'INSERT_AFTER_CURRENT_VIDEO')
? queueItems.findIndex( ? queueItems.findIndex(
(it) => (it) =>
( (
@ -390,14 +383,8 @@ const defineYTMDTransElements = () => {
YTMDTrans.prototype.connectedCallback = function () { YTMDTrans.prototype.connectedCallback = function () {
const that = this as HTMLElement; const that = this as HTMLElement;
const key = that.getAttribute('key'); const key = that.getAttribute('key');
const options: Record<string, unknown> = {};
that.getAttributeNames().forEach((attr) => {
if (attr === 'key') return;
options[attr] = that.getAttribute(attr);
});
if (key) { if (key) {
const targetHtml = i18t(key, options); const targetHtml = i18t(key);
(that.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy (that.innerHTML as string | TrustedHTML) = defaultTrustedTypePolicy
? defaultTrustedTypePolicy.createHTML(targetHtml) ? defaultTrustedTypePolicy.createHTML(targetHtml)
: targetHtml; : targetHtml;

View File

@ -42,17 +42,6 @@ declare module 'solid-js' {
YpYtPaperSpinnerLiteProps; YpYtPaperSpinnerLiteProps;
'tp-yt-paper-icon-button': ComponentProps<'div'> & 'tp-yt-paper-icon-button': ComponentProps<'div'> &
TpYtPaperIconButtonProps; TpYtPaperIconButtonProps;
'tp-yt-paper-listbox': ComponentProps<'div'>;
// Non-ytmusic elements
'ytmd-trans': ComponentProps<'span'> & {
key: string;
} & {
[key: `attr:${strig}`]: unknown;
};
// fallback
'marquee': ComponentProps<'marquee'>;
} }
} }
} }

View File

@ -130,7 +130,7 @@ export default function (
const stubContexts = const stubContexts =
mode === 'backend' mode === 'backend'
? contexts.filter((ctx) => ctx !== 'backend') ? contexts.filter((ctx) => ctx !== 'menu')
: contexts; : contexts;
for (const ctx of stubContexts) { for (const ctx of stubContexts) {
if (stubMap.has(ctx)) { if (stubMap.has(ctx)) {