feat: Playtube Stream Dock Plugin 1.0.0 (Ajazz AKP153E) mit Icon-Pack

This commit is contained in:
2026-09-26 21:00:36 +02:00
commit 179a785adb
221 changed files with 10254 additions and 0 deletions
+112
View File
@@ -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(''), '');
});
+204
View File
@@ -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);
});
+70
View File
@@ -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 & <Live>', 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, /^<svg /);
assert.ok(svg.includes('Tom &amp;'));
assert.ok(svg.includes('&lt;Live&gt;'));
assert.ok(svg.includes('Interpret'));
assert.ok(!svg.includes('<Live>'));
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('<image'));
});
test('Fortschrittsbalken ist auf 0..100% begrenzt', () => {
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('<svg/>');
assert.equal(Buffer.from(uri.split(',')[1], 'base64').toString(), '<svg/>');
});
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');
});
+129
View File
@@ -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();
}
});