VoicePult/integrations/opendeck/de.voicepult.control.sdPlugin/plugin.js
2026-10-03 11:07:47 +02:00

77 lines
3.8 KiB
JavaScript

'use strict';
// OpenAction / Stream Deck protocol; Node 22+, no npm dependencies.
const args = process.argv.slice(2);
const arg = name => args[args.indexOf(name) + 1];
const port = Number(arg('-port'));
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Missing OpenDeck port');
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
const visible = new Map(), rendered = new Map();
let cached = null, refreshing = false, queue = Promise.resolve();
function send(event, context, payload) {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({event,context,payload}));
}
async function state() {
const response = await fetch('http://127.0.0.1:8765/api/state',{signal:AbortSignal.timeout(3000)});
if (!response.ok) throw new Error('VoicePult unavailable');
return response.json();
}
function itemFor(settings, data) {
return (settings.kind === 'target' ? data.targets : data.channels)?.find(c=>c.id === settings.id);
}
function paint(context, settings, data) {
const item = data && itemFor(settings, data);
const label = item?.name || settings.name || 'Kanal wählen';
const lines = label.replace(/(.{1,12})(?:\s+|$)/g,'$1\n').trim();
const status = !data ? 'OFFLINE' : !item ? 'FEHLT' : item.muted ? 'AUS' : 'AN';
const selected = !item || item.muted || !data ? 1 : 0;
const title = `${lines}\n${status}`;
const signature = JSON.stringify([selected,title]);
if (rendered.get(context) === signature) return;
send('setTitle',context,{title,target:0,state:0});
send('setTitle',context,{title,target:0,state:1});
send('setState',context,{state:selected});
rendered.set(context,signature);
}
async function refresh() {
if (refreshing || !visible.size) return;
refreshing = true;
try { cached = await state(); }
catch { cached = null; }
finally { refreshing = false; }
for (const [context, settings] of visible) paint(context,settings,cached);
}
async function toggle(context, settings) {
try {
// Server toggles atomically; don't infer the next state from a stale key image.
const before = await state();
if (!itemFor(settings,before)) throw new Error('Channel removed');
const response = await fetch('http://127.0.0.1:8765/api/action',{
method:'POST', headers:{'Content-Type':'application/json','X-VoicePult-Token':before.token},
body:JSON.stringify({action:settings.kind==='target'?'toggle_target':'toggle_channel',id:settings.id}),
signal:AbortSignal.timeout(15000)
});
if (!response.ok) throw new Error('VoicePult action failed');
cached = await response.json();
for (const [key,settings] of visible) paint(key,settings,cached);
} catch (error) { send('showAlert',context); console.error(error.message); await refresh(); }
}
socket.addEventListener('open',()=>socket.send(JSON.stringify({event:arg('-registerEvent'),uuid:arg('-pluginUUID')})));
socket.addEventListener('message',async event=>{
try {
const msg=JSON.parse(event.data), settings=msg.payload?.settings || visible.get(msg.context) || {};
if (msg.event==='willAppear' || msg.event==='didReceiveSettings') {
visible.set(msg.context,settings); rendered.delete(msg.context); await refresh();
} else if (msg.event==='willDisappear') {
visible.delete(msg.context); rendered.delete(msg.context);
} else if (msg.event==='keyDown') {
queue=queue.then(()=>toggle(msg.context,settings)).catch(error=>console.error(error.message));
} else if (msg.event==='sendToPlugin') {
const data=await state();
send('sendToPropertyInspector',msg.context,{items:[...data.channels.map(c=>({...c,kind:'channel'})),...data.targets.map(c=>({...c,kind:'target'}))]});
}
} catch(error) {console.error(error.message);}
});
socket.addEventListener('close',()=>process.exit(0));
socket.addEventListener('error',()=>console.error('OpenDeck connection failed'));
setInterval(refresh,1000);