commit 179a785adb52754ff8f883c1b23020879cf02e98 Author: fojadrachi Date: Sat Sep 26 21:00:36 2026 +0200 feat: Playtube Stream Dock Plugin 1.0.0 (Ajazz AKP153E) mit Icon-Pack diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..497a428 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +log/ +# Das Icon-Pack wird mit "npm run iconpack" erzeugt; das fertige .sdIconPack liegt im Repo, +# die losen Ordner (gradient/, dark/) und das Zip nicht. +iconpack/Playtube-Icons/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2d53abb --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Playtube – Stream Dock Plugin + +Plugin für **Stream Dock** (Ajazz AKP153E und andere Mirabox/HotSpot-Geräte), mit dem du +[Playtube](https://git.fojadrachi.de/admin/Playtube) steuern kannst: Wiedergabe, Lautstärke +(lauter/leiser/stumm), Playlist-Auswahl, Cover und Titel auf den Tasten. + +## Voraussetzungen + +- Stream Dock Software ab 3.10.188 (Windows), Gerät z. B. Ajazz AKP153E. +- **Playtube ab 4.5.1** (oder Playtube Edge), denn erst ab dieser Version gibt es die lokale + Fernsteuerung. Das Plugin verbindet sich über eine Named Pipe + (`\\.\pipe\Playtube.Remote.`, nur dein Windows-Benutzer) – kein Netzwerk, kein Konto. +- Ohne laufendes Playtube zeigen die Tasten „Playtube offline". + +## Installieren + +```powershell +powershell -ExecutionPolicy Bypass -File install.ps1 +``` + +Danach Stream Dock komplett beenden (Tray-Symbol → Beenden) und neu starten. Die Aktionen +findest du in der Kategorie **Playtube**. Das Skript installiert auch das Icon-Pack. + +## Aktionen + +| Aktion | Was sie tut | +|---|---| +| Wiedergabe/Pause | Umschalten, Symbol zeigt den Zustand | +| Vorheriger / Nächster Titel | Titel springen | +| Zurück-/Vorspulen | ± Sekunden (einstellbar) | +| Lauter / Leiser | Schrittweite einstellbar, gedrückt halten wiederholt | +| Lautstärke setzen | Feste Lautstärke | +| Stumm | Ton aus/ein, zeigt die Lautstärke | +| Aktueller Titel | Cover und Titel; auf dem Info-Display eine große Karte mit Interpret und Fortschritt | +| Gefällt mir, Zufall, Wiederholen | Umschalten, Symbol zeigt den Zustand | +| Playlist abspielen | Playlist aus YouTube Music wählen (Liste wird von Playtube geladen) | +| Tab wechseln / Playtube anzeigen | YouTube ↔ YouTube Music, Fenster nach vorn | + +Pro Taste einstellbar: Ziel-Tab (automatisch = aktiver Tab, YouTube oder YouTube Music), je +nach Aktion Schrittweite, Sekunden, Lautstärke, Playlist, Titelanzeige. Alle Aktionen sind auch +auf den Info-Feldern rechts am Gerät nutzbar. + +## Icon-Pack + +`iconpack/com.fojadrachi.playtube.sdIconPack` enthält 75 Symbole im Playtube-Look (Verlauf +violett → pink): Ordner, Pfeile, Einstellungen, Player-Symbole und vereinfachte App-Symbole +(Chrome, Discord, Steam, …; eigene Zeichnungen, keine Original-Logos). Zwei Varianten: +`gradient_*` und `dark_*`. In der Stream-Dock-Software über das Bild einer Taste auswählbar. + +## Entwicklung + +```bash +npm install +npm test # 40 Tests (Node >= 20) +npm run build # bündelt src/ nach com.fojadrachi.playtube.sdPlugin/plugin/index.js +npm run assets # erzeugt manifest.json, Übersetzungen und Tasten-Icons +npm run iconpack # erzeugt das Icon-Pack (+ Zip in dist/) +npm run package # Zip des Plugins in dist/ +``` + +Aufbau: `src/playtubeClient.js` (Pipe-Protokoll), `src/actions.js` (Aktionen, reine +Funktionen), `src/controller.js` (Tasten ↔ Playtube), `src/infoPanel.js` (Karte fürs +Info-Display), `src/host.js` (Stream-Dock-WebSocket), `tools/` (Icon-Generator). +Das gebündelte `plugin/index.js` liegt im Repo, damit `install.ps1` ohne Build funktioniert. diff --git a/build.mjs b/build.mjs new file mode 100644 index 0000000..0a963e7 --- /dev/null +++ b/build.mjs @@ -0,0 +1,36 @@ +// Buendelt das Plugin (inkl. "ws") zu com.fojadrachi.playtube.sdPlugin/plugin/index.js. +// Mit --zip zusaetzlich dist/Playtube-StreamDock-.zip erzeugen. +import { execFileSync } from 'node:child_process'; +import { mkdirSync, readFileSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const PLUGIN_NAME = 'com.fojadrachi.playtube.sdPlugin'; +const OUTFILE = path.join(ROOT, PLUGIN_NAME, 'plugin', 'index.js'); +const { version } = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')); + +await build({ + entryPoints: [path.join(ROOT, 'src', 'main.js')], + outfile: OUTFILE, + bundle: true, + platform: 'node', + target: 'node20', + format: 'cjs', + // Optionale native Beschleuniger von "ws" - nicht noetig, ws faellt auf reines JS zurueck. + external: ['bufferutil', 'utf-8-validate'], + legalComments: 'none', + logLevel: 'info', +}); + +if (process.argv.includes('--zip')) { + const dist = path.join(ROOT, 'dist'); + const zip = path.join(dist, `Playtube-StreamDock-${version}.zip`); + mkdirSync(dist, { recursive: true }); + rmSync(zip, { force: true }); + // tar.exe (bsdtar, in Windows 10+ enthalten) kann Zip-Archive schreiben. + const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; + execFileSync(tar, ['-a', '-c', '-f', zip, '-C', ROOT, PLUGIN_NAME], { stdio: 'inherit' }); + process.stdout.write(`Paket: ${zip}\n`); +} diff --git a/com.fojadrachi.playtube.sdPlugin/de.json b/com.fojadrachi.playtube.sdPlugin/de.json new file mode 100644 index 0000000..2723b4d --- /dev/null +++ b/com.fojadrachi.playtube.sdPlugin/de.json @@ -0,0 +1,69 @@ +{ + "Name": "Playtube", + "Description": "Playtube (YouTube / YouTube Musik) steuern", + "Category": "Playtube", + "com.fojadrachi.playtube.playpause": { + "Name": "Wiedergabe/Pause", + "Tooltip": "Wiedergabe starten oder pausieren" + }, + "com.fojadrachi.playtube.previous": { + "Name": "Vorheriger Titel", + "Tooltip": "Zum vorherigen Titel springen" + }, + "com.fojadrachi.playtube.next": { + "Name": "Nächster Titel", + "Tooltip": "Zum nächsten Titel springen" + }, + "com.fojadrachi.playtube.seekback": { + "Name": "Zurückspulen", + "Tooltip": "Ein paar Sekunden zurückspringen" + }, + "com.fojadrachi.playtube.seekforward": { + "Name": "Vorspulen", + "Tooltip": "Ein paar Sekunden vorspringen" + }, + "com.fojadrachi.playtube.volumeup": { + "Name": "Lauter", + "Tooltip": "Lauter (gedrückt halten für Dauer-Anpassung)" + }, + "com.fojadrachi.playtube.volumedown": { + "Name": "Leiser", + "Tooltip": "Leiser (gedrückt halten für Dauer-Anpassung)" + }, + "com.fojadrachi.playtube.volumeset": { + "Name": "Lautstärke setzen", + "Tooltip": "Auf eine feste Lautstärke springen" + }, + "com.fojadrachi.playtube.mute": { + "Name": "Stummschalten", + "Tooltip": "Ton aus- oder einschalten" + }, + "com.fojadrachi.playtube.nowplaying": { + "Name": "Aktueller Titel", + "Tooltip": "Cover und Titel, Drücken = Wiedergabe/Pause" + }, + "com.fojadrachi.playtube.like": { + "Name": "Gefällt mir", + "Tooltip": "Aktuellen Titel mit „Gefällt mir“ markieren" + }, + "com.fojadrachi.playtube.shuffle": { + "Name": "Zufallswiedergabe", + "Tooltip": "Zufallswiedergabe umschalten" + }, + "com.fojadrachi.playtube.repeat": { + "Name": "Wiederholen", + "Tooltip": "Wiederholungsmodus wechseln" + }, + "com.fojadrachi.playtube.playlist": { + "Name": "Playlist abspielen", + "Tooltip": "Eine Playlist aus YouTube Music starten" + }, + "com.fojadrachi.playtube.switchtab": { + "Name": "Tab wechseln", + "Tooltip": "Zwischen YouTube und YouTube Musik wechseln" + }, + "com.fojadrachi.playtube.show": { + "Name": "Playtube anzeigen", + "Tooltip": "Das Playtube-Fenster in den Vordergrund holen" + } +} diff --git a/com.fojadrachi.playtube.sdPlugin/en.json b/com.fojadrachi.playtube.sdPlugin/en.json new file mode 100644 index 0000000..1c485e2 --- /dev/null +++ b/com.fojadrachi.playtube.sdPlugin/en.json @@ -0,0 +1,69 @@ +{ + "Name": "Playtube", + "Description": "Control Playtube (YouTube / YouTube Music)", + "Category": "Playtube", + "com.fojadrachi.playtube.playpause": { + "Name": "Play/Pause", + "Tooltip": "Toggle playback" + }, + "com.fojadrachi.playtube.previous": { + "Name": "Previous track", + "Tooltip": "Go to the previous track" + }, + "com.fojadrachi.playtube.next": { + "Name": "Next track", + "Tooltip": "Skip to the next track" + }, + "com.fojadrachi.playtube.seekback": { + "Name": "Rewind", + "Tooltip": "Jump back a few seconds" + }, + "com.fojadrachi.playtube.seekforward": { + "Name": "Fast forward", + "Tooltip": "Jump forward a few seconds" + }, + "com.fojadrachi.playtube.volumeup": { + "Name": "Volume up", + "Tooltip": "Louder (hold to keep going)" + }, + "com.fojadrachi.playtube.volumedown": { + "Name": "Volume down", + "Tooltip": "Quieter (hold to keep going)" + }, + "com.fojadrachi.playtube.volumeset": { + "Name": "Set volume", + "Tooltip": "Jump to a fixed volume" + }, + "com.fojadrachi.playtube.mute": { + "Name": "Mute", + "Tooltip": "Mute or unmute" + }, + "com.fojadrachi.playtube.nowplaying": { + "Name": "Now playing", + "Tooltip": "Cover and title, press to play/pause" + }, + "com.fojadrachi.playtube.like": { + "Name": "Like", + "Tooltip": "Like the current track" + }, + "com.fojadrachi.playtube.shuffle": { + "Name": "Shuffle", + "Tooltip": "Toggle shuffle" + }, + "com.fojadrachi.playtube.repeat": { + "Name": "Repeat", + "Tooltip": "Cycle repeat mode" + }, + "com.fojadrachi.playtube.playlist": { + "Name": "Play playlist", + "Tooltip": "Start a playlist from YouTube Music" + }, + "com.fojadrachi.playtube.switchtab": { + "Name": "Switch tab", + "Tooltip": "Switch between YouTube and YouTube Music" + }, + "com.fojadrachi.playtube.show": { + "Name": "Show Playtube", + "Tooltip": "Bring the Playtube window to the front" + } +} diff --git a/com.fojadrachi.playtube.sdPlugin/manifest.json b/com.fojadrachi.playtube.sdPlugin/manifest.json new file mode 100644 index 0000000..b5125f9 --- /dev/null +++ b/com.fojadrachi.playtube.sdPlugin/manifest.json @@ -0,0 +1,444 @@ +{ + "Actions": [ + { + "Icon": "static/icon/playpause@2x.png", + "Name": "Play/Pause", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/playpause_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + }, + { + "Image": "static/btn/playpause_1.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Toggle playback", + "UUID": "com.fojadrachi.playtube.playpause", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/previous@2x.png", + "Name": "Previous track", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/previous_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Go to the previous track", + "UUID": "com.fojadrachi.playtube.previous", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/next@2x.png", + "Name": "Next track", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/next_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Skip to the next track", + "UUID": "com.fojadrachi.playtube.next", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/seekback@2x.png", + "Name": "Rewind", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/seekback_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Jump back a few seconds", + "UUID": "com.fojadrachi.playtube.seekback", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/seekforward@2x.png", + "Name": "Fast forward", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/seekforward_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Jump forward a few seconds", + "UUID": "com.fojadrachi.playtube.seekforward", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/volumeup@2x.png", + "Name": "Volume up", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/volumeup_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Louder (hold to keep going)", + "UUID": "com.fojadrachi.playtube.volumeup", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/volumedown@2x.png", + "Name": "Volume down", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/volumedown_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Quieter (hold to keep going)", + "UUID": "com.fojadrachi.playtube.volumedown", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/volumeset@2x.png", + "Name": "Set volume", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/volumeset_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Jump to a fixed volume", + "UUID": "com.fojadrachi.playtube.volumeset", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/mute@2x.png", + "Name": "Mute", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/mute_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + }, + { + "Image": "static/btn/mute_1.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Mute or unmute", + "UUID": "com.fojadrachi.playtube.mute", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/nowplaying@2x.png", + "Name": "Now playing", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/nowplaying_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Cover and title, press to play/pause", + "UUID": "com.fojadrachi.playtube.nowplaying", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/like@2x.png", + "Name": "Like", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/like_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + }, + { + "Image": "static/btn/like_1.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Like the current track", + "UUID": "com.fojadrachi.playtube.like", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/shuffle@2x.png", + "Name": "Shuffle", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/shuffle_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Toggle shuffle", + "UUID": "com.fojadrachi.playtube.shuffle", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/repeat@2x.png", + "Name": "Repeat", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/repeat_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + }, + { + "Image": "static/btn/repeat_1.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + }, + { + "Image": "static/btn/repeat_2.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Cycle repeat mode", + "UUID": "com.fojadrachi.playtube.repeat", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/playlist@2x.png", + "Name": "Play playlist", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/playlist_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Start a playlist from YouTube Music", + "UUID": "com.fojadrachi.playtube.playlist", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/switchtab@2x.png", + "Name": "Switch tab", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/switchtab_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Switch between YouTube and YouTube Music", + "UUID": "com.fojadrachi.playtube.switchtab", + "PropertyInspectorPath": "propertyInspector/index.html" + }, + { + "Icon": "static/icon/show@2x.png", + "Name": "Show Playtube", + "DisableAutomaticStates": true, + "States": [ + { + "Image": "static/btn/show_0.png", + "TitleAlignment": "bottom", + "FontSize": "11", + "FontStyle": "Bold", + "TitleColor": "#ffffff" + } + ], + "Settings": {}, + "Controllers": [ + "Keypad", + "Information" + ], + "UserTitleEnabled": false, + "SupportedInMultiActions": true, + "Tooltip": "Bring the Playtube window to the front", + "UUID": "com.fojadrachi.playtube.show", + "PropertyInspectorPath": "propertyInspector/index.html" + } + ], + "SDKVersion": 1, + "Author": "Playtube", + "Name": "Playtube", + "Icon": "static/icon/icon@2x.png", + "CodePathWin": "plugin/index.js", + "Description": "Control Playtube (YouTube / YouTube Music) from your Stream Dock", + "Category": "Playtube", + "CategoryIcon": "static/icon/icon@2x.png", + "Version": "1.0.0", + "OS": [ + { + "Platform": "windows", + "MinimumVersion": "10" + } + ], + "Software": { + "MinimumVersion": "3.10.188.226" + }, + "Nodejs": { + "Version": "20" + } +} diff --git a/com.fojadrachi.playtube.sdPlugin/plugin/index.js b/com.fojadrachi.playtube.sdPlugin/plugin/index.js new file mode 100644 index 0000000..49fa2fe --- /dev/null +++ b/com.fojadrachi.playtube.sdPlugin/plugin/index.js @@ -0,0 +1,4629 @@ +"use strict"; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; + +// node_modules/ws/lib/constants.js +var require_constants = __commonJS({ + "node_modules/ws/lib/constants.js"(exports2, module2) { + "use strict"; + var BINARY_TYPES = ["nodebuffer", "arraybuffer", "fragments"]; + var hasBlob = typeof Blob !== "undefined"; + if (hasBlob) BINARY_TYPES.push("blob"); + module2.exports = { + BINARY_TYPES, + CLOSE_TIMEOUT: 3e4, + EMPTY_BUFFER: Buffer.alloc(0), + GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11", + hasBlob, + kForOnEventAttribute: Symbol("kIsForOnEventAttribute"), + kListener: Symbol("kListener"), + kStatusCode: Symbol("status-code"), + kWebSocket: Symbol("websocket"), + NOOP: () => { + } + }; + } +}); + +// node_modules/ws/lib/buffer-util.js +var require_buffer_util = __commonJS({ + "node_modules/ws/lib/buffer-util.js"(exports2, module2) { + "use strict"; + var { EMPTY_BUFFER } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + function concat(list, totalLength) { + if (list.length === 0) return EMPTY_BUFFER; + if (list.length === 1) return list[0]; + const target = Buffer.allocUnsafe(totalLength); + let offset = 0; + for (let i = 0; i < list.length; i++) { + const buf = list[i]; + target.set(buf, offset); + offset += buf.length; + } + if (offset < totalLength) { + return new FastBuffer(target.buffer, target.byteOffset, offset); + } + return target; + } + function _mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } + } + function _unmask(buffer, mask) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] ^= mask[i & 3]; + } + } + function toArrayBuffer(buf) { + if (buf.length === buf.buffer.byteLength) { + return buf.buffer; + } + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); + } + function toBuffer(data) { + toBuffer.readOnly = true; + if (Buffer.isBuffer(data)) return data; + let buf; + if (data instanceof ArrayBuffer) { + buf = new FastBuffer(data); + } else if (ArrayBuffer.isView(data)) { + buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); + } else { + buf = Buffer.from(data); + toBuffer.readOnly = false; + } + return buf; + } + module2.exports = { + concat, + mask: _mask, + toArrayBuffer, + toBuffer, + unmask: _unmask + }; + if (!process.env.WS_NO_BUFFER_UTIL) { + try { + const bufferUtil = require("bufferutil"); + module2.exports.mask = function(source, mask, output, offset, length) { + if (length < 48) _mask(source, mask, output, offset, length); + else bufferUtil.mask(source, mask, output, offset, length); + }; + module2.exports.unmask = function(buffer, mask) { + if (buffer.length < 32) _unmask(buffer, mask); + else bufferUtil.unmask(buffer, mask); + }; + } catch (e) { + } + } + } +}); + +// node_modules/ws/lib/limiter.js +var require_limiter = __commonJS({ + "node_modules/ws/lib/limiter.js"(exports2, module2) { + "use strict"; + var kDone = Symbol("kDone"); + var kRun = Symbol("kRun"); + var Limiter = class { + /** + * Creates a new `Limiter`. + * + * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed + * to run concurrently + */ + constructor(concurrency) { + this[kDone] = () => { + this.pending--; + this[kRun](); + }; + this.concurrency = concurrency || Infinity; + this.jobs = []; + this.pending = 0; + } + /** + * Adds a job to the queue. + * + * @param {Function} job The job to run + * @public + */ + add(job) { + this.jobs.push(job); + this[kRun](); + } + /** + * Removes a job from the queue and runs it if possible. + * + * @private + */ + [kRun]() { + if (this.pending === this.concurrency) return; + if (this.jobs.length) { + const job = this.jobs.shift(); + this.pending++; + job(this[kDone]); + } + } + }; + module2.exports = Limiter; + } +}); + +// node_modules/ws/lib/permessage-deflate.js +var require_permessage_deflate = __commonJS({ + "node_modules/ws/lib/permessage-deflate.js"(exports2, module2) { + "use strict"; + var zlib = require("zlib"); + var bufferUtil = require_buffer_util(); + var Limiter = require_limiter(); + var { kStatusCode } = require_constants(); + var FastBuffer = Buffer[Symbol.species]; + var TRAILER = Buffer.from([0, 0, 255, 255]); + var kPerMessageDeflate = Symbol("permessage-deflate"); + var kTotalLength = Symbol("total-length"); + var kCallback = Symbol("callback"); + var kBuffers = Symbol("buffers"); + var kError = Symbol("error"); + var zlibLimiter; + var PerMessageDeflate = class { + /** + * Creates a PerMessageDeflate instance. + * + * @param {Object} [options] Configuration options + * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support + * for, or request, a custom client window size + * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ + * acknowledge disabling of client context takeover + * @param {Number} [options.concurrencyLimit=10] The number of concurrent + * calls to zlib + * @param {Boolean} [options.isServer=false] Create the instance in either + * server or client mode + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the + * use of a custom server window size + * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept + * disabling of server context takeover + * @param {Number} [options.threshold=1024] Size (in bytes) below which + * messages should not be compressed if context takeover is disabled + * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on + * deflate + * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on + * inflate + */ + constructor(options) { + this._options = options || {}; + this._threshold = this._options.threshold !== void 0 ? this._options.threshold : 1024; + this._maxPayload = this._options.maxPayload | 0; + this._isServer = !!this._options.isServer; + this._deflate = null; + this._inflate = null; + this.params = null; + if (!zlibLimiter) { + const concurrency = this._options.concurrencyLimit !== void 0 ? this._options.concurrencyLimit : 10; + zlibLimiter = new Limiter(concurrency); + } + } + /** + * @type {String} + */ + static get extensionName() { + return "permessage-deflate"; + } + /** + * Create an extension negotiation offer. + * + * @return {Object} Extension parameters + * @public + */ + offer() { + const params = {}; + if (this._options.serverNoContextTakeover) { + params.server_no_context_takeover = true; + } + if (this._options.clientNoContextTakeover) { + params.client_no_context_takeover = true; + } + if (this._options.serverMaxWindowBits) { + params.server_max_window_bits = this._options.serverMaxWindowBits; + } + if (this._options.clientMaxWindowBits) { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } else if (this._options.clientMaxWindowBits == null) { + params.client_max_window_bits = true; + } + return params; + } + /** + * Accept an extension negotiation offer/response. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Object} Accepted configuration + * @public + */ + accept(configurations) { + configurations = this.normalizeParams(configurations); + this.params = this._isServer ? this.acceptAsServer(configurations) : this.acceptAsClient(configurations); + return this.params; + } + /** + * Releases all resources used by the extension. + * + * @public + */ + cleanup() { + if (this._inflate) { + this._inflate.close(); + this._inflate = null; + } + if (this._deflate) { + const callback = this._deflate[kCallback]; + this._deflate.close(); + this._deflate = null; + if (callback) { + callback( + new Error( + "The deflate stream was closed while data was being processed" + ) + ); + } + } + } + /** + * Accept an extension negotiation offer. + * + * @param {Array} offers The extension negotiation offers + * @return {Object} Accepted configuration + * @private + */ + acceptAsServer(offers) { + const opts = this._options; + const accepted = offers.find((params) => { + if (opts.serverNoContextTakeover === false && params.server_no_context_takeover || params.server_max_window_bits && (opts.serverMaxWindowBits === false || typeof opts.serverMaxWindowBits === "number" && opts.serverMaxWindowBits > params.server_max_window_bits) || typeof opts.clientMaxWindowBits === "number" && (typeof params.client_max_window_bits === "number" ? opts.clientMaxWindowBits > params.client_max_window_bits : !params.client_max_window_bits)) { + return false; + } + return true; + }); + if (!accepted) { + throw new Error("None of the extension offers can be accepted"); + } + if (opts.serverNoContextTakeover) { + accepted.server_no_context_takeover = true; + } + if (opts.clientNoContextTakeover) { + accepted.client_no_context_takeover = true; + } + if (typeof opts.serverMaxWindowBits === "number") { + accepted.server_max_window_bits = opts.serverMaxWindowBits; + } + if (typeof opts.clientMaxWindowBits === "number") { + accepted.client_max_window_bits = opts.clientMaxWindowBits; + } else if (accepted.client_max_window_bits === true || opts.clientMaxWindowBits === false) { + delete accepted.client_max_window_bits; + } + return accepted; + } + /** + * Accept the extension negotiation response. + * + * @param {Array} response The extension negotiation response + * @return {Object} Accepted configuration + * @private + */ + acceptAsClient(response) { + const params = response[0]; + if (this._options.clientNoContextTakeover === false && params.client_no_context_takeover) { + throw new Error('Unexpected parameter "client_no_context_takeover"'); + } + if (!params.client_max_window_bits) { + if (typeof this._options.clientMaxWindowBits === "number") { + params.client_max_window_bits = this._options.clientMaxWindowBits; + } + } else if (this._options.clientMaxWindowBits === false || typeof this._options.clientMaxWindowBits === "number" && params.client_max_window_bits > this._options.clientMaxWindowBits) { + throw new Error( + 'Unexpected or invalid parameter "client_max_window_bits"' + ); + } + return params; + } + /** + * Normalize parameters. + * + * @param {Array} configurations The extension negotiation offers/reponse + * @return {Array} The offers/response with normalized parameters + * @private + */ + normalizeParams(configurations) { + configurations.forEach((params) => { + Object.keys(params).forEach((key) => { + let value = params[key]; + if (value.length > 1) { + throw new Error(`Parameter "${key}" must have only a single value`); + } + value = value[0]; + if (key === "client_max_window_bits") { + if (value !== true) { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (!this._isServer) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else if (key === "server_max_window_bits") { + const num = +value; + if (!Number.isInteger(num) || num < 8 || num > 15) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + value = num; + } else if (key === "client_no_context_takeover" || key === "server_no_context_takeover") { + if (value !== true) { + throw new TypeError( + `Invalid value for parameter "${key}": ${value}` + ); + } + } else { + throw new Error(`Unknown parameter "${key}"`); + } + params[key] = value; + }); + }); + return configurations; + } + /** + * Decompress data. Concurrency limited. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + decompress(data, fin, callback) { + zlibLimiter.add((done) => { + this._decompress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Compress data. Concurrency limited. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @public + */ + compress(data, fin, callback) { + zlibLimiter.add((done) => { + this._compress(data, fin, (err, result) => { + done(); + callback(err, result); + }); + }); + } + /** + * Decompress data. + * + * @param {Buffer} data Compressed data + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _decompress(data, fin, callback) { + const endpoint = this._isServer ? "client" : "server"; + if (!this._inflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._inflate = zlib.createInflateRaw({ + ...this._options.zlibInflateOptions, + windowBits + }); + this._inflate[kPerMessageDeflate] = this; + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + this._inflate.on("error", inflateOnError); + this._inflate.on("data", inflateOnData); + } + this._inflate[kCallback] = callback; + this._inflate.write(data); + if (fin) this._inflate.write(TRAILER); + this._inflate.flush(() => { + const err = this._inflate[kError]; + if (err) { + this._inflate.close(); + this._inflate = null; + callback(err); + return; + } + const data2 = bufferUtil.concat( + this._inflate[kBuffers], + this._inflate[kTotalLength] + ); + if (this._inflate._readableState.endEmitted) { + this._inflate.close(); + this._inflate = null; + } else { + this._inflate[kTotalLength] = 0; + this._inflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._inflate.reset(); + } + } + callback(null, data2); + }); + } + /** + * Compress data. + * + * @param {(Buffer|String)} data Data to compress + * @param {Boolean} fin Specifies whether or not this is the last fragment + * @param {Function} callback Callback + * @private + */ + _compress(data, fin, callback) { + const endpoint = this._isServer ? "server" : "client"; + if (!this._deflate) { + const key = `${endpoint}_max_window_bits`; + const windowBits = typeof this.params[key] !== "number" ? zlib.Z_DEFAULT_WINDOWBITS : this.params[key]; + this._deflate = zlib.createDeflateRaw({ + ...this._options.zlibDeflateOptions, + windowBits + }); + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + this._deflate.on("data", deflateOnData); + } + this._deflate[kCallback] = callback; + this._deflate.write(data); + this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { + if (!this._deflate) { + return; + } + let data2 = bufferUtil.concat( + this._deflate[kBuffers], + this._deflate[kTotalLength] + ); + if (fin) { + data2 = new FastBuffer(data2.buffer, data2.byteOffset, data2.length - 4); + } + this._deflate[kCallback] = null; + this._deflate[kTotalLength] = 0; + this._deflate[kBuffers] = []; + if (fin && this.params[`${endpoint}_no_context_takeover`]) { + this._deflate.reset(); + } + callback(null, data2); + }); + } + }; + module2.exports = PerMessageDeflate; + function deflateOnData(chunk) { + this[kBuffers].push(chunk); + this[kTotalLength] += chunk.length; + } + function inflateOnData(chunk) { + this[kTotalLength] += chunk.length; + if (this[kPerMessageDeflate]._maxPayload < 1 || this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload) { + this[kBuffers].push(chunk); + return; + } + this[kError] = new RangeError("Max payload size exceeded"); + this[kError].code = "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH"; + this[kError][kStatusCode] = 1009; + this.removeListener("data", inflateOnData); + this.reset(); + } + function inflateOnError(err) { + this[kPerMessageDeflate]._inflate = null; + if (this[kError]) { + this[kCallback](this[kError]); + return; + } + err[kStatusCode] = 1007; + this[kCallback](err); + } + } +}); + +// node_modules/ws/lib/validation.js +var require_validation = __commonJS({ + "node_modules/ws/lib/validation.js"(exports2, module2) { + "use strict"; + var { isUtf8 } = require("buffer"); + var { hasBlob } = require_constants(); + var tokenChars = [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 0 - 15 + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + // 16 - 31 + 0, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 0, + 1, + 1, + 0, + // 32 - 47 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + // 48 - 63 + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 64 - 79 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + // 80 - 95 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + // 96 - 111 + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 0, + 1, + 0 + // 112 - 127 + ]; + function isValidStatusCode(code) { + return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006 || code >= 3e3 && code <= 4999; + } + function _isValidUTF8(buf) { + const len = buf.length; + let i = 0; + while (i < len) { + if ((buf[i] & 128) === 0) { + i++; + } else if ((buf[i] & 224) === 192) { + if (i + 1 === len || (buf[i + 1] & 192) !== 128 || (buf[i] & 254) === 192) { + return false; + } + i += 2; + } else if ((buf[i] & 240) === 224) { + if (i + 2 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || buf[i] === 224 && (buf[i + 1] & 224) === 128 || // Overlong + buf[i] === 237 && (buf[i + 1] & 224) === 160) { + return false; + } + i += 3; + } else if ((buf[i] & 248) === 240) { + if (i + 3 >= len || (buf[i + 1] & 192) !== 128 || (buf[i + 2] & 192) !== 128 || (buf[i + 3] & 192) !== 128 || buf[i] === 240 && (buf[i + 1] & 240) === 128 || // Overlong + buf[i] === 244 && buf[i + 1] > 143 || buf[i] > 244) { + return false; + } + i += 4; + } else { + return false; + } + } + return true; + } + function isBlob(value) { + return hasBlob && typeof value === "object" && typeof value.arrayBuffer === "function" && typeof value.type === "string" && typeof value.stream === "function" && (value[Symbol.toStringTag] === "Blob" || value[Symbol.toStringTag] === "File"); + } + module2.exports = { + isBlob, + isValidStatusCode, + isValidUTF8: _isValidUTF8, + tokenChars + }; + if (isUtf8) { + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); + }; + } else if (!process.env.WS_NO_UTF_8_VALIDATE) { + try { + const isValidUTF8 = require("utf-8-validate"); + module2.exports.isValidUTF8 = function(buf) { + return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); + }; + } catch (e) { + } + } + } +}); + +// node_modules/ws/lib/receiver.js +var require_receiver = __commonJS({ + "node_modules/ws/lib/receiver.js"(exports2, module2) { + "use strict"; + var { Writable } = require("stream"); + var PerMessageDeflate = require_permessage_deflate(); + var { + BINARY_TYPES, + EMPTY_BUFFER, + kStatusCode, + kWebSocket + } = require_constants(); + var { concat, toArrayBuffer, unmask } = require_buffer_util(); + var { isValidStatusCode, isValidUTF8 } = require_validation(); + var FastBuffer = Buffer[Symbol.species]; + var GET_INFO = 0; + var GET_PAYLOAD_LENGTH_16 = 1; + var GET_PAYLOAD_LENGTH_64 = 2; + var GET_MASK = 3; + var GET_DATA = 4; + var INFLATING = 5; + var DEFER_EVENT = 6; + var Receiver = class extends Writable { + /** + * Creates a Receiver instance. + * + * @param {Object} [options] Options object + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {String} [options.binaryType=nodebuffer] The type for binary data + * @param {Object} [options.extensions] An object containing the negotiated + * extensions + * @param {Boolean} [options.isServer=false] Specifies whether to operate in + * client or server mode + * @param {Number} [options.maxBufferedChunks=0] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=0] The maximum number of message + * fragments + * @param {Number} [options.maxPayload=0] The maximum allowed message length + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + */ + constructor(options = {}) { + super(); + this._allowSynchronousEvents = options.allowSynchronousEvents !== void 0 ? options.allowSynchronousEvents : true; + this._binaryType = options.binaryType || BINARY_TYPES[0]; + this._extensions = options.extensions || {}; + this._isServer = !!options.isServer; + this._maxBufferedChunks = options.maxBufferedChunks | 0; + this._maxFragments = options.maxFragments | 0; + this._maxPayload = options.maxPayload | 0; + this._skipUTF8Validation = !!options.skipUTF8Validation; + this[kWebSocket] = void 0; + this._bufferedBytes = 0; + this._buffers = []; + this._compressed = false; + this._payloadLength = 0; + this._mask = void 0; + this._fragmented = 0; + this._masked = false; + this._fin = false; + this._opcode = 0; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._numFragments = 0; + this._fragments = []; + this._errored = false; + this._loop = false; + this._state = GET_INFO; + } + /** + * Implements `Writable.prototype._write()`. + * + * @param {Buffer} chunk The chunk of data to write + * @param {String} encoding The character encoding of `chunk` + * @param {Function} cb Callback + * @private + */ + _write(chunk, encoding, cb) { + if (this._opcode === 8 && this._state == GET_INFO) return cb(); + if (this._maxBufferedChunks > 0 && this._buffers.length >= this._maxBufferedChunks) { + cb( + this.createError( + RangeError, + "Too many buffered chunks", + false, + 1008, + "WS_ERR_TOO_MANY_BUFFERED_PARTS" + ) + ); + return; + } + this._bufferedBytes += chunk.length; + this._buffers.push(chunk); + this.startLoop(cb); + } + /** + * Consumes `n` bytes from the buffered data. + * + * @param {Number} n The number of bytes to consume + * @return {Buffer} The consumed bytes + * @private + */ + consume(n) { + this._bufferedBytes -= n; + if (n === this._buffers[0].length) return this._buffers.shift(); + if (n < this._buffers[0].length) { + const buf = this._buffers[0]; + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + return new FastBuffer(buf.buffer, buf.byteOffset, n); + } + const dst = Buffer.allocUnsafe(n); + do { + const buf = this._buffers[0]; + const offset = dst.length - n; + if (n >= buf.length) { + dst.set(this._buffers.shift(), offset); + } else { + dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset); + this._buffers[0] = new FastBuffer( + buf.buffer, + buf.byteOffset + n, + buf.length - n + ); + } + n -= buf.length; + } while (n > 0); + return dst; + } + /** + * Starts the parsing loop. + * + * @param {Function} cb Callback + * @private + */ + startLoop(cb) { + this._loop = true; + do { + switch (this._state) { + case GET_INFO: + this.getInfo(cb); + break; + case GET_PAYLOAD_LENGTH_16: + this.getPayloadLength16(cb); + break; + case GET_PAYLOAD_LENGTH_64: + this.getPayloadLength64(cb); + break; + case GET_MASK: + this.getMask(); + break; + case GET_DATA: + this.getData(cb); + break; + case INFLATING: + case DEFER_EVENT: + this._loop = false; + return; + } + } while (this._loop); + if (!this._errored) cb(); + } + /** + * Reads the first two bytes of a frame. + * + * @param {Function} cb Callback + * @private + */ + getInfo(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + const buf = this.consume(2); + if ((buf[0] & 48) !== 0) { + const error = this.createError( + RangeError, + "RSV2 and RSV3 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_2_3" + ); + cb(error); + return; + } + const compressed = (buf[0] & 64) === 64; + if (compressed && !this._extensions[PerMessageDeflate.extensionName]) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + this._fin = (buf[0] & 128) === 128; + this._opcode = buf[0] & 15; + this._payloadLength = buf[1] & 127; + if (this._opcode === 0) { + if (compressed) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + if (!this._fragmented) { + const error = this.createError( + RangeError, + "invalid opcode 0", + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + this._opcode = this._fragmented; + } else if (this._opcode === 1 || this._opcode === 2) { + if (this._fragmented) { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + this._compressed = compressed; + } else if (this._opcode > 7 && this._opcode < 11) { + if (!this._fin) { + const error = this.createError( + RangeError, + "FIN must be set", + true, + 1002, + "WS_ERR_EXPECTED_FIN" + ); + cb(error); + return; + } + if (compressed) { + const error = this.createError( + RangeError, + "RSV1 must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_RSV_1" + ); + cb(error); + return; + } + if (this._payloadLength > 125 || this._opcode === 8 && this._payloadLength === 1) { + const error = this.createError( + RangeError, + `invalid payload length ${this._payloadLength}`, + true, + 1002, + "WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH" + ); + cb(error); + return; + } + } else { + const error = this.createError( + RangeError, + `invalid opcode ${this._opcode}`, + true, + 1002, + "WS_ERR_INVALID_OPCODE" + ); + cb(error); + return; + } + if (!this._fin && !this._fragmented) this._fragmented = this._opcode; + this._masked = (buf[1] & 128) === 128; + if (this._isServer) { + if (!this._masked) { + const error = this.createError( + RangeError, + "MASK must be set", + true, + 1002, + "WS_ERR_EXPECTED_MASK" + ); + cb(error); + return; + } + } else if (this._masked) { + const error = this.createError( + RangeError, + "MASK must be clear", + true, + 1002, + "WS_ERR_UNEXPECTED_MASK" + ); + cb(error); + return; + } + if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16; + else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64; + else this.haveLength(cb); + } + /** + * Gets extended payload length (7+16). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength16(cb) { + if (this._bufferedBytes < 2) { + this._loop = false; + return; + } + this._payloadLength = this.consume(2).readUInt16BE(0); + this.haveLength(cb); + } + /** + * Gets extended payload length (7+64). + * + * @param {Function} cb Callback + * @private + */ + getPayloadLength64(cb) { + if (this._bufferedBytes < 8) { + this._loop = false; + return; + } + const buf = this.consume(8); + const num = buf.readUInt32BE(0); + if (num > Math.pow(2, 53 - 32) - 1) { + const error = this.createError( + RangeError, + "Unsupported WebSocket frame: payload length > 2^53 - 1", + false, + 1009, + "WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH" + ); + cb(error); + return; + } + this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4); + this.haveLength(cb); + } + /** + * Payload length has been read. + * + * @param {Function} cb Callback + * @private + */ + haveLength(cb) { + if (this._payloadLength && this._opcode < 8) { + this._totalPayloadLength += this._payloadLength; + if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error); + return; + } + } + if (this._masked) this._state = GET_MASK; + else this._state = GET_DATA; + } + /** + * Reads mask bytes. + * + * @private + */ + getMask() { + if (this._bufferedBytes < 4) { + this._loop = false; + return; + } + this._mask = this.consume(4); + this._state = GET_DATA; + } + /** + * Reads data bytes. + * + * @param {Function} cb Callback + * @private + */ + getData(cb) { + let data = EMPTY_BUFFER; + if (this._payloadLength) { + if (this._bufferedBytes < this._payloadLength) { + this._loop = false; + return; + } + data = this.consume(this._payloadLength); + if (this._masked && (this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0) { + unmask(data, this._mask); + } + } + if (this._opcode > 7) { + this.controlMessage(data, cb); + return; + } + if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) { + const error = this.createError( + RangeError, + "Too many message fragments", + false, + 1008, + "WS_ERR_TOO_MANY_BUFFERED_PARTS" + ); + cb(error); + return; + } + if (this._compressed) { + this._state = INFLATING; + this.decompress(data, cb); + return; + } + if (data.length) { + this._messageLength = this._totalPayloadLength; + this._fragments.push(data); + } + this.dataMessage(cb); + } + /** + * Decompresses data. + * + * @param {Buffer} data Compressed data + * @param {Function} cb Callback + * @private + */ + decompress(data, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + perMessageDeflate.decompress(data, this._fin, (err, buf) => { + if (err) return cb(err); + if (buf.length) { + this._messageLength += buf.length; + if (this._messageLength > this._maxPayload && this._maxPayload > 0) { + const error = this.createError( + RangeError, + "Max payload size exceeded", + false, + 1009, + "WS_ERR_UNSUPPORTED_MESSAGE_LENGTH" + ); + cb(error); + return; + } + this._fragments.push(buf); + } + this.dataMessage(cb); + if (this._state === GET_INFO) this.startLoop(cb); + }); + } + /** + * Handles a data message. + * + * @param {Function} cb Callback + * @private + */ + dataMessage(cb) { + if (!this._fin) { + this._state = GET_INFO; + return; + } + const messageLength = this._messageLength; + const fragments = this._fragments; + this._totalPayloadLength = 0; + this._messageLength = 0; + this._fragmented = 0; + this._numFragments = 0; + this._fragments = []; + if (this._opcode === 2) { + let data; + if (this._binaryType === "nodebuffer") { + data = concat(fragments, messageLength); + } else if (this._binaryType === "arraybuffer") { + data = toArrayBuffer(concat(fragments, messageLength)); + } else if (this._binaryType === "blob") { + data = new Blob(fragments); + } else { + data = fragments; + } + if (this._allowSynchronousEvents) { + this.emit("message", data, true); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", data, true); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } else { + const buf = concat(fragments, messageLength); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error); + return; + } + if (this._state === INFLATING || this._allowSynchronousEvents) { + this.emit("message", buf, false); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit("message", buf, false); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + } + /** + * Handles a control message. + * + * @param {Buffer} data Data to handle + * @return {(Error|RangeError|undefined)} A possible error + * @private + */ + controlMessage(data, cb) { + if (this._opcode === 8) { + if (data.length === 0) { + this._loop = false; + this.emit("conclude", 1005, EMPTY_BUFFER); + this.end(); + } else { + const code = data.readUInt16BE(0); + if (!isValidStatusCode(code)) { + const error = this.createError( + RangeError, + `invalid status code ${code}`, + true, + 1002, + "WS_ERR_INVALID_CLOSE_CODE" + ); + cb(error); + return; + } + const buf = new FastBuffer( + data.buffer, + data.byteOffset + 2, + data.length - 2 + ); + if (!this._skipUTF8Validation && !isValidUTF8(buf)) { + const error = this.createError( + Error, + "invalid UTF-8 sequence", + true, + 1007, + "WS_ERR_INVALID_UTF8" + ); + cb(error); + return; + } + this._loop = false; + this.emit("conclude", code, buf); + this.end(); + } + this._state = GET_INFO; + return; + } + if (this._allowSynchronousEvents) { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + } else { + this._state = DEFER_EVENT; + setImmediate(() => { + this.emit(this._opcode === 9 ? "ping" : "pong", data); + this._state = GET_INFO; + this.startLoop(cb); + }); + } + } + /** + * Builds an error object. + * + * @param {function(new:Error|RangeError)} ErrorCtor The error constructor + * @param {String} message The error message + * @param {Boolean} prefix Specifies whether or not to add a default prefix to + * `message` + * @param {Number} statusCode The status code + * @param {String} errorCode The exposed error code + * @return {(Error|RangeError)} The error + * @private + */ + createError(ErrorCtor, message, prefix, statusCode, errorCode) { + this._loop = false; + this._errored = true; + const err = new ErrorCtor( + prefix ? `Invalid WebSocket frame: ${message}` : message + ); + Error.captureStackTrace(err, this.createError); + err.code = errorCode; + err[kStatusCode] = statusCode; + return err; + } + }; + module2.exports = Receiver; + } +}); + +// node_modules/ws/lib/sender.js +var require_sender = __commonJS({ + "node_modules/ws/lib/sender.js"(exports2, module2) { + "use strict"; + var { Duplex } = require("stream"); + var { randomFillSync } = require("crypto"); + var { + types: { isUint8Array } + } = require("util"); + var PerMessageDeflate = require_permessage_deflate(); + var { EMPTY_BUFFER, kWebSocket, NOOP } = require_constants(); + var { isBlob, isValidStatusCode } = require_validation(); + var { mask: applyMask, toBuffer } = require_buffer_util(); + var kByteLength = Symbol("kByteLength"); + var maskBuffer = Buffer.alloc(4); + var RANDOM_POOL_SIZE = 8 * 1024; + var randomPool; + var randomPoolPointer = RANDOM_POOL_SIZE; + var DEFAULT = 0; + var DEFLATING = 1; + var GET_BLOB_DATA = 2; + var Sender = class _Sender { + /** + * Creates a Sender instance. + * + * @param {Duplex} socket The connection socket + * @param {Object} [extensions] An object containing the negotiated extensions + * @param {Function} [generateMask] The function used to generate the masking + * key + */ + constructor(socket, extensions, generateMask) { + this._extensions = extensions || {}; + if (generateMask) { + this._generateMask = generateMask; + this._maskBuffer = Buffer.alloc(4); + } + this._socket = socket; + this._firstFragment = true; + this._compress = false; + this._bufferedBytes = 0; + this._queue = []; + this._state = DEFAULT; + this.onerror = NOOP; + this[kWebSocket] = void 0; + } + /** + * Frames a piece of data according to the HyBi WebSocket protocol. + * + * @param {(Buffer|String)} data The data to frame + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @return {(Buffer|String)[]} The framed data + * @public + */ + static frame(data, options) { + let mask; + let merge = false; + let offset = 2; + let skipMasking = false; + if (options.mask) { + mask = options.maskBuffer || maskBuffer; + if (options.generateMask) { + options.generateMask(mask); + } else { + if (randomPoolPointer === RANDOM_POOL_SIZE) { + if (randomPool === void 0) { + randomPool = Buffer.alloc(RANDOM_POOL_SIZE); + } + randomFillSync(randomPool, 0, RANDOM_POOL_SIZE); + randomPoolPointer = 0; + } + mask[0] = randomPool[randomPoolPointer++]; + mask[1] = randomPool[randomPoolPointer++]; + mask[2] = randomPool[randomPoolPointer++]; + mask[3] = randomPool[randomPoolPointer++]; + } + skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0; + offset = 6; + } + let dataLength; + if (typeof data === "string") { + if ((!options.mask || skipMasking) && options[kByteLength] !== void 0) { + dataLength = options[kByteLength]; + } else { + data = Buffer.from(data); + dataLength = data.length; + } + } else { + dataLength = data.length; + merge = options.mask && options.readOnly && !skipMasking; + } + let payloadLength = dataLength; + if (dataLength >= 65536) { + offset += 8; + payloadLength = 127; + } else if (dataLength > 125) { + offset += 2; + payloadLength = 126; + } + const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset); + target[0] = options.fin ? options.opcode | 128 : options.opcode; + if (options.rsv1) target[0] |= 64; + target[1] = payloadLength; + if (payloadLength === 126) { + target.writeUInt16BE(dataLength, 2); + } else if (payloadLength === 127) { + target[2] = target[3] = 0; + target.writeUIntBE(dataLength, 4, 6); + } + if (!options.mask) return [target, data]; + target[1] |= 128; + target[offset - 4] = mask[0]; + target[offset - 3] = mask[1]; + target[offset - 2] = mask[2]; + target[offset - 1] = mask[3]; + if (skipMasking) return [target, data]; + if (merge) { + applyMask(data, mask, target, offset, dataLength); + return [target]; + } + applyMask(data, mask, data, 0, dataLength); + return [target, data]; + } + /** + * Sends a close message to the other peer. + * + * @param {Number} [code] The status code component of the body + * @param {(String|Buffer)} [data] The message component of the body + * @param {Boolean} [mask=false] Specifies whether or not to mask the message + * @param {Function} [cb] Callback + * @public + */ + close(code, data, mask, cb) { + let buf; + if (code === void 0) { + buf = EMPTY_BUFFER; + } else if (typeof code !== "number" || !isValidStatusCode(code)) { + throw new TypeError("First argument must be a valid error code number"); + } else if (data === void 0 || !data.length) { + buf = Buffer.allocUnsafe(2); + buf.writeUInt16BE(code, 0); + } else { + const length = Buffer.byteLength(data); + if (length > 123) { + throw new RangeError("The message must not be greater than 123 bytes"); + } + buf = Buffer.allocUnsafe(2 + length); + buf.writeUInt16BE(code, 0); + if (typeof data === "string") { + buf.write(data, 2); + } else if (isUint8Array(data)) { + buf.set(data, 2); + } else { + throw new TypeError("Second argument must be a string or a Uint8Array"); + } + } + const options = { + [kByteLength]: buf.length, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 8, + readOnly: false, + rsv1: false + }; + if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, buf, false, options, cb]); + } else { + this.sendFrame(_Sender.frame(buf, options), cb); + } + } + /** + * Sends a ping message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + ping(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 9, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(_Sender.frame(data, options), cb); + } + } + /** + * Sends a pong message to the other peer. + * + * @param {*} data The message to send + * @param {Boolean} [mask=false] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback + * @public + */ + pong(data, mask, cb) { + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (byteLength > 125) { + throw new RangeError("The data size must not be greater than 125 bytes"); + } + const options = { + [kByteLength]: byteLength, + fin: true, + generateMask: this._generateMask, + mask, + maskBuffer: this._maskBuffer, + opcode: 10, + readOnly, + rsv1: false + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, false, options, cb]); + } else { + this.getBlobData(data, false, options, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, false, options, cb]); + } else { + this.sendFrame(_Sender.frame(data, options), cb); + } + } + /** + * Sends a data message to the other peer. + * + * @param {*} data The message to send + * @param {Object} options Options object + * @param {Boolean} [options.binary=false] Specifies whether `data` is binary + * or text + * @param {Boolean} [options.compress=false] Specifies whether or not to + * compress `data` + * @param {Boolean} [options.fin=false] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Function} [cb] Callback + * @public + */ + send(data, options, cb) { + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + let opcode = options.binary ? 2 : 1; + let rsv1 = options.compress; + let byteLength; + let readOnly; + if (typeof data === "string") { + byteLength = Buffer.byteLength(data); + readOnly = false; + } else if (isBlob(data)) { + byteLength = data.size; + readOnly = false; + } else { + data = toBuffer(data); + byteLength = data.length; + readOnly = toBuffer.readOnly; + } + if (this._firstFragment) { + this._firstFragment = false; + if (rsv1 && perMessageDeflate && perMessageDeflate.params[perMessageDeflate._isServer ? "server_no_context_takeover" : "client_no_context_takeover"]) { + rsv1 = byteLength >= perMessageDeflate._threshold; + } + this._compress = rsv1; + } else { + rsv1 = false; + opcode = 0; + } + if (options.fin) this._firstFragment = true; + const opts = { + [kByteLength]: byteLength, + fin: options.fin, + generateMask: this._generateMask, + mask: options.mask, + maskBuffer: this._maskBuffer, + opcode, + readOnly, + rsv1 + }; + if (isBlob(data)) { + if (this._state !== DEFAULT) { + this.enqueue([this.getBlobData, data, this._compress, opts, cb]); + } else { + this.getBlobData(data, this._compress, opts, cb); + } + } else if (this._state !== DEFAULT) { + this.enqueue([this.dispatch, data, this._compress, opts, cb]); + } else { + this.dispatch(data, this._compress, opts, cb); + } + } + /** + * Gets the contents of a blob as binary data. + * + * @param {Blob} blob The blob + * @param {Boolean} [compress=false] Specifies whether or not to compress + * the data + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + getBlobData(blob, compress, options, cb) { + this._bufferedBytes += options[kByteLength]; + this._state = GET_BLOB_DATA; + blob.arrayBuffer().then((arrayBuffer) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while the blob was being read" + ); + process.nextTick(callCallbacks, this, err, cb); + return; + } + this._bufferedBytes -= options[kByteLength]; + const data = toBuffer(arrayBuffer); + if (!compress) { + this._state = DEFAULT; + this.sendFrame(_Sender.frame(data, options), cb); + this.dequeue(); + } else { + this.dispatch(data, compress, options, cb); + } + }).catch((err) => { + process.nextTick(onError, this, err, cb); + }); + } + /** + * Dispatches a message. + * + * @param {(Buffer|String)} data The message to send + * @param {Boolean} [compress=false] Specifies whether or not to compress + * `data` + * @param {Object} options Options object + * @param {Boolean} [options.fin=false] Specifies whether or not to set the + * FIN bit + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Boolean} [options.mask=false] Specifies whether or not to mask + * `data` + * @param {Buffer} [options.maskBuffer] The buffer used to store the masking + * key + * @param {Number} options.opcode The opcode + * @param {Boolean} [options.readOnly=false] Specifies whether `data` can be + * modified + * @param {Boolean} [options.rsv1=false] Specifies whether or not to set the + * RSV1 bit + * @param {Function} [cb] Callback + * @private + */ + dispatch(data, compress, options, cb) { + if (!compress) { + this.sendFrame(_Sender.frame(data, options), cb); + return; + } + const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName]; + this._bufferedBytes += options[kByteLength]; + this._state = DEFLATING; + perMessageDeflate.compress(data, options.fin, (_, buf) => { + if (this._socket.destroyed) { + const err = new Error( + "The socket was closed while data was being compressed" + ); + callCallbacks(this, err, cb); + return; + } + this._bufferedBytes -= options[kByteLength]; + this._state = DEFAULT; + options.readOnly = false; + this.sendFrame(_Sender.frame(buf, options), cb); + this.dequeue(); + }); + } + /** + * Executes queued send operations. + * + * @private + */ + dequeue() { + while (this._state === DEFAULT && this._queue.length) { + const params = this._queue.shift(); + this._bufferedBytes -= params[3][kByteLength]; + Reflect.apply(params[0], this, params.slice(1)); + } + } + /** + * Enqueues a send operation. + * + * @param {Array} params Send operation parameters. + * @private + */ + enqueue(params) { + this._bufferedBytes += params[3][kByteLength]; + this._queue.push(params); + } + /** + * Sends a frame. + * + * @param {(Buffer | String)[]} list The frame to send + * @param {Function} [cb] Callback + * @private + */ + sendFrame(list, cb) { + if (list.length === 2) { + this._socket.cork(); + this._socket.write(list[0]); + this._socket.write(list[1], cb); + this._socket.uncork(); + } else { + this._socket.write(list[0], cb); + } + } + }; + module2.exports = Sender; + function callCallbacks(sender, err, cb) { + if (typeof cb === "function") cb(err); + for (let i = 0; i < sender._queue.length; i++) { + const params = sender._queue[i]; + const callback = params[params.length - 1]; + if (typeof callback === "function") callback(err); + } + } + function onError(sender, err, cb) { + callCallbacks(sender, err, cb); + sender.onerror(err); + } + } +}); + +// node_modules/ws/lib/event-target.js +var require_event_target = __commonJS({ + "node_modules/ws/lib/event-target.js"(exports2, module2) { + "use strict"; + var { kForOnEventAttribute, kListener } = require_constants(); + var kCode = Symbol("kCode"); + var kData = Symbol("kData"); + var kError = Symbol("kError"); + var kMessage = Symbol("kMessage"); + var kReason = Symbol("kReason"); + var kTarget = Symbol("kTarget"); + var kType = Symbol("kType"); + var kWasClean = Symbol("kWasClean"); + var Event = class { + /** + * Create a new `Event`. + * + * @param {String} type The name of the event + * @throws {TypeError} If the `type` argument is not specified + */ + constructor(type) { + this[kTarget] = null; + this[kType] = type; + } + /** + * @type {*} + */ + get target() { + return this[kTarget]; + } + /** + * @type {String} + */ + get type() { + return this[kType]; + } + }; + Object.defineProperty(Event.prototype, "target", { enumerable: true }); + Object.defineProperty(Event.prototype, "type", { enumerable: true }); + var CloseEvent = class extends Event { + /** + * Create a new `CloseEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {Number} [options.code=0] The status code explaining why the + * connection was closed + * @param {String} [options.reason=''] A human-readable string explaining why + * the connection was closed + * @param {Boolean} [options.wasClean=false] Indicates whether or not the + * connection was cleanly closed + */ + constructor(type, options = {}) { + super(type); + this[kCode] = options.code === void 0 ? 0 : options.code; + this[kReason] = options.reason === void 0 ? "" : options.reason; + this[kWasClean] = options.wasClean === void 0 ? false : options.wasClean; + } + /** + * @type {Number} + */ + get code() { + return this[kCode]; + } + /** + * @type {String} + */ + get reason() { + return this[kReason]; + } + /** + * @type {Boolean} + */ + get wasClean() { + return this[kWasClean]; + } + }; + Object.defineProperty(CloseEvent.prototype, "code", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "reason", { enumerable: true }); + Object.defineProperty(CloseEvent.prototype, "wasClean", { enumerable: true }); + var ErrorEvent = class extends Event { + /** + * Create a new `ErrorEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.error=null] The error that generated this event + * @param {String} [options.message=''] The error message + */ + constructor(type, options = {}) { + super(type); + this[kError] = options.error === void 0 ? null : options.error; + this[kMessage] = options.message === void 0 ? "" : options.message; + } + /** + * @type {*} + */ + get error() { + return this[kError]; + } + /** + * @type {String} + */ + get message() { + return this[kMessage]; + } + }; + Object.defineProperty(ErrorEvent.prototype, "error", { enumerable: true }); + Object.defineProperty(ErrorEvent.prototype, "message", { enumerable: true }); + var MessageEvent = class extends Event { + /** + * Create a new `MessageEvent`. + * + * @param {String} type The name of the event + * @param {Object} [options] A dictionary object that allows for setting + * attributes via object members of the same name + * @param {*} [options.data=null] The message content + */ + constructor(type, options = {}) { + super(type); + this[kData] = options.data === void 0 ? null : options.data; + } + /** + * @type {*} + */ + get data() { + return this[kData]; + } + }; + Object.defineProperty(MessageEvent.prototype, "data", { enumerable: true }); + var EventTarget = { + /** + * Register an event listener. + * + * @param {String} type A string representing the event type to listen for + * @param {(Function|Object)} handler The listener to add + * @param {Object} [options] An options object specifies characteristics about + * the event listener + * @param {Boolean} [options.once=false] A `Boolean` indicating that the + * listener should be invoked at most once after being added. If `true`, + * the listener would be automatically removed when invoked. + * @public + */ + addEventListener(type, handler, options = {}) { + for (const listener of this.listeners(type)) { + if (!options[kForOnEventAttribute] && listener[kListener] === handler && !listener[kForOnEventAttribute]) { + return; + } + } + let wrapper; + if (type === "message") { + wrapper = function onMessage(data, isBinary) { + const event = new MessageEvent("message", { + data: isBinary ? data : data.toString() + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "close") { + wrapper = function onClose(code, message) { + const event = new CloseEvent("close", { + code, + reason: message.toString(), + wasClean: this._closeFrameReceived && this._closeFrameSent + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "error") { + wrapper = function onError(error) { + const event = new ErrorEvent("error", { + error, + message: error.message + }); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else if (type === "open") { + wrapper = function onOpen() { + const event = new Event("open"); + event[kTarget] = this; + callListener(handler, this, event); + }; + } else { + return; + } + wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; + wrapper[kListener] = handler; + if (options.once) { + this.once(type, wrapper); + } else { + this.on(type, wrapper); + } + }, + /** + * Remove an event listener. + * + * @param {String} type A string representing the event type to remove + * @param {(Function|Object)} handler The listener to remove + * @public + */ + removeEventListener(type, handler) { + for (const listener of this.listeners(type)) { + if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { + this.removeListener(type, listener); + break; + } + } + } + }; + module2.exports = { + CloseEvent, + ErrorEvent, + Event, + EventTarget, + MessageEvent + }; + function callListener(listener, thisArg, event) { + if (typeof listener === "object" && listener.handleEvent) { + listener.handleEvent.call(listener, event); + } else { + listener.call(thisArg, event); + } + } + } +}); + +// node_modules/ws/lib/extension.js +var require_extension = __commonJS({ + "node_modules/ws/lib/extension.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation(); + function push(dest, name, elem) { + if (dest[name] === void 0) dest[name] = [elem]; + else dest[name].push(elem); + } + function parse(header) { + const offers = /* @__PURE__ */ Object.create(null); + let params = /* @__PURE__ */ Object.create(null); + let mustUnescape = false; + let isEscaping = false; + let inQuotes = false; + let extensionName; + let paramName; + let start = -1; + let code = -1; + let end = -1; + let i = 0; + for (; i < header.length; i++) { + code = header.charCodeAt(i); + if (extensionName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + const name = header.slice(start, end); + if (code === 44) { + push(offers, name, params); + params = /* @__PURE__ */ Object.create(null); + } else { + extensionName = name; + } + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (paramName === void 0) { + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 32 || code === 9) { + if (end === -1 && start !== -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + push(params, header.slice(start, end), true); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + start = end = -1; + } else if (code === 61 && start !== -1 && end === -1) { + paramName = header.slice(start, i); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else { + if (isEscaping) { + if (tokenChars[code] !== 1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (start === -1) start = i; + else if (!mustUnescape) mustUnescape = true; + isEscaping = false; + } else if (inQuotes) { + if (tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (code === 34 && start !== -1) { + inQuotes = false; + end = i; + } else if (code === 92) { + isEscaping = true; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } else if (code === 34 && header.charCodeAt(i - 1) === 61) { + inQuotes = true; + } else if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (start !== -1 && (code === 32 || code === 9)) { + if (end === -1) end = i; + } else if (code === 59 || code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + let value = header.slice(start, end); + if (mustUnescape) { + value = value.replace(/\\/g, ""); + mustUnescape = false; + } + push(params, paramName, value); + if (code === 44) { + push(offers, extensionName, params); + params = /* @__PURE__ */ Object.create(null); + extensionName = void 0; + } + paramName = void 0; + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + } + if (start === -1 || inQuotes || code === 32 || code === 9) { + throw new SyntaxError("Unexpected end of input"); + } + if (end === -1) end = i; + const token = header.slice(start, end); + if (extensionName === void 0) { + push(offers, token, params); + } else { + if (paramName === void 0) { + push(params, token, true); + } else if (mustUnescape) { + push(params, paramName, token.replace(/\\/g, "")); + } else { + push(params, paramName, token); + } + push(offers, extensionName, params); + } + return offers; + } + function format(extensions) { + return Object.keys(extensions).map((extension) => { + let configurations = extensions[extension]; + if (!Array.isArray(configurations)) configurations = [configurations]; + return configurations.map((params) => { + return [extension].concat( + Object.keys(params).map((k) => { + let values = params[k]; + if (!Array.isArray(values)) values = [values]; + return values.map((v) => v === true ? k : `${k}=${v}`).join("; "); + }) + ).join("; "); + }).join(", "); + }).join(", "); + } + module2.exports = { format, parse }; + } +}); + +// node_modules/ws/lib/websocket.js +var require_websocket = __commonJS({ + "node_modules/ws/lib/websocket.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var https = require("https"); + var http = require("http"); + var net = require("net"); + var tls = require("tls"); + var { randomBytes, createHash } = require("crypto"); + var { Duplex, Readable } = require("stream"); + var { URL } = require("url"); + var PerMessageDeflate = require_permessage_deflate(); + var Receiver = require_receiver(); + var Sender = require_sender(); + var { isBlob } = require_validation(); + var { + BINARY_TYPES, + CLOSE_TIMEOUT, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP + } = require_constants(); + var { + EventTarget: { addEventListener, removeEventListener } + } = require_event_target(); + var { format, parse } = require_extension(); + var { toBuffer } = require_buffer_util(); + var kAborted = Symbol("kAborted"); + var protocolVersions = [8, 13]; + var readyStates = ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]; + var subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + var WebSocket = class _WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options) { + super(); + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ""; + this._readyState = _WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + if (protocols === void 0) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === "object" && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + initAsClient(this, address, protocols, options); + } else { + this._autoPong = options.autoPong; + this._closeTimeout = options.closeTimeout; + this._isServer = true; + } + } + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + this._binaryType = type; + if (this._receiver) this._receiver._binaryType = type; + } + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + return this._socket._writableState.length + this._sender._bufferedBytes; + } + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + /** + * @type {String} + */ + get url() { + return this._url; + } + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxBufferedChunks=0] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=0] The maximum number of message + * fragments + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver({ + allowSynchronousEvents: options.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxBufferedChunks: options.maxBufferedChunks, + maxFragments: options.maxFragments, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + const sender = new Sender(socket, this._extensions, options.generateMask); + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + receiver.on("conclude", receiverOnConclude); + receiver.on("drain", receiverOnDrain); + receiver.on("error", receiverOnError); + receiver.on("message", receiverOnMessage); + receiver.on("ping", receiverOnPing); + receiver.on("pong", receiverOnPong); + sender.onerror = senderOnError; + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + if (head.length > 0) socket.unshift(head); + socket.on("close", socketOnClose); + socket.on("data", socketOnData); + socket.on("end", socketOnEnd); + socket.on("error", socketOnError); + this._readyState = _WebSocket.OPEN; + this.emit("open"); + } + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = _WebSocket.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + return; + } + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + this._receiver.removeAllListeners(); + this._readyState = _WebSocket.CLOSED; + this.emit("close", this._closeCode, this._closeMessage); + } + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === _WebSocket.CLOSED) return; + if (this.readyState === _WebSocket.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this.readyState === _WebSocket.CLOSING) { + if (this._closeFrameSent && (this._closeFrameReceived || this._receiver._writableState.errorEmitted)) { + this._socket.end(); + } + return; + } + this._readyState = _WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + if (err) return; + this._closeFrameSent = true; + if (this._closeFrameReceived || this._receiver._writableState.errorEmitted) { + this._socket.end(); + } + }); + setCloseTimer(this); + } + /** + * Pause the socket. + * + * @public + */ + pause() { + if (this.readyState === _WebSocket.CONNECTING || this.readyState === _WebSocket.CLOSED) { + return; + } + this._paused = true; + this._socket.pause(); + } + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof data === "function") { + cb = data; + data = mask = void 0; + } else if (typeof mask === "function") { + cb = mask; + mask = void 0; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + if (mask === void 0) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + /** + * Resume the socket. + * + * @public + */ + resume() { + if (this.readyState === _WebSocket.CONNECTING || this.readyState === _WebSocket.CLOSED) { + return; + } + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options, cb) { + if (this.readyState === _WebSocket.CONNECTING) { + throw new Error("WebSocket is not open: readyState 0 (CONNECTING)"); + } + if (typeof options === "function") { + cb = options; + options = {}; + } + if (typeof data === "number") data = data.toString(); + if (this.readyState !== _WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + const opts = { + binary: typeof data !== "string", + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === _WebSocket.CLOSED) return; + if (this.readyState === _WebSocket.CONNECTING) { + const msg = "WebSocket was closed before the connection was established"; + abortHandshake(this, this._req, msg); + return; + } + if (this._socket) { + this._readyState = _WebSocket.CLOSING; + this._socket.destroy(); + } + } + }; + Object.defineProperty(WebSocket, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket.prototype, "CONNECTING", { + enumerable: true, + value: readyStates.indexOf("CONNECTING") + }); + Object.defineProperty(WebSocket, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket.prototype, "OPEN", { + enumerable: true, + value: readyStates.indexOf("OPEN") + }); + Object.defineProperty(WebSocket, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket.prototype, "CLOSING", { + enumerable: true, + value: readyStates.indexOf("CLOSING") + }); + Object.defineProperty(WebSocket, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + Object.defineProperty(WebSocket.prototype, "CLOSED", { + enumerable: true, + value: readyStates.indexOf("CLOSED") + }); + [ + "binaryType", + "bufferedAmount", + "extensions", + "isPaused", + "protocol", + "readyState", + "url" + ].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); + }); + ["open", "error", "close", "message"].forEach((method) => { + Object.defineProperty(WebSocket.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + if (typeof handler !== "function") return; + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); + }); + WebSocket.prototype.addEventListener = addEventListener; + WebSocket.prototype.removeEventListener = removeEventListener; + module2.exports = WebSocket; + function initAsClient(websocket, address, protocols, options) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + closeTimeout: CLOSE_TIMEOUT, + protocolVersion: protocolVersions[1], + maxBufferedChunks: 256 * 1024, + maxFragments: 16 * 1024, + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + socketPath: void 0, + hostname: void 0, + protocol: void 0, + timeout: void 0, + method: "GET", + host: void 0, + path: void 0, + port: void 0 + }; + websocket._autoPong = opts.autoPong; + websocket._closeTimeout = opts.closeTimeout; + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} (supported versions: ${protocolVersions.join(", ")})` + ); + } + let parsedUrl; + if (address instanceof URL) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL(address); + } catch { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + if (parsedUrl.protocol === "http:") { + parsedUrl.protocol = "ws:"; + } else if (parsedUrl.protocol === "https:") { + parsedUrl.protocol = "wss:"; + } + websocket._url = parsedUrl.href; + const isSecure = parsedUrl.protocol === "wss:"; + const isIpcUrl = parsedUrl.protocol === "ws+unix:"; + let invalidUrlMessage; + if (parsedUrl.protocol !== "ws:" && !isSecure && !isIpcUrl) { + invalidUrlMessage = `The URL's protocol must be one of "ws:", "wss:", "http:", "https:", or "ws+unix:"`; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = "The URL contains a fragment identifier"; + } + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString("base64"); + const request = isSecure ? https.request : http.request; + const protocolSet = /* @__PURE__ */ new Set(); + let perMessageDeflate; + opts.createConnection = opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith("[") ? parsedUrl.hostname.slice(1, -1) : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + "Sec-WebSocket-Version": opts.protocolVersion, + "Sec-WebSocket-Key": key, + Connection: "Upgrade", + Upgrade: "websocket" + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate({ + ...opts.perMessageDeflate, + isServer: false, + maxPayload: opts.maxPayload + }); + opts.headers["Sec-WebSocket-Extensions"] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if (typeof protocol !== "string" || !subprotocolRegex.test(protocol) || protocolSet.has(protocol)) { + throw new SyntaxError( + "An invalid or duplicated subprotocol was specified" + ); + } + protocolSet.add(protocol); + } + opts.headers["Sec-WebSocket-Protocol"] = protocols.join(","); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers["Sec-WebSocket-Origin"] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + if (isIpcUrl) { + const parts = opts.path.split(":"); + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + let req; + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl ? opts.socketPath : parsedUrl.host; + const headers = options && options.headers; + options = { ...options, headers: {} }; + if (headers) { + for (const [key2, value] of Object.entries(headers)) { + options.headers[key2.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount("redirect") === 0) { + const isSameHost = isIpcUrl ? websocket._originalIpc ? opts.socketPath === websocket._originalHostOrSocketPath : false : websocket._originalIpc ? false : parsedUrl.host === websocket._originalHostOrSocketPath; + if (!isSameHost || websocket._originalSecure && !isSecure) { + delete opts.headers.authorization; + delete opts.headers.cookie; + if (!isSameHost) delete opts.headers.host; + opts.auth = void 0; + } + } + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = "Basic " + Buffer.from(opts.auth).toString("base64"); + } + req = websocket._req = request(opts); + if (websocket._redirects) { + websocket.emit("redirect", websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + if (opts.timeout) { + req.on("timeout", () => { + abortHandshake(websocket, req, "Opening handshake has timed out"); + }); + } + req.on("error", (err) => { + if (req === null || req[kAborted]) return; + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + req.on("response", (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + if (location && opts.followRedirects && statusCode >= 300 && statusCode < 400) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, "Maximum redirects exceeded"); + return; + } + req.abort(); + let addr; + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit("unexpected-response", req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + req.on("upgrade", (res, socket, head) => { + websocket.emit("upgrade", res); + if (websocket.readyState !== WebSocket.CONNECTING) return; + req = websocket._req = null; + const upgrade = res.headers.upgrade; + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + abortHandshake(websocket, socket, "Invalid Upgrade header"); + return; + } + const digest = createHash("sha1").update(key + GUID).digest("base64"); + if (res.headers["sec-websocket-accept"] !== digest) { + abortHandshake(websocket, socket, "Invalid Sec-WebSocket-Accept header"); + return; + } + const serverProt = res.headers["sec-websocket-protocol"]; + let protError; + if (serverProt !== void 0) { + if (!protocolSet.size) { + protError = "Server sent a subprotocol but none was requested"; + } else if (!protocolSet.has(serverProt)) { + protError = "Server sent an invalid subprotocol"; + } + } else if (protocolSet.size) { + protError = "Server sent no subprotocol"; + } + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + if (serverProt) websocket._protocol = serverProt; + const secWebSocketExtensions = res.headers["sec-websocket-extensions"]; + if (secWebSocketExtensions !== void 0) { + if (!perMessageDeflate) { + const message = "Server sent a Sec-WebSocket-Extensions header but no extension was requested"; + abortHandshake(websocket, socket, message); + return; + } + let extensions; + try { + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + const extensionNames = Object.keys(extensions); + if (extensionNames.length !== 1 || extensionNames[0] !== PerMessageDeflate.extensionName) { + const message = "Server indicated an extension that was not requested"; + abortHandshake(websocket, socket, message); + return; + } + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = "Invalid Sec-WebSocket-Extensions header"; + abortHandshake(websocket, socket, message); + return; + } + websocket._extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxBufferedChunks: opts.maxBufferedChunks, + maxFragments: opts.maxFragments, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } + } + function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + websocket._errorEmitted = true; + websocket.emit("error", err); + websocket.emitClose(); + } + function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); + } + function tlsConnect(options) { + options.path = void 0; + if (!options.servername && options.servername !== "") { + options.servername = net.isIP(options.host) ? "" : options.host; + } + return tls.connect(options); + } + function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket.CLOSING; + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + if (stream.socket && !stream.socket.destroyed) { + stream.socket.destroy(); + } + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once("error", websocket.emit.bind(websocket, "error")); + stream.once("close", websocket.emitClose.bind(websocket)); + } + } + function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} (${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } + } + function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + if (websocket._socket[kWebSocket] === void 0) return; + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + if (code === 1005) websocket.close(); + else websocket.close(code, reason); + } + function receiverOnDrain() { + const websocket = this[kWebSocket]; + if (!websocket.isPaused) websocket._socket.resume(); + } + function receiverOnError(err) { + const websocket = this[kWebSocket]; + if (websocket._socket[kWebSocket] !== void 0) { + websocket._socket.removeListener("data", socketOnData); + process.nextTick(resume, websocket._socket); + websocket.close(err[kStatusCode]); + } + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function receiverOnFinish() { + this[kWebSocket].emitClose(); + } + function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit("message", data, isBinary); + } + function receiverOnPing(data) { + const websocket = this[kWebSocket]; + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit("ping", data); + } + function receiverOnPong(data) { + this[kWebSocket].emit("pong", data); + } + function resume(stream) { + stream.resume(); + } + function senderOnError(err) { + const websocket = this[kWebSocket]; + if (websocket.readyState === WebSocket.CLOSED) return; + if (websocket.readyState === WebSocket.OPEN) { + websocket._readyState = WebSocket.CLOSING; + setCloseTimer(websocket); + } + this._socket.end(); + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit("error", err); + } + } + function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + websocket._closeTimeout + ); + } + function socketOnClose() { + const websocket = this[kWebSocket]; + this.removeListener("close", socketOnClose); + this.removeListener("data", socketOnData); + this.removeListener("end", socketOnEnd); + websocket._readyState = WebSocket.CLOSING; + if (!this._readableState.endEmitted && !websocket._closeFrameReceived && !websocket._receiver._writableState.errorEmitted && this._readableState.length !== 0) { + const chunk = this.read(this._readableState.length); + websocket._receiver.write(chunk); + } + websocket._receiver.end(); + this[kWebSocket] = void 0; + clearTimeout(websocket._closeTimer); + if (websocket._receiver._writableState.finished || websocket._receiver._writableState.errorEmitted) { + websocket.emitClose(); + } else { + websocket._receiver.on("error", receiverOnFinish); + websocket._receiver.on("finish", receiverOnFinish); + } + } + function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } + } + function socketOnEnd() { + const websocket = this[kWebSocket]; + websocket._readyState = WebSocket.CLOSING; + websocket._receiver.end(); + this.end(); + } + function socketOnError() { + const websocket = this[kWebSocket]; + this.removeListener("error", socketOnError); + this.on("error", NOOP); + if (websocket) { + websocket._readyState = WebSocket.CLOSING; + this.destroy(); + } + } + } +}); + +// node_modules/ws/lib/stream.js +var require_stream = __commonJS({ + "node_modules/ws/lib/stream.js"(exports2, module2) { + "use strict"; + var WebSocket = require_websocket(); + var { Duplex } = require("stream"); + function emitClose(stream) { + stream.emit("close"); + } + function duplexOnEnd() { + if (!this.destroyed && this._writableState.finished) { + this.destroy(); + } + } + function duplexOnError(err) { + this.removeListener("error", duplexOnError); + this.destroy(); + if (this.listenerCount("error") === 0) { + this.emit("error", err); + } + } + function createWebSocketStream(ws, options) { + let terminateOnDestroy = true; + const duplex = new Duplex({ + ...options, + autoDestroy: false, + emitClose: false, + objectMode: false, + writableObjectMode: false + }); + ws.on("message", function message(msg, isBinary) { + const data = !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; + if (!duplex.push(data)) ws.pause(); + }); + ws.once("error", function error(err) { + if (duplex.destroyed) return; + terminateOnDestroy = false; + duplex.destroy(err); + }); + ws.once("close", function close() { + if (duplex.destroyed) return; + duplex.push(null); + }); + duplex._destroy = function(err, callback) { + if (ws.readyState === ws.CLOSED) { + callback(err); + process.nextTick(emitClose, duplex); + return; + } + let called = false; + ws.once("error", function error(err2) { + called = true; + callback(err2); + }); + ws.once("close", function close() { + if (!called) callback(err); + process.nextTick(emitClose, duplex); + }); + if (terminateOnDestroy) ws.terminate(); + }; + duplex._final = function(callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._final(callback); + }); + return; + } + if (ws._socket === null) return; + if (ws._socket._writableState.finished) { + callback(); + if (duplex._readableState.endEmitted) duplex.destroy(); + } else { + ws._socket.once("finish", function finish() { + callback(); + }); + ws.close(); + } + }; + duplex._read = function() { + if (ws.isPaused) ws.resume(); + }; + duplex._write = function(chunk, encoding, callback) { + if (ws.readyState === ws.CONNECTING) { + ws.once("open", function open() { + duplex._write(chunk, encoding, callback); + }); + return; + } + ws.send(chunk, callback); + }; + duplex.on("end", duplexOnEnd); + duplex.on("error", duplexOnError); + return duplex; + } + module2.exports = createWebSocketStream; + } +}); + +// node_modules/ws/lib/subprotocol.js +var require_subprotocol = __commonJS({ + "node_modules/ws/lib/subprotocol.js"(exports2, module2) { + "use strict"; + var { tokenChars } = require_validation(); + function parse(header) { + const protocols = /* @__PURE__ */ new Set(); + let start = -1; + let end = -1; + let i = 0; + for (i; i < header.length; i++) { + const code = header.charCodeAt(i); + if (end === -1 && tokenChars[code] === 1) { + if (start === -1) start = i; + } else if (i !== 0 && (code === 32 || code === 9)) { + if (end === -1 && start !== -1) end = i; + } else if (code === 44) { + if (start === -1) { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + if (end === -1) end = i; + const protocol2 = header.slice(start, end); + if (protocols.has(protocol2)) { + throw new SyntaxError(`The "${protocol2}" subprotocol is duplicated`); + } + protocols.add(protocol2); + start = end = -1; + } else { + throw new SyntaxError(`Unexpected character at index ${i}`); + } + } + if (start === -1 || end !== -1) { + throw new SyntaxError("Unexpected end of input"); + } + const protocol = header.slice(start, i); + if (protocols.has(protocol)) { + throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); + } + protocols.add(protocol); + return protocols; + } + module2.exports = { parse }; + } +}); + +// node_modules/ws/lib/websocket-server.js +var require_websocket_server = __commonJS({ + "node_modules/ws/lib/websocket-server.js"(exports2, module2) { + "use strict"; + var EventEmitter = require("events"); + var http = require("http"); + var { Duplex } = require("stream"); + var { createHash } = require("crypto"); + var extension = require_extension(); + var PerMessageDeflate = require_permessage_deflate(); + var subprotocol = require_subprotocol(); + var WebSocket = require_websocket(); + var { CLOSE_TIMEOUT, GUID, kWebSocket } = require_constants(); + var keyRegex = /^[+/0-9A-Za-z]{22}==$/; + var RUNNING = 0; + var CLOSING = 1; + var CLOSED = 2; + var WebSocketServer = class extends EventEmitter { + /** + * Create a `WebSocketServer` instance. + * + * @param {Object} options Configuration options + * @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Boolean} [options.autoPong=true] Specifies whether or not to + * automatically send a pong in response to a ping + * @param {Number} [options.backlog=511] The maximum length of the queue of + * pending connections + * @param {Boolean} [options.clientTracking=true] Specifies whether or not to + * track clients + * @param {Number} [options.closeTimeout=30000] Duration in milliseconds to + * wait for the closing handshake to finish after `websocket.close()` is + * called + * @param {Function} [options.handleProtocols] A hook to handle protocols + * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxBufferedChunks=262144] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=16384] The maximum number of message + * fragments + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Boolean} [options.noServer=false] Enable no server mode + * @param {String} [options.path] Accept only connections matching this path + * @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable + * permessage-deflate + * @param {Number} [options.port] The port where to bind the server + * @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S + * server to use + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @param {Function} [options.verifyClient] A hook to reject connections + * @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket` + * class to use. It must be the `WebSocket` class or class that extends it + * @param {Function} [callback] A listener for the `listening` event + */ + constructor(options, callback) { + super(); + options = { + allowSynchronousEvents: true, + autoPong: true, + maxBufferedChunks: 256 * 1024, + maxFragments: 16 * 1024, + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: false, + handleProtocols: null, + clientTracking: true, + closeTimeout: CLOSE_TIMEOUT, + verifyClient: null, + noServer: false, + backlog: null, + // use default (511 as implemented in net.js) + server: null, + host: null, + path: null, + port: null, + WebSocket, + ...options + }; + if (options.port == null && !options.server && !options.noServer || options.port != null && (options.server || options.noServer) || options.server && options.noServer) { + throw new TypeError( + 'One and only one of the "port", "server", or "noServer" options must be specified' + ); + } + if (options.port != null) { + this._server = http.createServer((req, res) => { + const body = http.STATUS_CODES[426]; + res.writeHead(426, { + "Content-Length": body.length, + "Content-Type": "text/plain" + }); + res.end(body); + }); + this._server.listen( + options.port, + options.host, + options.backlog, + callback + ); + } else if (options.server) { + this._server = options.server; + } + if (this._server) { + const emitConnection = this.emit.bind(this, "connection"); + this._removeListeners = addListeners(this._server, { + listening: this.emit.bind(this, "listening"), + error: this.emit.bind(this, "error"), + upgrade: (req, socket, head) => { + this.handleUpgrade(req, socket, head, emitConnection); + } + }); + } + if (options.perMessageDeflate === true) options.perMessageDeflate = {}; + if (options.clientTracking) { + this.clients = /* @__PURE__ */ new Set(); + this._shouldEmitClose = false; + } + this.options = options; + this._state = RUNNING; + } + /** + * Returns the bound address, the address family name, and port of the server + * as reported by the operating system if listening on an IP socket. + * If the server is listening on a pipe or UNIX domain socket, the name is + * returned as a string. + * + * @return {(Object|String|null)} The address of the server + * @public + */ + address() { + if (this.options.noServer) { + throw new Error('The server is operating in "noServer" mode'); + } + if (!this._server) return null; + return this._server.address(); + } + /** + * Stop the server from accepting new connections and emit the `'close'` event + * when all existing connections are closed. + * + * @param {Function} [cb] A one-time listener for the `'close'` event + * @public + */ + close(cb) { + if (this._state === CLOSED) { + if (cb) { + this.once("close", () => { + cb(new Error("The server is not running")); + }); + } + process.nextTick(emitClose, this); + return; + } + if (cb) this.once("close", cb); + if (this._state === CLOSING) return; + this._state = CLOSING; + if (this.options.noServer || this.options.server) { + if (this._server) { + this._removeListeners(); + this._removeListeners = this._server = null; + } + if (this.clients) { + if (!this.clients.size) { + process.nextTick(emitClose, this); + } else { + this._shouldEmitClose = true; + } + } else { + process.nextTick(emitClose, this); + } + } else { + const server = this._server; + this._removeListeners(); + this._removeListeners = this._server = null; + server.close(() => { + emitClose(this); + }); + } + } + /** + * See if a given request should be handled by this server instance. + * + * @param {http.IncomingMessage} req Request object to inspect + * @return {Boolean} `true` if the request is valid, else `false` + * @public + */ + shouldHandle(req) { + if (this.options.path) { + const index = req.url.indexOf("?"); + const pathname = index !== -1 ? req.url.slice(0, index) : req.url; + if (pathname !== this.options.path) return false; + } + return true; + } + /** + * Handle a HTTP Upgrade request. + * + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @public + */ + handleUpgrade(req, socket, head, cb) { + socket.on("error", socketOnError); + const key = req.headers["sec-websocket-key"]; + const upgrade = req.headers.upgrade; + const version = +req.headers["sec-websocket-version"]; + if (req.method !== "GET") { + const message = "Invalid HTTP method"; + abortHandshakeOrEmitwsClientError(this, req, socket, 405, message); + return; + } + if (upgrade === void 0 || upgrade.toLowerCase() !== "websocket") { + const message = "Invalid Upgrade header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (key === void 0 || !keyRegex.test(key)) { + const message = "Missing or invalid Sec-WebSocket-Key header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + if (version !== 13 && version !== 8) { + const message = "Missing or invalid Sec-WebSocket-Version header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, { + "Sec-WebSocket-Version": "13, 8" + }); + return; + } + if (!this.shouldHandle(req)) { + abortHandshake(socket, 400); + return; + } + const secWebSocketProtocol = req.headers["sec-websocket-protocol"]; + let protocols = /* @__PURE__ */ new Set(); + if (secWebSocketProtocol !== void 0) { + try { + protocols = subprotocol.parse(secWebSocketProtocol); + } catch (err) { + const message = "Invalid Sec-WebSocket-Protocol header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + const secWebSocketExtensions = req.headers["sec-websocket-extensions"]; + const extensions = {}; + if (this.options.perMessageDeflate && secWebSocketExtensions !== void 0) { + const perMessageDeflate = new PerMessageDeflate({ + ...this.options.perMessageDeflate, + isServer: true, + maxPayload: this.options.maxPayload + }); + try { + const offers = extension.parse(secWebSocketExtensions); + if (offers[PerMessageDeflate.extensionName]) { + perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]); + extensions[PerMessageDeflate.extensionName] = perMessageDeflate; + } + } catch (err) { + const message = "Invalid or unacceptable Sec-WebSocket-Extensions header"; + abortHandshakeOrEmitwsClientError(this, req, socket, 400, message); + return; + } + } + if (this.options.verifyClient) { + const info = { + origin: req.headers[`${version === 8 ? "sec-websocket-origin" : "origin"}`], + secure: !!(req.socket.authorized || req.socket.encrypted), + req + }; + if (this.options.verifyClient.length === 2) { + this.options.verifyClient(info, (verified, code, message, headers) => { + if (!verified) { + return abortHandshake(socket, code || 401, message, headers); + } + this.completeUpgrade( + extensions, + key, + protocols, + req, + socket, + head, + cb + ); + }); + return; + } + if (!this.options.verifyClient(info)) return abortHandshake(socket, 401); + } + this.completeUpgrade(extensions, key, protocols, req, socket, head, cb); + } + /** + * Upgrade the connection to WebSocket. + * + * @param {Object} extensions The accepted extensions + * @param {String} key The value of the `Sec-WebSocket-Key` header + * @param {Set} protocols The subprotocols + * @param {http.IncomingMessage} req The request object + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Function} cb Callback + * @throws {Error} If called more than once with the same socket + * @private + */ + completeUpgrade(extensions, key, protocols, req, socket, head, cb) { + if (!socket.readable || !socket.writable) return socket.destroy(); + if (socket[kWebSocket]) { + throw new Error( + "server.handleUpgrade() was called more than once with the same socket, possibly due to a misconfiguration" + ); + } + if (this._state > RUNNING) return abortHandshake(socket, 503); + const digest = createHash("sha1").update(key + GUID).digest("base64"); + const headers = [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${digest}` + ]; + const ws = new this.options.WebSocket(null, void 0, this.options); + if (protocols.size) { + const protocol = this.options.handleProtocols ? this.options.handleProtocols(protocols, req) : protocols.values().next().value; + if (protocol) { + headers.push(`Sec-WebSocket-Protocol: ${protocol}`); + ws._protocol = protocol; + } + } + if (extensions[PerMessageDeflate.extensionName]) { + const params = extensions[PerMessageDeflate.extensionName].params; + const value = extension.format({ + [PerMessageDeflate.extensionName]: [params] + }); + headers.push(`Sec-WebSocket-Extensions: ${value}`); + ws._extensions = extensions; + } + this.emit("headers", headers, req); + socket.write(headers.concat("\r\n").join("\r\n")); + socket.removeListener("error", socketOnError); + ws.setSocket(socket, head, { + allowSynchronousEvents: this.options.allowSynchronousEvents, + maxBufferedChunks: this.options.maxBufferedChunks, + maxFragments: this.options.maxFragments, + maxPayload: this.options.maxPayload, + skipUTF8Validation: this.options.skipUTF8Validation + }); + if (this.clients) { + this.clients.add(ws); + ws.on("close", () => { + this.clients.delete(ws); + if (this._shouldEmitClose && !this.clients.size) { + process.nextTick(emitClose, this); + } + }); + } + cb(ws, req); + } + }; + module2.exports = WebSocketServer; + function addListeners(server, map) { + for (const event of Object.keys(map)) server.on(event, map[event]); + return function removeListeners() { + for (const event of Object.keys(map)) { + server.removeListener(event, map[event]); + } + }; + } + function emitClose(server) { + server._state = CLOSED; + server.emit("close"); + } + function socketOnError() { + this.destroy(); + } + function abortHandshake(socket, code, message, headers) { + message = message || http.STATUS_CODES[code]; + headers = { + Connection: "close", + "Content-Type": "text/html", + "Content-Length": Buffer.byteLength(message), + ...headers + }; + socket.once("finish", socket.destroy); + socket.end( + `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r +` + Object.keys(headers).map((h) => `${h}: ${headers[h]}`).join("\r\n") + "\r\n\r\n" + message + ); + } + function abortHandshakeOrEmitwsClientError(server, req, socket, code, message, headers) { + if (server.listenerCount("wsClientError")) { + const err = new Error(message); + Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError); + server.emit("wsClientError", err, socket, req); + } else { + abortHandshake(socket, code, message, headers); + } + } + } +}); + +// node_modules/ws/index.js +var require_ws = __commonJS({ + "node_modules/ws/index.js"(exports2, module2) { + "use strict"; + var createWebSocketStream = require_stream(); + var extension = require_extension(); + var PerMessageDeflate = require_permessage_deflate(); + var Receiver = require_receiver(); + var Sender = require_sender(); + var subprotocol = require_subprotocol(); + var WebSocket = require_websocket(); + var WebSocketServer = require_websocket_server(); + WebSocket.createWebSocketStream = createWebSocketStream; + WebSocket.extension = extension; + WebSocket.PerMessageDeflate = PerMessageDeflate; + WebSocket.Receiver = Receiver; + WebSocket.Sender = Sender; + WebSocket.Server = WebSocketServer; + WebSocket.subprotocol = subprotocol; + WebSocket.WebSocket = WebSocket; + WebSocket.WebSocketServer = WebSocketServer; + module2.exports = WebSocket; + } +}); + +// src/host.js +var require_host = __commonJS({ + "src/host.js"(exports2, module2) { + "use strict"; + var { EventEmitter } = require("events"); + function parseArgs2(argv) { + const found = {}; + for (let i = 0; i < argv.length - 1; i += 1) { + switch (argv[i]) { + case "-port": + found.port = argv[i + 1]; + break; + case "-pluginUUID": + found.uuid = argv[i + 1]; + break; + case "-registerEvent": + found.registerEvent = argv[i + 1]; + break; + case "-info": + found.info = argv[i + 1]; + break; + default: + break; + } + } + let info = {}; + try { + info = JSON.parse(found.info || "{}"); + } catch { + info = {}; + } + return { port: found.port, uuid: found.uuid, registerEvent: found.registerEvent, info }; + } + var WS_OPEN = 1; + var StreamDockHost2 = class extends EventEmitter { + /** + * @param {{port: string|number, uuid: string, registerEvent: string, info?: object}} args + * @param {{WebSocket?: Function}} [deps] - Ersatz fuer das ws-Paket (Tests) + */ + constructor(args, deps = {}) { + super(); + this.port = args.port; + this.uuid = args.uuid; + this.registerEvent = args.registerEvent; + this.info = args.info || {}; + this.WebSocket = deps.WebSocket || require_ws(); + this.ws = null; + } + get language() { + return this.info.application && this.info.application.language || "en"; + } + connect() { + this.ws = new this.WebSocket(`ws://127.0.0.1:${this.port}`); + this.ws.on("open", () => { + this._send({ uuid: this.uuid, event: this.registerEvent }); + this.emit("open"); + }); + this.ws.on("message", (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + this.emit("event", message); + }); + this.ws.on("close", () => this.emit("close")); + this.ws.on("error", (error) => this.emit("error", error)); + } + close() { + if (this.ws) this.ws.close(); + } + // ------------------------------------------------------------- Befehle + setTitle(context, title) { + this._send({ event: "setTitle", context, payload: { target: 0, title: String(title ?? "") } }); + } + /** image = Data-URI (PNG/JPEG/SVG); ohne Wert wird das Standardbild des Zustands wiederhergestellt. */ + setImage(context, image) { + const payload = { target: 0 }; + if (image) payload.image = image; + this._send({ event: "setImage", context, payload }); + } + setState(context, state) { + this._send({ event: "setState", context, payload: { state } }); + } + setSettings(context, settings) { + this._send({ event: "setSettings", context, payload: settings }); + } + getSettings(context) { + this._send({ event: "getSettings", context }); + } + showAlert(context) { + this._send({ event: "showAlert", context }); + } + showOk(context) { + this._send({ event: "showOk", context }); + } + sendToPropertyInspector(action, context, payload) { + this._send({ event: "sendToPropertyInspector", action, context, payload }); + } + _send(message) { + if (this.ws && this.ws.readyState === WS_OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + }; + module2.exports = { StreamDockHost: StreamDockHost2, parseArgs: parseArgs2 }; + } +}); + +// src/playtubeClient.js +var require_playtubeClient = __commonJS({ + "src/playtubeClient.js"(exports2, module2) { + "use strict"; + var net = require("net"); + var os = require("os"); + var path2 = require("path"); + var { EventEmitter } = require("events"); + var PIPE_PREFIX = "Playtube.Remote."; + var PIPE_NAMES = ["Playtube", "PlaytubeDev", "PlaytubeEdge", "PlaytubeEdgeDev"]; + var MAX_BUFFER_CHARS = 1 << 20; + var PlaytubeError = class extends Error { + constructor(code, message) { + super(message); + this.name = "PlaytubeError"; + this.code = code; + } + }; + function pipePath(name, platform = process.platform) { + const full = PIPE_PREFIX + name; + return platform === "win32" ? `\\\\.\\pipe\\${full}` : path2.join(os.tmpdir(), full); + } + var PlaytubeClient2 = class extends EventEmitter { + /** + * @param {object} [options] + * @param {string[]} [options.pipeNames] + * @param {string} [options.platform] + * @param {Function} [options.connect] - Ersatz fuer net.connect (Tests) + * @param {number} [options.requestTimeoutMs] + * @param {number[]} [options.backoffMs] + */ + constructor(options = {}) { + super(); + this.pipeNames = options.pipeNames || PIPE_NAMES; + this.platform = options.platform || process.platform; + this.connectFn = options.connect || net.connect; + this.requestTimeoutMs = options.requestTimeoutMs ?? 3e3; + this.backoffMs = options.backoffMs || [300, 600, 1200, 2500, 5e3]; + this.socket = null; + this.connected = false; + this.pipeName = null; + this.running = false; + this._nextId = 1; + this._pending = /* @__PURE__ */ new Map(); + this._buffer = ""; + this._failedRounds = 0; + this._retryTimer = null; + } + start() { + if (this.running) return; + this.running = true; + this._attempt(0); + } + stop() { + this.running = false; + clearTimeout(this._retryTimer); + this._retryTimer = null; + const socket = this.socket; + this.socket = null; + if (this.connected) { + this.connected = false; + this._rejectAll(new PlaytubeError("stopped", "Client gestoppt")); + } + if (socket) socket.destroy(); + } + /** + * Schickt einen Befehl und wartet auf die Antwort. + * @param {string} cmd + * @param {object} [fields] - target / value / playlist (undefined-Werte werden weggelassen) + * @returns {Promise} die komplette Antwort ({ok: true, ...}) + */ + request(cmd, fields = {}) { + if (!this.connected || !this.socket) { + return Promise.reject(new PlaytubeError("not_connected", "Playtube ist nicht verbunden")); + } + const id = this._nextId++; + const payload = { id, cmd }; + for (const [key, value] of Object.entries(fields)) { + if (value !== void 0 && value !== null) payload[key] = value; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new PlaytubeError("timeout", `Keine Antwort von Playtube auf "${cmd}"`)); + }, this.requestTimeoutMs); + this._pending.set(id, { resolve, reject, timer }); + this.socket.write(`${JSON.stringify(payload)} +`, (error) => { + if (error && this._pending.delete(id)) { + clearTimeout(timer); + reject(new PlaytubeError("write_failed", error.message)); + } + }); + }); + } + async status() { + const response = await this.request("status"); + if (!response.state) throw new PlaytubeError("bad_response", 'Status ohne "state"'); + return response.state; + } + async listPlaylists() { + const response = await this.request("list_playlists"); + return Array.isArray(response.playlists) ? response.playlists : []; + } + playPlaylist(playlistId) { + return this.request("play_playlist", { playlist: playlistId }); + } + // ------------------------------------------------------------------ intern + _attempt(index) { + if (!this.running) return; + if (index >= this.pipeNames.length) { + this._scheduleRetry(); + return; + } + const name = this.pipeNames[index]; + const socket = this.connectFn(pipePath(name, this.platform)); + let opened = false; + socket.setEncoding("utf8"); + socket.once("connect", () => { + if (!this.running) { + socket.destroy(); + return; + } + opened = true; + this.socket = socket; + this.connected = true; + this.pipeName = name; + this._failedRounds = 0; + this._buffer = ""; + this.emit("connected", name); + }); + socket.on("data", (chunk) => { + if (socket === this.socket) this._onData(chunk); + }); + socket.on("error", () => { + }); + socket.on("close", () => { + if (!opened) { + this._attempt(index + 1); + } else if (socket === this.socket) { + this._onLost(); + } + }); + } + _onLost() { + this.socket = null; + this.connected = false; + this.pipeName = null; + this._rejectAll(new PlaytubeError("disconnected", "Verbindung zu Playtube getrennt")); + this.emit("disconnected"); + this._failedRounds = 0; + this._scheduleRetry(); + } + _scheduleRetry() { + if (!this.running) return; + const delay = this.backoffMs[Math.min(this._failedRounds, this.backoffMs.length - 1)]; + this._failedRounds += 1; + this._retryTimer = setTimeout(() => this._attempt(0), delay); + } + _onData(chunk) { + this._buffer += chunk; + let newline; + while ((newline = this._buffer.indexOf("\n")) >= 0) { + const line = this._buffer.slice(0, newline).trim(); + this._buffer = this._buffer.slice(newline + 1); + if (line) this._onLine(line); + } + if (this._buffer.length > MAX_BUFFER_CHARS) { + this._buffer = ""; + if (this.socket) this.socket.destroy(); + } + } + _onLine(line) { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + const entry = this._pending.get(message.id); + if (!entry) return; + this._pending.delete(message.id); + clearTimeout(entry.timer); + if (message.ok) { + entry.resolve(message); + } else { + entry.reject(new PlaytubeError("remote", message.error || "Fehler in Playtube")); + } + } + _rejectAll(error) { + for (const entry of this._pending.values()) { + clearTimeout(entry.timer); + entry.reject(error); + } + this._pending.clear(); + } + }; + module2.exports = { PlaytubeClient: PlaytubeClient2, PlaytubeError, pipePath, PIPE_NAMES, PIPE_PREFIX }; + } +}); + +// src/actions.js +var require_actions = __commonJS({ + "src/actions.js"(exports2, module2) { + "use strict"; + var PREFIX = "com.fojadrachi.playtube."; + var DEFAULTS = Object.freeze({ + target: "auto", + // "auto" | "youtube" | "music" + step: 5, + // Lautstaerke-Schritt in Prozent + volume: 30, + // Ziel-Lautstaerke der Aktion "Lautstaerke setzen" + seconds: 10, + // Sprungweite beim Vor-/Zurueckspulen + tab: "toggle", + // Aktion "Tab wechseln": "toggle" | "youtube" | "music" + playlistId: "", + playlistTitle: "" + }); + var TARGETS = ["auto", "youtube", "music"]; + var TABS = ["toggle", "youtube", "music"]; + function clampInt(value, min, max, fallback) { + const number = Number.parseInt(value, 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, number)); + } + function normalizeSettings(raw, titleDefault = false) { + const settings = { ...DEFAULTS, ...raw || {} }; + if (!TARGETS.includes(settings.target)) settings.target = DEFAULTS.target; + if (!TABS.includes(settings.tab)) settings.tab = DEFAULTS.tab; + settings.step = clampInt(settings.step, 1, 25, DEFAULTS.step); + settings.volume = clampInt(settings.volume, 0, 100, DEFAULTS.volume); + settings.seconds = clampInt(settings.seconds, 1, 300, DEFAULTS.seconds); + const show = raw && raw.showTitle !== void 0 ? raw.showTitle : titleDefault; + settings.showTitle = show === true || show === "true"; + settings.playlistId = typeof settings.playlistId === "string" ? settings.playlistId.trim() : ""; + settings.playlistTitle = typeof settings.playlistTitle === "string" ? settings.playlistTitle : ""; + return settings; + } + function pickTab(state, target) { + if (!state || !state.tabs) return null; + const name = target === "auto" ? state.activeTab : target; + const info = state.tabs[name]; + return info ? { name, info } : null; + } + function wrapTitle(text, perLine = 8, maxLines = 3) { + const words = String(text || "").trim().split(/\s+/).filter(Boolean); + const lines = []; + let current = ""; + let truncated = false; + const push = (line) => { + if (lines.length < maxLines) lines.push(line); + else truncated = true; + }; + for (let word of words) { + while (word.length > perLine) { + if (current) { + push(current); + current = ""; + } + push(word.slice(0, perLine)); + word = word.slice(perLine); + } + if (!word) continue; + if (!current) current = word; + else if (current.length + 1 + word.length <= perLine) current += ` ${word}`; + else { + push(current); + current = word; + } + } + if (current) push(current); + if (truncated && lines.length) lines[lines.length - 1] = `${lines[lines.length - 1].slice(0, perLine - 2)}..`; + return lines.join("\n"); + } + function volumeTitle(tab) { + const volume = tab && tab.info.volume; + return typeof volume === "number" ? `${Math.round(volume)}%` : ""; + } + var OFFLINE_TITLE = "Playtube\noffline"; + var DEFINITIONS = { + playpause: { + titleDefault: false, + request: (s) => ({ cmd: "play_pause", fields: { target: s.target } }), + render: (s, state, connected) => { + if (!connected) return { state: 0, title: s.showTitle ? OFFLINE_TITLE : "" }; + const tab = pickTab(state, s.target); + return { + state: tab && tab.info.playing ? 1 : 0, + title: s.showTitle && tab && tab.info.title ? wrapTitle(tab.info.title) : "" + }; + } + }, + next: { request: (s) => ({ cmd: "next", fields: { target: s.target } }) }, + previous: { request: (s) => ({ cmd: "previous", fields: { target: s.target } }) }, + seekback: { + request: (s) => ({ cmd: "seek", fields: { target: s.target, value: -s.seconds } }), + render: (s) => ({ title: `-${s.seconds}s` }) + }, + seekforward: { + request: (s) => ({ cmd: "seek", fields: { target: s.target, value: s.seconds } }), + render: (s) => ({ title: `+${s.seconds}s` }) + }, + volumeup: { + press: "down", + repeat: { delayMs: 450, intervalMs: 140 }, + request: (s) => ({ cmd: "volume_change", fields: { target: s.target, value: s.step } }), + render: (s, state, connected) => ({ title: connected ? volumeTitle(pickTab(state, s.target)) : "" }) + }, + volumedown: { + press: "down", + repeat: { delayMs: 450, intervalMs: 140 }, + request: (s) => ({ cmd: "volume_change", fields: { target: s.target, value: -s.step } }), + render: (s, state, connected) => ({ title: connected ? volumeTitle(pickTab(state, s.target)) : "" }) + }, + volumeset: { + request: (s) => ({ cmd: "set_volume", fields: { target: s.target, value: s.volume } }), + render: (s) => ({ title: `${s.volume}%` }) + }, + mute: { + request: (s) => ({ cmd: "mute_toggle", fields: { target: s.target } }), + render: (s, state, connected) => { + const tab = connected ? pickTab(state, s.target) : null; + return { state: tab && tab.info.muted ? 1 : 0, title: tab && !tab.info.muted ? volumeTitle(tab) : "" }; + } + }, + nowplaying: { + titleDefault: true, + request: (s) => ({ cmd: "play_pause", fields: { target: s.target } }), + render: (s, state, connected) => { + if (!connected) return { coverUrl: null, title: s.showTitle ? OFFLINE_TITLE : "" }; + const tab = pickTab(state, s.target); + const info = tab && tab.info; + return { + coverUrl: info && info.hasVideo && info.thumbnail ? info.thumbnail : null, + title: s.showTitle && info && info.title ? wrapTitle(info.title) : "" + }; + } + }, + like: { + request: (s) => ({ cmd: "like", fields: { target: s.target } }), + render: (s, state, connected) => { + const tab = connected ? pickTab(state, s.target) : null; + return { state: tab && tab.info.likeStatus === "like" ? 1 : 0 }; + } + }, + shuffle: { request: (s) => ({ cmd: "shuffle", fields: { target: s.target } }) }, + repeat: { + request: (s) => ({ cmd: "repeat", fields: { target: s.target } }), + render: (s, state, connected) => { + const tab = connected ? pickTab(state, s.target) : null; + const mode = String(tab && tab.info.repeatMode || "").toUpperCase(); + return { state: mode === "ALL" ? 1 : mode === "ONE" ? 2 : 0 }; + } + }, + playlist: { + request: (s) => s.playlistId ? { cmd: "play_playlist", fields: { playlist: s.playlistId } } : null, + render: (s) => ({ title: s.playlistTitle ? wrapTitle(s.playlistTitle) : "" }) + }, + switchtab: { + request: (s) => ({ cmd: "switch_tab", fields: { target: s.tab === "toggle" ? "auto" : s.tab } }), + render: (s, state, connected) => { + if (!connected || !state) return { title: "" }; + if (state.visibleTab === "music") return { title: "Music" }; + if (state.visibleTab === "youtube") return { title: "YouTube" }; + return { title: "" }; + } + }, + show: { request: () => ({ cmd: "show", fields: {} }) } + }; + function actionIdFromUuid(uuid) { + if (typeof uuid !== "string" || !uuid.startsWith(PREFIX)) return null; + const id = uuid.slice(PREFIX.length); + return Object.prototype.hasOwnProperty.call(DEFINITIONS, id) ? id : null; + } + function renderAction(id, rawSettings, state, connected) { + const definition = DEFINITIONS[id]; + const settings = normalizeSettings(rawSettings, definition.titleDefault); + return definition.render ? definition.render(settings, state, connected) : {}; + } + function buildRequest(id, rawSettings) { + const definition = DEFINITIONS[id]; + const settings = normalizeSettings(rawSettings, definition.titleDefault); + return definition.request(settings); + } + module2.exports = { + PREFIX, + DEFAULTS, + DEFINITIONS, + normalizeSettings, + pickTab, + wrapTitle, + actionIdFromUuid, + renderAction, + buildRequest + }; + } +}); + +// src/infoPanel.js +var require_infoPanel = __commonJS({ + "src/infoPanel.js"(exports2, module2) { + "use strict"; + var { wrapTitle } = require_actions(); + var SIZE = 144; + var BACKGROUND = "#171b24"; + var ACCENT = "#ff4d5e"; + var TRACK = "#3a4152"; + var MUTED_TEXT = "#c3c9d6"; + var BAR_X = 10; + var BAR_WIDTH = SIZE - 2 * BAR_X; + var BAR_Y = 132; + var BAR_HEIGHT = 6; + var TITLE_LINE_HEIGHT = 25; + var TITLE_BASELINE_OFFSET = 7; + var FONT = 'font-family="Segoe UI, Arial, sans-serif"'; + var XML_ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; + function escapeXml(text) { + return String(text).replace(/[&<>"']/g, (char) => XML_ESCAPES[char]); + } + function formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return "0:00"; + const whole = Math.floor(seconds); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`; + } + function textLines(text, x, y, lineHeight, size, color, weight, anchor = "middle") { + return String(text).split("\n").filter(Boolean).map((line, index) => `${escapeXml(line)}`).join(""); + } + function noteMarkup(cx, cy, scale) { + const s = scale; + return ``; + } + function backgroundMarkup(coverDataUri) { + if (!coverDataUri) return `${noteMarkup(72, 44, 1.1)}`; + return ``; + } + function shadeMarkup() { + return ``; + } + function playStateMarkup(playing) { + const cx = 118; + const cy = 26; + const disc = ``; + if (playing) { + return `${disc}`; + } + return `${disc}`; + } + function buildInfoSvg(view) { + const head = ``; + if (!view) { + return `${head}${noteMarkup(72, 44, 1.1)}${textLines("Playtube offline", 72, 120, 0, 14, "#ffffff", "bold")}`; + } + const hasTrack = Boolean(view.title); + const ratio = view.duration > 0 ? Math.max(0, Math.min(1, view.currentTime / view.duration)) : 0; + const parts = [backgroundMarkup(view.coverDataUri), shadeMarkup()]; + if (hasTrack) { + const title = wrapTitle(view.title, 10, 2); + const lineCount = title.split("\n").length; + const blockCenter = view.subtitle ? SIZE / 2 - 12 : SIZE / 2; + const firstBaseline = blockCenter - (lineCount - 1) * TITLE_LINE_HEIGHT / 2 + TITLE_BASELINE_OFFSET; + parts.push(textLines(title, SIZE / 2, firstBaseline, TITLE_LINE_HEIGHT, 22, "#ffffff", "bold")); + if (view.subtitle) { + const subtitleY = firstBaseline + (lineCount - 1) * TITLE_LINE_HEIGHT + 27; + parts.push(textLines(wrapTitle(view.subtitle, 13, 1), SIZE / 2, subtitleY, 0, 16, MUTED_TEXT, "normal")); + } + parts.push(playStateMarkup(view.playing)); + } else { + parts.push(textLines("Nichts spielt", 72, 120, 0, 14, "#ffffff", "bold")); + } + parts.push(``); + parts.push(``); + return `${head}${parts.join("")}`; + } + function svgToDataUri(svg) { + return `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`; + } + module2.exports = { buildInfoSvg, svgToDataUri, formatTime, SIZE }; + } +}); + +// src/controller.js +var require_controller = __commonJS({ + "src/controller.js"(exports2, module2) { + "use strict"; + var { + DEFINITIONS, + actionIdFromUuid, + buildRequest, + normalizeSettings, + pickTab, + renderAction + } = require_actions(); + var { buildInfoSvg, svgToDataUri } = require_infoPanel(); + var POLL_INTERVAL_MS = 1e3; + var REFRESH_AFTER_COMMAND_MS = 350; + var COVER_CACHE_LIMIT = 24; + var COVER_TIMEOUT_MS = 4e3; + var KEY_IMAGE_PX = 144; + function sharpCoverUrl(url) { + return String(url).replace(/=w\d+-h\d+/, `=w${KEY_IMAGE_PX}-h${KEY_IMAGE_PX}`); + } + async function defaultFetchImage(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(COVER_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const type = (response.headers.get("content-type") || "image/jpeg").split(";")[0]; + const bytes = Buffer.from(await response.arrayBuffer()); + return `data:${type};base64,${bytes.toString("base64")}`; + } + var Controller2 = class { + /** + * @param {object} deps + * @param {import('./host').StreamDockHost} deps.host + * @param {import('./playtubeClient').PlaytubeClient} deps.client + * @param {{info: Function, warn: Function, error: Function}} deps.logger + * @param {Function} [deps.fetchImage] - (url) => Promise + * @param {number} [deps.pollIntervalMs] + */ + constructor({ host, client, logger, fetchImage, pollIntervalMs }) { + this.host = host; + this.client = client; + this.logger = logger; + this.fetchImage = fetchImage || defaultFetchImage; + this.pollIntervalMs = pollIntervalMs ?? POLL_INTERVAL_MS; + this.keys = /* @__PURE__ */ new Map(); + this.state = null; + this.covers = /* @__PURE__ */ new Map(); + this.coverLoading = /* @__PURE__ */ new Set(); + this.repeaters = /* @__PURE__ */ new Map(); + this.pollTimer = null; + this.refreshTimer = null; + this.polling = false; + } + start() { + this.host.on("event", (message) => this.handleEvent(message)); + this.client.on("connected", (name) => { + this.logger.info(`Mit Playtube verbunden (${name})`); + this.poll(); + }); + this.client.on("disconnected", () => { + this.logger.info("Verbindung zu Playtube getrennt"); + this.state = null; + this.renderAll(); + }); + this.client.start(); + this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs); + } + stop() { + clearInterval(this.pollTimer); + clearTimeout(this.refreshTimer); + for (const context of [...this.repeaters.keys()]) this.stopRepeat(context); + this.client.stop(); + } + // ------------------------------------------------------------ Ereignisse + handleEvent(message) { + const { event, context } = message; + switch (event) { + case "willAppear": + return this.onAppear(message); + case "willDisappear": + this.stopRepeat(context); + this.keys.delete(context); + return void 0; + case "didReceiveSettings": + return this.onSettings(message); + case "keyDown": + return this.onKeyDown(context); + case "keyUp": + return this.onKeyUp(context); + case "sendToPlugin": + return this.onPropertyInspectorMessage(message); + default: + return void 0; + } + } + onAppear(message) { + const actionId = actionIdFromUuid(message.action); + if (!actionId) return; + const payload = message.payload || {}; + const settings = payload.settings || {}; + const info = payload.controller === "Information"; + this.logger.info(`willAppear ${actionId} controller=${payload.controller} info=${info}`); + this.keys.set(message.context, { actionId, settings, info, last: null }); + this.renderKey(message.context); + if (this.client.connected && !this.state) this.poll(); + } + onSettings(message) { + const key = this.keys.get(message.context); + if (!key) return; + key.settings = message.payload && message.payload.settings || {}; + key.last = null; + this.renderKey(message.context); + } + onKeyDown(context) { + const key = this.keys.get(context); + if (!key) return; + const definition = DEFINITIONS[key.actionId]; + if (definition.press !== "down") return; + this.execute(context); + const { delayMs, intervalMs } = definition.repeat || {}; + if (!delayMs) return; + this.stopRepeat(context); + const timers = {}; + timers.delayTimer = setTimeout(() => { + timers.intervalTimer = setInterval(() => this.execute(context, { quiet: true }), intervalMs); + }, delayMs); + this.repeaters.set(context, timers); + } + onKeyUp(context) { + const key = this.keys.get(context); + if (!key) return; + if (DEFINITIONS[key.actionId].press === "down") { + this.stopRepeat(context); + return; + } + this.execute(context); + } + stopRepeat(context) { + const timers = this.repeaters.get(context); + if (!timers) return; + clearTimeout(timers.delayTimer); + clearInterval(timers.intervalTimer); + this.repeaters.delete(context); + } + // ------------------------------------------------------------ Befehle + /** Fuehrt die Aktion einer Taste aus; `quiet` = kein Alarm bei Fehler (Dauerdruck). */ + async execute(context, { quiet = false } = {}) { + const key = this.keys.get(context); + if (!key) return; + const request = buildRequest(key.actionId, key.settings); + if (!request) { + this.host.showAlert(context); + return; + } + try { + await this.client.request(request.cmd, request.fields); + this.scheduleRefresh(); + } catch (error) { + this.logger.warn(`${request.cmd} fehlgeschlagen: ${error.message}`); + if (quiet) this.stopRepeat(context); + else this.host.showAlert(context); + } + } + scheduleRefresh() { + clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => this.poll(), REFRESH_AFTER_COMMAND_MS); + } + // ------------------------------------------------------------ Status + async poll() { + if (this.polling || !this.client.connected || this.keys.size === 0) return; + this.polling = true; + try { + this.state = await this.client.status(); + this.renderAll(); + } catch (error) { + this.logger.warn(`Status nicht lesbar: ${error.message}`); + } finally { + this.polling = false; + } + } + renderAll() { + for (const context of this.keys.keys()) this.renderKey(context); + } + renderKey(context) { + const key = this.keys.get(context); + if (!key) return; + if (key.info && key.actionId === "nowplaying") { + this.renderInfo(context, key); + return; + } + const view = renderAction(key.actionId, key.settings, this.state, this.client.connected); + const coverUrl = view.coverUrl ? sharpCoverUrl(view.coverUrl) : null; + const cover = coverUrl ? this.covers.get(coverUrl) || null : null; + if (coverUrl && !cover) this.loadCover(coverUrl); + const next = { state: view.state, title: view.title, cover: cover ? coverUrl : null }; + const previous = key.last || {}; + const changed = (name) => previous[name] !== next[name]; + key.last = next; + if (view.state !== void 0 && changed("state")) this.host.setState(context, view.state); + if (view.title !== void 0 && changed("title")) this.host.setTitle(context, view.title); + if (view.coverUrl !== void 0 && changed("cover")) this.host.setImage(context, cover); + } + /** Hohes Info-Display: komplette "Aktueller Titel"-Karte als SVG-Bild. */ + renderInfo(context, key) { + let view = null; + if (this.client.connected) { + const tab = pickTab(this.state, normalizeSettings(key.settings).target); + const info = tab && tab.info || {}; + const coverUrl = info.thumbnail ? sharpCoverUrl(info.thumbnail) : null; + const coverDataUri = coverUrl ? this.covers.get(coverUrl) || null : null; + if (coverUrl && !coverDataUri) this.loadCover(coverUrl); + view = { + title: info.title || "", + subtitle: info.subtitle || "", + playing: Boolean(info.playing), + currentTime: info.currentTime, + duration: info.duration, + volume: info.volume, + coverDataUri + }; + } + const svg = buildInfoSvg(view); + if (svg === key.lastSvg) return; + key.lastSvg = svg; + this.host.setTitle(context, ""); + this.host.setImage(context, svgToDataUri(svg)); + } + async loadCover(url) { + if (this.coverLoading.has(url)) return; + this.coverLoading.add(url); + try { + const dataUri = await this.fetchImage(url); + this.covers.set(url, dataUri); + while (this.covers.size > COVER_CACHE_LIMIT) this.covers.delete(this.covers.keys().next().value); + this.renderAll(); + } catch (error) { + this.logger.warn(`Cover nicht ladbar: ${error.message}`); + } finally { + this.coverLoading.delete(url); + } + } + // ------------------------------------------------------------ Property Inspector + async onPropertyInspectorMessage(message) { + const payload = message.payload || {}; + if (payload.command !== "requestPlaylists") return; + const reply = (body) => this.host.sendToPropertyInspector(message.action, message.context, body); + if (!this.client.connected) { + reply({ command: "playlists", ok: false, error: "offline", playlists: [] }); + return; + } + try { + const playlists = await this.client.listPlaylists(); + reply({ command: "playlists", ok: true, playlists }); + } catch (error) { + this.logger.warn(`Playlists nicht lesbar: ${error.message}`); + reply({ command: "playlists", ok: false, error: error.code || "error", message: error.message, playlists: [] }); + } + } + }; + module2.exports = { Controller: Controller2, sharpCoverUrl }; + } +}); + +// src/logger.js +var require_logger = __commonJS({ + "src/logger.js"(exports2, module2) { + "use strict"; + var fs = require("fs"); + var path2 = require("path"); + var MAX_BYTES = 512 * 1024; + function createLogger2(directory) { + const file = path2.join(directory, "plugin.log"); + let ready = false; + function ensure() { + if (ready) return true; + try { + fs.mkdirSync(directory, { recursive: true }); + ready = true; + } catch { + ready = false; + } + return ready; + } + function write(level, message) { + if (!ensure()) return; + try { + try { + if (fs.statSync(file).size > MAX_BYTES) fs.renameSync(file, path2.join(directory, "plugin.old.log")); + } catch { + } + fs.appendFileSync(file, `${(/* @__PURE__ */ new Date()).toISOString()} ${level} ${message} +`); + } catch { + } + } + const describe = (value) => value instanceof Error ? value.stack || value.message : String(value); + return { + info: (message) => write("INFO ", describe(message)), + warn: (message) => write("WARN ", describe(message)), + error: (message) => write("ERROR", describe(message)) + }; + } + module2.exports = { createLogger: createLogger2 }; + } +}); + +// src/main.js +var path = require("path"); +var { StreamDockHost, parseArgs } = require_host(); +var { PlaytubeClient } = require_playtubeClient(); +var { Controller } = require_controller(); +var { createLogger } = require_logger(); +var EXIT_GRACE_MS = 2e3; +function main() { + const args = parseArgs(process.argv.slice(2)); + const logger = createLogger(path.join(__dirname, "..", "log")); + if (!args.port || !args.uuid || !args.registerEvent) { + logger.error(`Ungueltige Startparameter: ${process.argv.slice(2).join(" ")}`); + process.exit(1); + } + process.on("uncaughtException", (error) => logger.error(error)); + process.on("unhandledRejection", (error) => logger.error(error)); + const host = new StreamDockHost(args); + const pipeNames = process.env.PLAYTUBE_PIPE_NAMES ? process.env.PLAYTUBE_PIPE_NAMES.split(",") : void 0; + const client = new PlaytubeClient({ pipeNames }); + const controller = new Controller({ host, client, logger }); + controller.start(); + host.on("open", () => logger.info(`Bei Stream Dock registriert (Port ${args.port})`)); + host.on("error", (error) => logger.warn(`WebSocket-Fehler: ${error.message}`)); + host.on("close", () => { + logger.info("Verbindung zu Stream Dock beendet"); + controller.stop(); + setTimeout(() => process.exit(0), EXIT_GRACE_MS).unref(); + }); + host.connect(); +} +main(); diff --git a/com.fojadrachi.playtube.sdPlugin/propertyInspector/index.html b/com.fojadrachi.playtube.sdPlugin/propertyInspector/index.html new file mode 100644 index 0000000..5d29eb5 --- /dev/null +++ b/com.fojadrachi.playtube.sdPlugin/propertyInspector/index.html @@ -0,0 +1,246 @@ + + + + + Playtube + + + +
+ + +
+ + + + + + + + + + + + + + + + + + diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/like_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/like_0.png new file mode 100644 index 0000000..96e8892 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/like_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/like_1.png b/com.fojadrachi.playtube.sdPlugin/static/btn/like_1.png new file mode 100644 index 0000000..b5d86f6 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/like_1.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/mute_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/mute_0.png new file mode 100644 index 0000000..6236bcf Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/mute_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/mute_1.png b/com.fojadrachi.playtube.sdPlugin/static/btn/mute_1.png new file mode 100644 index 0000000..2362d13 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/mute_1.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/next_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/next_0.png new file mode 100644 index 0000000..f6a97f4 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/next_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/nowplaying_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/nowplaying_0.png new file mode 100644 index 0000000..2f61b9e Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/nowplaying_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/playlist_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/playlist_0.png new file mode 100644 index 0000000..0af047f Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/playlist_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/playpause_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/playpause_0.png new file mode 100644 index 0000000..54acb25 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/playpause_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/playpause_1.png b/com.fojadrachi.playtube.sdPlugin/static/btn/playpause_1.png new file mode 100644 index 0000000..604b7e4 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/playpause_1.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/previous_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/previous_0.png new file mode 100644 index 0000000..a7349cc Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/previous_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_0.png new file mode 100644 index 0000000..e29b768 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_1.png b/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_1.png new file mode 100644 index 0000000..61825eb Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_1.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_2.png b/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_2.png new file mode 100644 index 0000000..ec03b42 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/repeat_2.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/seekback_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/seekback_0.png new file mode 100644 index 0000000..e287b88 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/seekback_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/seekforward_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/seekforward_0.png new file mode 100644 index 0000000..7d12dae Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/seekforward_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/show_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/show_0.png new file mode 100644 index 0000000..9c643c7 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/show_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/shuffle_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/shuffle_0.png new file mode 100644 index 0000000..1634a5c Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/shuffle_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/switchtab_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/switchtab_0.png new file mode 100644 index 0000000..470784c Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/switchtab_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/volumedown_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/volumedown_0.png new file mode 100644 index 0000000..580ca6d Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/volumedown_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/volumeset_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/volumeset_0.png new file mode 100644 index 0000000..6236bcf Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/volumeset_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/btn/volumeup_0.png b/com.fojadrachi.playtube.sdPlugin/static/btn/volumeup_0.png new file mode 100644 index 0000000..fdfacf6 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/btn/volumeup_0.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/icon@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/icon@2x.png new file mode 100644 index 0000000..7b224e4 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/icon@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/like@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/like@2x.png new file mode 100644 index 0000000..1cca08e Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/like@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/mute@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/mute@2x.png new file mode 100644 index 0000000..cd4635f Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/mute@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/next@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/next@2x.png new file mode 100644 index 0000000..e6a1c3b Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/next@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/nowplaying@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/nowplaying@2x.png new file mode 100644 index 0000000..73c7f44 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/nowplaying@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/playlist@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/playlist@2x.png new file mode 100644 index 0000000..cc4f075 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/playlist@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/playpause@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/playpause@2x.png new file mode 100644 index 0000000..59efee9 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/playpause@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/previous@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/previous@2x.png new file mode 100644 index 0000000..3c7d71e Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/previous@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/repeat@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/repeat@2x.png new file mode 100644 index 0000000..7509185 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/repeat@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/seekback@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/seekback@2x.png new file mode 100644 index 0000000..fce3f35 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/seekback@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/seekforward@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/seekforward@2x.png new file mode 100644 index 0000000..7ddc91e Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/seekforward@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/show@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/show@2x.png new file mode 100644 index 0000000..3fcb2a0 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/show@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/shuffle@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/shuffle@2x.png new file mode 100644 index 0000000..e4a1e39 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/shuffle@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/switchtab@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/switchtab@2x.png new file mode 100644 index 0000000..685e926 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/switchtab@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/volumedown@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/volumedown@2x.png new file mode 100644 index 0000000..83c47a3 Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/volumedown@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/volumeset@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/volumeset@2x.png new file mode 100644 index 0000000..cd4635f Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/volumeset@2x.png differ diff --git a/com.fojadrachi.playtube.sdPlugin/static/icon/volumeup@2x.png b/com.fojadrachi.playtube.sdPlugin/static/icon/volumeup@2x.png new file mode 100644 index 0000000..1e540cd Binary files /dev/null and b/com.fojadrachi.playtube.sdPlugin/static/icon/volumeup@2x.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icon.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icon.png new file mode 100644 index 0000000..08b6492 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icon.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons.json b/iconpack/com.fojadrachi.playtube.sdIconPack/icons.json new file mode 100644 index 0000000..543d7e0 --- /dev/null +++ b/iconpack/com.fojadrachi.playtube.sdIconPack/icons.json @@ -0,0 +1,1562 @@ +[ + { + "path": "gradient_play.png", + "name": "play (gradient)", + "tags": [ + "playtube", + "gradient", + "play", + "abspielen" + ] + }, + { + "path": "gradient_pause.png", + "name": "pause (gradient)", + "tags": [ + "playtube", + "gradient", + "pause", + "pause" + ] + }, + { + "path": "gradient_next.png", + "name": "next (gradient)", + "tags": [ + "playtube", + "gradient", + "next", + "weiter", + "naechster" + ] + }, + { + "path": "gradient_previous.png", + "name": "previous (gradient)", + "tags": [ + "playtube", + "gradient", + "previous", + "zurueck", + "vorheriger" + ] + }, + { + "path": "gradient_rewind.png", + "name": "rewind (gradient)", + "tags": [ + "playtube", + "gradient", + "rewind" + ] + }, + { + "path": "gradient_forward.png", + "name": "forward (gradient)", + "tags": [ + "playtube", + "gradient", + "forward" + ] + }, + { + "path": "gradient_volup.png", + "name": "volup (gradient)", + "tags": [ + "playtube", + "gradient", + "volup", + "lauter" + ] + }, + { + "path": "gradient_voldown.png", + "name": "voldown (gradient)", + "tags": [ + "playtube", + "gradient", + "voldown", + "leiser" + ] + }, + { + "path": "gradient_vol.png", + "name": "vol (gradient)", + "tags": [ + "playtube", + "gradient", + "vol", + "lautstaerke" + ] + }, + { + "path": "gradient_mute.png", + "name": "mute (gradient)", + "tags": [ + "playtube", + "gradient", + "mute", + "stumm" + ] + }, + { + "path": "gradient_note.png", + "name": "note (gradient)", + "tags": [ + "playtube", + "gradient", + "note" + ] + }, + { + "path": "gradient_heart.png", + "name": "heart (gradient)", + "tags": [ + "playtube", + "gradient", + "heart", + "herz", + "gefaellt mir" + ] + }, + { + "path": "gradient_heartfill.png", + "name": "heartfill (gradient)", + "tags": [ + "playtube", + "gradient", + "heartfill" + ] + }, + { + "path": "gradient_shuffle.png", + "name": "shuffle (gradient)", + "tags": [ + "playtube", + "gradient", + "shuffle", + "zufall" + ] + }, + { + "path": "gradient_repeat.png", + "name": "repeat (gradient)", + "tags": [ + "playtube", + "gradient", + "repeat", + "wiederholen" + ] + }, + { + "path": "gradient_repeatone.png", + "name": "repeatone (gradient)", + "tags": [ + "playtube", + "gradient", + "repeatone" + ] + }, + { + "path": "gradient_playlist.png", + "name": "playlist (gradient)", + "tags": [ + "playtube", + "gradient", + "playlist", + "liste" + ] + }, + { + "path": "gradient_tabs.png", + "name": "tabs (gradient)", + "tags": [ + "playtube", + "gradient", + "tabs" + ] + }, + { + "path": "gradient_window.png", + "name": "window (gradient)", + "tags": [ + "playtube", + "gradient", + "window" + ] + }, + { + "path": "gradient_folder.png", + "name": "folder (gradient)", + "tags": [ + "playtube", + "gradient", + "folder", + "ordner" + ] + }, + { + "path": "gradient_file.png", + "name": "file (gradient)", + "tags": [ + "playtube", + "gradient", + "file", + "datei" + ] + }, + { + "path": "gradient_home.png", + "name": "home (gradient)", + "tags": [ + "playtube", + "gradient", + "home", + "start", + "zuhause" + ] + }, + { + "path": "gradient_gear.png", + "name": "gear (gradient)", + "tags": [ + "playtube", + "gradient", + "gear", + "einstellungen", + "zahnrad" + ] + }, + { + "path": "gradient_plus.png", + "name": "plus (gradient)", + "tags": [ + "playtube", + "gradient", + "plus", + "hinzufuegen" + ] + }, + { + "path": "gradient_minus.png", + "name": "minus (gradient)", + "tags": [ + "playtube", + "gradient", + "minus", + "entfernen" + ] + }, + { + "path": "gradient_close.png", + "name": "close (gradient)", + "tags": [ + "playtube", + "gradient", + "close", + "schliessen" + ] + }, + { + "path": "gradient_check.png", + "name": "check (gradient)", + "tags": [ + "playtube", + "gradient", + "check", + "haken", + "ok" + ] + }, + { + "path": "gradient_star.png", + "name": "star (gradient)", + "tags": [ + "playtube", + "gradient", + "star", + "stern", + "favorit" + ] + }, + { + "path": "gradient_search.png", + "name": "search (gradient)", + "tags": [ + "playtube", + "gradient", + "search", + "suche", + "lupe" + ] + }, + { + "path": "gradient_power.png", + "name": "power (gradient)", + "tags": [ + "playtube", + "gradient", + "power", + "ein", + "aus" + ] + }, + { + "path": "gradient_mic.png", + "name": "mic (gradient)", + "tags": [ + "playtube", + "gradient", + "mic", + "mikrofon" + ] + }, + { + "path": "gradient_headphones.png", + "name": "headphones (gradient)", + "tags": [ + "playtube", + "gradient", + "headphones", + "kopfhoerer" + ] + }, + { + "path": "gradient_lock.png", + "name": "lock (gradient)", + "tags": [ + "playtube", + "gradient", + "lock", + "schloss" + ] + }, + { + "path": "gradient_bell.png", + "name": "bell (gradient)", + "tags": [ + "playtube", + "gradient", + "bell", + "glocke", + "benachrichtigung" + ] + }, + { + "path": "gradient_clock.png", + "name": "clock (gradient)", + "tags": [ + "playtube", + "gradient", + "clock", + "uhr", + "zeit" + ] + }, + { + "path": "gradient_calendar.png", + "name": "calendar (gradient)", + "tags": [ + "playtube", + "gradient", + "calendar", + "kalender" + ] + }, + { + "path": "gradient_mail.png", + "name": "mail (gradient)", + "tags": [ + "playtube", + "gradient", + "mail", + "post", + "email" + ] + }, + { + "path": "gradient_globe.png", + "name": "globe (gradient)", + "tags": [ + "playtube", + "gradient", + "globe", + "welt", + "internet" + ] + }, + { + "path": "gradient_download.png", + "name": "download (gradient)", + "tags": [ + "playtube", + "gradient", + "download", + "herunterladen" + ] + }, + { + "path": "gradient_upload.png", + "name": "upload (gradient)", + "tags": [ + "playtube", + "gradient", + "upload", + "hochladen" + ] + }, + { + "path": "gradient_trash.png", + "name": "trash (gradient)", + "tags": [ + "playtube", + "gradient", + "trash", + "papierkorb", + "loeschen" + ] + }, + { + "path": "gradient_eye.png", + "name": "eye (gradient)", + "tags": [ + "playtube", + "gradient", + "eye", + "auge", + "anzeigen" + ] + }, + { + "path": "gradient_stop.png", + "name": "stop (gradient)", + "tags": [ + "playtube", + "gradient", + "stop", + "stopp" + ] + }, + { + "path": "gradient_refresh.png", + "name": "refresh (gradient)", + "tags": [ + "playtube", + "gradient", + "refresh", + "aktualisieren", + "neu laden" + ] + }, + { + "path": "gradient_wifi.png", + "name": "wifi (gradient)", + "tags": [ + "playtube", + "gradient", + "wifi", + "wlan" + ] + }, + { + "path": "gradient_grid.png", + "name": "grid (gradient)", + "tags": [ + "playtube", + "gradient", + "grid", + "raster" + ] + }, + { + "path": "gradient_menu.png", + "name": "menu (gradient)", + "tags": [ + "playtube", + "gradient", + "menu", + "menue" + ] + }, + { + "path": "gradient_more.png", + "name": "more (gradient)", + "tags": [ + "playtube", + "gradient", + "more", + "mehr" + ] + }, + { + "path": "gradient_info.png", + "name": "info (gradient)", + "tags": [ + "playtube", + "gradient", + "info", + "information" + ] + }, + { + "path": "gradient_warning.png", + "name": "warning (gradient)", + "tags": [ + "playtube", + "gradient", + "warning", + "warnung" + ] + }, + { + "path": "gradient_monitor.png", + "name": "monitor (gradient)", + "tags": [ + "playtube", + "gradient", + "monitor", + "bildschirm" + ] + }, + { + "path": "gradient_sun.png", + "name": "sun (gradient)", + "tags": [ + "playtube", + "gradient", + "sun", + "sonne", + "hell" + ] + }, + { + "path": "gradient_moon.png", + "name": "moon (gradient)", + "tags": [ + "playtube", + "gradient", + "moon", + "mond", + "dunkel" + ] + }, + { + "path": "gradient_arrow-up.png", + "name": "arrow up (gradient)", + "tags": [ + "playtube", + "gradient", + "arrow", + "up" + ] + }, + { + "path": "gradient_arrow-down.png", + "name": "arrow down (gradient)", + "tags": [ + "playtube", + "gradient", + "arrow", + "down" + ] + }, + { + "path": "gradient_arrow-left.png", + "name": "arrow left (gradient)", + "tags": [ + "playtube", + "gradient", + "arrow", + "left" + ] + }, + { + "path": "gradient_arrow-right.png", + "name": "arrow right (gradient)", + "tags": [ + "playtube", + "gradient", + "arrow", + "right" + ] + }, + { + "path": "gradient_chevron-up.png", + "name": "chevron up (gradient)", + "tags": [ + "playtube", + "gradient", + "chevron", + "up" + ] + }, + { + "path": "gradient_chevron-down.png", + "name": "chevron down (gradient)", + "tags": [ + "playtube", + "gradient", + "chevron", + "down" + ] + }, + { + "path": "gradient_chevron-left.png", + "name": "chevron left (gradient)", + "tags": [ + "playtube", + "gradient", + "chevron", + "left" + ] + }, + { + "path": "gradient_chevron-right.png", + "name": "chevron right (gradient)", + "tags": [ + "playtube", + "gradient", + "chevron", + "right" + ] + }, + { + "path": "gradient_eye-off.png", + "name": "eye off (gradient)", + "tags": [ + "playtube", + "gradient", + "eye", + "off", + "auge", + "ausblenden", + "verstecken" + ] + }, + { + "path": "gradient_lightbulb.png", + "name": "lightbulb (gradient)", + "tags": [ + "playtube", + "gradient", + "lightbulb", + "gluehbirne", + "licht", + "lampe" + ] + }, + { + "path": "gradient_pc-monitor.png", + "name": "pc monitor (gradient)", + "tags": [ + "playtube", + "gradient", + "pc", + "monitor", + "pc", + "computer", + "system", + "monitor", + "leistung" + ] + }, + { + "path": "gradient_chrome.png", + "name": "chrome (gradient)", + "tags": [ + "playtube", + "gradient", + "chrome", + "browser", + "google" + ] + }, + { + "path": "gradient_discord.png", + "name": "discord (gradient)", + "tags": [ + "playtube", + "gradient", + "discord", + "chat", + "voice" + ] + }, + { + "path": "gradient_steam.png", + "name": "steam (gradient)", + "tags": [ + "playtube", + "gradient", + "steam", + "spiele", + "games" + ] + }, + { + "path": "gradient_twitch.png", + "name": "twitch (gradient)", + "tags": [ + "playtube", + "gradient", + "twitch", + "stream", + "live" + ] + }, + { + "path": "gradient_netflix.png", + "name": "netflix (gradient)", + "tags": [ + "playtube", + "gradient", + "netflix", + "serien", + "filme" + ] + }, + { + "path": "gradient_youtube.png", + "name": "youtube (gradient)", + "tags": [ + "playtube", + "gradient", + "youtube", + "video" + ] + }, + { + "path": "gradient_medal.png", + "name": "medal (gradient)", + "tags": [ + "playtube", + "gradient", + "medal", + "clips", + "aufnahme" + ] + }, + { + "path": "gradient_nvidia.png", + "name": "nvidia (gradient)", + "tags": [ + "playtube", + "gradient", + "nvidia", + "grafikkarte", + "gpu" + ] + }, + { + "path": "gradient_steelseries.png", + "name": "steelseries (gradient)", + "tags": [ + "playtube", + "gradient", + "steelseries", + "sonar", + "audio" + ] + }, + { + "path": "gradient_aniworld.png", + "name": "aniworld (gradient)", + "tags": [ + "playtube", + "gradient", + "aniworld", + "anime" + ] + }, + { + "path": "gradient_prismlens.png", + "name": "prismlens (gradient)", + "tags": [ + "playtube", + "gradient", + "prismlens", + "filter", + "kamera" + ] + }, + { + "path": "dark_play.png", + "name": "play (dark)", + "tags": [ + "playtube", + "dark", + "play", + "abspielen" + ] + }, + { + "path": "dark_pause.png", + "name": "pause (dark)", + "tags": [ + "playtube", + "dark", + "pause", + "pause" + ] + }, + { + "path": "dark_next.png", + "name": "next (dark)", + "tags": [ + "playtube", + "dark", + "next", + "weiter", + "naechster" + ] + }, + { + "path": "dark_previous.png", + "name": "previous (dark)", + "tags": [ + "playtube", + "dark", + "previous", + "zurueck", + "vorheriger" + ] + }, + { + "path": "dark_rewind.png", + "name": "rewind (dark)", + "tags": [ + "playtube", + "dark", + "rewind" + ] + }, + { + "path": "dark_forward.png", + "name": "forward (dark)", + "tags": [ + "playtube", + "dark", + "forward" + ] + }, + { + "path": "dark_volup.png", + "name": "volup (dark)", + "tags": [ + "playtube", + "dark", + "volup", + "lauter" + ] + }, + { + "path": "dark_voldown.png", + "name": "voldown (dark)", + "tags": [ + "playtube", + "dark", + "voldown", + "leiser" + ] + }, + { + "path": "dark_vol.png", + "name": "vol (dark)", + "tags": [ + "playtube", + "dark", + "vol", + "lautstaerke" + ] + }, + { + "path": "dark_mute.png", + "name": "mute (dark)", + "tags": [ + "playtube", + "dark", + "mute", + "stumm" + ] + }, + { + "path": "dark_note.png", + "name": "note (dark)", + "tags": [ + "playtube", + "dark", + "note" + ] + }, + { + "path": "dark_heart.png", + "name": "heart (dark)", + "tags": [ + "playtube", + "dark", + "heart", + "herz", + "gefaellt mir" + ] + }, + { + "path": "dark_heartfill.png", + "name": "heartfill (dark)", + "tags": [ + "playtube", + "dark", + "heartfill" + ] + }, + { + "path": "dark_shuffle.png", + "name": "shuffle (dark)", + "tags": [ + "playtube", + "dark", + "shuffle", + "zufall" + ] + }, + { + "path": "dark_repeat.png", + "name": "repeat (dark)", + "tags": [ + "playtube", + "dark", + "repeat", + "wiederholen" + ] + }, + { + "path": "dark_repeatone.png", + "name": "repeatone (dark)", + "tags": [ + "playtube", + "dark", + "repeatone" + ] + }, + { + "path": "dark_playlist.png", + "name": "playlist (dark)", + "tags": [ + "playtube", + "dark", + "playlist", + "liste" + ] + }, + { + "path": "dark_tabs.png", + "name": "tabs (dark)", + "tags": [ + "playtube", + "dark", + "tabs" + ] + }, + { + "path": "dark_window.png", + "name": "window (dark)", + "tags": [ + "playtube", + "dark", + "window" + ] + }, + { + "path": "dark_folder.png", + "name": "folder (dark)", + "tags": [ + "playtube", + "dark", + "folder", + "ordner" + ] + }, + { + "path": "dark_file.png", + "name": "file (dark)", + "tags": [ + "playtube", + "dark", + "file", + "datei" + ] + }, + { + "path": "dark_home.png", + "name": "home (dark)", + "tags": [ + "playtube", + "dark", + "home", + "start", + "zuhause" + ] + }, + { + "path": "dark_gear.png", + "name": "gear (dark)", + "tags": [ + "playtube", + "dark", + "gear", + "einstellungen", + "zahnrad" + ] + }, + { + "path": "dark_plus.png", + "name": "plus (dark)", + "tags": [ + "playtube", + "dark", + "plus", + "hinzufuegen" + ] + }, + { + "path": "dark_minus.png", + "name": "minus (dark)", + "tags": [ + "playtube", + "dark", + "minus", + "entfernen" + ] + }, + { + "path": "dark_close.png", + "name": "close (dark)", + "tags": [ + "playtube", + "dark", + "close", + "schliessen" + ] + }, + { + "path": "dark_check.png", + "name": "check (dark)", + "tags": [ + "playtube", + "dark", + "check", + "haken", + "ok" + ] + }, + { + "path": "dark_star.png", + "name": "star (dark)", + "tags": [ + "playtube", + "dark", + "star", + "stern", + "favorit" + ] + }, + { + "path": "dark_search.png", + "name": "search (dark)", + "tags": [ + "playtube", + "dark", + "search", + "suche", + "lupe" + ] + }, + { + "path": "dark_power.png", + "name": "power (dark)", + "tags": [ + "playtube", + "dark", + "power", + "ein", + "aus" + ] + }, + { + "path": "dark_mic.png", + "name": "mic (dark)", + "tags": [ + "playtube", + "dark", + "mic", + "mikrofon" + ] + }, + { + "path": "dark_headphones.png", + "name": "headphones (dark)", + "tags": [ + "playtube", + "dark", + "headphones", + "kopfhoerer" + ] + }, + { + "path": "dark_lock.png", + "name": "lock (dark)", + "tags": [ + "playtube", + "dark", + "lock", + "schloss" + ] + }, + { + "path": "dark_bell.png", + "name": "bell (dark)", + "tags": [ + "playtube", + "dark", + "bell", + "glocke", + "benachrichtigung" + ] + }, + { + "path": "dark_clock.png", + "name": "clock (dark)", + "tags": [ + "playtube", + "dark", + "clock", + "uhr", + "zeit" + ] + }, + { + "path": "dark_calendar.png", + "name": "calendar (dark)", + "tags": [ + "playtube", + "dark", + "calendar", + "kalender" + ] + }, + { + "path": "dark_mail.png", + "name": "mail (dark)", + "tags": [ + "playtube", + "dark", + "mail", + "post", + "email" + ] + }, + { + "path": "dark_globe.png", + "name": "globe (dark)", + "tags": [ + "playtube", + "dark", + "globe", + "welt", + "internet" + ] + }, + { + "path": "dark_download.png", + "name": "download (dark)", + "tags": [ + "playtube", + "dark", + "download", + "herunterladen" + ] + }, + { + "path": "dark_upload.png", + "name": "upload (dark)", + "tags": [ + "playtube", + "dark", + "upload", + "hochladen" + ] + }, + { + "path": "dark_trash.png", + "name": "trash (dark)", + "tags": [ + "playtube", + "dark", + "trash", + "papierkorb", + "loeschen" + ] + }, + { + "path": "dark_eye.png", + "name": "eye (dark)", + "tags": [ + "playtube", + "dark", + "eye", + "auge", + "anzeigen" + ] + }, + { + "path": "dark_stop.png", + "name": "stop (dark)", + "tags": [ + "playtube", + "dark", + "stop", + "stopp" + ] + }, + { + "path": "dark_refresh.png", + "name": "refresh (dark)", + "tags": [ + "playtube", + "dark", + "refresh", + "aktualisieren", + "neu laden" + ] + }, + { + "path": "dark_wifi.png", + "name": "wifi (dark)", + "tags": [ + "playtube", + "dark", + "wifi", + "wlan" + ] + }, + { + "path": "dark_grid.png", + "name": "grid (dark)", + "tags": [ + "playtube", + "dark", + "grid", + "raster" + ] + }, + { + "path": "dark_menu.png", + "name": "menu (dark)", + "tags": [ + "playtube", + "dark", + "menu", + "menue" + ] + }, + { + "path": "dark_more.png", + "name": "more (dark)", + "tags": [ + "playtube", + "dark", + "more", + "mehr" + ] + }, + { + "path": "dark_info.png", + "name": "info (dark)", + "tags": [ + "playtube", + "dark", + "info", + "information" + ] + }, + { + "path": "dark_warning.png", + "name": "warning (dark)", + "tags": [ + "playtube", + "dark", + "warning", + "warnung" + ] + }, + { + "path": "dark_monitor.png", + "name": "monitor (dark)", + "tags": [ + "playtube", + "dark", + "monitor", + "bildschirm" + ] + }, + { + "path": "dark_sun.png", + "name": "sun (dark)", + "tags": [ + "playtube", + "dark", + "sun", + "sonne", + "hell" + ] + }, + { + "path": "dark_moon.png", + "name": "moon (dark)", + "tags": [ + "playtube", + "dark", + "moon", + "mond", + "dunkel" + ] + }, + { + "path": "dark_arrow-up.png", + "name": "arrow up (dark)", + "tags": [ + "playtube", + "dark", + "arrow", + "up" + ] + }, + { + "path": "dark_arrow-down.png", + "name": "arrow down (dark)", + "tags": [ + "playtube", + "dark", + "arrow", + "down" + ] + }, + { + "path": "dark_arrow-left.png", + "name": "arrow left (dark)", + "tags": [ + "playtube", + "dark", + "arrow", + "left" + ] + }, + { + "path": "dark_arrow-right.png", + "name": "arrow right (dark)", + "tags": [ + "playtube", + "dark", + "arrow", + "right" + ] + }, + { + "path": "dark_chevron-up.png", + "name": "chevron up (dark)", + "tags": [ + "playtube", + "dark", + "chevron", + "up" + ] + }, + { + "path": "dark_chevron-down.png", + "name": "chevron down (dark)", + "tags": [ + "playtube", + "dark", + "chevron", + "down" + ] + }, + { + "path": "dark_chevron-left.png", + "name": "chevron left (dark)", + "tags": [ + "playtube", + "dark", + "chevron", + "left" + ] + }, + { + "path": "dark_chevron-right.png", + "name": "chevron right (dark)", + "tags": [ + "playtube", + "dark", + "chevron", + "right" + ] + }, + { + "path": "dark_eye-off.png", + "name": "eye off (dark)", + "tags": [ + "playtube", + "dark", + "eye", + "off", + "auge", + "ausblenden", + "verstecken" + ] + }, + { + "path": "dark_lightbulb.png", + "name": "lightbulb (dark)", + "tags": [ + "playtube", + "dark", + "lightbulb", + "gluehbirne", + "licht", + "lampe" + ] + }, + { + "path": "dark_pc-monitor.png", + "name": "pc monitor (dark)", + "tags": [ + "playtube", + "dark", + "pc", + "monitor", + "pc", + "computer", + "system", + "monitor", + "leistung" + ] + }, + { + "path": "dark_chrome.png", + "name": "chrome (dark)", + "tags": [ + "playtube", + "dark", + "chrome", + "browser", + "google" + ] + }, + { + "path": "dark_discord.png", + "name": "discord (dark)", + "tags": [ + "playtube", + "dark", + "discord", + "chat", + "voice" + ] + }, + { + "path": "dark_steam.png", + "name": "steam (dark)", + "tags": [ + "playtube", + "dark", + "steam", + "spiele", + "games" + ] + }, + { + "path": "dark_twitch.png", + "name": "twitch (dark)", + "tags": [ + "playtube", + "dark", + "twitch", + "stream", + "live" + ] + }, + { + "path": "dark_netflix.png", + "name": "netflix (dark)", + "tags": [ + "playtube", + "dark", + "netflix", + "serien", + "filme" + ] + }, + { + "path": "dark_youtube.png", + "name": "youtube (dark)", + "tags": [ + "playtube", + "dark", + "youtube", + "video" + ] + }, + { + "path": "dark_medal.png", + "name": "medal (dark)", + "tags": [ + "playtube", + "dark", + "medal", + "clips", + "aufnahme" + ] + }, + { + "path": "dark_nvidia.png", + "name": "nvidia (dark)", + "tags": [ + "playtube", + "dark", + "nvidia", + "grafikkarte", + "gpu" + ] + }, + { + "path": "dark_steelseries.png", + "name": "steelseries (dark)", + "tags": [ + "playtube", + "dark", + "steelseries", + "sonar", + "audio" + ] + }, + { + "path": "dark_aniworld.png", + "name": "aniworld (dark)", + "tags": [ + "playtube", + "dark", + "aniworld", + "anime" + ] + }, + { + "path": "dark_prismlens.png", + "name": "prismlens (dark)", + "tags": [ + "playtube", + "dark", + "prismlens", + "filter", + "kamera" + ] + } +] diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_aniworld.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_aniworld.png new file mode 100644 index 0000000..43dc955 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_aniworld.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-down.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-down.png new file mode 100644 index 0000000..e9f54c5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-down.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-left.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-left.png new file mode 100644 index 0000000..4e3c2c1 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-left.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-right.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-right.png new file mode 100644 index 0000000..4297fd5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-right.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-up.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-up.png new file mode 100644 index 0000000..e6672ed Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_arrow-up.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_bell.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_bell.png new file mode 100644 index 0000000..2831e5a Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_bell.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_calendar.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_calendar.png new file mode 100644 index 0000000..12c2748 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_calendar.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_check.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_check.png new file mode 100644 index 0000000..00fae57 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_check.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-down.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-down.png new file mode 100644 index 0000000..469bb7b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-down.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-left.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-left.png new file mode 100644 index 0000000..7617ab3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-left.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-right.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-right.png new file mode 100644 index 0000000..be00bb3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-right.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-up.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-up.png new file mode 100644 index 0000000..163082e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chevron-up.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chrome.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chrome.png new file mode 100644 index 0000000..4e6d888 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_chrome.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_clock.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_clock.png new file mode 100644 index 0000000..af2ff56 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_clock.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_close.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_close.png new file mode 100644 index 0000000..02dee8e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_close.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_discord.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_discord.png new file mode 100644 index 0000000..d763f71 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_discord.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_download.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_download.png new file mode 100644 index 0000000..4b4cad3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_download.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_eye-off.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_eye-off.png new file mode 100644 index 0000000..766d8d3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_eye-off.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_eye.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_eye.png new file mode 100644 index 0000000..9148c31 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_eye.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_file.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_file.png new file mode 100644 index 0000000..d8c2bc0 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_file.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_folder.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_folder.png new file mode 100644 index 0000000..431ad86 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_folder.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_forward.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_forward.png new file mode 100644 index 0000000..bfe7196 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_forward.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_gear.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_gear.png new file mode 100644 index 0000000..56b1c44 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_gear.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_globe.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_globe.png new file mode 100644 index 0000000..c899437 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_globe.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_grid.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_grid.png new file mode 100644 index 0000000..a4fdec5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_grid.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_headphones.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_headphones.png new file mode 100644 index 0000000..ec2a2f5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_headphones.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_heart.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_heart.png new file mode 100644 index 0000000..ea89722 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_heart.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_heartfill.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_heartfill.png new file mode 100644 index 0000000..e2f8ad3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_heartfill.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_home.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_home.png new file mode 100644 index 0000000..ff7ec63 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_home.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_info.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_info.png new file mode 100644 index 0000000..f914bb3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_info.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_lightbulb.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_lightbulb.png new file mode 100644 index 0000000..f57b322 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_lightbulb.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_lock.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_lock.png new file mode 100644 index 0000000..00321dc Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_lock.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mail.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mail.png new file mode 100644 index 0000000..48d1a8e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mail.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_medal.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_medal.png new file mode 100644 index 0000000..15a0110 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_medal.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_menu.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_menu.png new file mode 100644 index 0000000..36be864 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_menu.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mic.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mic.png new file mode 100644 index 0000000..f98c2fc Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mic.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_minus.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_minus.png new file mode 100644 index 0000000..0d0b4dc Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_minus.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_monitor.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_monitor.png new file mode 100644 index 0000000..9215198 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_monitor.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_moon.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_moon.png new file mode 100644 index 0000000..bb96598 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_moon.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_more.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_more.png new file mode 100644 index 0000000..40582f7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_more.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mute.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mute.png new file mode 100644 index 0000000..24abd0a Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_mute.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_netflix.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_netflix.png new file mode 100644 index 0000000..209e270 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_netflix.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_next.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_next.png new file mode 100644 index 0000000..fe9f3df Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_next.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_note.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_note.png new file mode 100644 index 0000000..a8c5dc7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_note.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_nvidia.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_nvidia.png new file mode 100644 index 0000000..2cc4558 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_nvidia.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_pause.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_pause.png new file mode 100644 index 0000000..e23f0b0 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_pause.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_pc-monitor.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_pc-monitor.png new file mode 100644 index 0000000..a7b7783 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_pc-monitor.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_play.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_play.png new file mode 100644 index 0000000..6ee566b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_play.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_playlist.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_playlist.png new file mode 100644 index 0000000..8ed90e1 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_playlist.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_plus.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_plus.png new file mode 100644 index 0000000..d7ae3be Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_plus.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_power.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_power.png new file mode 100644 index 0000000..f63a862 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_power.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_previous.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_previous.png new file mode 100644 index 0000000..765c5df Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_previous.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_prismlens.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_prismlens.png new file mode 100644 index 0000000..e2ce6b6 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_prismlens.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_refresh.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_refresh.png new file mode 100644 index 0000000..a01f5fe Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_refresh.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_repeat.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_repeat.png new file mode 100644 index 0000000..744161e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_repeat.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_repeatone.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_repeatone.png new file mode 100644 index 0000000..ff66778 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_repeatone.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_rewind.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_rewind.png new file mode 100644 index 0000000..9c41cad Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_rewind.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_search.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_search.png new file mode 100644 index 0000000..4e12aed Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_search.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_shuffle.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_shuffle.png new file mode 100644 index 0000000..fcacbf2 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_shuffle.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_star.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_star.png new file mode 100644 index 0000000..e265923 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_star.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_steam.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_steam.png new file mode 100644 index 0000000..5de222b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_steam.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_steelseries.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_steelseries.png new file mode 100644 index 0000000..5eccca9 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_steelseries.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_stop.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_stop.png new file mode 100644 index 0000000..bbbf1bc Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_stop.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_sun.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_sun.png new file mode 100644 index 0000000..eb061c7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_sun.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_tabs.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_tabs.png new file mode 100644 index 0000000..7048b3e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_tabs.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_trash.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_trash.png new file mode 100644 index 0000000..ded13b1 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_trash.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_twitch.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_twitch.png new file mode 100644 index 0000000..93b6cb7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_twitch.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_upload.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_upload.png new file mode 100644 index 0000000..39d5136 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_upload.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_vol.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_vol.png new file mode 100644 index 0000000..012ccc8 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_vol.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_voldown.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_voldown.png new file mode 100644 index 0000000..22ec85c Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_voldown.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_volup.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_volup.png new file mode 100644 index 0000000..0907d22 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_volup.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_warning.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_warning.png new file mode 100644 index 0000000..3f40037 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_warning.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_wifi.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_wifi.png new file mode 100644 index 0000000..a0451f5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_wifi.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_window.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_window.png new file mode 100644 index 0000000..10a9d4e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_window.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_youtube.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_youtube.png new file mode 100644 index 0000000..69e30c9 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/dark_youtube.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_aniworld.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_aniworld.png new file mode 100644 index 0000000..64e3982 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_aniworld.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-down.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-down.png new file mode 100644 index 0000000..f751610 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-down.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-left.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-left.png new file mode 100644 index 0000000..867ca3f Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-left.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-right.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-right.png new file mode 100644 index 0000000..fec9f6b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-right.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-up.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-up.png new file mode 100644 index 0000000..3bcfaee Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_arrow-up.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_bell.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_bell.png new file mode 100644 index 0000000..5ce2a12 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_bell.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_calendar.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_calendar.png new file mode 100644 index 0000000..f2c286d Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_calendar.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_check.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_check.png new file mode 100644 index 0000000..1de6676 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_check.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-down.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-down.png new file mode 100644 index 0000000..a464303 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-down.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-left.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-left.png new file mode 100644 index 0000000..f1aaced Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-left.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-right.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-right.png new file mode 100644 index 0000000..c772943 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-right.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-up.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-up.png new file mode 100644 index 0000000..f0d50d7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chevron-up.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chrome.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chrome.png new file mode 100644 index 0000000..672df20 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_chrome.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_clock.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_clock.png new file mode 100644 index 0000000..4c74a04 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_clock.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_close.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_close.png new file mode 100644 index 0000000..7bfc3a6 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_close.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_discord.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_discord.png new file mode 100644 index 0000000..ea1b8fa Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_discord.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_download.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_download.png new file mode 100644 index 0000000..2573f0f Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_download.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_eye-off.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_eye-off.png new file mode 100644 index 0000000..991e690 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_eye-off.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_eye.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_eye.png new file mode 100644 index 0000000..7b1bb8e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_eye.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_file.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_file.png new file mode 100644 index 0000000..fc459bd Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_file.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_folder.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_folder.png new file mode 100644 index 0000000..c7baeca Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_folder.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_forward.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_forward.png new file mode 100644 index 0000000..a53c52b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_forward.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_gear.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_gear.png new file mode 100644 index 0000000..54f5c32 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_gear.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_globe.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_globe.png new file mode 100644 index 0000000..508d211 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_globe.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_grid.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_grid.png new file mode 100644 index 0000000..f545844 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_grid.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_headphones.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_headphones.png new file mode 100644 index 0000000..5f67a77 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_headphones.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_heart.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_heart.png new file mode 100644 index 0000000..4e0aa1f Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_heart.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_heartfill.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_heartfill.png new file mode 100644 index 0000000..a15598b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_heartfill.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_home.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_home.png new file mode 100644 index 0000000..92a2994 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_home.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_info.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_info.png new file mode 100644 index 0000000..8e87b78 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_info.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_lightbulb.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_lightbulb.png new file mode 100644 index 0000000..9405d19 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_lightbulb.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_lock.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_lock.png new file mode 100644 index 0000000..0262d9c Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_lock.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mail.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mail.png new file mode 100644 index 0000000..8ae54ec Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mail.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_medal.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_medal.png new file mode 100644 index 0000000..bbfdfaa Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_medal.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_menu.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_menu.png new file mode 100644 index 0000000..6030a81 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_menu.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mic.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mic.png new file mode 100644 index 0000000..cc5a0bc Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mic.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_minus.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_minus.png new file mode 100644 index 0000000..0a14456 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_minus.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_monitor.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_monitor.png new file mode 100644 index 0000000..aa1a1ea Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_monitor.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_moon.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_moon.png new file mode 100644 index 0000000..f1d18b6 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_moon.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_more.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_more.png new file mode 100644 index 0000000..3c3f9f0 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_more.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mute.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mute.png new file mode 100644 index 0000000..73c9274 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_mute.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_netflix.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_netflix.png new file mode 100644 index 0000000..2409913 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_netflix.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_next.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_next.png new file mode 100644 index 0000000..0ee91a5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_next.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_note.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_note.png new file mode 100644 index 0000000..89508e9 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_note.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_nvidia.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_nvidia.png new file mode 100644 index 0000000..5d07ace Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_nvidia.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_pause.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_pause.png new file mode 100644 index 0000000..6d61001 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_pause.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_pc-monitor.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_pc-monitor.png new file mode 100644 index 0000000..e09f116 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_pc-monitor.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_play.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_play.png new file mode 100644 index 0000000..08b6492 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_play.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_playlist.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_playlist.png new file mode 100644 index 0000000..ed3b76f Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_playlist.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_plus.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_plus.png new file mode 100644 index 0000000..a5d76a7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_plus.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_power.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_power.png new file mode 100644 index 0000000..c4e65b4 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_power.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_previous.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_previous.png new file mode 100644 index 0000000..03f6553 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_previous.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_prismlens.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_prismlens.png new file mode 100644 index 0000000..4b3c8d3 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_prismlens.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_refresh.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_refresh.png new file mode 100644 index 0000000..66d0b00 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_refresh.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_repeat.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_repeat.png new file mode 100644 index 0000000..e4afefd Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_repeat.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_repeatone.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_repeatone.png new file mode 100644 index 0000000..285d741 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_repeatone.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_rewind.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_rewind.png new file mode 100644 index 0000000..e3e007e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_rewind.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_search.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_search.png new file mode 100644 index 0000000..c3f605e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_search.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_shuffle.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_shuffle.png new file mode 100644 index 0000000..1c7f9a1 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_shuffle.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_star.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_star.png new file mode 100644 index 0000000..496db10 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_star.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_steam.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_steam.png new file mode 100644 index 0000000..7ed247a Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_steam.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_steelseries.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_steelseries.png new file mode 100644 index 0000000..e2b497e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_steelseries.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_stop.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_stop.png new file mode 100644 index 0000000..2c79560 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_stop.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_sun.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_sun.png new file mode 100644 index 0000000..7c93674 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_sun.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_tabs.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_tabs.png new file mode 100644 index 0000000..2c6e1ba Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_tabs.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_trash.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_trash.png new file mode 100644 index 0000000..e31283a Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_trash.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_twitch.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_twitch.png new file mode 100644 index 0000000..39834c7 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_twitch.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_upload.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_upload.png new file mode 100644 index 0000000..efc65a8 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_upload.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_vol.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_vol.png new file mode 100644 index 0000000..cca75b5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_vol.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_voldown.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_voldown.png new file mode 100644 index 0000000..76109d5 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_voldown.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_volup.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_volup.png new file mode 100644 index 0000000..9641b6b Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_volup.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_warning.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_warning.png new file mode 100644 index 0000000..e6328cd Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_warning.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_wifi.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_wifi.png new file mode 100644 index 0000000..2849745 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_wifi.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_window.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_window.png new file mode 100644 index 0000000..ff1142e Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_window.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_youtube.png b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_youtube.png new file mode 100644 index 0000000..5174bc6 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/icons/gradient_youtube.png differ diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/manifest.json b/iconpack/com.fojadrachi.playtube.sdIconPack/manifest.json new file mode 100644 index 0000000..3833aba --- /dev/null +++ b/iconpack/com.fojadrachi.playtube.sdIconPack/manifest.json @@ -0,0 +1,7 @@ +{ + "Name": "Playtube Icons", + "Version": "1.0.0", + "Description": "Icons im Playtube-Look (Verlauf violett/pink): Ordner, Pfeile, Player-Symbole und mehr.", + "Author": "Playtube", + "Icon": "icon.png" +} diff --git a/iconpack/com.fojadrachi.playtube.sdIconPack/previews/preview1.png b/iconpack/com.fojadrachi.playtube.sdIconPack/previews/preview1.png new file mode 100644 index 0000000..08b6492 Binary files /dev/null and b/iconpack/com.fojadrachi.playtube.sdIconPack/previews/preview1.png differ diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..b6f4783 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,34 @@ +# Installiert das Playtube-Plugin und das Icon-Pack in die Stream-Dock-Software (Ajazz/Mirabox). +# +# Aufruf (im Repo-Ordner): +# powershell -ExecutionPolicy Bypass -File install.ps1 +# +# Danach die Stream-Dock-Software komplett beenden (Tray-Symbol -> Beenden) und neu starten. + +$ErrorActionPreference = "Stop" + +$sdRoot = Join-Path $env:APPDATA "HotSpot\StreamDock" +if (-not (Test-Path $sdRoot)) { + throw "Stream Dock ist nicht installiert: $sdRoot nicht gefunden." +} + +function Install-Folder([string]$Source, [string]$TargetParent) { + if (-not (Test-Path $Source)) { throw "Nicht gefunden: $Source (im Repo-Ordner ausfuehren)" } + New-Item -ItemType Directory -Path $TargetParent -ErrorAction SilentlyContinue | Out-Null + $target = Join-Path $TargetParent (Split-Path $Source -Leaf) + # Nur den eigenen Ordner ersetzen, damit keine Reste alter Versionen bleiben. + if (Test-Path $target) { Remove-Item -Recurse -Path $target } + Copy-Item -Recurse -Path $Source -Destination $target + Write-Host "Installiert: $target" -ForegroundColor Green +} + +Install-Folder (Join-Path $PSScriptRoot "com.fojadrachi.playtube.sdPlugin") (Join-Path $sdRoot "plugins") + +$iconPack = Join-Path $PSScriptRoot "iconpack\com.fojadrachi.playtube.sdIconPack" +if (Test-Path $iconPack) { + Install-Folder $iconPack (Join-Path $sdRoot "icons") +} + +Write-Host "" +Write-Host "Fertig. Stream Dock jetzt beenden (Tray-Symbol) und neu starten." -ForegroundColor Cyan +Write-Host "Aktionen: Kategorie 'Playtube'. Playtube ab 4.5.1 muss laufen." -ForegroundColor Cyan diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac66a48 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,527 @@ +{ + "name": "playtube-streamdock-plugin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playtube-streamdock-plugin", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "esbuild": "^0.25.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c8eb343 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "playtube-streamdock-plugin", + "version": "1.0.0", + "private": true, + "description": "Stream Dock plugin (Ajazz AKP153E and other Stream Dock devices) to control Playtube", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "scripts": { + "assets": "node tools/generate-assets.mjs", + "iconpack": "node tools/generate-iconpack.mjs", + "build": "node build.mjs", + "test": "node --test \"tests/*.test.js\"", + "package": "node build.mjs --zip" + }, + "dependencies": { + "ws": "^8.18.0" + }, + "devDependencies": { + "esbuild": "^0.25.0" + } +} diff --git a/src/actions.js b/src/actions.js new file mode 100644 index 0000000..34017cb --- /dev/null +++ b/src/actions.js @@ -0,0 +1,220 @@ +'use strict'; + +/** + * Alle Tastenaktionen des Plugins: was ein Tastendruck an Playtube schickt (request) und wie die + * Taste dazu aussieht (render). Reine Funktionen ohne Netzwerk - dadurch gut testbar. + * + * Zustaende (State) entsprechen den Bildern im manifest.json, Reihenfolge = Index. + */ + +const PREFIX = 'com.fojadrachi.playtube.'; + +const DEFAULTS = Object.freeze({ + target: 'auto', // "auto" | "youtube" | "music" + step: 5, // Lautstaerke-Schritt in Prozent + volume: 30, // Ziel-Lautstaerke der Aktion "Lautstaerke setzen" + seconds: 10, // Sprungweite beim Vor-/Zurueckspulen + tab: 'toggle', // Aktion "Tab wechseln": "toggle" | "youtube" | "music" + playlistId: '', + playlistTitle: '', +}); + +const TARGETS = ['auto', 'youtube', 'music']; +const TABS = ['toggle', 'youtube', 'music']; + +function clampInt(value, min, max, fallback) { + const number = Number.parseInt(value, 10); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, number)); +} + +/** Macht aus den (evtl. leeren/fehlerhaften) Einstellungen einer Taste gueltige Werte. */ +function normalizeSettings(raw, titleDefault = false) { + const settings = { ...DEFAULTS, ...(raw || {}) }; + if (!TARGETS.includes(settings.target)) settings.target = DEFAULTS.target; + if (!TABS.includes(settings.tab)) settings.tab = DEFAULTS.tab; + settings.step = clampInt(settings.step, 1, 25, DEFAULTS.step); + settings.volume = clampInt(settings.volume, 0, 100, DEFAULTS.volume); + settings.seconds = clampInt(settings.seconds, 1, 300, DEFAULTS.seconds); + const show = raw && raw.showTitle !== undefined ? raw.showTitle : titleDefault; + settings.showTitle = show === true || show === 'true'; + settings.playlistId = typeof settings.playlistId === 'string' ? settings.playlistId.trim() : ''; + settings.playlistTitle = typeof settings.playlistTitle === 'string' ? settings.playlistTitle : ''; + return settings; +} + +/** Welcher Tab ist gemeint? "auto" = der Tab, den Playtube als aktiv meldet. */ +function pickTab(state, target) { + if (!state || !state.tabs) return null; + const name = target === 'auto' ? state.activeTab : target; + const info = state.tabs[name]; + return info ? { name, info } : null; +} + +/** Bricht Text fuer die kleinen Tasten-Displays um (Woerter, dann harte Trennung). */ +function wrapTitle(text, perLine = 8, maxLines = 3) { + const words = String(text || '').trim().split(/\s+/).filter(Boolean); + const lines = []; + let current = ''; + let truncated = false; + const push = (line) => { + if (lines.length < maxLines) lines.push(line); + else truncated = true; + }; + for (let word of words) { + while (word.length > perLine) { + if (current) { + push(current); + current = ''; + } + push(word.slice(0, perLine)); + word = word.slice(perLine); + } + if (!word) continue; + if (!current) current = word; + else if (current.length + 1 + word.length <= perLine) current += ` ${word}`; + else { + push(current); + current = word; + } + } + if (current) push(current); + if (truncated && lines.length) lines[lines.length - 1] = `${lines[lines.length - 1].slice(0, perLine - 2)}..`; + return lines.join('\n'); +} + +function volumeTitle(tab) { + const volume = tab && tab.info.volume; + return typeof volume === 'number' ? `${Math.round(volume)}%` : ''; +} + +const OFFLINE_TITLE = 'Playtube\noffline'; + +/** + * Definition pro Aktion. `id` = letzter Teil der UUID (siehe manifest.json). + * - request(settings): {cmd, fields} oder null (Taste tut dann nichts, zeigt Alarm) + * - render(settings, state, connected): {state?, title?, coverUrl?} + * - press: "up" (Standard, beim Loslassen) oder "down" (sofort; mit repeat = Dauerdruck) + */ +const DEFINITIONS = { + playpause: { + titleDefault: false, + request: (s) => ({ cmd: 'play_pause', fields: { target: s.target } }), + render: (s, state, connected) => { + if (!connected) return { state: 0, title: s.showTitle ? OFFLINE_TITLE : '' }; + const tab = pickTab(state, s.target); + return { + state: tab && tab.info.playing ? 1 : 0, + title: s.showTitle && tab && tab.info.title ? wrapTitle(tab.info.title) : '', + }; + }, + }, + next: { request: (s) => ({ cmd: 'next', fields: { target: s.target } }) }, + previous: { request: (s) => ({ cmd: 'previous', fields: { target: s.target } }) }, + seekback: { + request: (s) => ({ cmd: 'seek', fields: { target: s.target, value: -s.seconds } }), + render: (s) => ({ title: `-${s.seconds}s` }), + }, + seekforward: { + request: (s) => ({ cmd: 'seek', fields: { target: s.target, value: s.seconds } }), + render: (s) => ({ title: `+${s.seconds}s` }), + }, + volumeup: { + press: 'down', + repeat: { delayMs: 450, intervalMs: 140 }, + request: (s) => ({ cmd: 'volume_change', fields: { target: s.target, value: s.step } }), + render: (s, state, connected) => ({ title: connected ? volumeTitle(pickTab(state, s.target)) : '' }), + }, + volumedown: { + press: 'down', + repeat: { delayMs: 450, intervalMs: 140 }, + request: (s) => ({ cmd: 'volume_change', fields: { target: s.target, value: -s.step } }), + render: (s, state, connected) => ({ title: connected ? volumeTitle(pickTab(state, s.target)) : '' }), + }, + volumeset: { + request: (s) => ({ cmd: 'set_volume', fields: { target: s.target, value: s.volume } }), + render: (s) => ({ title: `${s.volume}%` }), + }, + mute: { + request: (s) => ({ cmd: 'mute_toggle', fields: { target: s.target } }), + render: (s, state, connected) => { + const tab = connected ? pickTab(state, s.target) : null; + return { state: tab && tab.info.muted ? 1 : 0, title: tab && !tab.info.muted ? volumeTitle(tab) : '' }; + }, + }, + nowplaying: { + titleDefault: true, + request: (s) => ({ cmd: 'play_pause', fields: { target: s.target } }), + render: (s, state, connected) => { + if (!connected) return { coverUrl: null, title: s.showTitle ? OFFLINE_TITLE : '' }; + const tab = pickTab(state, s.target); + const info = tab && tab.info; + return { + coverUrl: info && info.hasVideo && info.thumbnail ? info.thumbnail : null, + title: s.showTitle && info && info.title ? wrapTitle(info.title) : '', + }; + }, + }, + like: { + request: (s) => ({ cmd: 'like', fields: { target: s.target } }), + render: (s, state, connected) => { + const tab = connected ? pickTab(state, s.target) : null; + return { state: tab && tab.info.likeStatus === 'like' ? 1 : 0 }; + }, + }, + shuffle: { request: (s) => ({ cmd: 'shuffle', fields: { target: s.target } }) }, + repeat: { + request: (s) => ({ cmd: 'repeat', fields: { target: s.target } }), + render: (s, state, connected) => { + const tab = connected ? pickTab(state, s.target) : null; + const mode = String((tab && tab.info.repeatMode) || '').toUpperCase(); + return { state: mode === 'ALL' ? 1 : mode === 'ONE' ? 2 : 0 }; + }, + }, + playlist: { + request: (s) => (s.playlistId ? { cmd: 'play_playlist', fields: { playlist: s.playlistId } } : null), + render: (s) => ({ title: s.playlistTitle ? wrapTitle(s.playlistTitle) : '' }), + }, + switchtab: { + request: (s) => ({ cmd: 'switch_tab', fields: { target: s.tab === 'toggle' ? 'auto' : s.tab } }), + render: (s, state, connected) => { + if (!connected || !state) return { title: '' }; + if (state.visibleTab === 'music') return { title: 'Music' }; + if (state.visibleTab === 'youtube') return { title: 'YouTube' }; + return { title: '' }; + }, + }, + show: { request: () => ({ cmd: 'show', fields: {} }) }, +}; + +function actionIdFromUuid(uuid) { + if (typeof uuid !== 'string' || !uuid.startsWith(PREFIX)) return null; + const id = uuid.slice(PREFIX.length); + return Object.prototype.hasOwnProperty.call(DEFINITIONS, id) ? id : null; +} + +/** Darstellung einer Taste; fehlende Felder heissen "nicht aendern". */ +function renderAction(id, rawSettings, state, connected) { + const definition = DEFINITIONS[id]; + const settings = normalizeSettings(rawSettings, definition.titleDefault); + return definition.render ? definition.render(settings, state, connected) : {}; +} + +/** {cmd, fields} fuer einen Tastendruck oder null. */ +function buildRequest(id, rawSettings) { + const definition = DEFINITIONS[id]; + const settings = normalizeSettings(rawSettings, definition.titleDefault); + return definition.request(settings); +} + +module.exports = { + PREFIX, + DEFAULTS, + DEFINITIONS, + normalizeSettings, + pickTab, + wrapTitle, + actionIdFromUuid, + renderAction, + buildRequest, +}; diff --git a/src/controller.js b/src/controller.js new file mode 100644 index 0000000..105ad95 --- /dev/null +++ b/src/controller.js @@ -0,0 +1,290 @@ +'use strict'; + +/** + * Verbindet Stream Dock (host), Playtube (client) und die Aktionsdefinitionen (actions): + * - haelt fuer jede sichtbare Taste Aktion + Einstellungen, + * - fuehrt Tastendruecke als Playtube-Befehle aus (Lautstaerke: Dauerdruck mit Wiederholung), + * - fragt den Status regelmaessig ab und aktualisiert Titel/Zustand/Cover der Tasten, + * - beantwortet die Playlist-Anfrage des Property Inspectors. + */ + +const { + DEFINITIONS, actionIdFromUuid, buildRequest, normalizeSettings, pickTab, renderAction, +} = require('./actions'); +const { buildInfoSvg, svgToDataUri } = require('./infoPanel'); + +const POLL_INTERVAL_MS = 1000; +const REFRESH_AFTER_COMMAND_MS = 350; +const COVER_CACHE_LIMIT = 24; +const COVER_TIMEOUT_MS = 4000; +const KEY_IMAGE_PX = 144; + +/** YouTube Music liefert Cover mit "=w60-h60-..." - fuer scharfe Tasten auf Tastengroesse ziehen. */ +function sharpCoverUrl(url) { + return String(url).replace(/=w\d+-h\d+/, `=w${KEY_IMAGE_PX}-h${KEY_IMAGE_PX}`); +} + +async function defaultFetchImage(url) { + const response = await fetch(url, { signal: AbortSignal.timeout(COVER_TIMEOUT_MS) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const type = (response.headers.get('content-type') || 'image/jpeg').split(';')[0]; + const bytes = Buffer.from(await response.arrayBuffer()); + return `data:${type};base64,${bytes.toString('base64')}`; +} + +class Controller { + /** + * @param {object} deps + * @param {import('./host').StreamDockHost} deps.host + * @param {import('./playtubeClient').PlaytubeClient} deps.client + * @param {{info: Function, warn: Function, error: Function}} deps.logger + * @param {Function} [deps.fetchImage] - (url) => Promise + * @param {number} [deps.pollIntervalMs] + */ + constructor({ host, client, logger, fetchImage, pollIntervalMs }) { + this.host = host; + this.client = client; + this.logger = logger; + this.fetchImage = fetchImage || defaultFetchImage; + this.pollIntervalMs = pollIntervalMs ?? POLL_INTERVAL_MS; + + this.keys = new Map(); // context -> {actionId, settings, last (zuletzt gesendete Darstellung)} + this.state = null; + this.covers = new Map(); // url -> dataUri (Einfuegereihenfolge = Alter) + this.coverLoading = new Set(); + this.repeaters = new Map(); // context -> {delayTimer, intervalTimer} + this.pollTimer = null; + this.refreshTimer = null; + this.polling = false; + } + + start() { + this.host.on('event', (message) => this.handleEvent(message)); + this.client.on('connected', (name) => { + this.logger.info(`Mit Playtube verbunden (${name})`); + this.poll(); + }); + this.client.on('disconnected', () => { + this.logger.info('Verbindung zu Playtube getrennt'); + this.state = null; + this.renderAll(); + }); + this.client.start(); + this.pollTimer = setInterval(() => this.poll(), this.pollIntervalMs); + } + + stop() { + clearInterval(this.pollTimer); + clearTimeout(this.refreshTimer); + for (const context of [...this.repeaters.keys()]) this.stopRepeat(context); + this.client.stop(); + } + + // ------------------------------------------------------------ Ereignisse + + handleEvent(message) { + const { event, context } = message; + switch (event) { + case 'willAppear': + return this.onAppear(message); + case 'willDisappear': + this.stopRepeat(context); + this.keys.delete(context); + return undefined; + case 'didReceiveSettings': + return this.onSettings(message); + case 'keyDown': + return this.onKeyDown(context); + case 'keyUp': + return this.onKeyUp(context); + case 'sendToPlugin': + return this.onPropertyInspectorMessage(message); + default: + return undefined; + } + } + + onAppear(message) { + const actionId = actionIdFromUuid(message.action); + if (!actionId) return; + const payload = message.payload || {}; + const settings = payload.settings || {}; + const info = payload.controller === 'Information'; + this.logger.info(`willAppear ${actionId} controller=${payload.controller} info=${info}`); + this.keys.set(message.context, { actionId, settings, info, last: null }); + this.renderKey(message.context); + if (this.client.connected && !this.state) this.poll(); + } + + onSettings(message) { + const key = this.keys.get(message.context); + if (!key) return; + key.settings = (message.payload && message.payload.settings) || {}; + key.last = null; + this.renderKey(message.context); + } + + onKeyDown(context) { + const key = this.keys.get(context); + if (!key) return; + const definition = DEFINITIONS[key.actionId]; + if (definition.press !== 'down') return; + this.execute(context); + const { delayMs, intervalMs } = definition.repeat || {}; + if (!delayMs) return; + this.stopRepeat(context); + const timers = {}; + timers.delayTimer = setTimeout(() => { + timers.intervalTimer = setInterval(() => this.execute(context, { quiet: true }), intervalMs); + }, delayMs); + this.repeaters.set(context, timers); + } + + onKeyUp(context) { + const key = this.keys.get(context); + if (!key) return; + if (DEFINITIONS[key.actionId].press === 'down') { + this.stopRepeat(context); + return; + } + this.execute(context); + } + + stopRepeat(context) { + const timers = this.repeaters.get(context); + if (!timers) return; + clearTimeout(timers.delayTimer); + clearInterval(timers.intervalTimer); + this.repeaters.delete(context); + } + + // ------------------------------------------------------------ Befehle + + /** Fuehrt die Aktion einer Taste aus; `quiet` = kein Alarm bei Fehler (Dauerdruck). */ + async execute(context, { quiet = false } = {}) { + const key = this.keys.get(context); + if (!key) return; + const request = buildRequest(key.actionId, key.settings); + if (!request) { + this.host.showAlert(context); + return; + } + try { + await this.client.request(request.cmd, request.fields); + this.scheduleRefresh(); + } catch (error) { + this.logger.warn(`${request.cmd} fehlgeschlagen: ${error.message}`); + if (quiet) this.stopRepeat(context); + else this.host.showAlert(context); + } + } + + scheduleRefresh() { + clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => this.poll(), REFRESH_AFTER_COMMAND_MS); + } + + // ------------------------------------------------------------ Status + + async poll() { + if (this.polling || !this.client.connected || this.keys.size === 0) return; + this.polling = true; + try { + this.state = await this.client.status(); + this.renderAll(); + } catch (error) { + this.logger.warn(`Status nicht lesbar: ${error.message}`); + } finally { + this.polling = false; + } + } + + renderAll() { + for (const context of this.keys.keys()) this.renderKey(context); + } + + renderKey(context) { + const key = this.keys.get(context); + if (!key) return; + if (key.info && key.actionId === 'nowplaying') { + this.renderInfo(context, key); + return; + } + const view = renderAction(key.actionId, key.settings, this.state, this.client.connected); + const coverUrl = view.coverUrl ? sharpCoverUrl(view.coverUrl) : null; + const cover = coverUrl ? this.covers.get(coverUrl) || null : null; + if (coverUrl && !cover) this.loadCover(coverUrl); + + const next = { state: view.state, title: view.title, cover: cover ? coverUrl : null }; + const previous = key.last || {}; + const changed = (name) => previous[name] !== next[name]; + key.last = next; + + if (view.state !== undefined && changed('state')) this.host.setState(context, view.state); + if (view.title !== undefined && changed('title')) this.host.setTitle(context, view.title); + // Cover anzeigen; ohne Cover (nichts laeuft) das Standardbild der Taste wiederherstellen. + if (view.coverUrl !== undefined && changed('cover')) this.host.setImage(context, cover); + } + + /** Hohes Info-Display: komplette "Aktueller Titel"-Karte als SVG-Bild. */ + renderInfo(context, key) { + let view = null; + if (this.client.connected) { + const tab = pickTab(this.state, normalizeSettings(key.settings).target); + const info = (tab && tab.info) || {}; + const coverUrl = info.thumbnail ? sharpCoverUrl(info.thumbnail) : null; + const coverDataUri = coverUrl ? this.covers.get(coverUrl) || null : null; + if (coverUrl && !coverDataUri) this.loadCover(coverUrl); + view = { + title: info.title || '', + subtitle: info.subtitle || '', + playing: Boolean(info.playing), + currentTime: info.currentTime, + duration: info.duration, + volume: info.volume, + coverDataUri, + }; + } + const svg = buildInfoSvg(view); + if (svg === key.lastSvg) return; + key.lastSvg = svg; + this.host.setTitle(context, ''); + this.host.setImage(context, svgToDataUri(svg)); + } + + async loadCover(url) { + if (this.coverLoading.has(url)) return; + this.coverLoading.add(url); + try { + const dataUri = await this.fetchImage(url); + this.covers.set(url, dataUri); + while (this.covers.size > COVER_CACHE_LIMIT) this.covers.delete(this.covers.keys().next().value); + this.renderAll(); + } catch (error) { + this.logger.warn(`Cover nicht ladbar: ${error.message}`); + } finally { + this.coverLoading.delete(url); + } + } + + // ------------------------------------------------------------ Property Inspector + + async onPropertyInspectorMessage(message) { + const payload = message.payload || {}; + if (payload.command !== 'requestPlaylists') return; + const reply = (body) => this.host.sendToPropertyInspector(message.action, message.context, body); + if (!this.client.connected) { + reply({ command: 'playlists', ok: false, error: 'offline', playlists: [] }); + return; + } + try { + const playlists = await this.client.listPlaylists(); + reply({ command: 'playlists', ok: true, playlists }); + } catch (error) { + this.logger.warn(`Playlists nicht lesbar: ${error.message}`); + reply({ command: 'playlists', ok: false, error: error.code || 'error', message: error.message, playlists: [] }); + } + } +} + +module.exports = { Controller, sharpCoverUrl }; diff --git a/src/host.js b/src/host.js new file mode 100644 index 0000000..b7a2370 --- /dev/null +++ b/src/host.js @@ -0,0 +1,125 @@ +'use strict'; + +/** + * Verbindung zur Stream-Dock-Software (Ajazz/Mirabox "Stream Dock"). + * + * Die Software startet das Plugin mit + * node plugin/index.js -port

-pluginUUID -registerEvent -info + * und erwartet, dass sich das Plugin per WebSocket auf ws://127.0.0.1:

mit + * {"uuid": , "event": } registriert. Danach kommen Ereignisse (willAppear, keyDown, + * keyUp, didReceiveSettings, sendToPlugin, ...) als JSON; Befehle (setTitle, setImage, + * setState, ...) gehen als JSON zurueck. Das Protokoll ist mit dem Elgato-Stream-Deck-SDK + * kompatibel. + */ + +const { EventEmitter } = require('events'); + +function parseArgs(argv) { + const found = {}; + for (let i = 0; i < argv.length - 1; i += 1) { + switch (argv[i]) { + case '-port': found.port = argv[i + 1]; break; + case '-pluginUUID': found.uuid = argv[i + 1]; break; + case '-registerEvent': found.registerEvent = argv[i + 1]; break; + case '-info': found.info = argv[i + 1]; break; + default: break; + } + } + let info = {}; + try { + info = JSON.parse(found.info || '{}'); + } catch { + info = {}; + } + return { port: found.port, uuid: found.uuid, registerEvent: found.registerEvent, info }; +} + +const WS_OPEN = 1; + +class StreamDockHost extends EventEmitter { + /** + * @param {{port: string|number, uuid: string, registerEvent: string, info?: object}} args + * @param {{WebSocket?: Function}} [deps] - Ersatz fuer das ws-Paket (Tests) + */ + constructor(args, deps = {}) { + super(); + this.port = args.port; + this.uuid = args.uuid; + this.registerEvent = args.registerEvent; + this.info = args.info || {}; + this.WebSocket = deps.WebSocket || require('ws'); + this.ws = null; + } + + get language() { + return (this.info.application && this.info.application.language) || 'en'; + } + + connect() { + this.ws = new this.WebSocket(`ws://127.0.0.1:${this.port}`); + this.ws.on('open', () => { + this._send({ uuid: this.uuid, event: this.registerEvent }); + this.emit('open'); + }); + this.ws.on('message', (raw) => { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + this.emit('event', message); + }); + this.ws.on('close', () => this.emit('close')); + this.ws.on('error', (error) => this.emit('error', error)); + } + + close() { + if (this.ws) this.ws.close(); + } + + // ------------------------------------------------------------- Befehle + + setTitle(context, title) { + this._send({ event: 'setTitle', context, payload: { target: 0, title: String(title ?? '') } }); + } + + /** image = Data-URI (PNG/JPEG/SVG); ohne Wert wird das Standardbild des Zustands wiederhergestellt. */ + setImage(context, image) { + const payload = { target: 0 }; + if (image) payload.image = image; + this._send({ event: 'setImage', context, payload }); + } + + setState(context, state) { + this._send({ event: 'setState', context, payload: { state } }); + } + + setSettings(context, settings) { + this._send({ event: 'setSettings', context, payload: settings }); + } + + getSettings(context) { + this._send({ event: 'getSettings', context }); + } + + showAlert(context) { + this._send({ event: 'showAlert', context }); + } + + showOk(context) { + this._send({ event: 'showOk', context }); + } + + sendToPropertyInspector(action, context, payload) { + this._send({ event: 'sendToPropertyInspector', action, context, payload }); + } + + _send(message) { + if (this.ws && this.ws.readyState === WS_OPEN) { + this.ws.send(JSON.stringify(message)); + } + } +} + +module.exports = { StreamDockHost, parseArgs }; diff --git a/src/infoPanel.js b/src/infoPanel.js new file mode 100644 index 0000000..5bbd49e --- /dev/null +++ b/src/infoPanel.js @@ -0,0 +1,110 @@ +'use strict'; + +/** + * "Aktueller Titel" fuer die Info-Felder rechts am Geraet (Controller "Information"): ein + * einzelnes quadratisches Bild - Cover als Hintergrund, unten Titel, Interpret und Fortschritt. + */ + +const { wrapTitle } = require('./actions'); + +const SIZE = 144; +const BACKGROUND = '#171b24'; +const ACCENT = '#ff4d5e'; +const TRACK = '#3a4152'; +const MUTED_TEXT = '#c3c9d6'; +const BAR_X = 10; +const BAR_WIDTH = SIZE - 2 * BAR_X; +const BAR_Y = 132; +const BAR_HEIGHT = 6; +const TITLE_LINE_HEIGHT = 25; +const TITLE_BASELINE_OFFSET = 7; // Schrifthoehe/3: bringt die optische Mitte der Zeile auf die Blockmitte +const FONT = 'font-family="Segoe UI, Arial, sans-serif"'; +const XML_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +function escapeXml(text) { + return String(text).replace(/[&<>"']/g, (char) => XML_ESCAPES[char]); +} + +function formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; + const whole = Math.floor(seconds); + return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, '0')}`; +} + +function textLines(text, x, y, lineHeight, size, color, weight, anchor = 'middle') { + return String(text).split('\n').filter(Boolean).map((line, index) => ( + `${escapeXml(line)}`)).join(''); +} + +function noteMarkup(cx, cy, scale) { + const s = scale; + return `` + + `` + + ``; +} + +function backgroundMarkup(coverDataUri) { + if (!coverDataUri) return `${noteMarkup(72, 44, 1.1)}`; + return `` + + ``; +} + +/** Abdunklung unten, damit Text auf jedem Cover lesbar bleibt. */ +function shadeMarkup() { + return '' + + '' + + '' + + ``; +} + +function playStateMarkup(playing) { + const cx = 118; + const cy = 26; + const disc = ``; + if (playing) { + return `${disc}` + + ``; + } + return `${disc}`; +} + +/** + * @param {object|null} view - {title, subtitle, playing, currentTime, duration, coverDataUri} + * oder null, wenn Playtube nicht verbunden ist + * @returns {string} SVG-Text (144x144) + */ +function buildInfoSvg(view) { + const head = ``; + if (!view) { + return `${head}${noteMarkup(72, 44, 1.1)}` + + `${textLines('Playtube offline', 72, 120, 0, 14, '#ffffff', 'bold')}`; + } + const hasTrack = Boolean(view.title); + const ratio = view.duration > 0 ? Math.max(0, Math.min(1, view.currentTime / view.duration)) : 0; + const parts = [backgroundMarkup(view.coverDataUri), shadeMarkup()]; + if (hasTrack) { + // Titel (und darunter der Interpret) als Block genau in der Bildmitte, mittig ausgerichtet. + const title = wrapTitle(view.title, 10, 2); + const lineCount = title.split('\n').length; + const blockCenter = view.subtitle ? SIZE / 2 - 12 : SIZE / 2; + const firstBaseline = blockCenter - ((lineCount - 1) * TITLE_LINE_HEIGHT) / 2 + TITLE_BASELINE_OFFSET; + parts.push(textLines(title, SIZE / 2, firstBaseline, TITLE_LINE_HEIGHT, 22, '#ffffff', 'bold')); + if (view.subtitle) { + const subtitleY = firstBaseline + (lineCount - 1) * TITLE_LINE_HEIGHT + 27; + parts.push(textLines(wrapTitle(view.subtitle, 13, 1), SIZE / 2, subtitleY, 0, 16, MUTED_TEXT, 'normal')); + } + parts.push(playStateMarkup(view.playing)); + } else { + parts.push(textLines('Nichts spielt', 72, 120, 0, 14, '#ffffff', 'bold')); + } + parts.push(``); + parts.push(``); + return `${head}${parts.join('')}`; +} + +function svgToDataUri(svg) { + return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf8').toString('base64')}`; +} + +module.exports = { buildInfoSvg, svgToDataUri, formatTime, SIZE }; diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..7a23e2c --- /dev/null +++ b/src/logger.js @@ -0,0 +1,50 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const MAX_BYTES = 512 * 1024; + +/** + * Sehr einfacher Datei-Logger (log/plugin.log neben dem Plugin). Wird beim Ueberschreiten von + * 512 KB rotiert (plugin.log -> plugin.old.log). Logging darf das Plugin nie stoeren, jeder + * Dateifehler wird geschluckt. + */ +function createLogger(directory) { + const file = path.join(directory, 'plugin.log'); + let ready = false; + + function ensure() { + if (ready) return true; + try { + fs.mkdirSync(directory, { recursive: true }); + ready = true; + } catch { + ready = false; + } + return ready; + } + + function write(level, message) { + if (!ensure()) return; + try { + try { + if (fs.statSync(file).size > MAX_BYTES) fs.renameSync(file, path.join(directory, 'plugin.old.log')); + } catch { + // Datei existiert noch nicht - nichts zu rotieren + } + fs.appendFileSync(file, `${new Date().toISOString()} ${level} ${message}\n`); + } catch { + // Logging ist optional + } + } + + const describe = (value) => (value instanceof Error ? value.stack || value.message : String(value)); + return { + info: (message) => write('INFO ', describe(message)), + warn: (message) => write('WARN ', describe(message)), + error: (message) => write('ERROR', describe(message)), + }; +} + +module.exports = { createLogger }; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..0120e75 --- /dev/null +++ b/src/main.js @@ -0,0 +1,45 @@ +'use strict'; + +/** + * Einstiegspunkt des Plugins (wird von build.mjs zu plugin/index.js gebuendelt). + * Stream Dock startet: node plugin/index.js -port P -pluginUUID U -registerEvent E -info {...} + */ + +const path = require('path'); +const { StreamDockHost, parseArgs } = require('./host'); +const { PlaytubeClient } = require('./playtubeClient'); +const { Controller } = require('./controller'); +const { createLogger } = require('./logger'); + +const EXIT_GRACE_MS = 2000; + +function main() { + const args = parseArgs(process.argv.slice(2)); + const logger = createLogger(path.join(__dirname, '..', 'log')); + if (!args.port || !args.uuid || !args.registerEvent) { + logger.error(`Ungueltige Startparameter: ${process.argv.slice(2).join(' ')}`); + process.exit(1); + } + + process.on('uncaughtException', (error) => logger.error(error)); + process.on('unhandledRejection', (error) => logger.error(error)); + + const host = new StreamDockHost(args); + // Nur fuer Tests: PLAYTUBE_PIPE_NAMES="a,b" ersetzt die Standard-Pipe-Namen. + const pipeNames = process.env.PLAYTUBE_PIPE_NAMES ? process.env.PLAYTUBE_PIPE_NAMES.split(',') : undefined; + const client = new PlaytubeClient({ pipeNames }); + const controller = new Controller({ host, client, logger }); + controller.start(); + + host.on('open', () => logger.info(`Bei Stream Dock registriert (Port ${args.port})`)); + host.on('error', (error) => logger.warn(`WebSocket-Fehler: ${error.message}`)); + host.on('close', () => { + // Stream Dock beendet Plugins, indem es die Verbindung schliesst - dann sauber aufraeumen. + logger.info('Verbindung zu Stream Dock beendet'); + controller.stop(); + setTimeout(() => process.exit(0), EXIT_GRACE_MS).unref(); + }); + host.connect(); +} + +main(); diff --git a/src/playtubeClient.js b/src/playtubeClient.js new file mode 100644 index 0000000..7e50dea --- /dev/null +++ b/src/playtubeClient.js @@ -0,0 +1,233 @@ +'use strict'; + +/** + * Client fuer die lokale Fernsteuerung von Playtube (playtube/remote_control.py). + * + * Transport: Named Pipe \\.\pipe\Playtube.Remote. - "Playtube" (installierte + * Playtube.exe) bzw. "PlaytubeDev" (python main.py). Nur der angemeldete Benutzer darf + * verbinden. Protokoll: eine JSON-Nachricht pro Zeile (UTF-8, "\n"), Verbindung bleibt offen: + * + * -> {"id": 1, "cmd": "play_pause", "target": "auto"} + * <- {"id": 1, "ok": true} + * <- {"id": 2, "ok": false, "error": "..."} + * + * Der Client verbindet sich selbststaendig (auch wenn Playtube erst spaeter startet) und + * baut die Verbindung nach einem Playtube-Neustart wieder auf. + */ + +const net = require('net'); +const os = require('os'); +const path = require('path'); +const { EventEmitter } = require('events'); + +const PIPE_PREFIX = 'Playtube.Remote.'; +// Installierte Version zuerst, dann der Entwicklungsmodus; danach die Edge-Variante (WebView2). +const PIPE_NAMES = ['Playtube', 'PlaytubeDev', 'PlaytubeEdge', 'PlaytubeEdgeDev']; +const MAX_BUFFER_CHARS = 1 << 20; + +class PlaytubeError extends Error { + constructor(code, message) { + super(message); + this.name = 'PlaytubeError'; + this.code = code; + } +} + +function pipePath(name, platform = process.platform) { + const full = PIPE_PREFIX + name; + return platform === 'win32' ? `\\\\.\\pipe\\${full}` : path.join(os.tmpdir(), full); +} + +class PlaytubeClient extends EventEmitter { + /** + * @param {object} [options] + * @param {string[]} [options.pipeNames] + * @param {string} [options.platform] + * @param {Function} [options.connect] - Ersatz fuer net.connect (Tests) + * @param {number} [options.requestTimeoutMs] + * @param {number[]} [options.backoffMs] + */ + constructor(options = {}) { + super(); + this.pipeNames = options.pipeNames || PIPE_NAMES; + this.platform = options.platform || process.platform; + this.connectFn = options.connect || net.connect; + this.requestTimeoutMs = options.requestTimeoutMs ?? 3000; + this.backoffMs = options.backoffMs || [300, 600, 1200, 2500, 5000]; + + this.socket = null; + this.connected = false; + this.pipeName = null; + this.running = false; + + this._nextId = 1; + this._pending = new Map(); + this._buffer = ''; + this._failedRounds = 0; + this._retryTimer = null; + } + + start() { + if (this.running) return; + this.running = true; + this._attempt(0); + } + + stop() { + this.running = false; + clearTimeout(this._retryTimer); + this._retryTimer = null; + const socket = this.socket; + this.socket = null; + if (this.connected) { + this.connected = false; + this._rejectAll(new PlaytubeError('stopped', 'Client gestoppt')); + } + if (socket) socket.destroy(); + } + + /** + * Schickt einen Befehl und wartet auf die Antwort. + * @param {string} cmd + * @param {object} [fields] - target / value / playlist (undefined-Werte werden weggelassen) + * @returns {Promise} die komplette Antwort ({ok: true, ...}) + */ + request(cmd, fields = {}) { + if (!this.connected || !this.socket) { + return Promise.reject(new PlaytubeError('not_connected', 'Playtube ist nicht verbunden')); + } + const id = this._nextId++; + const payload = { id, cmd }; + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null) payload[key] = value; + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new PlaytubeError('timeout', `Keine Antwort von Playtube auf "${cmd}"`)); + }, this.requestTimeoutMs); + this._pending.set(id, { resolve, reject, timer }); + this.socket.write(`${JSON.stringify(payload)}\n`, (error) => { + if (error && this._pending.delete(id)) { + clearTimeout(timer); + reject(new PlaytubeError('write_failed', error.message)); + } + }); + }); + } + + async status() { + const response = await this.request('status'); + if (!response.state) throw new PlaytubeError('bad_response', 'Status ohne "state"'); + return response.state; + } + + async listPlaylists() { + const response = await this.request('list_playlists'); + return Array.isArray(response.playlists) ? response.playlists : []; + } + + playPlaylist(playlistId) { + return this.request('play_playlist', { playlist: playlistId }); + } + + // ------------------------------------------------------------------ intern + + _attempt(index) { + if (!this.running) return; + if (index >= this.pipeNames.length) { + this._scheduleRetry(); + return; + } + const name = this.pipeNames[index]; + const socket = this.connectFn(pipePath(name, this.platform)); + let opened = false; + socket.setEncoding('utf8'); + socket.once('connect', () => { + if (!this.running) { + socket.destroy(); + return; + } + opened = true; + this.socket = socket; + this.connected = true; + this.pipeName = name; + this._failedRounds = 0; + this._buffer = ''; + this.emit('connected', name); + }); + socket.on('data', (chunk) => { + if (socket === this.socket) this._onData(chunk); + }); + socket.on('error', () => { + // Folgt immer ein 'close' - dort wird weitergemacht. + }); + socket.on('close', () => { + if (!opened) { + this._attempt(index + 1); + } else if (socket === this.socket) { + this._onLost(); + } + }); + } + + _onLost() { + this.socket = null; + this.connected = false; + this.pipeName = null; + this._rejectAll(new PlaytubeError('disconnected', 'Verbindung zu Playtube getrennt')); + this.emit('disconnected'); + this._failedRounds = 0; + this._scheduleRetry(); + } + + _scheduleRetry() { + if (!this.running) return; + const delay = this.backoffMs[Math.min(this._failedRounds, this.backoffMs.length - 1)]; + this._failedRounds += 1; + this._retryTimer = setTimeout(() => this._attempt(0), delay); + } + + _onData(chunk) { + this._buffer += chunk; + let newline; + while ((newline = this._buffer.indexOf('\n')) >= 0) { + const line = this._buffer.slice(0, newline).trim(); + this._buffer = this._buffer.slice(newline + 1); + if (line) this._onLine(line); + } + if (this._buffer.length > MAX_BUFFER_CHARS) { + // Kein Zeilenende in Sicht: Gegenstelle verhaelt sich falsch -> Verbindung neu aufbauen. + this._buffer = ''; + if (this.socket) this.socket.destroy(); + } + } + + _onLine(line) { + let message; + try { + message = JSON.parse(line); + } catch { + return; // kaputte Zeile ignorieren, naechste abwarten + } + const entry = this._pending.get(message.id); + if (!entry) return; + this._pending.delete(message.id); + clearTimeout(entry.timer); + if (message.ok) { + entry.resolve(message); + } else { + entry.reject(new PlaytubeError('remote', message.error || 'Fehler in Playtube')); + } + } + + _rejectAll(error) { + for (const entry of this._pending.values()) { + clearTimeout(entry.timer); + entry.reject(error); + } + this._pending.clear(); + } +} + +module.exports = { PlaytubeClient, PlaytubeError, pipePath, PIPE_NAMES, PIPE_PREFIX }; diff --git a/tests/actions.test.js b/tests/actions.test.js new file mode 100644 index 0000000..b807dc2 --- /dev/null +++ b/tests/actions.test.js @@ -0,0 +1,112 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + DEFINITIONS, PREFIX, actionIdFromUuid, buildRequest, normalizeSettings, renderAction, wrapTitle, +} = require('../src/actions'); + +const STATE = { + activeTab: 'music', + visibleTab: 'music', + tabs: { + music: { playing: true, title: 'Ein sehr langer Songtitel', volume: 42.4, muted: false, likeStatus: 'like', + repeatMode: 'ALL', hasVideo: true, thumbnail: 'https://example.test/c=w60-h60' }, + youtube: { playing: false, title: 'Video', volume: 80, muted: true, likeStatus: 'none', repeatMode: 'NONE' }, + }, +}; + +test('actionIdFromUuid erkennt nur bekannte Aktionen', () => { + assert.equal(actionIdFromUuid(`${PREFIX}volumeup`), 'volumeup'); + assert.equal(actionIdFromUuid(`${PREFIX}gibtesnicht`), null); + assert.equal(actionIdFromUuid('com.other.volumeup'), null); + assert.equal(actionIdFromUuid(undefined), null); +}); + +test('normalizeSettings begrenzt Werte und ersetzt ungueltige durch Standardwerte', () => { + const settings = normalizeSettings({ step: '999', volume: -5, seconds: 'abc', target: 'x', tab: 'y' }); + assert.equal(settings.step, 25); + assert.equal(settings.volume, 0); + assert.equal(settings.seconds, 10); + assert.equal(settings.target, 'auto'); + assert.equal(settings.tab, 'toggle'); +}); + +test('normalizeSettings: showTitle nutzt den Standard der Aktion, wenn nichts gespeichert ist', () => { + assert.equal(normalizeSettings({}, true).showTitle, true); + assert.equal(normalizeSettings({}, false).showTitle, false); + assert.equal(normalizeSettings({ showTitle: false }, true).showTitle, false); + assert.equal(normalizeSettings({ showTitle: 'true' }, false).showTitle, true); +}); + +test('Lauter/Leiser senden volume_change mit Vorzeichen und Schrittweite', () => { + assert.deepEqual(buildRequest('volumeup', { step: 7 }), { cmd: 'volume_change', fields: { target: 'auto', value: 7 } }); + assert.deepEqual(buildRequest('volumedown', { step: 7, target: 'music' }), + { cmd: 'volume_change', fields: { target: 'music', value: -7 } }); +}); + +test('Lautstaerke setzen und Spulen', () => { + assert.deepEqual(buildRequest('volumeset', { volume: 55 }), { cmd: 'set_volume', fields: { target: 'auto', value: 55 } }); + assert.deepEqual(buildRequest('seekback', { seconds: 15 }), { cmd: 'seek', fields: { target: 'auto', value: -15 } }); + assert.deepEqual(buildRequest('seekforward', {}), { cmd: 'seek', fields: { target: 'auto', value: 10 } }); +}); + +test('Playlist-Taste ohne ID sendet nichts, mit ID play_playlist', () => { + assert.equal(buildRequest('playlist', {}), null); + assert.deepEqual(buildRequest('playlist', { playlistId: ' PL123 ' }), + { cmd: 'play_playlist', fields: { playlist: 'PL123' } }); +}); + +test('Tab wechseln: "toggle" wird zu auto', () => { + assert.equal(buildRequest('switchtab', {}).fields.target, 'auto'); + assert.equal(buildRequest('switchtab', { tab: 'youtube' }).fields.target, 'youtube'); +}); + +test('jede Aktion hat request() und liefert gueltiges cmd', () => { + for (const id of Object.keys(DEFINITIONS)) { + const request = buildRequest(id, { playlistId: 'PLx' }); + assert.ok(request && typeof request.cmd === 'string', id); + } +}); + +test('Play/Pause-Zustand folgt dem aktiven Tab bzw. dem gewaehlten Ziel', () => { + assert.equal(renderAction('playpause', {}, STATE, true).state, 1); + assert.equal(renderAction('playpause', { target: 'youtube' }, STATE, true).state, 0); +}); + +test('Lautstaerke-Tasten zeigen die gerundete Lautstaerke', () => { + assert.equal(renderAction('volumeup', {}, STATE, true).title, '42%'); + assert.equal(renderAction('volumedown', { target: 'youtube' }, STATE, true).title, '80%'); +}); + +test('offline: Titel leer bzw. Offline-Hinweis, Zustand 0', () => { + assert.equal(renderAction('volumeup', {}, null, false).title, ''); + const view = renderAction('playpause', { showTitle: true }, null, false); + assert.equal(view.state, 0); + assert.match(view.title, /offline/); +}); + +test('Mute, Like und Repeat bilden den Playtube-Zustand auf State-Indizes ab', () => { + assert.equal(renderAction('mute', { target: 'youtube' }, STATE, true).state, 1); + assert.equal(renderAction('mute', {}, STATE, true).state, 0); + assert.equal(renderAction('like', {}, STATE, true).state, 1); + assert.equal(renderAction('repeat', {}, STATE, true).state, 1); + assert.equal(renderAction('repeat', { target: 'youtube' }, STATE, true).state, 0); + const one = { ...STATE, tabs: { ...STATE.tabs, music: { ...STATE.tabs.music, repeatMode: 'ONE' } } }; + assert.equal(renderAction('repeat', {}, one, true).state, 2); +}); + +test('Cover-Taste liefert die Thumbnail-URL nur bei vorhandenem Video/Titel', () => { + assert.equal(renderAction('nowplaying', {}, STATE, true).coverUrl, 'https://example.test/c=w60-h60'); + assert.equal(renderAction('nowplaying', { target: 'youtube' }, STATE, true).coverUrl, null); + assert.equal(renderAction('nowplaying', {}, null, false).coverUrl, null); +}); + +test('wrapTitle bricht um, trennt lange Woerter und kuerzt', () => { + assert.equal(wrapTitle('Hello World'), 'Hello\nWorld'); + assert.equal(wrapTitle('Supercalifragilistic'), 'Supercal\nifragili\nstic'); + const lines = wrapTitle('eins zwei drei vier fuenf sechs sieben acht neun zehn').split('\n'); + assert.equal(lines.length, 3); + assert.ok(lines[2].endsWith('..')); + assert.equal(wrapTitle(''), ''); +}); diff --git a/tests/controller.test.js b/tests/controller.test.js new file mode 100644 index 0000000..4df74f4 --- /dev/null +++ b/tests/controller.test.js @@ -0,0 +1,204 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const { Controller, sharpCoverUrl } = require('../src/controller'); +const { PlaytubeError } = require('../src/playtubeClient'); +const { PREFIX } = require('../src/actions'); + +const silentLogger = { info() {}, warn() {}, error() {} }; +const tick = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); + +const STATE = { + activeTab: 'music', + visibleTab: 'music', + tabs: { + music: { playing: true, title: 'Song', volume: 40, muted: false, likeStatus: 'none', repeatMode: 'NONE', + hasVideo: true, thumbnail: 'https://example.test/x=w60-h60-l90' }, + youtube: { playing: false, title: '', volume: 100, muted: false }, + }, +}; + +class FakeHost extends EventEmitter { + constructor() { + super(); + this.calls = []; + for (const name of ['setTitle', 'setImage', 'setState', 'showAlert', 'showOk', 'sendToPropertyInspector']) { + this[name] = (...args) => this.calls.push([name, ...args]); + } + } + + named(name) { return this.calls.filter((call) => call[0] === name); } +} + +class FakeClient extends EventEmitter { + constructor() { + super(); + this.connected = true; + this.requests = []; + this.state = STATE; + this.failWith = null; + this.playlists = [{ id: 'LM', title: 'Liked' }]; + } + + start() {} + stop() {} + async request(cmd, fields) { + if (this.failWith) throw this.failWith; + this.requests.push({ cmd, ...fields }); + return { ok: true }; + } + async status() { return this.state; } + async listPlaylists() { return this.playlists; } +} + +function setup() { + const host = new FakeHost(); + const client = new FakeClient(); + const covers = []; + const controller = new Controller({ + host, client, logger: silentLogger, pollIntervalMs: 100000, + fetchImage: async (url) => { covers.push(url); return `data:image/jpeg;base64,${Buffer.from(url).toString('base64')}`; }, + }); + return { host, client, controller, covers }; +} + +const appear = (controller, id, context = `ctx-${id}`, settings = {}) => controller.handleEvent({ + event: 'willAppear', action: `${PREFIX}${id}`, context, payload: { settings }, +}); + +test('sharpCoverUrl hebt die Aufloesung von Music-Covern auf Tastengroesse', () => { + assert.equal(sharpCoverUrl('https://x/a=w60-h60-l90-rj'), 'https://x/a=w144-h144-l90-rj'); + assert.equal(sharpCoverUrl('https://i.ytimg.com/vi/abc/hq.jpg'), 'https://i.ytimg.com/vi/abc/hq.jpg'); +}); + +test('Taste erscheint offline: Offline-Titel', () => { + const { host, client, controller } = setup(); + client.connected = false; + appear(controller, 'playpause', 'k1', { showTitle: true }); + assert.match(host.named('setTitle')[0][2], /offline/); +}); + +test('Lauter: sofort beim Druecken, Wiederholung beim Halten, Stopp beim Loslassen', async () => { + const { client, controller } = setup(); + appear(controller, 'volumeup', 'v1', { step: 3 }); + controller.handleEvent({ event: 'keyDown', context: 'v1' }); + await tick(); + assert.equal(client.requests.length, 1); + assert.deepEqual(client.requests[0], { cmd: 'volume_change', target: 'auto', value: 3 }); + assert.ok(controller.repeaters.has('v1')); + controller.handleEvent({ event: 'keyUp', context: 'v1' }); + assert.equal(controller.repeaters.size, 0); + await tick(); + assert.equal(client.requests.length, 1); +}); + +test('normale Aktionen laufen beim Loslassen, nicht beim Druecken', async () => { + const { client, controller } = setup(); + appear(controller, 'next', 'n1'); + controller.handleEvent({ event: 'keyDown', context: 'n1' }); + await tick(); + assert.equal(client.requests.length, 0); + controller.handleEvent({ event: 'keyUp', context: 'n1' }); + await tick(); + assert.deepEqual(client.requests, [{ cmd: 'next', target: 'auto' }]); +}); + +test('Fehler von Playtube loesen einen Alarm auf der Taste aus', async () => { + const { host, client, controller } = setup(); + client.failWith = new PlaytubeError('not_connected', 'offline'); + appear(controller, 'next', 'n1'); + controller.handleEvent({ event: 'keyUp', context: 'n1' }); + await tick(); + assert.equal(host.named('showAlert').length, 1); +}); + +test('Playlist-Taste ohne Auswahl zeigt Alarm und sendet nichts', async () => { + const { host, client, controller } = setup(); + appear(controller, 'playlist', 'p1'); + controller.handleEvent({ event: 'keyUp', context: 'p1' }); + await tick(); + assert.equal(client.requests.length, 0); + assert.equal(host.named('showAlert').length, 1); +}); + +test('Playlist-Taste startet die gewaehlte Playlist und zeigt ihren Namen', async () => { + const { host, client, controller } = setup(); + appear(controller, 'playlist', 'p1', { playlistId: 'PLabc', playlistTitle: 'Mein Mix' }); + assert.equal(host.named('setTitle').at(-1)[2], 'Mein Mix'); + controller.handleEvent({ event: 'keyUp', context: 'p1' }); + await tick(); + assert.deepEqual(client.requests, [{ cmd: 'play_playlist', playlist: 'PLabc' }]); +}); + +test('Status aktualisiert Zustand und Lautstaerke-Anzeige, nur bei Aenderung', async () => { + const { host, controller } = setup(); + appear(controller, 'playpause', 'pp'); + appear(controller, 'volumeup', 'vu'); + await controller.poll(); + assert.deepEqual(host.named('setState').filter((c) => c[1] === 'pp').at(-1), ['setState', 'pp', 1]); + assert.equal(host.named('setTitle').filter((c) => c[1] === 'vu').at(-1)[2], '40%'); + const before = host.calls.length; + await controller.poll(); + assert.equal(host.calls.length, before, 'unveraenderter Status sendet nichts erneut'); +}); + +test('Cover wird in hoher Aufloesung geladen und als Bild gesetzt', async () => { + const { host, controller, covers } = setup(); + appear(controller, 'nowplaying', 'np'); + await controller.poll(); + await tick(20); + assert.deepEqual(covers, ['https://example.test/x=w144-h144-l90']); + const image = host.named('setImage').at(-1); + assert.equal(image[1], 'np'); + assert.match(image[2], /^data:image\/jpeg;base64,/); +}); + +test('Verbindungsverlust stellt Offline-Anzeige her', async () => { + const { host, client, controller } = setup(); + controller.start(); + appear(controller, 'volumeup', 'vu'); + await controller.poll(); + assert.equal(host.named('setTitle').filter((c) => c[1] === 'vu').at(-1)[2], '40%'); + client.connected = false; + client.emit('disconnected'); + assert.equal(host.named('setTitle').filter((c) => c[1] === 'vu').at(-1)[2], ''); + controller.stop(); +}); + +test('Property Inspector bekommt die Playlists', async () => { + const { host, controller } = setup(); + controller.handleEvent({ event: 'sendToPlugin', action: `${PREFIX}playlist`, context: 'p1', + payload: { command: 'requestPlaylists' } }); + await tick(); + const [, action, context, payload] = host.named('sendToPropertyInspector')[0]; + assert.equal(action, `${PREFIX}playlist`); + assert.equal(context, 'p1'); + assert.deepEqual(payload, { command: 'playlists', ok: true, playlists: [{ id: 'LM', title: 'Liked' }] }); +}); + +test('Property Inspector: offline und Fehler werden gemeldet', async () => { + const { host, client, controller } = setup(); + client.connected = false; + controller.handleEvent({ event: 'sendToPlugin', action: 'a', context: 'c', payload: { command: 'requestPlaylists' } }); + await tick(); + assert.equal(host.named('sendToPropertyInspector')[0][3].error, 'offline'); + + client.connected = true; + client.listPlaylists = async () => { throw new PlaytubeError('remote', 'unbekannter Befehl'); }; + controller.handleEvent({ event: 'sendToPlugin', action: 'a', context: 'c', payload: { command: 'requestPlaylists' } }); + await tick(); + const payload = host.named('sendToPropertyInspector')[1][3]; + assert.equal(payload.ok, false); + assert.equal(payload.error, 'remote'); +}); + +test('willDisappear raeumt Taste und Wiederholung auf', () => { + const { controller } = setup(); + appear(controller, 'volumeup', 'v1'); + controller.handleEvent({ event: 'keyDown', context: 'v1' }); + controller.handleEvent({ event: 'willDisappear', context: 'v1' }); + assert.equal(controller.keys.size, 0); + assert.equal(controller.repeaters.size, 0); +}); diff --git a/tests/infoPanel.test.js b/tests/infoPanel.test.js new file mode 100644 index 0000000..6c4a81e --- /dev/null +++ b/tests/infoPanel.test.js @@ -0,0 +1,70 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const { buildInfoSvg, formatTime, svgToDataUri } = require('../src/infoPanel'); +const { Controller } = require('../src/controller'); +const { PREFIX } = require('../src/actions'); + +const VIEW = { title: 'Tom & ', subtitle: 'Interpret', playing: true, currentTime: 65, duration: 200, volume: 42.4 }; + +test('formatTime formatiert Sekunden als m:ss', () => { + assert.equal(formatTime(65), '1:05'); + assert.equal(formatTime(0), '0:00'); + assert.equal(formatTime(NaN), '0:00'); + assert.equal(formatTime(-3), '0:00'); +}); + +test('SVG enthaelt Titel (XML-escaped) und Interpret, quadratisch', () => { + const svg = buildInfoSvg(VIEW); + assert.match(svg, /^')); + assert.ok(svg.includes('width="144" height="144"')); +}); + +test('Cover wird eingebettet, sonst Platzhalter', () => { + assert.ok(buildInfoSvg({ ...VIEW, coverDataUri: 'data:image/jpeg;base64,AAAA' }).includes('href="data:image/jpeg;base64,AAAA"')); + assert.ok(!buildInfoSvg(VIEW).includes(' { + assert.ok(buildInfoSvg({ ...VIEW, currentTime: 999 }).includes('width="124.0"')); + assert.ok(buildInfoSvg({ ...VIEW, duration: 0 }).includes('width="0.0"')); +}); + +test('offline und "nichts spielt" haben eigene Darstellungen', () => { + assert.ok(buildInfoSvg(null).includes('offline')); + assert.ok(buildInfoSvg({ ...VIEW, title: '' }).includes('spielt')); +}); + +test('svgToDataUri erzeugt eine dekodierbare Data-URI', () => { + const uri = svgToDataUri(''); + assert.equal(Buffer.from(uri.split(',')[1], 'base64').toString(), ''); +}); + +test('Controller: nowplaying auf dem Info-Display sendet ein SVG-Bild', () => { + const calls = []; + const host = new EventEmitter(); + host.setTitle = (...a) => calls.push(['setTitle', ...a]); + host.setImage = (...a) => calls.push(['setImage', ...a]); + host.setState = () => {}; + const client = new EventEmitter(); + client.connected = true; + client.status = async () => ({ activeTab: 'music', tabs: { music: { ...VIEW } } }); + const controller = new Controller({ + host, client, logger: { info() {}, warn() {}, error() {} }, fetchImage: async () => 'data:image/jpeg;base64,AA', + }); + controller.handleEvent({ event: 'willAppear', action: `${PREFIX}nowplaying`, context: 'INFO', + payload: { controller: 'Information', settings: {} } }); + controller.state = { activeTab: 'music', tabs: { music: { ...VIEW } } }; + controller.renderAll(); + const image = calls.filter((c) => c[0] === 'setImage').at(-1); + assert.match(image[2], /^data:image\/svg\+xml;base64,/); + const before = calls.length; + controller.renderAll(); + assert.equal(calls.length, before, 'unveraendert sendet nichts erneut'); +}); diff --git a/tests/playtubeClient.test.js b/tests/playtubeClient.test.js new file mode 100644 index 0000000..ffc65b5 --- /dev/null +++ b/tests/playtubeClient.test.js @@ -0,0 +1,129 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const net = require('node:net'); +const { PlaytubeClient, PlaytubeError, pipePath } = require('../src/playtubeClient'); + +const uniqueName = () => `Test${process.pid}${Date.now()}${Math.floor(Math.random() * 1e6)}`; +const until = async (condition, timeoutMs = 3000) => { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) throw new Error('Zeitueberschreitung im Test'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +}; + +/** Fake-Playtube: beantwortet zeilenweise JSON-Befehle wie playtube/remote_control.py. */ +function startServer(name, handler) { + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.setEncoding('utf8'); + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + const request = JSON.parse(line); + const reply = handler(request); + if (reply) socket.write(`${JSON.stringify({ id: request.id, ...reply })}\n`); + } + }); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + }); + return new Promise((resolve) => { + server.listen(pipePath(name), () => resolve({ + dropClients: () => sockets.forEach((socket) => socket.destroy()), + close: () => new Promise((done) => { sockets.forEach((s) => s.destroy()); server.close(done); }), + })); + }); +} + +test('pipePath bildet den Windows-Pipe-Namen', () => { + assert.equal(pipePath('Playtube', 'win32'), '\\\\.\\pipe\\Playtube.Remote.Playtube'); +}); + +test('verbindet sich, sendet Befehle und liefert Antworten', async () => { + const name = uniqueName(); + const fake = await startServer(name, (request) => { + if (request.cmd === 'status') return { ok: true, state: { activeTab: 'music' } }; + if (request.cmd === 'list_playlists') return { ok: true, playlists: [{ id: 'LM', title: 'Liked' }] }; + if (request.cmd === 'boom') return { ok: false, error: 'kaputt' }; + return { ok: true, echo: request }; + }); + const client = new PlaytubeClient({ pipeNames: [name] }); + client.start(); + try { + await until(() => client.connected); + assert.deepEqual(await client.status(), { activeTab: 'music' }); + assert.deepEqual(await client.listPlaylists(), [{ id: 'LM', title: 'Liked' }]); + const echoed = await client.request('volume_change', { target: 'auto', value: 5, skip: undefined }); + assert.deepEqual(echoed.echo, { id: echoed.id, cmd: 'volume_change', target: 'auto', value: 5 }); + await assert.rejects(client.request('boom'), + (error) => error instanceof PlaytubeError && error.code === 'remote' && /kaputt/.test(error.message)); + } finally { + client.stop(); + await fake.close(); + } +}); + +test('ohne Verbindung wird sofort abgelehnt', async () => { + const client = new PlaytubeClient({ pipeNames: [uniqueName()] }); + await assert.rejects(client.request('status'), (error) => error.code === 'not_connected'); +}); + +test('Timeout, wenn Playtube nicht antwortet', async () => { + const name = uniqueName(); + const fake = await startServer(name, () => null); + const client = new PlaytubeClient({ pipeNames: [name], requestTimeoutMs: 80 }); + client.start(); + try { + await until(() => client.connected); + await assert.rejects(client.request('status'), (error) => error.code === 'timeout'); + } finally { + client.stop(); + await fake.close(); + } +}); + +test('Verbindungsabbruch: offene Anfragen scheitern, danach automatische Wiederverbindung', async () => { + const name = uniqueName(); + const fake = await startServer(name, () => null); + const client = new PlaytubeClient({ pipeNames: [name], backoffMs: [20] }); + const events = []; + client.on('connected', () => events.push('connected')); + client.on('disconnected', () => events.push('disconnected')); + client.start(); + try { + await until(() => client.connected); + const pending = client.request('status'); + fake.dropClients(); + await assert.rejects(pending, (error) => error.code === 'disconnected'); + await until(() => events.filter((e) => e === 'connected').length === 2); + assert.deepEqual(events, ['connected', 'disconnected', 'connected']); + } finally { + client.stop(); + await fake.close(); + } +}); + +test('probiert mehrere Pipe-Namen der Reihe nach und verbindet Playtube spaeter', async () => { + const missing = uniqueName(); + const present = uniqueName(); + const client = new PlaytubeClient({ pipeNames: [missing, present], backoffMs: [20] }); + client.start(); + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(client.connected, false); + const fake = await startServer(present, () => ({ ok: true })); + try { + await until(() => client.connected); + assert.equal(client.pipeName, present); + } finally { + client.stop(); + await fake.close(); + } +}); diff --git a/tools/brand.mjs b/tools/brand.mjs new file mode 100644 index 0000000..7138e1d --- /dev/null +++ b/tools/brand.mjs @@ -0,0 +1,15 @@ +// Playtube-Marke (siehe Playtube tools/generate_icon.py): Verlauf violett -> pink, weisses Symbol. +export const BRAND_FROM = [124, 58, 237]; +export const BRAND_TO = [236, 72, 153]; +export const WHITE = '#ffffff'; +export const DIM_BG = '#2b2540'; +export const DIM_GLYPH = '#9b93b8'; +export const DARK_BG = '#171b24'; + +/** Diagonaler Verlauf ueber eine Leinwand der Kantenlaenge `size` (Farbfunktion fuer Canvas.fill). */ +export function brandGradient(size) { + return (x, y) => { + const t = Math.max(0, Math.min(1, (x + y) / (2 * size))); + return BRAND_FROM.map((from, i) => Math.round(from + (BRAND_TO[i] - from) * t)); + }; +} diff --git a/tools/generate-assets.mjs b/tools/generate-assets.mjs new file mode 100644 index 0000000..f27dfc8 --- /dev/null +++ b/tools/generate-assets.mjs @@ -0,0 +1,151 @@ +// Erzeugt manifest.json, Uebersetzungen (en/de) und alle PNG-Bilder des Plugins. +// Aufruf: npm run assets +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Canvas } from './raster.mjs'; +import { drawGlyph } from './glyphs.mjs'; +import { DIM_BG, DIM_GLYPH, WHITE, brandGradient } from './brand.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PLUGIN_DIR = path.join(ROOT, 'com.fojadrachi.playtube.sdPlugin'); +const PREFIX = 'com.fojadrachi.playtube.'; +const VERSION = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; + +const KEY_SIZE = 144; +const ICON_SIZE = 80; +const PLUGIN_ICON_SIZE = 256; + +/** + * states: Reihenfolge = State-Index im Plugin (siehe actions.js). + * on = Symbol weiss auf Akzentflaeche (aktiv), dim = gedaempfte Farbe (aus). + * titled: Taste zeigt Text (Lautstaerke, Name ...) -> Symbol rutscht nach oben. + */ +const ACTIONS = [ + { id: 'playpause', accent: 'playback', titled: true, states: [{ glyph: 'play' }, { glyph: 'pause' }], + en: ['Play/Pause', 'Toggle playback'], de: ['Wiedergabe/Pause', 'Wiedergabe starten oder pausieren'] }, + { id: 'previous', accent: 'playback', states: [{ glyph: 'previous' }], + en: ['Previous track', 'Go to the previous track'], de: ['Vorheriger Titel', 'Zum vorherigen Titel springen'] }, + { id: 'next', accent: 'playback', states: [{ glyph: 'next' }], + en: ['Next track', 'Skip to the next track'], de: ['Nächster Titel', 'Zum nächsten Titel springen'] }, + { id: 'seekback', accent: 'playback', titled: true, states: [{ glyph: 'rewind' }], + en: ['Rewind', 'Jump back a few seconds'], de: ['Zurückspulen', 'Ein paar Sekunden zurückspringen'] }, + { id: 'seekforward', accent: 'playback', titled: true, states: [{ glyph: 'forward' }], + en: ['Fast forward', 'Jump forward a few seconds'], de: ['Vorspulen', 'Ein paar Sekunden vorspringen'] }, + { id: 'volumeup', accent: 'volume', titled: true, states: [{ glyph: 'volup' }], + en: ['Volume up', 'Louder (hold to keep going)'], de: ['Lauter', 'Lauter (gedrückt halten für Dauer-Anpassung)'] }, + { id: 'volumedown', accent: 'volume', titled: true, states: [{ glyph: 'voldown' }], + en: ['Volume down', 'Quieter (hold to keep going)'], de: ['Leiser', 'Leiser (gedrückt halten für Dauer-Anpassung)'] }, + { id: 'volumeset', accent: 'volume', titled: true, states: [{ glyph: 'vol' }], + en: ['Set volume', 'Jump to a fixed volume'], de: ['Lautstärke setzen', 'Auf eine feste Lautstärke springen'] }, + { id: 'mute', accent: 'volume', titled: true, states: [{ glyph: 'vol' }, { glyph: 'mute', on: true }], + en: ['Mute', 'Mute or unmute'], de: ['Stummschalten', 'Ton aus- oder einschalten'] }, + { id: 'nowplaying', accent: 'playback', titled: true, states: [{ glyph: 'note' }], en: ['Now playing', 'Cover and title, press to play/pause'], + de: ['Aktueller Titel', 'Cover und Titel, Drücken = Wiedergabe/Pause'] }, + { id: 'like', accent: 'like', states: [{ glyph: 'heart' }, { glyph: 'heartfill', on: true }], + en: ['Like', 'Like the current track'], de: ['Gefällt mir', 'Aktuellen Titel mit „Gefällt mir“ markieren'] }, + { id: 'shuffle', accent: 'misc', states: [{ glyph: 'shuffle' }], + en: ['Shuffle', 'Toggle shuffle'], de: ['Zufallswiedergabe', 'Zufallswiedergabe umschalten'] }, + { id: 'repeat', accent: 'misc', + states: [{ glyph: 'repeat', dim: true }, { glyph: 'repeat', on: true }, { glyph: 'repeatone', on: true }], + en: ['Repeat', 'Cycle repeat mode'], de: ['Wiederholen', 'Wiederholungsmodus wechseln'] }, + { id: 'playlist', accent: 'playlist', titled: true, states: [{ glyph: 'playlist' }], + en: ['Play playlist', 'Start a playlist from YouTube Music'], + de: ['Playlist abspielen', 'Eine Playlist aus YouTube Music starten'] }, + { id: 'switchtab', accent: 'misc', titled: true, states: [{ glyph: 'tabs' }], + en: ['Switch tab', 'Switch between YouTube and YouTube Music'], + de: ['Tab wechseln', 'Zwischen YouTube und YouTube Musik wechseln'] }, + { id: 'show', accent: 'misc', states: [{ glyph: 'window' }], + en: ['Show Playtube', 'Bring the Playtube window to the front'], + de: ['Playtube anzeigen', 'Das Playtube-Fenster in den Vordergrund holen'] }, +]; + +function keyImage(action, state) { + const canvas = new Canvas(KEY_SIZE); + const background = state.dim ? DIM_BG : brandGradient(KEY_SIZE); + canvas.roundRect(0, 0, KEY_SIZE, KEY_SIZE, 0, background); + // Symbol immer exakt mittig; bei Tasten mit Text etwas kleiner, damit der Text darunter Platz hat. + const r = action.titled ? 34 : 44; + drawGlyph(canvas, state.glyph, KEY_SIZE / 2, KEY_SIZE / 2, r, state.dim ? DIM_GLYPH : WHITE, background); + return canvas.png(); +} + +function iconImage(action) { + const canvas = new Canvas(ICON_SIZE); + const background = brandGradient(ICON_SIZE); + canvas.roundRect(0, 0, ICON_SIZE, ICON_SIZE, 18, background); + drawGlyph(canvas, action.states[0].glyph, ICON_SIZE / 2, ICON_SIZE / 2, 26, WHITE, background); + return canvas.png(); +} + +function pluginIcon() { + const canvas = new Canvas(PLUGIN_ICON_SIZE); + const background = brandGradient(PLUGIN_ICON_SIZE); + canvas.roundRect(0, 0, PLUGIN_ICON_SIZE, PLUGIN_ICON_SIZE, 56, background); + drawGlyph(canvas, 'play', PLUGIN_ICON_SIZE / 2, PLUGIN_ICON_SIZE / 2, 84, WHITE, background); + return canvas.png(); +} + +function write(relative, content) { + const target = path.join(PLUGIN_DIR, relative); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, content); +} + +function manifest() { + return { + Actions: ACTIONS.map((action) => ({ + Icon: `static/icon/${action.id}@2x.png`, + Name: action.en[0], + DisableAutomaticStates: true, + States: action.states.map((_, index) => ({ + Image: `static/btn/${action.id}_${index}.png`, + TitleAlignment: 'bottom', + FontSize: '11', + FontStyle: 'Bold', + TitleColor: '#ffffff', + })), + Settings: {}, + Controllers: ['Keypad', 'Information'], + UserTitleEnabled: false, + SupportedInMultiActions: true, + Tooltip: action.en[1], + UUID: `${PREFIX}${action.id}`, + PropertyInspectorPath: 'propertyInspector/index.html', + })), + SDKVersion: 1, + Author: 'Playtube', + Name: 'Playtube', + Icon: 'static/icon/icon@2x.png', + CodePathWin: 'plugin/index.js', + Description: 'Control Playtube (YouTube / YouTube Music) from your Stream Dock', + Category: 'Playtube', + CategoryIcon: 'static/icon/icon@2x.png', + Version: VERSION, + OS: [{ Platform: 'windows', MinimumVersion: '10' }], + Software: { MinimumVersion: '3.10.188.226' }, + Nodejs: { Version: '20' }, + }; +} + +function locale(language, description) { + const entries = Object.fromEntries(ACTIONS.map((action) => { + const [name, tooltip] = action[language]; + return [`${PREFIX}${action.id}`, { Name: name, Tooltip: tooltip }]; + })); + return { Name: 'Playtube', Description: description, Category: 'Playtube', ...entries }; +} + +function main() { + write('manifest.json', `${JSON.stringify(manifest(), null, 2)}\n`); + write('en.json', `${JSON.stringify(locale('en', 'Control Playtube (YouTube / YouTube Music)'), null, 2)}\n`); + write('de.json', `${JSON.stringify(locale('de', 'Playtube (YouTube / YouTube Musik) steuern'), null, 2)}\n`); + write('static/icon/icon@2x.png', pluginIcon()); + for (const action of ACTIONS) { + write(`static/icon/${action.id}@2x.png`, iconImage(action)); + action.states.forEach((state, index) => write(`static/btn/${action.id}_${index}.png`, keyImage(action, state))); + } + process.stdout.write(`Assets fuer ${ACTIONS.length} Aktionen nach ${PLUGIN_DIR} geschrieben\n`); +} + +main(); diff --git a/tools/generate-iconpack.mjs b/tools/generate-iconpack.mjs new file mode 100644 index 0000000..95f039d --- /dev/null +++ b/tools/generate-iconpack.mjs @@ -0,0 +1,119 @@ +// Erzeugt das Playtube-Icon-Pack (PNG, 288x288) in zwei Varianten und packt es als Zip. +// Aufruf: npm run iconpack +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Canvas } from './raster.mjs'; +import { drawGlyph, GLYPHS } from './glyphs.mjs'; +import { drawExtra, EXTRA_GLYPHS } from './glyphs-extra.mjs'; +import { drawApp, APP_GLYPHS } from './glyphs-apps.mjs'; +import { DARK_BG, WHITE, brandGradient } from './brand.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PACK_NAME = 'Playtube-Icons'; +const PACK_DIR = path.join(ROOT, 'iconpack', PACK_NAME); +const ZIP = path.join(ROOT, 'dist', `${PACK_NAME}.zip`); +const SIZE = 288; +const GLYPH_RADIUS = SIZE * 0.31; + +const README = `Playtube Icon Pack +================== + +Ordner: + gradient/ Playtube-Look: Verlauf violett -> pink, weisses Symbol + dark/ dunkler Hintergrund, Symbol im Playtube-Verlauf + +Alle Bilder: PNG, 288 x 288 Pixel (das Geraet nutzt 144 x 144, die doppelte Groesse bleibt beim +Verkleinern scharf). + +Benutzen in der Stream-Dock-Software: + Taste anklicken -> das Vorschaubild der Taste (oben links im Einstellungsbereich) anklicken -> + "Bild waehlen" / Ordner-Symbol -> eine PNG-Datei aus diesem Pack auswaehlen. +`; + +function render(variant, drawer, name) { + const canvas = new Canvas(SIZE); + const gradient = brandGradient(SIZE); + const background = variant === 'gradient' ? gradient : DARK_BG; + canvas.roundRect(0, 0, SIZE, SIZE, 0, background); + drawer(canvas, name, SIZE / 2, SIZE / 2, GLYPH_RADIUS, variant === 'gradient' ? WHITE : gradient, background); + return canvas.png(); +} + +const SD_PACK_DIR = path.join(ROOT, 'iconpack', 'com.fojadrachi.playtube.sdIconPack'); +// Deutsche Suchbegriffe zusaetzlich zu den englischen Symbolnamen. +const GERMAN_TAGS = { + folder: ['ordner'], file: ['datei'], home: ['start', 'zuhause'], gear: ['einstellungen', 'zahnrad'], + plus: ['hinzufuegen'], minus: ['entfernen'], close: ['schliessen'], check: ['haken', 'ok'], star: ['stern', 'favorit'], + search: ['suche', 'lupe'], power: ['ein', 'aus'], mic: ['mikrofon'], headphones: ['kopfhoerer'], lock: ['schloss'], + bell: ['glocke', 'benachrichtigung'], clock: ['uhr', 'zeit'], calendar: ['kalender'], mail: ['post', 'email'], + globe: ['welt', 'internet'], download: ['herunterladen'], upload: ['hochladen'], trash: ['papierkorb', 'loeschen'], + eye: ['auge', 'anzeigen'], stop: ['stopp'], refresh: ['aktualisieren', 'neu laden'], wifi: ['wlan'], grid: ['raster'], + menu: ['menue'], more: ['mehr'], info: ['information'], warning: ['warnung'], monitor: ['bildschirm'], + sun: ['sonne', 'hell'], moon: ['mond', 'dunkel'], play: ['abspielen'], pause: ['pause'], next: ['weiter', 'naechster'], + previous: ['zurueck', 'vorheriger'], volup: ['lauter'], voldown: ['leiser'], vol: ['lautstaerke'], mute: ['stumm'], + heart: ['herz', 'gefaellt mir'], shuffle: ['zufall'], repeat: ['wiederholen'], playlist: ['liste'], + 'eye-off': ['auge', 'ausblenden', 'verstecken'], lightbulb: ['gluehbirne', 'licht', 'lampe'], + 'pc-monitor': ['pc', 'computer', 'system', 'monitor', 'leistung'], chrome: ['browser', 'google'], + discord: ['chat', 'voice'], steam: ['spiele', 'games'], twitch: ['stream', 'live'], netflix: ['serien', 'filme'], + youtube: ['video'], medal: ['clips', 'aufnahme'], nvidia: ['grafikkarte', 'gpu'], steelseries: ['sonar', 'audio'], + aniworld: ['anime'], prismlens: ['filter', 'kamera'], +}; + +/** Stream-Dock-Icon-Pack (.sdIconPack): manifest.json, icons.json, icon.png und icons/*.png. */ +function buildStreamDockPack(jobs) { + rmSync(SD_PACK_DIR, { recursive: true, force: true }); + mkdirSync(path.join(SD_PACK_DIR, 'icons'), { recursive: true }); + mkdirSync(path.join(SD_PACK_DIR, 'previews'), { recursive: true }); + const entries = []; + for (const variant of ['gradient', 'dark']) { + for (const { name } of jobs) { + const file = `${variant}_${name}.png`; + copyFileSync(path.join(PACK_DIR, variant, `${name}.png`), path.join(SD_PACK_DIR, 'icons', file)); + const base = name.split('-')[0]; + entries.push({ + path: file, + name: `${name.replace(/-/g, ' ')} (${variant})`, + tags: ['playtube', variant, ...name.split('-'), ...(GERMAN_TAGS[name] || GERMAN_TAGS[base] || [])], + }); + } + } + writeFileSync(path.join(SD_PACK_DIR, 'icons.json'), `${JSON.stringify(entries, null, 2)}\n`); + writeFileSync(path.join(SD_PACK_DIR, 'manifest.json'), `${JSON.stringify({ + Name: 'Playtube Icons', + Version: '1.0.0', + Description: 'Icons im Playtube-Look (Verlauf violett/pink): Ordner, Pfeile, Player-Symbole und mehr.', + Author: 'Playtube', + Icon: 'icon.png', + }, null, 2)}\n`); + const logo = render('gradient', drawGlyph, 'play'); + writeFileSync(path.join(SD_PACK_DIR, 'icon.png'), logo); + writeFileSync(path.join(SD_PACK_DIR, 'previews', 'preview1.png'), logo); +} + +function main() { + rmSync(PACK_DIR, { recursive: true, force: true }); + const jobs = [ + ...GLYPHS.map((name) => ({ name, drawer: drawGlyph })), + ...EXTRA_GLYPHS.map((name) => ({ name, drawer: drawExtra })), + ...APP_GLYPHS.map((name) => ({ name, drawer: drawApp })), + ]; + for (const variant of ['gradient', 'dark']) { + mkdirSync(path.join(PACK_DIR, variant), { recursive: true }); + for (const { name, drawer } of jobs) { + writeFileSync(path.join(PACK_DIR, variant, `${name}.png`), render(variant, drawer, name)); + } + } + writeFileSync(path.join(PACK_DIR, 'LIESMICH.txt'), README); + buildStreamDockPack(jobs); + + mkdirSync(path.dirname(ZIP), { recursive: true }); + rmSync(ZIP, { force: true }); + // Unter Windows das mitgelieferte bsdtar (Git-Bash-GNU-tar liest "C:" als Netzwerk-Host). + const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar'; + execFileSync(tar, ['-a', '-c', '-f', ZIP, '-C', path.dirname(PACK_DIR), PACK_NAME], { stdio: 'inherit' }); + process.stdout.write(`${jobs.length} Symbole x 2 Varianten -> ${PACK_DIR}\nZip: ${ZIP}\n`); +} + +main(); diff --git a/tools/glyphs-apps.mjs b/tools/glyphs-apps.mjs new file mode 100644 index 0000000..35c1e6d --- /dev/null +++ b/tools/glyphs-apps.mjs @@ -0,0 +1,129 @@ +// Vereinfachte App-/Geraete-Symbole (eigene Zeichnungen, keine Original-Logos) in Einheitskoordinaten [-1, 1]. + +const TAU = Math.PI * 2; + +export const APP_GLYPHS = [ + 'eye-off', 'lightbulb', 'pc-monitor', + 'chrome', 'discord', 'steam', 'twitch', 'netflix', 'youtube', 'medal', 'nvidia', 'steelseries', 'aniworld', + 'prismlens', +]; + +/** + * @param {import('./raster.mjs').Canvas} canvas + * @param {string} name - Eintrag aus APP_GLYPHS + * @param {number} cx + * @param {number} cy + * @param {number} r - halbe Symbolgroesse in Pixeln + * @param {string|Function} color + * @param {string|Function} background - Farbe fuer "ausgeschnittene" Teile + */ +export function drawApp(canvas, name, cx, cy, r, color, background) { + const p = (x, y) => [cx + x * r, cy + y * r]; + const stroke = (x1, y1, x2, y2, w = 0.2, paint = color) => { + canvas.line(...p(x1, y1), ...p(x2, y2), w * r, paint); + }; + const path = (points, w = 0.2, paint = color) => { + points.slice(1).forEach((pt, i) => stroke(...points[i], ...pt, w, paint)); + }; + const poly = (points, paint = color) => canvas.polygon(points.map(([x, y]) => p(x, y)), paint); + const disc = (x, y, radius, paint = color) => canvas.circle(...p(x, y), radius * r, paint); + const ring = (x, y, outer, inner, from = 0, to = TAU) => canvas.ring(...p(x, y), outer * r, inner * r, color, from, to); + const box = (x, y, w, h, radius, paint = color) => canvas.roundRect(...p(x, y), w * r, h * r, radius * r, paint); + // Ellipse; thickness = Randstaerke relativ zum Radius, null = gefuellt. + const ellipse = (x, y, rx, ry, thickness, paint = color) => { + const [ex, ey] = p(x, y); + canvas.fill((px, py) => { + const d = Math.hypot((px - ex) / (rx * r), (py - ey) / (ry * r)); + return d <= 1 && (thickness === null || d >= 1 - thickness); + }, paint, [ex - rx * r, ey - ry * r, ex + rx * r, ey + ry * r]); + }; + + switch (name) { + case 'eye-off': + ellipse(0, 0, 0.98, 0.58, 0.22); + disc(0, 0, 0.3); + stroke(-0.75, 0.8, 0.75, -0.8, 0.42, background); + stroke(-0.75, 0.8, 0.75, -0.8, 0.2); + break; + case 'lightbulb': + disc(0, -0.25, 0.62); + poly([[-0.4, 0.1], [0.4, 0.1], [0.28, 0.45], [-0.28, 0.45]]); + box(-0.3, 0.5, 0.6, 0.16, 0.06); + box(-0.2, 0.72, 0.4, 0.14, 0.06); + path([[-0.2, 0.05], [0, -0.3], [0.2, 0.05]], 0.1, background); + break; + case 'pc-monitor': + box(-0.95, -0.8, 1.9, 1.35, 0.12); + box(-0.8, -0.65, 1.6, 1.05, 0.05, background); + path([[-0.6, -0.1], [-0.28, -0.1], [-0.12, -0.45], [0.08, 0.2], [0.24, -0.2], [0.6, -0.2]], 0.13); + stroke(0, 0.55, 0, 0.82, 0.2); + stroke(-0.5, 0.88, 0.5, 0.88, 0.2); + break; + case 'chrome': + ring(0, 0, 0.95, 0.72); + disc(0, 0, 0.36); + for (let i = 0; i < 3; i += 1) { + const a = (-90 + i * 120) * (Math.PI / 180); + stroke(Math.cos(a) * 0.3, Math.sin(a) * 0.3, Math.cos(a) * 0.85, Math.sin(a) * 0.85, 0.2); + } + disc(0, 0, 0.2, background); + break; + case 'discord': + ellipse(0, -0.05, 0.95, 0.7, null); + poly([[-0.5, 0.45], [-0.75, 0.9], [-0.15, 0.62]]); + poly([[0.5, 0.45], [0.75, 0.9], [0.15, 0.62]]); + disc(-0.36, -0.05, 0.19, background); + disc(0.36, -0.05, 0.19, background); + break; + case 'steam': + ring(0, 0, 0.95, 0.72); + disc(0.32, -0.28, 0.32); + disc(0.32, -0.28, 0.14, background); + disc(-0.36, 0.3, 0.2); + stroke(-0.36, 0.3, 0.2, -0.05, 0.16); + break; + case 'twitch': + poly([[-0.85, -0.85], [0.9, -0.85], [0.9, 0.4], [0.4, 0.4], [0.05, 0.8], [0.05, 0.4], [-0.85, 0.4]]); + stroke(0.02, -0.5, 0.02, 0, 0.16, background); + stroke(0.5, -0.5, 0.5, 0, 0.16, background); + break; + case 'netflix': + box(-0.55, -0.85, 0.28, 1.7, 0.02); + box(0.27, -0.85, 0.28, 1.7, 0.02); + poly([[-0.55, -0.85], [-0.27, -0.85], [0.55, 0.85], [0.27, 0.85]]); + break; + case 'youtube': + box(-0.95, -0.62, 1.9, 1.24, 0.34); + poly([[-0.2, -0.32], [0.34, 0], [-0.2, 0.32]], background); + break; + case 'medal': + poly([[0, -0.95], [0.85, -0.6], [0.85, 0.15], [0, 0.95], [-0.85, 0.15], [-0.85, -0.6]]); + path([[-0.45, 0.32], [-0.45, -0.32], [0, 0.1], [0.45, -0.32], [0.45, 0.32]], 0.16, background); + break; + case 'nvidia': + box(-0.95, -0.55, 1.9, 1.1, 0.12); + disc(0, 0, 0.42, background); + disc(0, 0, 0.17); + for (let i = 0; i < 4; i += 1) { + const a = i * (Math.PI / 2) + 0.4; + stroke(Math.cos(a) * 0.14, Math.sin(a) * 0.14, Math.cos(a) * 0.36, Math.sin(a) * 0.36, 0.12); + } + break; + case 'steelseries': + ring(0, -0.42, 0.56, 0.34, Math.PI / 2, TAU); + ring(0, 0.42, 0.56, 0.34, (3 * Math.PI) / 2, TAU); + ring(0, 0.42, 0.56, 0.34, 0, Math.PI); + break; + case 'aniworld': + ring(0, 0, 0.95, 0.72); + poly([[-0.22, -0.42], [0.5, 0], [-0.22, 0.42]]); + break; + case 'prismlens': + ring(0, 0, 0.95, 0.75); + ring(0, 0, 0.58, 0.4); + disc(0, 0, 0.2); + break; + default: + throw new Error(`Unbekanntes Symbol: ${name}`); + } +} diff --git a/tools/glyphs-extra.mjs b/tools/glyphs-extra.mjs new file mode 100644 index 0000000..fa2e4f2 --- /dev/null +++ b/tools/glyphs-extra.mjs @@ -0,0 +1,248 @@ +// Allgemeine Symbole (Ordner, Pfeile, Einstellungen ...) fuer das Icon-Pack, in Einheitskoordinaten [-1, 1]. + +const TAU = Math.PI * 2; + +export const EXTRA_GLYPHS = [ + 'folder', 'file', 'home', 'gear', 'plus', 'minus', 'close', 'check', 'star', 'search', 'power', 'mic', + 'headphones', 'lock', 'bell', 'clock', 'calendar', 'mail', 'globe', 'download', 'upload', 'trash', 'eye', + 'stop', 'refresh', 'wifi', 'grid', 'menu', 'more', 'info', 'warning', 'monitor', 'sun', 'moon', + 'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'chevron-up', 'chevron-down', 'chevron-left', + 'chevron-right', +]; + +const ARROW_ANGLES = { 'arrow-right': 0, 'arrow-down': 90, 'arrow-left': 180, 'arrow-up': 270 }; +const CHEVRON_ANGLES = { 'chevron-right': 0, 'chevron-down': 90, 'chevron-left': 180, 'chevron-up': 270 }; + +function starPoints() { + const points = []; + for (let i = 0; i < 10; i += 1) { + const angle = -Math.PI / 2 + (i * Math.PI) / 5; + const radius = i % 2 === 0 ? 0.98 : 0.42; + points.push([Math.cos(angle) * radius, Math.sin(angle) * radius + 0.05]); + } + return points; +} +const STAR = starPoints(); + +/** + * @param {import('./raster.mjs').Canvas} canvas + * @param {string} name - Eintrag aus EXTRA_GLYPHS + * @param {number} cx + * @param {number} cy + * @param {number} r - halbe Symbolgroesse in Pixeln + * @param {string|Function} color + * @param {string|Function} background - Farbe fuer "ausgeschnittene" Teile + */ +export function drawExtra(canvas, name, cx, cy, r, color, background) { + const p = (x, y) => [cx + x * r, cy + y * r]; + const rot = (x, y, degrees) => { + const a = (degrees * Math.PI) / 180; + return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)]; + }; + const stroke = (x1, y1, x2, y2, w = 0.2, paint = color, degrees = 0) => { + const [ax, ay] = p(...rot(x1, y1, degrees)); + const [bx, by] = p(...rot(x2, y2, degrees)); + canvas.line(ax, ay, bx, by, w * r, paint); + }; + const path = (points, w = 0.2, degrees = 0) => { + points.slice(1).forEach((pt, i) => stroke(...points[i], ...pt, w, color, degrees)); + }; + const poly = (points, paint = color) => canvas.polygon(points.map(([x, y]) => p(x, y)), paint); + const disc = (x, y, radius, paint = color) => canvas.circle(...p(x, y), radius * r, paint); + const ring = (x, y, outer, inner, from = 0, to = TAU) => canvas.ring(...p(x, y), outer * r, inner * r, color, from, to); + const box = (x, y, w, h, radius, paint = color) => canvas.roundRect(...p(x, y), w * r, h * r, radius * r, paint); + // Ellipse; thickness = Randstaerke relativ zum Radius, null = gefuellt. + const ellipse = (rx, ry, thickness) => { + const [ex, ey] = p(0, 0); + canvas.fill((px, py) => { + const d = Math.hypot((px - ex) / (rx * r), (py - ey) / (ry * r)); + return d <= 1 && (thickness === null || d >= 1 - thickness); + }, color, [ex - rx * r, ey - ry * r, ex + rx * r, ey + ry * r]); + }; + + if (name in ARROW_ANGLES) { + const a = ARROW_ANGLES[name]; + stroke(-0.8, 0, 0.8, 0, 0.2, color, a); + stroke(0.8, 0, 0.2, -0.6, 0.2, color, a); + stroke(0.8, 0, 0.2, 0.6, 0.2, color, a); + return; + } + if (name in CHEVRON_ANGLES) { + path([[-0.3, -0.75], [0.35, 0], [-0.3, 0.75]], 0.24, CHEVRON_ANGLES[name]); + return; + } + + switch (name) { + case 'folder': + poly([[-0.92, -0.5], [-0.92, -0.78], [-0.15, -0.78], [0.1, -0.5]]); + box(-0.92, -0.5, 1.84, 1.3, 0.12); + break; + case 'file': + poly([[-0.62, -0.92], [0.18, -0.92], [0.68, -0.42], [0.68, 0.92], [-0.62, 0.92]]); + poly([[0.18, -0.92], [0.18, -0.42], [0.68, -0.42]], background); + stroke(-0.3, 0.05, 0.36, 0.05, 0.14, background); + stroke(-0.3, 0.45, 0.36, 0.45, 0.14, background); + break; + case 'home': + poly([[-1, 0], [0, -0.9], [1, 0]]); + box(-0.62, -0.05, 1.24, 0.9, 0.06); + box(-0.16, 0.25, 0.32, 0.6, 0.04, background); + break; + case 'gear': + ring(0, 0, 0.66, 0.3); + for (let i = 0; i < 8; i += 1) { + const a = (i * 45 * Math.PI) / 180; + stroke(Math.cos(a) * 0.6, Math.sin(a) * 0.6, Math.cos(a) * 0.9, Math.sin(a) * 0.9, 0.3); + } + break; + case 'plus': + stroke(-0.8, 0, 0.8, 0, 0.24); + stroke(0, -0.8, 0, 0.8, 0.24); + break; + case 'minus': + stroke(-0.8, 0, 0.8, 0, 0.24); + break; + case 'close': + stroke(-0.7, -0.7, 0.7, 0.7, 0.24); + stroke(-0.7, 0.7, 0.7, -0.7, 0.24); + break; + case 'check': + path([[-0.8, 0.05], [-0.25, 0.6], [0.85, -0.55]], 0.26); + break; + case 'star': + poly(STAR); + break; + case 'search': + ring(-0.2, -0.2, 0.68, 0.44); + stroke(0.3, 0.3, 0.82, 0.82, 0.28); + break; + case 'power': + ring(0, 0.08, 0.8, 0.58, (3 * Math.PI) / 2 + 0.65, TAU); + ring(0, 0.08, 0.8, 0.58, 0, (3 * Math.PI) / 2 - 0.65); + stroke(0, -0.9, 0, -0.1, 0.24); + break; + case 'mic': + box(-0.28, -0.9, 0.56, 1.1, 0.28); + ring(0, -0.05, 0.6, 0.42, 0, Math.PI); + stroke(0, 0.55, 0, 0.9, 0.16); + stroke(-0.35, 0.9, 0.35, 0.9, 0.16); + break; + case 'headphones': + ring(0, 0.05, 0.85, 0.62, Math.PI, TAU); + box(-0.95, 0.05, 0.4, 0.7, 0.12); + box(0.55, 0.05, 0.4, 0.7, 0.12); + break; + case 'lock': + ring(0, -0.15, 0.45, 0.25, Math.PI, TAU); + stroke(-0.35, -0.15, -0.35, 0.05, 0.2); + stroke(0.35, -0.15, 0.35, 0.05, 0.2); + box(-0.65, 0, 1.3, 0.9, 0.12); + disc(0, 0.4, 0.13, background); + break; + case 'bell': + disc(0, -0.15, 0.55); + poly([[-0.55, -0.15], [0.55, -0.15], [0.82, 0.5], [-0.82, 0.5]]); + disc(0, 0.72, 0.16); + stroke(0, -0.85, 0, -0.65, 0.16); + break; + case 'clock': + ring(0, 0, 0.92, 0.7); + stroke(0, 0, 0, -0.5, 0.15); + stroke(0, 0, 0.36, 0.22, 0.15); + break; + case 'calendar': + box(-0.88, -0.68, 1.76, 1.58, 0.12); + box(-0.72, -0.22, 1.44, 1.0, 0.05, background); + stroke(-0.4, -0.92, -0.4, -0.5, 0.18); + stroke(0.4, -0.92, 0.4, -0.5, 0.18); + break; + case 'mail': + box(-0.92, -0.62, 1.84, 1.24, 0.12); + stroke(-0.8, -0.5, 0, 0.12, 0.15, background); + stroke(0, 0.12, 0.8, -0.5, 0.15, background); + break; + case 'globe': + ring(0, 0, 0.92, 0.75); + ellipse(0.42, 0.92, 0.18); + stroke(-0.92, 0, 0.92, 0, 0.13); + break; + case 'download': + stroke(0, -0.9, 0, 0.3, 0.22); + path([[-0.5, -0.2], [0, 0.4], [0.5, -0.2]], 0.22); + path([[-0.85, 0.35], [-0.85, 0.85], [0.85, 0.85], [0.85, 0.35]], 0.2); + break; + case 'upload': + stroke(0, 0.3, 0, -0.9, 0.22); + path([[-0.5, -0.4], [0, -0.95], [0.5, -0.4]], 0.22); + path([[-0.85, 0.35], [-0.85, 0.85], [0.85, 0.85], [0.85, 0.35]], 0.2); + break; + case 'trash': + box(-0.6, -0.3, 1.2, 1.2, 0.1); + stroke(-0.85, -0.5, 0.85, -0.5, 0.2); + stroke(-0.25, -0.75, 0.25, -0.75, 0.16); + stroke(-0.2, -0.05, -0.2, 0.65, 0.13, background); + stroke(0.2, -0.05, 0.2, 0.65, 0.13, background); + break; + case 'eye': + ellipse(0.98, 0.58, 0.22); + disc(0, 0, 0.3); + break; + case 'stop': + box(-0.72, -0.72, 1.44, 1.44, 0.16); + break; + case 'refresh': { + const end = 5.5; + ring(0, 0, 0.85, 0.6, 0.7, end); + const bx = Math.cos(end) * 0.725; + const by = Math.sin(end) * 0.725; + poly([[bx - Math.sin(end) * 0.55, by + Math.cos(end) * 0.55], + [bx + Math.cos(end) * 0.5, by + Math.sin(end) * 0.5], + [bx - Math.cos(end) * 0.5, by - Math.sin(end) * 0.5]]); + break; + } + case 'wifi': + for (const radius of [0.45, 0.8, 1.15]) ring(0, 0.6, radius + 0.09, radius - 0.09, 1.25 * Math.PI, 1.75 * Math.PI); + disc(0, 0.6, 0.13); + break; + case 'grid': + for (const x of [-0.85, 0.08]) for (const y of [-0.85, 0.08]) box(x, y, 0.77, 0.77, 0.14); + break; + case 'menu': + stroke(-0.8, -0.5, 0.8, -0.5, 0.22); + stroke(-0.8, 0, 0.8, 0, 0.22); + stroke(-0.8, 0.5, 0.8, 0.5, 0.22); + break; + case 'more': + for (const x of [-0.6, 0, 0.6]) disc(x, 0, 0.17); + break; + case 'info': + ring(0, 0, 0.92, 0.72); + disc(0, -0.42, 0.11); + stroke(0, -0.12, 0, 0.5, 0.18); + break; + case 'warning': + poly([[0, -0.9], [0.98, 0.78], [-0.98, 0.78]]); + stroke(0, -0.3, 0, 0.25, 0.16, background); + disc(0, 0.52, 0.09, background); + break; + case 'monitor': + box(-0.92, -0.72, 1.84, 1.2, 0.12); + box(-0.78, -0.58, 1.56, 0.92, 0.05, background); + stroke(0, 0.5, 0, 0.8, 0.2); + stroke(-0.45, 0.85, 0.45, 0.85, 0.2); + break; + case 'sun': + disc(0, 0, 0.42); + for (let i = 0; i < 8; i += 1) { + const a = (i * 45 * Math.PI) / 180; + stroke(Math.cos(a) * 0.68, Math.sin(a) * 0.68, Math.cos(a) * 0.95, Math.sin(a) * 0.95, 0.17); + } + break; + case 'moon': + disc(0, 0, 0.8); + disc(0.38, -0.3, 0.68, background); + break; + default: + throw new Error(`Unbekanntes Symbol: ${name}`); + } +} diff --git a/tools/glyphs.mjs b/tools/glyphs.mjs new file mode 100644 index 0000000..ca5e45f --- /dev/null +++ b/tools/glyphs.mjs @@ -0,0 +1,141 @@ +// Symbole fuer die Tasten, gezeichnet in Einheitskoordinaten [-1, 1] um (cx, cy) mit Radius r. + +function heartPoints() { + const points = []; + for (let i = 0; i < 72; i += 1) { + const t = (i / 72) * Math.PI * 2; + points.push([ + (16 * Math.sin(t) ** 3) / 17, + -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 17, + ]); + } + return points; +} +const HEART = heartPoints(); + +export const GLYPHS = [ + 'play', 'pause', 'next', 'previous', 'rewind', 'forward', 'volup', 'voldown', 'vol', 'mute', + 'note', 'heart', 'heartfill', 'shuffle', 'repeat', 'repeatone', 'playlist', 'tabs', 'window', +]; + +/** + * @param {import('./raster.mjs').Canvas} canvas + * @param {string} name - Eintrag aus GLYPHS + * @param {number} cx + * @param {number} cy + * @param {number} r - halbe Symbolgroesse in Pixeln + * @param {string} color - Symbolfarbe + * @param {string} background - Farbe hinter dem Symbol (fuer "ausgeschnittene" Formen) + */ +export function drawGlyph(canvas, name, cx, cy, r, color, background) { + const p = (x, y) => [cx + x * r, cy + y * r]; + const poly = (points) => canvas.polygon(points.map(([x, y]) => p(x, y)), color); + const stroke = (x1, y1, x2, y2, w = 0.2) => { + const [ax, ay] = p(x1, y1); + const [bx, by] = p(x2, y2); + canvas.line(ax, ay, bx, by, w * r, color); + }; + const path = (points, w = 0.2) => points.slice(1).forEach((pt, i) => stroke(...points[i], ...pt, w)); + const speaker = (dx = 0) => poly([[-0.9 + dx, -0.3], [-0.5 + dx, -0.3], [-0.1 + dx, -0.7], + [-0.1 + dx, 0.7], [-0.5 + dx, 0.3], [-0.9 + dx, 0.3]]); + const arc = (dx, outer, inner) => { + const [ax, ay] = p(dx, 0); + canvas.ring(ax, ay, outer * r, inner * r, color, Math.PI * 2 - 0.75, Math.PI * 2); + canvas.ring(ax, ay, outer * r, inner * r, color, 0, 0.75); + }; + const heart = (scale, fill) => canvas.polygon( + HEART.map(([x, y]) => [cx + x * r * scale, cy + (y * scale + 0.05) * r]), fill); + + switch (name) { + case 'play': + poly([[-0.7, -0.85], [0.75, 0], [-0.7, 0.85]]); + break; + case 'pause': + poly([[-0.65, -0.8], [-0.2, -0.8], [-0.2, 0.8], [-0.65, 0.8]]); + poly([[0.2, -0.8], [0.65, -0.8], [0.65, 0.8], [0.2, 0.8]]); + break; + case 'next': + poly([[-0.8, -0.75], [0.4, 0], [-0.8, 0.75]]); + poly([[0.55, -0.75], [0.85, -0.75], [0.85, 0.75], [0.55, 0.75]]); + break; + case 'previous': + poly([[0.8, -0.75], [-0.4, 0], [0.8, 0.75]]); + poly([[-0.55, -0.75], [-0.85, -0.75], [-0.85, 0.75], [-0.55, 0.75]]); + break; + case 'rewind': + poly([[0.05, -0.65], [-0.55, 0], [0.05, 0.65]]); + poly([[0.85, -0.65], [0.25, 0], [0.85, 0.65]]); + break; + case 'forward': + poly([[-0.05, -0.65], [0.55, 0], [-0.05, 0.65]]); + poly([[-0.85, -0.65], [-0.25, 0], [-0.85, 0.65]]); + break; + case 'volup': + speaker(-0.1); + stroke(0.3, 0, 0.9, 0); + stroke(0.6, -0.3, 0.6, 0.3); + break; + case 'voldown': + speaker(-0.1); + stroke(0.3, 0, 0.9, 0); + break; + case 'vol': + speaker(-0.2); + arc(-0.3, 0.75, 0.55); + arc(-0.3, 1.1, 0.9); + break; + case 'mute': + speaker(-0.1); + stroke(0.3, -0.35, 0.9, 0.35); + stroke(0.3, 0.35, 0.9, -0.35); + break; + case 'note': { + const [nx, ny] = p(-0.35, 0.5); + canvas.circle(nx, ny, 0.38 * r, color); + stroke(0.0, 0.45, 0.0, -0.85, 0.2); + stroke(0.0, -0.85, 0.7, -0.5, 0.2); + break; + } + case 'heart': + heart(1, color); + heart(0.62, background); + break; + case 'heartfill': + heart(1, color); + break; + case 'shuffle': + path([[-0.9, 0.5], [-0.3, 0.5], [0.3, -0.5], [0.55, -0.5]], 0.18); + path([[-0.9, -0.5], [-0.3, -0.5], [0.3, 0.5], [0.55, 0.5]], 0.18); + poly([[0.5, -0.85], [0.95, -0.5], [0.5, -0.15]]); + poly([[0.5, 0.15], [0.95, 0.5], [0.5, 0.85]]); + break; + case 'repeat': + case 'repeatone': + path([[-0.8, 0.15], [-0.8, -0.4], [0.3, -0.4]], 0.18); + poly([[0.3, -0.8], [0.9, -0.4], [0.3, 0]]); + path([[0.8, -0.15], [0.8, 0.4], [-0.3, 0.4]], 0.18); + poly([[-0.3, 0.8], [-0.9, 0.4], [-0.3, 0]]); + if (name === 'repeatone') { + stroke(0.0, -0.18, 0.0, 0.2, 0.14); + stroke(-0.13, -0.1, 0.0, -0.18, 0.14); + } + break; + case 'playlist': + stroke(-0.85, -0.55, 0.15, -0.55, 0.18); + stroke(-0.85, -0.05, 0.15, -0.05, 0.18); + stroke(-0.85, 0.45, -0.25, 0.45, 0.18); + poly([[0.3, 0.05], [0.95, 0.45], [0.3, 0.85]]); + break; + case 'tabs': + canvas.roundRect(...p(-0.85, -0.7), 1.1 * r, 0.9 * r, 0.12 * r, color); + canvas.roundRect(...p(-0.75, -0.6), 0.9 * r, 0.7 * r, 0.06 * r, background); + canvas.roundRect(...p(-0.25, -0.15), 1.1 * r, 0.9 * r, 0.12 * r, color); + break; + case 'window': + canvas.roundRect(...p(-0.9, -0.7), 1.8 * r, 1.4 * r, 0.14 * r, color); + canvas.roundRect(...p(-0.75, -0.25), 1.5 * r, 0.85 * r, 0.05 * r, background); + break; + default: + throw new Error(`Unbekanntes Symbol: ${name}`); + } +} diff --git a/tools/raster.mjs b/tools/raster.mjs new file mode 100644 index 0000000..4d5d780 --- /dev/null +++ b/tools/raster.mjs @@ -0,0 +1,146 @@ +// Winziger Software-Rasterizer + PNG-Encoder (keine Abhaengigkeiten), fuer die Tasten-Icons. +import { deflateSync } from 'node:zlib'; + +const SUPERSAMPLE = 4; + +let crcTable = null; +function crc32(buffer) { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crcTable[n] = c >>> 0; + } + } + let crc = 0xffffffff; + for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const tail = Buffer.alloc(4); + tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0); + return Buffer.concat([head, data, tail]); +} + +export function encodePng(width, height, rgba) { + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; // Bit-Tiefe + header[9] = 6; // RGBA + const stride = width * 4 + 1; + const rows = Buffer.alloc(stride * height); + for (let y = 0; y < height; y += 1) { + Buffer.from(rgba.buffer, rgba.byteOffset + y * width * 4, width * 4).copy(rows, y * stride + 1); + } + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', header), + chunk('IDAT', deflateSync(rows, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +export function parseColor(hex) { + const value = hex.replace('#', ''); + return [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16)); +} + +/** Leinwand mit Kantenglaettung; Formen werden per Punkt-in-Form-Test mit Supersampling gefuellt. */ +export class Canvas { + constructor(size) { + this.size = size; + this.data = new Uint8ClampedArray(size * size * 4); + } + + /** contains(x, y) -> boolean in Pixel-Koordinaten; bounds = [x0, y0, x1, y1] begrenzt die Suche. */ + fill(contains, color, bounds = [0, 0, this.size, this.size]) { + // color: "#rrggbb", [r, g, b] oder Funktion (x, y) => [r, g, b] (Farbverlaeufe) + const colorAt = typeof color === 'function' ? color + : ((fixed) => () => fixed)(typeof color === 'string' ? parseColor(color) : color); + const clamp = (v) => Math.max(0, Math.min(this.size, v)); + const x0 = clamp(Math.floor(bounds[0])); + const y0 = clamp(Math.floor(bounds[1])); + const x1 = clamp(Math.ceil(bounds[2])); + const y1 = clamp(Math.ceil(bounds[3])); + const total = SUPERSAMPLE * SUPERSAMPLE; + for (let y = y0; y < y1; y += 1) { + for (let x = x0; x < x1; x += 1) { + let hits = 0; + for (let sy = 0; sy < SUPERSAMPLE; sy += 1) { + for (let sx = 0; sx < SUPERSAMPLE; sx += 1) { + if (contains(x + (sx + 0.5) / SUPERSAMPLE, y + (sy + 0.5) / SUPERSAMPLE)) hits += 1; + } + } + if (hits) this.blend((y * this.size + x) * 4, colorAt(x + 0.5, y + 0.5), hits / total); + } + } + } + + blend(index, [r, g, b], alpha) { + const dstA = this.data[index + 3] / 255; + const outA = alpha + dstA * (1 - alpha); + [r, g, b].forEach((value, offset) => { + this.data[index + offset] = (value * alpha + this.data[index + offset] * dstA * (1 - alpha)) / outA; + }); + this.data[index + 3] = outA * 255; + } + + roundRect(x, y, w, h, radius, color) { + this.fill((px, py) => { + const cx = Math.max(x + radius, Math.min(x + w - radius, px)); + const cy = Math.max(y + radius, Math.min(y + h - radius, py)); + return px >= x && px <= x + w && py >= y && py <= y + h && (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2; + }, color, [x, y, x + w, y + h]); + } + + circle(cx, cy, radius, color) { + this.fill((px, py) => (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2, color, + [cx - radius, cy - radius, cx + radius, cy + radius]); + } + + /** Kreisring bzw. Bogen (Winkel in Radiant, 0 = rechts, im Uhrzeigersinn). */ + ring(cx, cy, outer, inner, color, from = 0, to = Math.PI * 2) { + this.fill((px, py) => { + const d = (px - cx) ** 2 + (py - cy) ** 2; + if (d > outer ** 2 || d < inner ** 2) return false; + let angle = Math.atan2(py - cy, px - cx); + if (angle < 0) angle += Math.PI * 2; + return angle >= from && angle <= to; + }, color, [cx - outer, cy - outer, cx + outer, cy + outer]); + } + + polygon(points, color) { + const xs = points.map((p) => p[0]); + const ys = points.map((p) => p[1]); + this.fill((px, py) => { + let inside = false; + for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) { + const [xi, yi] = points[i]; + const [xj, yj] = points[j]; + if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; + }, color, [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]); + } + + /** Strecke mit Breite `width` und runden Enden. */ + line(x1, y1, x2, y2, width, color) { + const half = width / 2; + const dx = x2 - x1; + const dy = y2 - y1; + this.fill((px, py) => { + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy || 1))); + return (px - (x1 + t * dx)) ** 2 + (py - (y1 + t * dy)) ** 2 <= half * half; + }, color, [Math.min(x1, x2) - half, Math.min(y1, y2) - half, Math.max(x1, x2) + half, Math.max(y1, y2) + half]); + } + + png() { + return encodePng(this.size, this.size, this.data); + } +}