Skip to content

Instantly share code, notes, and snippets.

@smitmartijn
Created November 11, 2022 15:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save smitmartijn/2d91ca465092e8104c1ed86778bb5625 to your computer and use it in GitHub Desktop.
Save smitmartijn/2d91ca465092e8104c1ed86778bb5625 to your computer and use it in GitHub Desktop.
MuteDeck Stream Deck Plugin
/* global $CC, Utils, $SD */
/* THIRD PARTY */
// https://github.com/joewalnes/reconnecting-websocket/blob/master/reconnecting-websocket.js
(function(global, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module !== 'undefined' && module.exports) {
module.exports = factory();
} else {
global.ReconnectingWebSocket = factory();
}
})(this, function() {
if (!('WebSocket' in window)) {
return;
}
function ReconnectingWebSocket(url, protocols, options) {
// Default settings
var settings = {
/** Whether this instance should log debug messages. */
debug: false,
/** Whether or not the websocket should attempt to connect immediately upon instantiation. */
automaticOpen: true,
/** The number of milliseconds to delay before attempting to reconnect. */
reconnectInterval: 1000,
/** The maximum number of milliseconds to delay a reconnection attempt. */
maxReconnectInterval: 30000,
/** The rate of increase of the reconnect delay. Allows reconnect attempts to back off when problems persist. */
reconnectDecay: 1.5,
/** The maximum time in milliseconds to wait for a connection to succeed before closing and retrying. */
timeoutInterval: 2000,
/** The maximum number of reconnection attempts to make. Unlimited if null. */
maxReconnectAttempts: null,
/** The binary type, possible values 'blob' or 'arraybuffer', default 'blob'. */
binaryType: 'blob'
}
if (!options) { options = {}; }
// Overwrite and define settings with options if they exist.
for (var key in settings) {
if (typeof options[key] !== 'undefined') {
this[key] = options[key];
} else {
this[key] = settings[key];
}
}
// These should be treated as read-only properties
/** The URL as resolved by the constructor. This is always an absolute URL. Read only. */
this.url = url;
/** The number of attempted reconnects since starting, or the last successful connection. Read only. */
this.reconnectAttempts = 0;
/**
* The current state of the connection.
* Can be one of: WebSocket.CONNECTING, WebSocket.OPEN, WebSocket.CLOSING, WebSocket.CLOSED
* Read only.
*/
this.readyState = WebSocket.CONNECTING;
/**
* A string indicating the name of the sub-protocol the server selected; this will be one of
* the strings specified in the protocols parameter when creating the WebSocket object.
* Read only.
*/
this.protocol = null;
// Private state variables
var self = this;
var ws;
var forcedClose = false;
var timedOut = false;
var eventTarget = document.createElement('div');
// Wire up "on*" properties as event handlers
eventTarget.addEventListener('open', function(event) { self.onopen(event); });
eventTarget.addEventListener('close', function(event) { self.onclose(event); });
eventTarget.addEventListener('connecting', function(event) { self.onconnecting(event); });
eventTarget.addEventListener('message', function(event) { self.onmessage(event); });
eventTarget.addEventListener('error', function(event) { self.onerror(event); });
// Expose the API required by EventTarget
this.addEventListener = eventTarget.addEventListener.bind(eventTarget);
this.removeEventListener = eventTarget.removeEventListener.bind(eventTarget);
this.dispatchEvent = eventTarget.dispatchEvent.bind(eventTarget);
/**
* This function generates an event that is compatible with standard
* compliant browsers and IE9 - IE11
*
* This will prevent the error:
* Object doesn't support this action
*
* http://stackoverflow.com/questions/19345392/why-arent-my-parameters-getting-passed-through-to-a-dispatched-event/19345563#19345563
* @param s String The name that the event should use
* @param args Object an optional object that the event will use
*/
function generateEvent(s, args) {
var evt = document.createEvent("CustomEvent");
evt.initCustomEvent(s, false, false, args);
return evt;
};
this.open = function(reconnectAttempt) {
ws = new WebSocket(self.url, protocols || []);
ws.binaryType = this.binaryType;
if (reconnectAttempt) {
if (this.maxReconnectAttempts && this.reconnectAttempts > this.maxReconnectAttempts) {
return;
}
} else {
eventTarget.dispatchEvent(generateEvent('connecting'));
this.reconnectAttempts = 0;
}
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'attempt-connect', self.url);
}
var localWs = ws;
var timeout = setTimeout(function() {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'connection-timeout', self.url);
}
timedOut = true;
localWs.close();
timedOut = false;
}, self.timeoutInterval);
ws.onopen = function(event) {
clearTimeout(timeout);
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onopen', self.url);
}
self.protocol = ws.protocol;
self.readyState = WebSocket.OPEN;
self.reconnectAttempts = 0;
var e = generateEvent('open');
e.isReconnect = reconnectAttempt;
reconnectAttempt = false;
eventTarget.dispatchEvent(e);
};
ws.onclose = function(event) {
clearTimeout(timeout);
ws = null;
if (forcedClose) {
self.readyState = WebSocket.CLOSED;
eventTarget.dispatchEvent(generateEvent('close'));
} else {
self.readyState = WebSocket.CONNECTING;
var e = generateEvent('connecting');
e.code = event.code;
e.reason = event.reason;
e.wasClean = event.wasClean;
eventTarget.dispatchEvent(e);
if (!reconnectAttempt && !timedOut) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onclose', self.url);
}
eventTarget.dispatchEvent(generateEvent('close'));
}
var timeout = self.reconnectInterval * Math.pow(self.reconnectDecay, self.reconnectAttempts);
setTimeout(function() {
self.reconnectAttempts++;
self.open(true);
}, timeout > self.maxReconnectInterval ? self.maxReconnectInterval : timeout);
}
};
ws.onmessage = function(event) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onmessage', self.url, event.data);
}
var e = generateEvent('message');
e.data = event.data;
eventTarget.dispatchEvent(e);
};
ws.onerror = function(event) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'onerror', self.url, event);
}
eventTarget.dispatchEvent(generateEvent('error'));
};
}
// Whether or not to create a websocket upon instantiation
if (this.automaticOpen == true) {
this.open(false);
}
/**
* Transmits data to the server over the WebSocket connection.
*
* @param data a text string, ArrayBuffer or Blob to send to the server.
*/
this.send = function(data) {
if (ws) {
if (self.debug || ReconnectingWebSocket.debugAll) {
console.debug('ReconnectingWebSocket', 'send', self.url, data);
}
return ws.send(data);
} else {
throw 'INVALID_STATE_ERR : Pausing to reconnect websocket';
}
};
/**
* Closes the WebSocket connection or connection attempt, if any.
* If the connection is already CLOSED, this method does nothing.
*/
this.close = function(code, reason) {
// Default CLOSE_NORMAL code
if (typeof code == 'undefined') {
code = 1000;
}
forcedClose = true;
if (ws) {
ws.close(code, reason);
}
};
/**
* Additional public API method to refresh the connection if still open (close, re-open).
* For example, if the app suspects bad data / missed heart beats, it can try to refresh.
*/
this.refresh = function() {
if (ws) {
ws.close();
}
};
}
/**
* An event listener to be called when the WebSocket connection's readyState changes to OPEN;
* this indicates that the connection is ready to send and receive data.
*/
ReconnectingWebSocket.prototype.onopen = function(event) {};
/** An event listener to be called when the WebSocket connection's readyState changes to CLOSED. */
ReconnectingWebSocket.prototype.onclose = function(event) {};
/** An event listener to be called when a connection begins being attempted. */
ReconnectingWebSocket.prototype.onconnecting = function(event) {};
/** An event listener to be called when a message is received from the server. */
ReconnectingWebSocket.prototype.onmessage = function(event) {};
/** An event listener to be called when an error occurs. */
ReconnectingWebSocket.prototype.onerror = function(event) {};
/**
* Whether all instances of ReconnectingWebSocket should log debug messages.
* Setting this to true is the equivalent of setting all instances of ReconnectingWebSocket.debug to true.
*/
ReconnectingWebSocket.debugAll = false;
ReconnectingWebSocket.CONNECTING = WebSocket.CONNECTING;
ReconnectingWebSocket.OPEN = WebSocket.OPEN;
ReconnectingWebSocket.CLOSING = WebSocket.CLOSING;
ReconnectingWebSocket.CLOSED = WebSocket.CLOSED;
return ReconnectingWebSocket;
});
/* END THIRD PARTY */
/**
* The 'connected' event is sent to your plugin, after the plugin's instance
* is registered with Stream Deck software. It carries the current websocket
* and other information about the current environmet in a JSON object
* You can use it to subscribe to events you want to use in your plugin.
*/
$SD.on("connected", (jsonObj) => connected(jsonObj));
var actionUUIDs = [
"togglemute",
"togglevideo",
"togglesharing",
"togglerecording",
"leavemeeting",
];
// this instantiates the timer that checks the MuteDeck API and changes the button images
let mdwebsocket = null; //new MuteDeckWebsocket();
let watcher = null; //new MuteDeckWatcher();
setTimeout(function() {
mdwebsocket = new MuteDeckWebsocket();
watcher = new MuteDeckWatcher();
}, 500);
function connected(jsn) {
for (var i = 0; i < actionUUIDs.length; i++) {
var actionName = "com.mutedeck.plugin." + actionUUIDs[i];
// Subscribe to the required events, that the `action` object will handle
// onWillAppear: fired when a button is added to the SD canvas
$SD.on(actionName + ".willAppear", (jsonObj) =>
action.onWillAppear(jsonObj)
);
// onWillDisappear: fired when a button is removed from the SD canvas
$SD.on(actionName + ".willDisappear", (jsonObj) =>
action.onWillDisappear(jsonObj)
);
// keyUp: when a key is pressed
$SD.on(actionName + ".keyUp", (jsonObj) => action.onKeyUp(jsonObj));
// didReceiveSettings: settings of the key changed in the SD software
$SD.on(actionName + ".didReceiveSettings", (jsonObj) =>
action.onDidReceiveSettings(jsonObj)
);
}
}
// this async function
async function postMuteDeckActionAsync(action) {
if (action == 'leave') {
action = 'leave_meeting';
} else {
action = 'toggle_' + action;
}
// send via the websocket if it's open, otherwise fall back to regular API
if (mdwebsocket.socket.readyState === WebSocket.OPEN) {
const message = {
'source': 'streamdeck-plugin',
'action': action
};
console.log("Sending message via websocket to MuteDeck: " + message);
mdwebsocket.socket.send(JSON.stringify(message));
return;
} else {
var url = "http://127.0.0.1:3491/v1/" + action;
console.log("Performing POST to MuteDeck: " + url);
const resp = await fetch(url, {
method: "POST",
});
console.log("Response: ");
console.log(resp);
if (resp.status != 200) {
return null;
}
const payload = await resp.json();
return payload;
}
}
// The action object. Here's where all button actions to go.
const action = {
// this is a static object, so these vars will persist through the plugin runtime
// buttonCache will contain all the button objects, i.e. to be used when updating the MuteDeck status
buttonCache: {},
// stateCache contains the current state of an action. Used to prevent mindless updates to buttons and only change them when necessary
stateCache: {},
getButtonFromCache: function(context) {
//console.log("getButtonFromCache: " + context);
return this.buttonCache[context];
},
getButtons: function() {
return this.buttonCache;
},
addButtonToCache: function(context, obj) {
//console.log("addButtonToCache: " + context + " obj: ");
//console.log(obj);
this.buttonCache[context] = obj;
this.stateCache[context] = "unknown";
},
removeButtonFromCache: function(context) {
//console.log("removeButtonFromCache: " + context);
if (this.getButtonFromCache(context)) {
delete this.buttonCache[context];
}
},
getStateCache: function(context) {
return this.stateCache[context];
},
setStateCache: function(context, val) {
this.stateCache[context] = val;
},
onDidReceiveSettings: function(jsn) {
console.log(
"%c%s",
"color: white; background: red; font-size: 15px;",
"[app.js]onDidReceiveSettings:"
);
},
// button removed from the SD canvas, so remove it from our cache and don't try to update it anymore
onWillDisappear: function(jsn) {
this.removeButtonFromCache(jsn.context);
},
// button added to the SD canvas, add it to the cache and start updating it
onWillAppear: function(jsn) {
this.addButtonToCache(jsn.context, jsn);
},
// user interaction, woo!
onKeyUp: function(jsn) {
var btn_action = jsn.action;
console.log("onKeyUp for: " + btn_action);
var action_url = "";
if (btn_action == "com.mutedeck.plugin.togglemute") {
action_url = "mute";
} else if (btn_action == "com.mutedeck.plugin.togglevideo") {
action_url = "video";
} else if (btn_action == "com.mutedeck.plugin.togglesharing") {
action_url = "share";
} else if (btn_action == "com.mutedeck.plugin.togglerecording") {
action_url = "record";
} else if (btn_action == "com.mutedeck.plugin.leavemeeting") {
action_url = "leave";
}
// shouldn't be possible, but just to be safe
if (action_url) {
// this is an async function, so we won't wait on it
postMuteDeckActionAsync(action_url);
}
},
};
function MuteDeckWatcher() {
var timer = 0;
function start() {
// don't start the timer twice
if (timer !== 0) {
return;
}
console.log("Starting MuteDeck poller");
// call the MuteDeck status API right away and start a 500 ms timer
refreshMuteDeckStatusAsync();
timer = setInterval(function(sx) {
refreshMuteDeckStatusAsync();
}, 500);
}
function stop() {
// don't stop if it's already stopped
if (timer === 0) {
return;
}
console.log("Stopping MuteDeck poller");
window.clearInterval(timer);
timer = 0;
}
async function refreshMuteDeckStatusAsync() {
// skip the manual API poll if the websocket is open
if (mdwebsocket.socket.readyState === WebSocket.OPEN) {
return;
}
try {
let newState = await fetchLastStateAsync();
// don't process empty results
if (newState === null) {
return;
}
// now that we have the updated MuteDeck status, go through all buttons and update their image and titles
var buttons = action.getButtons();
for (const context in buttons) {
var newImageURL = "";
var btn_action = buttons[context].action;
var updateButton = false;
if (btn_action == "com.mutedeck.plugin.togglemute") {
if (newState.mute == "inactive") {
newImageURL = "images/microphone-solid.png";
} else if (newState.mute == "active") {
newImageURL = "images/microphone-solid-slash.png";
} else {
newImageURL = "images/microphone-solid-disabled.png";
}
if (action.getStateCache(context) != newState.mute) {
updateButton = true;
action.setStateCache(context, newState.mute);
}
} else if (btn_action == "com.mutedeck.plugin.togglevideo") {
if (newState.video == "active") {
newImageURL = "images/video-solid.png";
} else if (newState.video == "inactive") {
newImageURL = "images/video-solid-slash.png";
} else {
newImageURL = "images/video-solid-disabled.png";
}
if (action.getStateCache(context) != newState.video) {
updateButton = true;
action.setStateCache(context, newState.video);
}
} else if (btn_action == "com.mutedeck.plugin.togglesharing") {
if (newState.share == "inactive") {
newImageURL = "images/share-solid.png";
} else if (newState.share == "active") {
newImageURL = "images/share-solid-slash.png";
} else {
newImageURL = "images/share-solid-disabled.png";
}
if (action.getStateCache(context) != newState.share) {
updateButton = true;
action.setStateCache(context, newState.share);
}
} else if (btn_action == "com.mutedeck.plugin.togglerecording") {
if (newState.record == "inactive") {
newImageURL = "images/record-stop-circle-regular-inactive.png";
} else if (newState.record == "active") {
newImageURL = "images/record-stop-circle-solid-active.png";
} else {
newImageURL = "images/record-stop-circle-regular-disabled.png";
}
if (action.getStateCache(context) != newState.record) {
updateButton = true;
action.setStateCache(context, newState.record);
}
} else if (btn_action == "com.mutedeck.plugin.leavemeeting") {
if (newState.control == "system") {
newImageURL = "images/portal-exit-disabled.png";
} else {
newImageURL = "images/portal-exit.png";
}
if (action.getStateCache(context) != newState.video) {
updateButton = true;
action.setStateCache(context, newState.video);
}
}
if (updateButton) {
Utils.loadImage(newImageURL, function(newImageBase64) {
$SD.api.setImage(context, newImageBase64);
$SD.api.setTitle(context, "");
});
}
}
} catch (error) {
console.log("Error when fetching the MuteDeck API:");
console.log(error);
// this happens when MuteDeck is not running and the API call fails
// loop through all the buttons and set them to disabled and that MuteDeck is not found
var buttons = action.getButtons();
for (const context in buttons) {
var btn_action = buttons[context].action;
var updateButton = false;
if (btn_action == "com.mutedeck.plugin.togglemute") {
newImageURL = "images/microphone-solid-disabled.png";
if (action.getStateCache(context) != "disabled") {
updateButton = true;
action.setStateCache(context, "disabled");
}
} else if (btn_action == "com.mutedeck.plugin.togglevideo") {
newImageURL = "images/video-solid-disabled.png";
if (action.getStateCache(context) != "disabled") {
updateButton = true;
action.setStateCache(context, "disabled");
}
} else if (btn_action == "com.mutedeck.plugin.togglesharing") {
newImageURL = "images/share-solid-disabled.png";
if (action.getStateCache(context) != "disabled") {
updateButton = true;
action.setStateCache(context, "disabled");
}
} else if (btn_action == "com.mutedeck.plugin.togglerecording") {
newImageURL = "images/record-stop-circle-regular-disabled.png";
if (action.getStateCache(context) != "disabled") {
updateButton = true;
action.setStateCache(context, "disabled");
}
} else if (btn_action == "com.mutedeck.plugin.leavemeeting") {
newImageURL = "images/portal-exit-disabled.png";
if (action.getStateCache(context) != "disabled") {
updateButton = true;
action.setStateCache(context, "disabled");
}
}
if (updateButton) {
Utils.loadImage(newImageURL, function(newImageBase64) {
$SD.api.setImage(context, newImageBase64);
$SD.api.setTitle(context, "MuteDeck\nnot found");
});
}
}
return;
}
} // end async function refreshMuteDeckStatusAsync() {
// this function gets the latest MuteDeck status via the API
async function fetchLastStateAsync() {
const resp = await fetch(`http://127.0.0.1:3491/v1/status`);
if (resp.status != 200) {
return null;
}
// convert it to
const payload = await resp.json();
return payload;
}
// start the timer when instantiating the object
start();
// export these functions
return {
timer: timer,
refreshMuteDeckStatusAsync: refreshMuteDeckStatusAsync,
stop: stop,
};
}
function updateButtonStates(newState) {
//console.log('updateButtonStates:');
//console.log(newState);
// now that we have the updated MuteDeck status, go through all buttons and update their image and titles
var buttons = action.getButtons();
for (const context in buttons) {
var newImageURL = "";
var btn_action = buttons[context].action;
var updateButton = false;
if (btn_action == "com.mutedeck.plugin.togglemute") {
if (newState.mute == "inactive") {
newImageURL = "images/microphone-solid.png";
} else if (newState.mute == "active") {
newImageURL = "images/microphone-solid-slash.png";
} else {
newImageURL = "images/microphone-solid-disabled.png";
}
if (action.getStateCache(context) != newState.mute) {
updateButton = true;
action.setStateCache(context, newState.mute);
}
} else if (btn_action == "com.mutedeck.plugin.togglevideo") {
if (newState.video == "active") {
newImageURL = "images/video-solid.png";
} else if (newState.video == "inactive") {
newImageURL = "images/video-solid-slash.png";
} else {
newImageURL = "images/video-solid-disabled.png";
}
if (action.getStateCache(context) != newState.video) {
updateButton = true;
action.setStateCache(context, newState.video);
}
} else if (btn_action == "com.mutedeck.plugin.togglesharing") {
if (newState.share == "inactive") {
newImageURL = "images/share-solid.png";
} else if (newState.share == "active") {
newImageURL = "images/share-solid-slash.png";
} else {
newImageURL = "images/share-solid-disabled.png";
}
if (action.getStateCache(context) != newState.share) {
updateButton = true;
action.setStateCache(context, newState.share);
}
} else if (btn_action == "com.mutedeck.plugin.togglerecording") {
if (newState.record == "inactive") {
newImageURL = "images/record-stop-circle-regular-inactive.png";
} else if (newState.record == "active") {
newImageURL = "images/record-stop-circle-solid-active.png";
} else {
newImageURL = "images/record-stop-circle-regular-disabled.png";
}
if (action.getStateCache(context) != newState.record) {
updateButton = true;
action.setStateCache(context, newState.record);
}
} else if (btn_action == "com.mutedeck.plugin.leavemeeting") {
if (newState.video == "disabled") {
newImageURL = "images/portal-exit-disabled.png";
} else {
newImageURL = "images/portal-exit.png";
}
if (action.getStateCache(context) != newState.video) {
updateButton = true;
action.setStateCache(context, newState.video);
}
}
if (updateButton) {
Utils.loadImage(newImageURL, function(newImageBase64) {
$SD.api.setImage(context, newImageBase64);
$SD.api.setTitle(context, "");
});
}
}
}
function MuteDeckWebsocket() {
var socket;
function connect() {
console.log("Connecting to MuteDeck websocket");
// get the websocket host and port from localstorage and reinitialise the websocket
var mutedeck_host = 'localhost';
var mutedeck_port = 3492;
let websocket_url = 'ws://' + mutedeck_host + ':' + mutedeck_port;
console.log(websocket_url);
socket = new ReconnectingWebSocket(websocket_url);
socket.addEventListener('open', () => {
console.log(`[open] Connected to MuteDeck`);
// identify as a streamdeck plugin, so MD can send messages back
const identify = {
'source': 'streamdeck-plugin',
'action': 'identify'
};
socket.send(JSON.stringify(identify));
});
socket.onclose = function(event) {
if (event.wasClean) {
console.log(`[close] Connection to MuteDeck closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
console.log(`[close] Connection to MuteDeck closed unexpected, code=${event.code} reason=${event.reason}`);
}
};
socket.onerror = function(error) {
console.log(`[error] ${error.message}`);
};
socket.addEventListener('message', function(event) {
console.log(`[websocket] received event: ${event.data}`);
var message = JSON.parse(event.data);
if (message.action === 'update-status') {
updateButtonStates(message);
} else {
console.log('Dont know this action: ' + message.action);
}
});
}
// connect when instantiating the object
connect();
// export these functions
return {
socket: socket
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment