Add Paarrot Stream Deck Plugin with core functionality
Some checks failed
Build / increment-version (push) Successful in 8s
Build / build-linux (push) Successful in 2m26s
Build Paarrot Windows / build (push) Has been cancelled
Build Paarrot Windows / start-vm (push) Has been cancelled
Build / build-windows (push) Successful in 4m28s
Build / create-release (push) Successful in 30s

- Implemented main plugin structure including manifest, HTML, and JavaScript files.
- Added actions for toggling mute, toggling deafen, changing channels, sending messages, and getting status.
- Created property inspector for changing channels and sending messages.
- Developed SVG icons for actions and status display.
- Established WebSocket connection for communication with the Stream Deck.
- Implemented API calls to interact with the Paarrot voice chat application.
- Added validation script to ensure proper plugin structure and manifest compliance.
- Included test Electron application for window creation and loading content.
This commit is contained in:
2026-03-23 20:34:26 +11:00
parent b71268a38f
commit 07feddd44b
26 changed files with 7642 additions and 3 deletions

View File

@@ -0,0 +1,9 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<rect x="30" y="30" width="40" height="40" rx="8" fill="#666" stroke="none"/>
<rect x="74" y="30" width="40" height="40" rx="8" fill="#888" stroke="none"/>
<rect x="30" y="74" width="40" height="40" rx="8" fill="#666" stroke="none"/>
<rect x="74" y="74" width="40" height="40" rx="8" fill="#666" stroke="none"/>
<path d="M50 50 L50 42 L58 50 L50 58 Z" fill="#444" stroke="none"/>
<path d="M94 50 L94 42 L102 50 L94 58 Z" fill="#fff" stroke="none"/>
</svg>

After

Width:  |  Height:  |  Size: 606 B

View File

@@ -0,0 +1,6 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M40 50 L40 94 L56 94 L76 110 L76 34 L56 50 Z" fill="#888" stroke="none"/>
<path d="M88 58 C94 64 94 80 88 86" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
<path d="M98 50 C108 60 108 84 98 94" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,7 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M40 50 L40 94 L56 94 L76 110 L76 34 L56 50 Z" fill="#444" stroke="none"/>
<path d="M88 58 C94 64 94 80 88 86" stroke="#444" stroke-width="6" stroke-linecap="round" fill="none"/>
<path d="M98 50 C108 60 108 84 98 94" stroke="#444" stroke-width="6" stroke-linecap="round" fill="none"/>
<line x1="112" y1="32" x2="32" y2="112" stroke="#ff4444" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 545 B

View File

@@ -0,0 +1,5 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M72 36 L90 54 L90 90 C90 100 82 108 72 108 C62 108 54 100 54 90 L54 54 Z" fill="#888" stroke="none"/>
<ellipse cx="72" cy="54" rx="18" ry="24" fill="#888"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,6 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M72 36 L90 54 L90 90 C90 100 82 108 72 108 C62 108 54 100 54 90 L54 54 Z" fill="#444" stroke="none"/>
<ellipse cx="72" cy="54" rx="18" ry="24" fill="#444"/>
<line x1="108" y1="36" x2="36" y2="108" stroke="#ff4444" stroke-width="8" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 889 KiB

View File

@@ -0,0 +1,5 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M30 72 L114 36 L90 108 L64 88 L74 64 L50 78 Z" fill="#888" stroke="none"/>
<line x1="64" y1="88" x2="74" y2="64" stroke="#1a1a1a" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -0,0 +1,7 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<circle cx="72" cy="72" r="36" fill="none" stroke="#888" stroke-width="6"/>
<circle cx="72" cy="72" r="8" fill="#888"/>
<line x1="72" y1="72" x2="72" y2="48" stroke="#888" stroke-width="6" stroke-linecap="round"/>
<line x1="72" y1="72" x2="90" y2="72" stroke="#888" stroke-width="6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 461 B

View File

@@ -0,0 +1,7 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M40 50 L40 94 L56 94 L76 110 L76 34 L56 50 Z" fill="#888" stroke="none"/>
<path d="M88 58 C94 64 94 80 88 86" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
<path d="M98 50 C108 60 108 84 98 94" stroke="#888" stroke-width="6" stroke-linecap="round" fill="none"/>
<path d="M108 54 L118 64 M108 90 L118 80" stroke="#888" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 544 B

