Initial commit

This commit is contained in:
pdmarf
2026-04-10 19:51:21 +01:00
commit bc383e4fd8
22 changed files with 2190 additions and 0 deletions

View File

@@ -0,0 +1,349 @@
/// <reference path="events.js"/>
class ELGSDPlugin {
#data = {};
#language = 'en';
#localization;
test = new Set();
on = EventEmitter.on;
emit = EventEmitter.emit;
localizationLoaded = false;
constructor () {
// super();
if(ELGSDPlugin.__instance) {
return ELGSDPlugin.__instance;
}
ELGSDPlugin.__instance = this;
const pathArr = location.pathname.split("/");
const idx = pathArr.findIndex(f => f.endsWith('sdPlugin'));
this.#data.__filename = pathArr[pathArr.length - 1];
this.#data.__dirname = pathArr[pathArr.length - 2];
this.#data.__folderpath = `${pathArr.slice(0, idx + 1).join("/")}/`;
this.#data.__folderroot = `${pathArr.slice(0, idx).join("/")}/`;
this.#data.__parentdir = `${pathArr.slice(0, idx-1).join("/")}/`;
}
set language(value) {
this.#language = value;
this.loadLocalization(this.#data.__folderpath).then(l => {
this.emit('languageChanged', value);
});
}
get language() {
return this.#language;
}
set localization(value) {
this.#localization = value;
this.localizeUI();
this.emit('localizationChanged', value);
}
get localization() {
return this.#localization;
}
get __filename() {
return this.#data.__filename;
}
get __dirname() {
return this.#data.__dirname;
}
get __folderpath() {
return this.#data.__folderpath;
}
get __folderroot() {
return this.#data.__folderroot;
}
get data() {
return this.#data;
}
/**
* Finds the original key of the string value
* Note: This is used by the localization UI to find the original key (not used here)
* @param {string} str
* @returns {string}
*/
localizedString(str) {
return Object.keys(this.localization).find(e => e == str);
}
/**
* Returns the localized string or the original string if not found
* @param {string} str
* @returns {string}
*/
localize(s) {
if(typeof s === 'undefined') return '';
let str = String(s);
try {
str = this.localization[str] || str;
} catch(b) {}
return str;
};
/**
* Searches the document tree to find elements with data-localize attributes
* and replaces their values with the localized string
* @returns {<void>}
*/
localizeUI = () => {
const el = document.querySelector('.sdpi-wrapper');
if(!el) return console.warn("No element found to localize");
const selectorsList = '[data-localize]';
// see if we have any data-localize attributes
// that means we can skip the rest of the DOM
el.querySelectorAll(selectorsList).forEach(e => {
const s = e.innerText.trim();
e.innerHTML = e.innerHTML.replace(s, this.localize(s));
if(e.placeholder && e.placeholder.length) {
e.placeholder = this.localize(e.placeholder);
}
if(e.title && e.title.length) {
e.title = this.localize(e.title);
}
});
};
/**
* Fetches the specified language json file
* @param {string} pathPrefix
* @returns {Promise<void>}
*/
async loadLocalization(pathPrefix) {
if(!pathPrefix) {
pathPrefix = this.#data.__folderpath;
}
// here we save the promise to the JSON-reader result,
// which we can later re-use to see, if the strings are already loaded
this.localizationLoaded = this.readJson(`${pathPrefix}${this.language}.json`);
const manifest = await this.localizationLoaded;
this.localization = manifest['Localization'] ?? null;
window.$localizedStrings = this.localization;
this.emit('localizationLoaded', this.localization);
return this.localization;
}
/**
*
* @param {string} path
* @returns {Promise<any>} json
*/
async readJson(path) {
if(!path) {
console.error('A path is required to readJson.');
}
return new Promise((resolve, reject) => {
const req = new XMLHttpRequest();
req.onerror = reject;
req.overrideMimeType('application/json');
req.open('GET', path, true);
req.onreadystatechange = (response) => {
if(req.readyState === 4) {
const jsonString = response?.target?.response;
if(jsonString) {
resolve(JSON.parse(response?.target?.response));
} else {
reject();
}
}
};
req.send();
});
}
}
class ELGSDApi extends ELGSDPlugin {
port;
uuid;
messageType;
actionInfo;
websocket;
appInfo;
#data = {};
/**
* Connect to Stream Deck
* @param {string} port
* @param {string} uuid
* @param {string} messageType
* @param {string} appInfoString
* @param {string} actionString
*/
connect(port, uuid, messageType, appInfoString, actionString) {
this.port = port;
this.uuid = uuid;
this.messageType = messageType;
this.actionInfo = actionString ? JSON.parse(actionString) : null;
this.appInfo = JSON.parse(appInfoString);
this.language = this.appInfo?.application?.language ?? null;
if(this.websocket) {
this.websocket.close();
this.websocket = null;
}
this.websocket = new WebSocket('ws://127.0.0.1:' + this.port);
this.websocket.onopen = () => {
const json = {
event: this.messageType,
uuid: this.uuid,
};
this.websocket.send(JSON.stringify(json));
this.emit(Events.connected, {
connection: this.websocket,
port: this.port,
uuid: this.uuid,
actionInfo: this.actionInfo,
appInfo: this.appInfo,
messageType: this.messageType,
});
};
this.websocket.onerror = (evt) => {
const error = `WEBSOCKET ERROR: ${evt}, ${evt.data}, ${SocketErrors[evt?.code]}`;
console.warn(error);
this.logMessage(error);
};
this.websocket.onclose = (evt) => {
console.warn('WEBSOCKET CLOSED:', SocketErrors[evt?.code]);
};
this.websocket.onmessage = (evt) => {
const data = evt?.data ? JSON.parse(evt.data) : null;
const {action, event} = data;
const message = action ? `${action}.${event}` : event;
if(message && message !== '') this.emit(message, data);
};
}
/**
* Write to log file
* @param {string} message
*/
logMessage(message) {
if(!message) {
console.error('A message is required for logMessage.');
}
try {
if(this.websocket) {
const json = {
event: Events.logMessage,
payload: {
message: message,
},
};
this.websocket.send(JSON.stringify(json));
} else {
console.error('Websocket not defined');
}
} catch(e) {
console.error('Websocket not defined');
}
}
/**
* Send JSON payload to StreamDeck
* @param {string} context
* @param {string} event
* @param {object} [payload]
*/
send(context, event, payload = {}) {
this.websocket && this.websocket.send(JSON.stringify({context, event, ...payload}));
}
/**
* Save the plugin's persistent data
* @param {object} payload
*/
setGlobalSettings(payload) {
this.send(this.uuid, Events.setGlobalSettings, {
payload: payload,
});
}
/**
* Request the plugin's persistent data. StreamDeck does not return the data, but trigger the plugin/property inspectors didReceiveGlobalSettings event
*/
getGlobalSettings() {
this.send(this.uuid, Events.getGlobalSettings);
}
/**
* Opens a URL in the default web browser
* @param {string} url
*/
openUrl(url) {
if(!url) {
console.error('A url is required for openUrl.');
}
this.send(this.uuid, Events.openUrl, {
payload: {
url,
},
});
}
/**
* Registers a callback function for when Stream Deck is connected
* @param {function} fn
* @returns ELGSDStreamDeck
*/
onConnected(fn) {
if(!fn) {
console.error('A callback function for the connected event is required for onConnected.');
}
this.on(Events.connected, (jsn) => fn(jsn));
return this;
}
/**
* Registers a callback function for the didReceiveGlobalSettings event, which fires when calling getGlobalSettings
* @param {function} fn
*/
onDidReceiveGlobalSettings(fn) {
if(!fn) {
console.error(
'A callback function for the didReceiveGlobalSettings event is required for onDidReceiveGlobalSettings.'
);
}
this.on(Events.didReceiveGlobalSettings, (jsn) => fn(jsn));
return this;
}
/**
* Registers a callback function for the didReceiveSettings event, which fires when calling getSettings
* @param {string} action
* @param {function} fn
*/
onDidReceiveSettings(action, fn) {
if(!fn) {
console.error(
'A callback function for the didReceiveSettings event is required for onDidReceiveSettings.'
);
}
this.on(`${action}.${Events.didReceiveSettings}`, (jsn) => fn(jsn));
return this;
}
}

View File

@@ -0,0 +1,83 @@
/**
* Errors received from WebSocket
*/
const SocketErrors = {
0: 'The connection has not yet been established',
1: 'The connection is established and communication is possible',
2: 'The connection is going through the closing handshake',
3: 'The connection has been closed or could not be opened',
1000: 'Normal Closure. The purpose for which the connection was established has been fulfilled.',
1001: 'Going Away. An endpoint is "going away", such as a server going down or a browser having navigated away from a page.',
1002: 'Protocol error. An endpoint is terminating the connection due to a protocol error',
1003: "Unsupported Data. An endpoint received a type of data it doesn't support.",
1004: '--Reserved--. The specific meaning might be defined in the future.',
1005: 'No Status. No status code was actually present.',
1006: 'Abnormal Closure. The connection was closed abnormally, e.g., without sending or receiving a Close control frame',
1007: 'Invalid frame payload data. The connection was closed, because the received data was not consistent with the type of the message (e.g., non-UTF-8 [http://tools.ietf.org/html/rfc3629]).',
1008: 'Policy Violation. The connection was closed, because current message data "violates its policy". This reason is given either if there is no other suitable reason, or if there is a need to hide specific details about the policy.',
1009: 'Message Too Big. Connection closed because the message is too big for it to process.',
1010: "Mandatory Extension. Connection is terminated the connection because the server didn't negotiate one or more extensions in the WebSocket handshake.",
1011: 'Internl Server Error. Connection closed because it encountered an unexpected condition that prevented it from fulfilling the request.',
1015: "TLS Handshake. The connection was closed due to a failure to perform a TLS handshake (e.g., the server certificate can't be verified).",
};
/**
* Events used for communicating with Stream Deck
*/
const Events = {
didReceiveSettings: 'didReceiveSettings',
didReceiveGlobalSettings: 'didReceiveGlobalSettings',
keyDown: 'keyDown',
keyUp: 'keyUp',
willAppear: 'willAppear',
willDisappear: 'willDisappear',
titleParametersDidChange: 'titleParametersDidChange',
deviceDidConnect: 'deviceDidConnect',
deviceDidDisconnect: 'deviceDidDisconnect',
applicationDidLaunch: 'applicationDidLaunch',
applicationDidTerminate: 'applicationDidTerminate',
systemDidWakeUp: 'systemDidWakeUp',
propertyInspectorDidAppear: 'propertyInspectorDidAppear',
propertyInspectorDidDisappear: 'propertyInspectorDidDisappear',
sendToPlugin: 'sendToPlugin',
sendToPropertyInspector: 'sendToPropertyInspector',
connected: 'connected',
setImage: 'setImage',
setXYWHImage: 'setXYWHImage',
setTitle: 'setTitle',
setState: 'setState',
showOk: 'showOk',
showAlert: 'showAlert',
openUrl: 'openUrl',
setGlobalSettings: 'setGlobalSettings',
getGlobalSettings: 'getGlobalSettings',
setSettings: 'setSettings',
getSettings: 'getSettings',
registerPropertyInspector: 'registerPropertyInspector',
registerPlugin: 'registerPlugin',
logMessage: 'logMessage',
switchToProfile: 'switchToProfile',
dialRotate: 'dialRotate',
dialPress: 'dialPress',
dialDown: 'dialDown',
dialUp: 'dialUp',
touchTap: 'touchTap',
setFeedback: 'setFeedback',
setFeedbackLayout: 'setFeedbackLayout',
};
/**
* Constants used for Stream Deck
*/
const Constants = {
dataLocalize: '[data-localize]',
hardwareAndSoftware: 0,
hardwareOnly: 1,
softwareOnly: 2,
};
const DestinationEnum = {
HARDWARE_AND_SOFTWARE: 0,
HARDWARE_ONLY: 1,
SOFTWARE_ONLY: 2,
};