View File

@@ -0,0 +1,6 @@
<svg width="144" height="144" viewBox="0 0 144 144" xmlns="http://www.w3.org/2000/svg">
<rect width="144" height="144" fill="#1a1a1a"/>
<path d="M72 36 L90 54 L90 90 C90 100 82 108 72 108 C62 108 54 100 54 90 L54 54 Z" fill="#888" stroke="none"/>
<ellipse cx="72" cy="54" rx="18" ry="24" fill="#888"/>
<path d="M98 58 L108 68 M98 86 L108 76" stroke="#888" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -0,0 +1,113 @@
{
"$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json",
"Actions": [
{
"DisableAutomaticStates": true,
"Icon": "images/toggle-mute",
"Name": "Toggle Mute",
"States": [
{
"Image": "images/mute-off",
"TitleAlignment": "bottom",
"ShowTitle": true
},
{
"Image": "images/mute-on",
"TitleAlignment": "bottom",
"ShowTitle": true
}
],
"SupportedInMultiActions": true,
"Tooltip": "Toggle microphone mute",
"UUID": "com.paarrot.streamdeck.togglemute"
},
{
"DisableAutomaticStates": true,
"Icon": "images/toggle-deafen",
"Name": "Toggle Deafen",
"States": [
{
"Image": "images/deafen-off",
"TitleAlignment": "bottom",
"ShowTitle": true
},
{
"Image": "images/deafen-on",
"TitleAlignment": "bottom",
"ShowTitle": true
}
],
"SupportedInMultiActions": true,
"Tooltip": "Toggle deafen (mute speakers)",
"UUID": "com.paarrot.streamdeck.toggledeafen"
},
{
"Icon": "images/change-channel",
"Name": "Change Channel",
"PropertyInspectorPath": "propertyinspector/change-channel.html",
"States": [
{
"Image": "images/change-channel",
"TitleAlignment": "bottom",
"ShowTitle": true
}
],
"SupportedInMultiActions": true,
"Tooltip": "Switch to a specific channel/room",
"UUID": "com.paarrot.streamdeck.changechannel"
},
{
"Icon": "images/send-message",
"Name": "Send Message",
"PropertyInspectorPath": "propertyinspector/send-message.html",
"States": [
{
"Image": "images/send-message",
"TitleAlignment": "bottom",
"ShowTitle": true
}
],
"SupportedInMultiActions": true,
"Tooltip": "Send a message to current room",
"UUID": "com.paarrot.streamdeck.sendmessage"
},
{
"Icon": "images/status",
"Name": "Get Status",
"States": [
{
"Image": "images/status",
"TitleAlignment": "bottom",
"ShowTitle": true
}
],
"SupportedInMultiActions": false,
"Tooltip": "Display current status (mute/deafen state)",
"UUID": "com.paarrot.streamdeck.getstatus"
}
],
"Author": "Paarrot",
"Category": "Paarrot",
"CategoryIcon": "images/paarrot",
"CodePath": "plugin/index.html",
"Description": "Control Paarrot voice chat (mute, deafen, channels, messages)",
"Icon": "images/paarrot",
"Name": "Paarrot",
"OS": [
{
"Platform": "windows",
"MinimumVersion": "10"
},
{
"Platform": "mac",
"MinimumVersion": "10.14"
}
],
"SDKVersion": 2,
"Software": {
"MinimumVersion": "6.6"
},
"UUID": "com.paarrot.streamdeck",
"URL": "https://github.com/yourusername/paarrot-streamdeck",
"Version": "1.0.0.0"
}

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Paarrot Plugin</title>
</head>
<body>
<script src="index.js"></script>
</body>
</html>

View File