View File

@@ -0,0 +1,38 @@
/** ELGEvents
* Publish/Subscribe pattern to quickly signal events to
* the plugin, property inspector and data.
*/
const ELGEvents = {
eventEmitter: function (name, fn) {
const eventList = new Map();
const on = (name, fn) => {
if (!eventList.has(name)) eventList.set(name, ELGEvents.pubSub());
return eventList.get(name).sub(fn);
};
const has = name => eventList.has(name);
const emit = (name, data) => eventList.has(name) && eventList.get(name).pub(data);
return Object.freeze({on, has, emit, eventList});
},
pubSub: function pubSub() {
const subscribers = new Set();
const sub = fn => {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
};
const pub = data => subscribers.forEach(fn => fn(data));
return Object.freeze({pub, sub});
}
};
const EventEmitter = ELGEvents.eventEmitter();

View File

@@ -0,0 +1,82 @@
/// <reference path="constants.js" />
/// <reference path="api.js" />
class ELGSDPropertyInspector extends ELGSDApi {
constructor() {
super();
if (ELGSDPropertyInspector.__instance) {
return ELGSDPropertyInspector.__instance;
}
ELGSDPropertyInspector.__instance = this;
}
/**
* Registers a callback function for when Stream Deck sends data to the property inspector
* @param {string} actionUUID
* @param {function} fn
* @returns ELGSDStreamDeck
*/
onSendToPropertyInspector(actionUUID, fn) {
if (typeof actionUUID != 'string') {
console.error('An action UUID string is required for onSendToPropertyInspector.');
}
if (!fn) {
console.error(
'A callback function for the sendToPropertyInspector event is required for onSendToPropertyInspector.'
);
}
this.on(`${actionUUID}.${Events.sendToPropertyInspector}`, (jsn) => fn(jsn));
return this;
}
/**
* Send payload from the property inspector to the plugin
* @param {object} payload
*/
sendToPlugin(payload) {
this.send(this.uuid, Events.sendToPlugin, {
action: this?.actionInfo?.action,
payload: payload || null,
});
}
/**
* Save the actions's persistent data.
* @param {object} payload
*/
setSettings(payload) {
this.send(this.uuid, Events.setSettings, {
action: this?.actionInfo?.action,
payload: payload || null,
});
}
/**
* Request the actions's persistent data. StreamDeck does not return the data, but trigger the actions's didReceiveSettings event
*/
getSettings() {
this.send(this.uuid, Events.getSettings);
}
}
const $PI = new ELGSDPropertyInspector();
/**
* connectElgatoStreamDeckSocket
* This is the first function StreamDeck Software calls, when
* establishing the connection to the plugin or the Property Inspector
* @param {string} port - The socket's port to communicate with StreamDeck software.
* @param {string} uuid - A unique identifier, which StreamDeck uses to communicate with the plugin
* @param {string} messageType - Identifies, if the event is meant for the property inspector or the plugin.
* @param {string} appInfoString - Information about the host (StreamDeck) application
* @param {string} actionInfo - Context is an internal identifier used to communicate to the host application.
*/
function connectElgatoStreamDeckSocket(port, uuid, messageType, appInfoString, actionInfo) {
const delay = window?.initialConnectionDelay || 0;
setTimeout(() => {
$PI.connect(port, uuid, messageType, appInfoString, actionInfo);
}, delay);
}

View File

@@ -0,0 +1,26 @@
/** reaches out for a magical global localization object, hopefully loaded by $SD and swaps the string **/
String.prototype.lox = function () {
var a = String(this);
try {
a = $localizedStrings[a] || a;
} catch (b) {
}
return a;
};
String.prototype.sprintf = function (inArr) {
let i = 0;
const args = inArr && Array.isArray(inArr) ? inArr : arguments;
return this.replace(/%s/g, function () {
return args[i++];
});
};
WebSocket.prototype.sendJSON = function (jsn, log) {
if (log) {
console.log('SendJSON', this, jsn);
}
// if (this.readyState) {
this.send(JSON.stringify(jsn));
// }
};

View File

@@ -0,0 +1,95 @@
/* global ESDTimerWorker */
/*eslint no-unused-vars: "off"*/
/*eslint-env es6*/
let ESDTimerWorker = new Worker(URL.createObjectURL(
new Blob([timerFn.toString().replace(/^[^{]*{\s*/, '').replace(/\s*}[^}]*$/, '')], {type: 'text/javascript'})
));
ESDTimerWorker.timerId = 1;
ESDTimerWorker.timers = {};
const ESDDefaultTimeouts = {
timeout: 0,
interval: 10
};
Object.freeze(ESDDefaultTimeouts);
function _setTimer(callback, delay, type, params) {
const id = ESDTimerWorker.timerId++;
ESDTimerWorker.timers[id] = {callback, params};
ESDTimerWorker.onmessage = (e) => {
if (ESDTimerWorker.timers[e.data.id]) {
if (e.data.type === 'clearTimer') {
delete ESDTimerWorker.timers[e.data.id];
} else {
const cb = ESDTimerWorker.timers[e.data.id].callback;
if (cb && typeof cb === 'function') cb(...ESDTimerWorker.timers[e.data.id].params);
}
}
};
ESDTimerWorker.postMessage({type, id, delay});
return id;
}
function _setTimeoutESD(...args) {
let [callback, delay = 0, ...params] = [...args];
return _setTimer(callback, delay, 'setTimeout', params);
}
function _setIntervalESD(...args) {
let [callback, delay = 0, ...params] = [...args];
return _setTimer(callback, delay, 'setInterval', params);
}
function _clearTimeoutESD(id) {
ESDTimerWorker.postMessage({type: 'clearTimeout', id}); // ESDTimerWorker.postMessage({type: 'clearInterval', id}); = same thing
delete ESDTimerWorker.timers[id];
}
window.setTimeout = _setTimeoutESD;
window.setInterval = _setIntervalESD;
window.clearTimeout = _clearTimeoutESD; //timeout and interval share the same timer-pool
window.clearInterval = _clearTimeoutESD;
/** This is our worker-code
* It is executed in it's own (global) scope
* which is wrapped above @ `let ESDTimerWorker`
*/
function timerFn() {
/*eslint indent: ["error", 4, { "SwitchCase": 1 }]*/
let timers = {};
let debug = false;
let supportedCommands = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'];
function log(e) {
console.log('Worker-Info::Timers', timers);
}
function clearTimerAndRemove(id) {
if (timers[id]) {
if (debug) console.log('clearTimerAndRemove', id, timers[id], timers);
clearTimeout(timers[id]);
delete timers[id];
postMessage({type: 'clearTimer', id: id});
if (debug) log();
}
}
onmessage = function (e) {
// first see, if we have a timer with this id and remove it
// this automatically fulfils clearTimeout and clearInterval
supportedCommands.includes(e.data.type) && timers[e.data.id] && clearTimerAndRemove(e.data.id);
if (e.data.type === 'setTimeout') {
timers[e.data.id] = setTimeout(() => {
postMessage({id: e.data.id});
clearTimerAndRemove(e.data.id); //cleaning up
}, Math.max(e.data.delay || 0));
} else if (e.data.type === 'setInterval') {
timers[e.data.id] = setInterval(() => {
postMessage({id: e.data.id});
}, Math.max(e.data.delay || ESDDefaultTimeouts.interval));
}
};
}

View File

@@ -0,0 +1,94 @@
class Utils {
/**
* Returns the value from a form using the form controls name property
* @param {Element | string} form
* @returns
*/
static getFormValue(form) {
if (typeof form === 'string') {
form = document.querySelector(form);
}
const elements = form?.elements;
if (!elements) {
console.error('Could not find form!');
}
const formData = new FormData(form);
let formValue = {};
formData.forEach((value, key) => {
if (!Reflect.has(formValue, key)) {
formValue[key] = value;
return;
}
if (!Array.isArray(formValue[key])) {
formValue[key] = [formValue[key]];
}
formValue[key].push(value);
});
return formValue;
}
/**
* Sets the value of form controls using their name attribute and the jsn object key
* @param {*} jsn
* @param {Element | string} form
*/
static setFormValue(jsn, form) {
if (!jsn) {
return;
}
if (typeof form === 'string') {
form = document.querySelector(form);
}
const elements = form?.elements;
if (!elements) {
console.error('Could not find form!');
}
Array.from(elements)
.filter((element) => element?.name)
.forEach((element) => {
const { name, type } = element;
const value = name in jsn ? jsn[name] : null;
const isCheckOrRadio = type === 'checkbox' || type === 'radio';
if (value === null) return;
if (isCheckOrRadio) {
const isSingle = value === element.value;
if (isSingle || (Array.isArray(value) && value.includes(element.value))) {
element.checked = true;
}
} else {
element.value = value ?? '';
}
});
}
/**
* This provides a slight delay before processing rapid events
* @param {number} wait - delay before processing function (recommended time 150ms)
* @param {function} fn
* @returns
*/
static debounce(wait, fn) {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
fn.apply(null, args);
}, wait);
};
}
static delay(wait) {
return new Promise((fn) => setTimeout(fn, wait));
}
}