@@ -0,0 +1,371 @@
/**
* Paarrot Stream Deck Plugin
* Controls the Paarrot desktop app via its local API
*/
const API_BASE_URL = 'http://127.0.0.1:33384';
const POLL_INTERVAL = 1000; // Poll status every second
/**
* Global plugin instance
*/
let websocket = null;
let pluginUUID = null;
let statusPollInterval = null;
/**
* Track state for each action instance
*/
const actionStates = new Map();
/**
* Action UUIDs
*/
const ACTIONS = {
TOGGLE_MUTE: 'com.paarrot.streamdeck.togglemute',
TOGGLE_DEAFEN: 'com.paarrot.streamdeck.toggledeafen',
CHANGE_CHANNEL: 'com.paarrot.streamdeck.changechannel',
SEND_MESSAGE: 'com.paarrot.streamdeck.sendmessage',
GET_STATUS: 'com.paarrot.streamdeck.getstatus'
};
/**
* Make API call to Paarrot
*/
async function callAPI(endpoint, method = 'GET', body = null) {
try {
const options = {
method,
headers: {
'Content-Type': 'application/json'
}
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'API call failed');
}
return data.data;
} catch (error) {
console.error(`API Error (${endpoint}):`, error);
showAlert();
throw error;
}
}
/**
* Get current app status
*/
async function getStatus() {
return await callAPI('/status');
}
/**
* Set mute state explicitly
* @param {boolean} muted - Whether to mute
*/
async function setMute(muted) {
return await callAPI('/mute', 'POST', { muted });
}
/**
* Set deafen state explicitly
* @param {boolean} deafened - Whether to deafen
*/
async function setDeafen(deafened) {
return await callAPI('/deafen', 'POST', { deafened });
}
/**
* Change channel
*/
async function changeChannel(roomId) {
return await callAPI('/channel', 'POST', { roomId });
}
/**
* Send message to current room
*/
async function sendMessage(message) {
return await callAPI('/message/current', 'POST', { message });
}
/**
* Get list of channels
*/
async function getChannels() {
return await callAPI('/channels');
}
/**
* Update button state based on status
*/
function updateButtonStates(status) {
actionStates.forEach((state, context) => {
if (state.action === ACTIONS.TOGGLE_MUTE) {
const newState = status.muted ? 1 : 0;
if (state.currentState !== newState) {
setState(context, newState);
state.currentState = newState;
}
} else if (state.action === ACTIONS.TOGGLE_DEAFEN) {
const newState = status.deafened ? 1 : 0;
if (state.currentState !== newState) {
setState(context, newState);
state.currentState = newState;
}
} else if (state.action === ACTIONS.GET_STATUS) {
const title = `${status.muted ? 'M' : ''}${status.deafened ? 'D' : ''}${!status.muted && !status.deafened ? 'OK' : ''}`;
setTitle(context, title);
}
});
}
/**
* Start polling for status updates
*/
function startStatusPolling() {
if (statusPollInterval) {
return;
}
statusPollInterval = setInterval(async () => {
try {
const status = await getStatus();
updateButtonStates(status);
} catch (error) {
// Silently fail - server might not be running
}
}, POLL_INTERVAL);
}
/**
* Stop polling for status updates
*/
function stopStatusPolling() {
if (statusPollInterval) {
clearInterval(statusPollInterval);
statusPollInterval = null;
}
}
/**
* Send command to Stream Deck
*/
function sendToStreamDeck(event, context, payload = {}) {
if (websocket && websocket.readyState === WebSocket.OPEN) {
const message = {
event,
context,
...payload
};
websocket.send(JSON.stringify(message));
}
}
/**
* Set button state
*/
function setState(context, state) {
sendToStreamDeck('setState', context, { payload: { state } });
}
/**
* Set button title
*/
function setTitle(context, title) {
sendToStreamDeck('setTitle', context, { payload: { title } });
}
/**
* Show alert on button
*/
function showAlert(context) {
if (context) {
sendToStreamDeck('showAlert', context);
}
}
/**
* Show OK on button
*/
function showOk(context) {
sendToStreamDeck('showOk', context);
}
/**
* Handle button press
*/
async function handleKeyDown(context, settings, action) {
try {
switch (action) {
case ACTIONS.TOGGLE_MUTE: {
// Get current state and set opposite
const status = await getStatus();
await setMute(!status.muted);
showOk(context);
break;
}
case ACTIONS.TOGGLE_DEAFEN: {
// Get current state and set opposite
const status = await getStatus();
await setDeafen(!status.deafened);
showOk(context);
break;
}
case ACTIONS.CHANGE_CHANNEL:
if (settings.roomId) {
await changeChannel(settings.roomId);
showOk(context);
} else {
showAlert(context);
}
break;
case ACTIONS.SEND_MESSAGE:
if (settings.message) {
await sendMessage(settings.message);
showOk(context);
} else {
showAlert(context);
}
break;
case ACTIONS.GET_STATUS:
const status = await getStatus();
updateButtonStates(status);
showOk(context);
break;
}
// Force immediate status update
const status = await getStatus();
updateButtonStates(status);
} catch (error) {
showAlert(context);
}
}
/**
* Handle action appearing
*/
function handleWillAppear(context, settings, action) {
actionStates.set(context, {
action,
settings,
currentState: 0
});
// Get initial state
getStatus()
.then(status => updateButtonStates(status))
.catch(() => {});
}
/**
* Handle action disappearing
*/
function handleWillDisappear(context) {
actionStates.delete(context);
}
/**
* Handle settings change
*/
function handleDidReceiveSettings(context, settings, action) {
const state = actionStates.get(context);
if (state) {
state.settings = settings;
}
}
/**
* Handle property inspector request
*/
function handleSendToPlugin(context, action, payload) {
// Handle requests from property inspector
if (payload.event === 'getChannels') {
getChannels()
.then(channels => {
sendToStreamDeck('sendToPropertyInspector', context, {
action,
payload: {
event: 'channelList',
channels
}
});
})
.catch(error => {
console.error('Failed to get channels:', error);
});
}
}
/**
* Setup WebSocket connection to Stream Deck
*/
function connectElgatoStreamDeckSocket(inPort, inPluginUUID, inRegisterEvent, inInfo) {
pluginUUID = inPluginUUID;
websocket = new WebSocket(`ws://127.0.0.1:${inPort}`);
websocket.onopen = () => {
const registerPayload = {
event: inRegisterEvent,
uuid: inPluginUUID
};
websocket.send(JSON.stringify(registerPayload));
// Start status polling
startStatusPolling();
};
websocket.onmessage = (evt) => {
try {
const message = JSON.parse(evt.data);
const { event, action, context, payload } = message;
const settings = payload?.settings || {};
switch (event) {
case 'keyDown':
handleKeyDown(context, settings, action);
break;
case 'willAppear':
handleWillAppear(context, settings, action);
break;
case 'willDisappear':
handleWillDisappear(context);
break;
case 'didReceiveSettings':
handleDidReceiveSettings(context, settings, action);
break;
case 'sendToPlugin':
handleSendToPlugin(context, action, payload);
break;
}
} catch (error) {
console.error('WebSocket message error:', error);
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onclose = () => {
stopStatusPolling();
};
}

View File

@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Change Channel Settings</title>
<style>
body {
margin: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: #d0d0d0;
background-color: #2d2d2d;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
select, input {
width: 100%;
padding: 6px 8px;
margin-bottom: 15px;
background-color: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #d0d0d0;
font-size: 13px;
}
select:focus, input:focus {
outline: none;
border-color: #0e99e0;
}
.info {
padding: 8px;
background-color: #3d3d3d;
border-left: 3px solid #0e99e0;
margin-bottom: 15px;
font-size: 12px;
}
.loading {
color: #888;
font-style: italic;
}
option {
background-color: #3d3d3d;
color: #d0d0d0;
}
</style>
</head>
<body>
<div class="info">
Select a room/channel to switch to when this button is pressed.
</div>
<label for="roomId">Channel/Room:</label>
<select id="roomId">
<option value="">Loading channels...</option>
</select>
<label for="customRoomId">Or enter Room ID manually:</label>
<input type="text" id="customRoomId" placeholder="!abc123:matrix.org" />
<script>
let websocket = null;
let uuid = null;
let actionInfo = null;
let currentSettings = {};
function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo) {
uuid = inUUID;
actionInfo = JSON.parse(inActionInfo);
currentSettings = actionInfo.payload.settings || {};
websocket = new WebSocket('ws://127.0.0.1:' + inPort);
websocket.onopen = function() {
const json = {
event: inRegisterEvent,
uuid: inUUID
};
websocket.send(JSON.stringify(json));
// Request channel list
sendToPlugin({ event: 'getChannels' });
};
websocket.onmessage = function(evt) {
const message = JSON.parse(evt.data);
if (message.event === 'sendToPropertyInspector') {
if (message.payload.event === 'channelList') {
populateChannels(message.payload.channels);
}
}
};
// Set current values
if (currentSettings.roomId) {
document.getElementById('customRoomId').value = currentSettings.roomId;
}
}
function sendToPlugin(payload) {
if (websocket && websocket.readyState === WebSocket.OPEN) {
const message = {
action: actionInfo.action,
event: 'sendToPlugin',
context: uuid,
payload: payload
};
websocket.send(JSON.stringify(message));
}
}
function saveSettings() {
const settings = {
roomId: document.getElementById('customRoomId').value || document.getElementById('roomId').value
};
if (websocket && websocket.readyState === WebSocket.OPEN) {
const message = {
event: 'setSettings',
context: uuid,
payload: settings
};
websocket.send(JSON.stringify(message));
}
}
function populateChannels(channels) {
const select = document.getElementById('roomId');
select.innerHTML = '<option value="">-- Select a channel --</option>';
if (!channels || channels.length === 0) {
select.innerHTML = '<option value="">No channels available</option>';
return;
}
// Group by server
const grouped = {};
channels.forEach(channel => {
if (!grouped[channel.server]) {
grouped[channel.server] = [];
}
grouped[channel.server].push(channel);
});
// Add options grouped by server
Object.keys(grouped).sort().forEach(server => {
const optgroup = document.createElement('optgroup');
optgroup.label = server;
grouped[server].forEach(channel => {
const option = document.createElement('option');
option.value = channel.roomId;
option.textContent = channel.name;
if (currentSettings.roomId === channel.roomId) {
option.selected = true;
}
optgroup.appendChild(option);
});
select.appendChild(optgroup);
});
}
// Event listeners
document.getElementById('roomId').addEventListener('change', saveSettings);
document.getElementById('customRoomId').addEventListener('input', saveSettings);
</script>
</body>
</html>

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Send Message Settings</title>
<style>
body {
margin: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: #d0d0d0;
background-color: #2d2d2d;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
textarea {
width: 100%;
padding: 6px 8px;
margin-bottom: 15px;
background-color: #3d3d3d;
border: 1px solid #555;
border-radius: 4px;
color: #d0d0d0;
font-size: 13px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
resize: vertical;
min-height: 80px;
}
textarea:focus {
outline: none;
border-color: #0e99e0;
}
.info {
padding: 8px;
background-color: #3d3d3d;
border-left: 3px solid #0e99e0;
margin-bottom: 15px;
font-size: 12px;
}
.examples {
padding: 8px;
background-color: #3d3d3d;
border-left: 3px solid #666;
margin-top: 10px;
font-size: 11px;
}
.examples h4 {
margin: 0 0 8px 0;
font-size: 12px;
color: #aaa;
}
.examples ul {
margin: 0;
padding-left: 20px;
}
.examples li {
margin-bottom: 4px;
color: #888;
}
</style>
</head>
<body>
<div class="info">
Enter the message to send to the currently active room when this button is pressed.
</div>
<label for="message">Message:</label>
<textarea id="message" placeholder="Type your message here..."></textarea>
<div class="examples">
<h4>Example Messages:</h4>
<ul>
<li>BRB! (Be Right Back)</li>
<li>AFK for a few minutes</li>
<li>On my way!</li>
<li>Thanks everyone!</li>
</ul>
</div>
<script>
let websocket = null;
let uuid = null;
let actionInfo = null;
let currentSettings = {};
let saveTimeout = null;
function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, inInfo, inActionInfo) {
uuid = inUUID;
actionInfo = JSON.parse(inActionInfo);
currentSettings = actionInfo.payload.settings || {};
websocket = new WebSocket('ws://127.0.0.1:' + inPort);
websocket.onopen = function() {
const json = {
event: inRegisterEvent,
uuid: inUUID
};
websocket.send(JSON.stringify(json));
};
// Set current value
if (currentSettings.message) {
document.getElementById('message').value = currentSettings.message;
}
}
function saveSettings() {
// Debounce saving
if (saveTimeout) {
clearTimeout(saveTimeout);
}
saveTimeout = setTimeout(() => {
const settings = {
message: document.getElementById('message').value
};
if (websocket && websocket.readyState === WebSocket.OPEN) {
const message = {
event: 'setSettings',
context: uuid,
payload: settings
};
websocket.send(JSON.stringify(message));
}
}, 300);
}
// Event listener
document.getElementById('message').addEventListener('input', saveSettings);
</script>
</body>
</html>