From 07feddd44b567a56d0c9f870e89da41814ad044f Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Mon, 23 Mar 2026 20:34:26 +1100 Subject: [PATCH] Add Paarrot Stream Deck Plugin with core functionality - 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. --- .gitea/workflows/build-windows.yml | 365 ++ cinny | 2 +- electron/main.js | 2 +- package.json | 2 +- streamdeck-plugin/.gitignore | 26 + streamdeck-plugin/QUICKSTART.md | 207 + streamdeck-plugin/README.md | 263 + streamdeck-plugin/build.js | 69 + .../images/change-channel.svg | 9 + .../images/deafen-off.svg | 6 + .../images/deafen-on.svg | 7 + .../images/mute-off.svg | 5 + .../images/mute-on.svg | 6 + .../images/paarrot.svg | 5634 +++++++++++++++++ .../images/send-message.svg | 5 + .../images/status.svg | 7 + .../images/toggle-deafen.svg | 7 + .../images/toggle-mute.svg | 6 + .../manifest.json | 113 + .../plugin/index.html | 10 + .../plugin/index.js | 371 ++ .../propertyinspector/change-channel.html | 178 + .../propertyinspector/send-message.html | 145 + streamdeck-plugin/package.json | 18 + streamdeck-plugin/validate.js | 156 + test-electron.js | 26 + 26 files changed, 7642 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/build-windows.yml create mode 100644 streamdeck-plugin/.gitignore create mode 100644 streamdeck-plugin/QUICKSTART.md create mode 100644 streamdeck-plugin/README.md create mode 100644 streamdeck-plugin/build.js create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/change-channel.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-off.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-on.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-off.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-on.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/paarrot.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/send-message.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/status.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-deafen.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-mute.svg create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.html create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.js create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/change-channel.html create mode 100644 streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/send-message.html create mode 100644 streamdeck-plugin/package.json create mode 100644 streamdeck-plugin/validate.js create mode 100644 test-electron.js diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml new file mode 100644 index 0000000..ff48dc6 --- /dev/null +++ b/.gitea/workflows/build-windows.yml @@ -0,0 +1,365 @@ +name: Build Paarrot Windows + +on: + push: + branches: [ main, development ] + pull_request: + branches: [ main ] + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: true + +jobs: + start-vm: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check if VM is already running + id: check-vm + run: | + if pgrep -f "qemu-system-x86_64.*windows.qcow2" > /dev/null; then + echo "VM is already running" + echo "vm_running=true" >> $GITHUB_OUTPUT + else + echo "VM is not running" + echo "vm_running=false" >> $GITHUB_OUTPUT + fi + + - name: Start Windows VM with retry + run: | + MAX_RETRIES=3 + RETRY_COUNT=0 + BOOT_WAIT=90 + RUNNER_CHECK_TIMEOUT=120 + + start_vm() { + echo "Starting Windows QEMU VM (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)..." + sudo -u litruv setsid bash -c 'DISPLAY=:0 XAUTHORITY=/home/litruv/.Xauthority qemu-system-x86_64 \ + -m 18G \ + -smp 4 \ + -enable-kvm \ + -cpu host \ + -drive file=/home/litruv/windows.qcow2,format=qcow2 \ + -boot d \ + -vga virtio \ + -display vnc=:0 \ + > /tmp/qemu.log 2>&1' & + disown + } + + kill_vm() { + echo "Killing VM..." + sudo pkill -9 qemu-system-x86 || true + sleep 5 + } + + check_runner() { + API_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/runners" + RESPONSE=$(curl -s -H "Authorization: token ${{ secrets.CI_BOT_TOKEN }}" "$API_URL" 2>/dev/null) + + if [ -z "$RESPONSE" ]; then + echo " Could not query runner API" + return 1 + fi + + ONLINE=$(echo "$RESPONSE" | jq -r '.runners[] | select(.labels[].name == "windows-vm") | select(.status == "online") | .name' 2>/dev/null) + + if [ -n "$ONLINE" ]; then + echo " Runner found online!" + return 0 + fi + + echo " Windows runner (windows-vm) is NOT online" + return 1 + } + + wait_for_runner() { + local max_checks=$1 + local required_successes=${2:-1} + local successes=0 + echo "Waiting for runner to come online (need ${required_successes} successful checks, max ${max_checks} attempts)..." + for i in $(seq 1 $max_checks); do + if check_runner; then + successes=$((successes + 1)) + echo "Runner online check $successes/$required_successes passed ($i/$max_checks)" + if [ $successes -ge $required_successes ]; then + echo "Runner confirmed online after $successes consecutive checks!" + return 0 + fi + else + successes=0 + echo "Waiting for runner... ($i/$max_checks)" + fi + sleep 5 + done + return 1 + } + + echo "Checking if runner is online..." + if wait_for_runner 12 12; then + echo "Runner verified online!" + exit 0 + fi + + if [ "${{ steps.check-vm.outputs.vm_running }}" == "true" ]; then + echo "VM process exists but runner not responding, restarting VM..." + kill_vm + fi + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + start_vm + echo "Waiting ${BOOT_WAIT}s for VM to boot..." + sleep $BOOT_WAIT + + if ! pgrep -f "qemu-system-x86_64.*windows.qcow2" > /dev/null; then + echo "VM process not found, retrying..." + RETRY_COUNT=$((RETRY_COUNT + 1)) + continue + fi + + if wait_for_runner $((RUNNER_CHECK_TIMEOUT / 5)); then + exit 0 + fi + + echo "Runner did not come online, killing VM and retrying..." + kill_vm + RETRY_COUNT=$((RETRY_COUNT + 1)) + done + + echo "ERROR: Failed to start VM after $MAX_RETRIES attempts" + exit 1 + + - name: VM Status + run: | + if [ "${{ steps.check-vm.outputs.vm_running }}" == "true" ]; then + echo "Using existing VM" + else + echo "Started new VM" + fi + + if pgrep -f "qemu-system-x86_64.*windows.qcow2" > /dev/null; then + echo "VM process confirmed running" + else + echo "ERROR: VM process not found!" + exit 1 + fi + + build: + runs-on: windows-vm + needs: start-vm + timeout-minutes: 60 + + steps: + - name: Cancel pending shutdown + shell: powershell + run: | + shutdown /s /t 1000 2>$null + shutdown /a 2>$null + Write-Host "Cancelled any pending shutdown" + + - name: Disable sleep and power saving + shell: powershell + run: | + powercfg /change standby-timeout-ac 0 + powercfg /change standby-timeout-dc 0 + powercfg /change hibernate-timeout-ac 0 + powercfg /change hibernate-timeout-dc 0 + powercfg /change monitor-timeout-ac 0 + powercfg /change monitor-timeout-dc 0 + powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c + Write-Host "Power saving disabled for build" + + - name: Fast checkout (persistent repo) + shell: powershell + run: | + $ErrorActionPreference = "Continue" + + $repoDir = "C:\BuildCache\cinny-desktop\Repo" + $serverUrl = "${{ github.server_url }}" + $repo = "${{ github.repository }}" + $branch = "${{ github.ref_name }}" + $token = "${{ secrets.CI_BOT_TOKEN }}" + + $uri = [System.Uri]$serverUrl + $scheme = $uri.Scheme + $authority = $uri.Authority + $repoUrl = "${scheme}://${authority}/${repo}" + + Write-Host "Using server: ${scheme}://${authority}" + + if (-not (Test-Path "C:\BuildCache\cinny-desktop")) { + New-Item -ItemType Directory -Path "C:\BuildCache\cinny-desktop" -Force + } + + git config --global credential.helper "" + git config --global "http.${scheme}://${authority}/.extraheader" "Authorization: token $token" + + function Remove-GitLocks { + $lockFiles = @( + "$repoDir\.git\shallow.lock", + "$repoDir\.git\index.lock", + "$repoDir\.git\HEAD.lock", + "$repoDir\.git\config.lock" + ) + foreach ($lock in $lockFiles) { + if (Test-Path $lock) { + Write-Host "Removing stale lock: $lock" + Remove-Item -Force $lock + } + } + } + + function Do-Clone { + Write-Host "Cloning repository fresh..." + if (Test-Path $repoDir) { + Remove-Item -Recurse -Force $repoDir + } + + $cloneSuccess = $false + for ($i = 1; $i -le 3; $i++) { + Write-Host "Clone attempt $i/3..." + & git clone --depth=1 --branch $branch $repoUrl $repoDir 2>&1 | Out-Host + if ($LASTEXITCODE -eq 0) { + $cloneSuccess = $true + break + } + Write-Host "Clone attempt $i failed, retrying in 5 seconds..." + if (Test-Path $repoDir) { + Remove-Item -Recurse -Force $repoDir + } + Start-Sleep -Seconds 5 + } + + if (-not $cloneSuccess) { + throw "Clone failed after 3 attempts" + } + + Push-Location $repoDir + & git submodule update --init --recursive --depth=1 2>&1 | Out-Host + Pop-Location + } + + function Do-Update { + Write-Host "Updating existing repo..." + Push-Location $repoDir + + Remove-GitLocks + + git remote set-url origin $repoUrl 2>$null + Write-Host "Remote URL updated" + + git reset --hard HEAD 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Host "Reset failed, repo may be corrupted" + Pop-Location + return $false + } + + git clean -fdx -e node_modules/ -e cinny/node_modules/ 2>$null + + $fetchSuccess = $false + for ($i = 1; $i -le 3; $i++) { + Write-Host "Fetch attempt $i/3..." + & git fetch origin $branch --depth=1 2>&1 | Out-Host + if ($LASTEXITCODE -eq 0) { + $fetchSuccess = $true + break + } + Write-Host "Fetch attempt $i failed, retrying in 5 seconds..." + Start-Sleep -Seconds 5 + } + + if (-not $fetchSuccess) { + Write-Host "All fetch attempts failed" + Pop-Location + return $false + } + + & git checkout -B $branch origin/$branch 2>&1 | Out-Host + if ($LASTEXITCODE -ne 0) { + Write-Host "Checkout failed" + Pop-Location + return $false + } + + & git submodule update --init --recursive --depth=1 2>&1 | Out-Host + + Pop-Location + return $true + } + + if (Test-Path "$repoDir\.git") { + $success = Do-Update + if (-not $success) { + Write-Host "Update failed, will re-clone..." + Do-Clone + } + } else { + Do-Clone + } + + if (-not (Test-Path "$repoDir\package.json")) { + throw "Checkout verification failed - package.json not found" + } + + Write-Host "Checkout complete at: $repoDir" + + - name: Install dependencies + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $repoDir = "C:\BuildCache\cinny-desktop\Repo" + + Push-Location $repoDir + + Write-Host "Installing root dependencies..." + npm ci --prefer-offline --no-audit + + Write-Host "Installing cinny dependencies..." + Push-Location cinny + npm ci --prefer-offline --no-audit + Pop-Location + + Pop-Location + Write-Host "Dependencies installed" + + - name: Build Paarrot + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $repoDir = "C:\BuildCache\cinny-desktop\Repo" + + Push-Location $repoDir + + Write-Host "Building Paarrot..." + npm run build:win + + Pop-Location + Write-Host "Build completed successfully!" + + - name: Upload artifacts + shell: powershell + run: | + $ErrorActionPreference = "Stop" + $repoDir = "C:\BuildCache\cinny-desktop\Repo" + $artifactDir = "$repoDir\dist-electron" + + if (-not (Test-Path $artifactDir)) { + Write-Error "Build artifacts not found at $artifactDir" + exit 1 + } + + Write-Host "Build artifacts:" + Get-ChildItem $artifactDir -Recurse | Select-Object FullName + + # TODO: Copy artifacts to artifact server or release location + Write-Host "Artifacts ready at: $artifactDir" + + - name: Schedule delayed shutdown + if: always() + shell: powershell + run: | + shutdown /s /t 900 /c "Build complete - shutting down in 15 minutes" + Write-Host "VM will shutdown in 15 minutes unless another build starts" diff --git a/cinny b/cinny index 5eb937a..f76fee2 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 5eb937ab3ecc0fe9b90e31a3c729c676a74bdf16 +Subproject commit f76fee23bff49574f7db7e48d06843aa2470f84c diff --git a/electron/main.js b/electron/main.js index 477ecab..71c963a 100644 --- a/electron/main.js +++ b/electron/main.js @@ -183,7 +183,7 @@ if (!gotTheLock) { // Development mode detection const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; -const VITE_DEV_SERVER = 'http://localhost:8080'; +const VITE_DEV_SERVER = 'http://localhost:38347'; const PORT = 44548; // Helper to get correct icon path in dev vs packaged app diff --git a/package.json b/package.json index 0c4834c..c5e27c0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "scripts": { "dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"", "dev:vite": "cd cinny && npm start", - "dev:electron": "wait-on http://localhost:8080 && cross-env NODE_ENV=development electron .", + "dev:electron": "wait-on http://localhost:38347 && cross-env NODE_ENV=development electron .", "build": "node -e \"require('fs').copyFileSync('config.json', 'cinny/config.json')\" && cd cinny && npm run build && cd .. && electron-builder --publish never", "build:linux": "npm run build -- --linux", "build:win": "npm run build -- --win", diff --git a/streamdeck-plugin/.gitignore b/streamdeck-plugin/.gitignore new file mode 100644 index 0000000..a0ea8ab --- /dev/null +++ b/streamdeck-plugin/.gitignore @@ -0,0 +1,26 @@ +# Distribution files +dist/ +*.streamDeckPlugin + +# Node modules (if any dependencies are added) +node_modules/ + +# OS files +.DS_Store +Thumbs.db +desktop.ini + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Logs +*.log +npm-debug.log* + +# Temporary files +*.tmp +*.temp diff --git a/streamdeck-plugin/QUICKSTART.md b/streamdeck-plugin/QUICKSTART.md new file mode 100644 index 0000000..47b533c --- /dev/null +++ b/streamdeck-plugin/QUICKSTART.md @@ -0,0 +1,207 @@ +# Quick Start Guide - Paarrot Stream Deck Plugin + +## For Users + +### Installation (3 simple steps) + +1. **Download the plugin** + - Get `com.paarrot.streamdeck-v1.0.0.streamDeckPlugin` from releases + +2. **Install** + - Double-click the `.streamDeckPlugin` file + - Stream Deck software will open and install it automatically + +3. **Add to your Stream Deck** + - Open Stream Deck software + - Find "Paarrot" in the actions list (under "Voice & Chat") + - Drag actions to your buttons + +### Quick Action Guide + +| Action | What it does | Setup needed? | +|--------|--------------|---------------| +| Toggle Mute | Mute/unmute mic | No | +| Toggle Deafen | Mute/unmute speakers | No | +| Change Channel | Switch rooms | Yes - pick room | +| Send Message | Send preset message | Yes - type message | +| Get Status | Show mute/deafen state | No | + +### Configuration Examples + +**Change Channel**: +1. Drag to button +2. Click button in Stream Deck to open settings +3. Select channel from dropdown +4. Done! + +**Send Message**: +1. Drag to button +2. Click button in Stream Deck to open settings +3. Type message (e.g., "BRB!") +4. Done! + +--- + +## For Developers + +### Prerequisites + +- Node.js (for build scripts) +- Stream Deck software +- Text editor + +### Development Setup + +```powershell +# Clone or download the repo +cd streamdeck-plugin + +# Validate plugin structure +node validate.js + +# Install to Stream Deck (Windows) +Copy-Item -Recurse -Force com.paarrot.streamdeck.sdPlugin "$env:APPDATA\Elgato\StreamDeck\Plugins\" + +# Install to Stream Deck (macOS) +cp -r com.paarrot.streamdeck.sdPlugin ~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/ + +# Restart Stream Deck software +``` + +### Making Changes + +1. Edit files in `com.paarrot.streamdeck.sdPlugin/` +2. Run validation: `node validate.js` +3. Restart Stream Deck software +4. Test your changes + +### Building for Distribution + +```powershell +# Validate first +node validate.js + +# Build .streamDeckPlugin file +node build.js + +# Output will be in dist/ folder +``` + +### File Guide + +``` +com.paarrot.streamdeck.sdPlugin/ +├── manifest.json # Edit: plugin info, actions +├── plugin/ +│ └── index.js # Edit: main logic, API calls +├── propertyinspector/ +│ ├── change-channel.html # Edit: channel picker UI +│ └── send-message.html # Edit: message input UI +└── images/ # Edit: replace SVGs with your icons + └── *.svg +``` + +### Adding a New Action + +1. **Add to manifest.json**: +```json +{ + "Icon": "images/my-action", + "Name": "My Action", + "States": [{ "Image": "images/my-action" }], + "Tooltip": "Does something cool", + "UUID": "com.paarrot.streamdeck.myaction" +} +``` + +2. **Add icon**: `images/my-action.svg` + +3. **Add logic in plugin/index.js**: +```javascript +const ACTIONS = { + MY_ACTION: 'com.paarrot.streamdeck.myaction' +}; + +// In handleKeyDown: +case ACTIONS.MY_ACTION: + // Your code here + break; +``` + +4. **Test**: Restart Stream Deck software + +### Testing + +1. Enable Stream Deck console: `View > Open Console` +2. Watch for errors when pressing buttons +3. Test all actions with Paarrot running +4. Test with Paarrot not running (should fail gracefully) + +### API Reference + +The plugin calls these Paarrot API endpoints: + +```javascript +// Status polling (every 1 second) +GET http://127.0.0.1:33384/status + +// Toggle actions +POST http://127.0.0.1:33384/mute/toggle +POST http://127.0.0.1:33384/deafen/toggle + +// Configured actions +POST http://127.0.0.1:33384/channel + Body: { "roomId": "!abc:server.com" } + +POST http://127.0.0.1:33384/message/current + Body: { "message": "Hello!" } + +// Property inspector +GET http://127.0.0.1:33384/channels +``` + +### Common Issues + +**Plugin not loading**: +- Check manifest.json syntax (use validator) +- Ensure folder name is exactly `com.paarrot.streamdeck.sdPlugin` +- Restart Stream Deck software + +**Actions not working**: +- Open console (View > Open Console) +- Check for JavaScript errors +- Verify Paarrot API is running + +**Icons not showing**: +- Ensure files exist at paths specified in manifest +- Use .svg or .png format +- Check file paths are relative to plugin root + +### Resources + +- [Stream Deck SDK Documentation](https://docs.elgato.com/sdk) +- [Paarrot API Documentation](../API.md) +- [Example Plugins](https://github.com/elgatosf/streamdeck-plugins) + +### Publishing Checklist + +- [ ] Update version in manifest.json +- [ ] Run `node validate.js` (no errors) +- [ ] Test all actions +- [ ] Test with Paarrot off (graceful failure) +- [ ] Update README.md version history +- [ ] Run `node build.js` +- [ ] Test .streamDeckPlugin file installs correctly +- [ ] Create GitHub release with .streamDeckPlugin file + +--- + +## Support + +**Users**: See README.md troubleshooting section + +**Developers**: Open an issue on GitHub with: +- What you're trying to do +- What's happening +- Console errors +- Plugin version diff --git a/streamdeck-plugin/README.md b/streamdeck-plugin/README.md new file mode 100644 index 0000000..0803151 --- /dev/null +++ b/streamdeck-plugin/README.md @@ -0,0 +1,263 @@ +# Paarrot Stream Deck Plugin + +Control your Paarrot desktop app directly from your Elgato Stream Deck! Toggle mute, deafen, switch channels, and send quick messages with the press of a button. + +## Features + +- **Toggle Mute** - Mute/unmute your microphone with visual feedback +- **Toggle Deafen** - Deafen/undeafen (mute speakers) with visual feedback +- **Change Channel** - Switch between rooms/channels instantly +- **Send Message** - Send predefined messages to the current room +- **Get Status** - Display current mute/deafen status + +## Requirements + +- Elgato Stream Deck (any model) +- Stream Deck software version 6.0 or later +- Paarrot desktop app running with API server enabled +- Windows 10+ or macOS 10.14+ + +## Installation + +### Method 1: Install from Release (easiest) + +1. Download the latest `.streamDeckPlugin` file from the [Releases](https://github.com/yourusername/paarrot-streamdeck/releases) page +2. Double-click the file to install +3. Stream Deck software will open and install the plugin automatically + +### Method 2: Manual Installation + +1. Download or clone this repository +2. Copy the `com.paarrot.streamdeck.sdPlugin` folder to: + - **Windows**: `%APPDATA%\Elgato\StreamDeck\Plugins\` + - **macOS**: `~/Library/Application Support/com.elgato.StreamDeck/Plugins/` +3. Restart the Stream Deck software + +### Method 3: Developer Mode + +1. Open Stream Deck software +2. Open the Plugins view +3. Enable "Developer Mode" in settings +4. Drag the `com.paarrot.streamdeck.sdPlugin` folder into the Stream Deck window + +## Setup + +### 1. Ensure Paarrot API is Running + +Make sure your Paarrot desktop app is running with the API server enabled on port 33384. You can test this by visiting: + +``` +http://127.0.0.1:33384/health +``` + +You should see: +```json +{ + "status": "ok", + "app": "Paarrot API", + "version": "1.0.0" +} +``` + +### 2. Add Actions to Stream Deck + +1. Open Stream Deck software +2. Find "Paarrot" in the actions list +3. Drag actions onto your Stream Deck buttons +4. Configure actions as needed (see below) + +## Actions + +### Toggle Mute + +**What it does**: Toggles your microphone mute status + +**Configuration**: No configuration needed + +**Visual Feedback**: +- Gray microphone icon = unmuted +- Red X over microphone = muted + +**Usage**: Press to toggle between muted and unmuted. The button automatically updates to show the current state. + +--- + +### Toggle Deafen + +**What it does**: Toggles deafen status (mutes speakers and microphone) + +**Configuration**: No configuration needed + +**Visual Feedback**: +- Gray speaker icon = not deafened +- Red X over speaker = deafened + +**Usage**: Press to toggle between deafened and not deafened. The button automatically updates to show the current state. + +--- + +### Change Channel + +**What it does**: Switches to a different room/channel + +**Configuration**: +1. Drag action to a button +2. Click the button in Stream Deck to open settings +3. Select a channel from the dropdown OR enter a room ID manually +4. Click away to save + +**Usage**: Press to instantly switch to the configured channel. + +**Tips**: +- Channels are grouped by server in the dropdown +- Use manual room ID for rooms not in the list +- Room ID format: `!abc123:matrix.org` + +--- + +### Send Message + +**What it does**: Sends a predefined message to the currently active room + +**Configuration**: +1. Drag action to a button +2. Click the button in Stream Deck to open settings +3. Type your message in the text area +4. Click away to save + +**Usage**: Press to send the message to whichever room is currently active. + +**Example Messages**: +- "BRB!" - Be right back +- "AFK for 5 minutes" +- "On my way!" +- "Thanks everyone!" +- "GG!" - Good game + +--- + +### Get Status + +**What it does**: Displays current mute/deafen status on the button + +**Configuration**: No configuration needed + +**Visual Feedback**: +- Shows "OK" when not muted or deafened +- Shows "M" when muted +- Shows "D" when deafened +- Shows "MD" when both muted and deafened + +**Usage**: Button automatically updates every second. Press to force an immediate update. + +## Troubleshooting + +### Plugin not showing up + +- Restart Stream Deck software +- Check that the plugin folder is in the correct location +- Ensure folder is named exactly `com.paarrot.streamdeck.sdPlugin` + +### Actions not working + +- Verify Paarrot is running +- Test API at `http://127.0.0.1:33384/health` +- Check console for errors (View > Open Console in Stream Deck) +- Ensure port 33384 is not blocked by firewall + +### Button states not updating + +- Check that Paarrot API is responding +- Try pressing the action to force an update +- Check Stream Deck console for connection errors + +### "No channels available" in dropdown + +- Ensure you're logged into Paarrot +- Verify you've joined at least one room +- Try entering the room ID manually instead + +### Actions show red X (alert) + +This means the API call failed. Common causes: +- Paarrot app not running +- API server not started +- Network/firewall blocking localhost connections +- Invalid room ID or message configuration + +## Development + +### Building from Source + +No build step required! The plugin uses vanilla JavaScript. + +### File Structure + +``` +com.paarrot.streamdeck.sdPlugin/ +├── manifest.json # Plugin metadata and action definitions +├── plugin/ +│ ├── index.html # Plugin entry point +│ └── index.js # Main plugin logic +├── propertyinspector/ +│ ├── change-channel.html # Channel selector UI +│ └── send-message.html # Message input UI +└── images/ # Action icons + ├── mute-off.svg + ├── mute-on.svg + ├── deafen-off.svg + ├── deafen-on.svg + ├── change-channel.svg + ├── send-message.svg + ├── status.svg + ├── toggle-mute.svg + ├── toggle-deafen.svg + ├── plugin-icon.svg + └── category-icon.svg +``` + +### Modifying the Plugin + +1. Make changes to the files +2. Restart Stream Deck software to reload the plugin +3. Test your changes + +### API Endpoints Used + +- `GET /health` - Check API is running +- `GET /status` - Get current mute/deafen state +- `POST /mute/toggle` - Toggle mute +- `POST /deafen/toggle` - Toggle deafen +- `POST /channel` - Change channel +- `POST /message/current` - Send message +- `GET /channels` - Get channel list + +## Support + +If you encounter issues: + +1. Check the troubleshooting section above +2. Enable Stream Deck console logging (View > Open Console) +3. Check Paarrot's console for API errors +4. File an issue on GitHub with logs and details + +## License + +This plugin is provided as-is. See LICENSE file for details. + +## Credits + +Created for Paarrot desktop app. + +Icons designed specifically for this plugin. + +## Version History + +### 1.0.0 (Initial Release) +- Toggle Mute action +- Toggle Deafen action +- Change Channel action with dropdown +- Send Message action +- Get Status action +- Auto-updating button states +- Visual feedback for all actions diff --git a/streamdeck-plugin/build.js b/streamdeck-plugin/build.js new file mode 100644 index 0000000..2c7380e --- /dev/null +++ b/streamdeck-plugin/build.js @@ -0,0 +1,69 @@ +/** + * Build script for Paarrot Stream Deck Plugin + * Creates a distributable .streamDeckPlugin file + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const PLUGIN_DIR = 'com.paarrot.streamdeck.sdPlugin'; +const OUTPUT_DIR = 'dist'; + +console.log('Building Paarrot Stream Deck Plugin...'); + +// Create dist directory if it doesn't exist +if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR); +} + +// Check if plugin directory exists +if (!fs.existsSync(PLUGIN_DIR)) { + console.error(`Error: Plugin directory '${PLUGIN_DIR}' not found!`); + process.exit(1); +} + +// Read manifest to get version +const manifest = JSON.parse( + fs.readFileSync(path.join(PLUGIN_DIR, 'manifest.json'), 'utf8') +); + +const version = manifest.Version || '1.0.0.0'; +const uuid = manifest.UUID || 'com.paarrot.streamdeck'; +const outputPath = path.join(OUTPUT_DIR, `${uuid}-v${version}.streamDeckPlugin`); + +console.log(`Creating plugin archive: ${outputPath}`); + +try { + // Clean up old archive + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + if (fs.existsSync(`${outputPath}.zip`)) { + fs.unlinkSync(`${outputPath}.zip`); + } + + // Use PowerShell's Compress-Archive on Windows + // IMPORTANT: Archive must contain the .sdPlugin folder at root level + if (process.platform === 'win32') { + const command = `powershell -Command "Compress-Archive -Path '${PLUGIN_DIR}' -DestinationPath '${outputPath}.zip' -Force"`; + execSync(command); + + // Rename .zip to .streamDeckPlugin + if (fs.existsSync(`${outputPath}.zip`)) { + fs.renameSync(`${outputPath}.zip`, outputPath); + } + } else { + // Use zip command on macOS/Linux - include the folder itself + execSync(`zip -r "${outputPath}" "${PLUGIN_DIR}"`); + } + + console.log('✓ Plugin built successfully!'); + console.log(` Output: ${outputPath}`); + console.log(` Version: ${version}`); + console.log('\nInstallation:'); + console.log(` Double-click ${path.basename(outputPath)} to install`); +} catch (error) { + console.error('Error building plugin:', error.message); + process.exit(1); +} diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/change-channel.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/change-channel.svg new file mode 100644 index 0000000..7963399 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/change-channel.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-off.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-off.svg new file mode 100644 index 0000000..83aa387 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-on.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-on.svg new file mode 100644 index 0000000..eb76bd6 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/deafen-on.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-off.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-off.svg new file mode 100644 index 0000000..a28b657 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-on.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-on.svg new file mode 100644 index 0000000..c75ba6d --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/mute-on.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/paarrot.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/paarrot.svg new file mode 100644 index 0000000..7122ce0 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/paarrot.svg @@ -0,0 +1,5634 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/send-message.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/send-message.svg new file mode 100644 index 0000000..2a94917 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/send-message.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/status.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/status.svg new file mode 100644 index 0000000..c519a71 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/status.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-deafen.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-deafen.svg new file mode 100644 index 0000000..6c46ce6 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-deafen.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-mute.svg b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-mute.svg new file mode 100644 index 0000000..b74c7e6 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/toggle-mute.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json new file mode 100644 index 0000000..f3c9adb --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json @@ -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" +} diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.html b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.html new file mode 100644 index 0000000..8768817 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.html @@ -0,0 +1,10 @@ + + + + + Paarrot Plugin + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.js b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.js new file mode 100644 index 0000000..2c0da23 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/plugin/index.js @@ -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(); + }; +} diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/change-channel.html b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/change-channel.html new file mode 100644 index 0000000..ca4d3a6 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/change-channel.html @@ -0,0 +1,178 @@ + + + + + Change Channel Settings + + + +
+ Select a room/channel to switch to when this button is pressed. +
+ + + + + + + + + + diff --git a/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/send-message.html b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/send-message.html new file mode 100644 index 0000000..e94d5c6 --- /dev/null +++ b/streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/propertyinspector/send-message.html @@ -0,0 +1,145 @@ + + + + + Send Message Settings + + + +
+ Enter the message to send to the currently active room when this button is pressed. +
+ + + + +
+

Example Messages:

+ +
+ + + + diff --git a/streamdeck-plugin/package.json b/streamdeck-plugin/package.json new file mode 100644 index 0000000..e7bf9ff --- /dev/null +++ b/streamdeck-plugin/package.json @@ -0,0 +1,18 @@ +{ + "name": "paarrot-streamdeck-plugin", + "version": "1.0.0", + "description": "Elgato Stream Deck plugin for Paarrot voice chat app", + "scripts": { + "build": "node build.js", + "validate": "node validate.js" + }, + "keywords": [ + "stream-deck", + "elgato", + "paarrot", + "voice-chat", + "matrix" + ], + "author": "Paarrot", + "license": "MIT" +} diff --git a/streamdeck-plugin/validate.js b/streamdeck-plugin/validate.js new file mode 100644 index 0000000..a49150b --- /dev/null +++ b/streamdeck-plugin/validate.js @@ -0,0 +1,156 @@ +/** + * Validation script for Paarrot Stream Deck Plugin + * Checks plugin structure and manifest + */ + +const fs = require('fs'); +const path = require('path'); + +const PLUGIN_DIR = 'com.paarrot.streamdeck.sdPlugin'; + +let errors = 0; +let warnings = 0; + +function error(message) { + console.error(`❌ ERROR: ${message}`); + errors++; +} + +function warn(message) { + console.warn(`⚠️ WARNING: ${message}`); + warnings++; +} + +function success(message) { + console.log(`✓ ${message}`); +} + +console.log('Validating Paarrot Stream Deck Plugin...\n'); + +// Check plugin directory exists +if (!fs.existsSync(PLUGIN_DIR)) { + error(`Plugin directory '${PLUGIN_DIR}' not found`); + process.exit(1); +} + +// Check manifest.json +const manifestPath = path.join(PLUGIN_DIR, 'manifest.json'); +if (!fs.existsSync(manifestPath)) { + error('manifest.json not found'); +} else { + success('manifest.json exists'); + + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + + // Validate required fields + const requiredFields = ['Actions', 'Author', 'CodePath', 'Description', 'Name', 'Version', 'SDKVersion']; + requiredFields.forEach(field => { + if (!manifest[field]) { + error(`manifest.json missing required field: ${field}`); + } + }); + + if (manifest.Actions && Array.isArray(manifest.Actions)) { + success(`Found ${manifest.Actions.length} actions`); + + // Validate each action + manifest.Actions.forEach((action, index) => { + const actionName = action.Name || `Action ${index}`; + + if (!action.UUID) { + error(`${actionName}: Missing UUID`); + } + + if (!action.Icon) { + error(`${actionName}: Missing Icon`); + } else { + // Check if icon file exists + const iconPath = path.join(PLUGIN_DIR, `${action.Icon}.svg`); + const iconPathPng = path.join(PLUGIN_DIR, `${action.Icon}.png`); + if (!fs.existsSync(iconPath) && !fs.existsSync(iconPathPng)) { + warn(`${actionName}: Icon file not found: ${action.Icon}`); + } + } + + if (!action.States || !Array.isArray(action.States) || action.States.length === 0) { + error(`${actionName}: Missing or invalid States`); + } else { + action.States.forEach((state, stateIndex) => { + if (!state.Image) { + warn(`${actionName} State ${stateIndex}: Missing Image`); + } else { + const imagePath = path.join(PLUGIN_DIR, `${state.Image}.svg`); + const imagePathPng = path.join(PLUGIN_DIR, `${state.Image}.png`); + if (!fs.existsSync(imagePath) && !fs.existsSync(imagePathPng)) { + warn(`${actionName} State ${stateIndex}: Image file not found: ${state.Image}`); + } + } + }); + } + + if (action.PropertyInspectorPath) { + const piPath = path.join(PLUGIN_DIR, action.PropertyInspectorPath); + if (!fs.existsSync(piPath)) { + error(`${actionName}: PropertyInspector file not found: ${action.PropertyInspectorPath}`); + } + } + }); + } + } catch (e) { + error(`manifest.json parse error: ${e.message}`); + } +} + +// Check plugin files +const pluginPath = path.join(PLUGIN_DIR, 'plugin', 'index.html'); +if (!fs.existsSync(pluginPath)) { + error('plugin/index.html not found'); +} else { + success('plugin/index.html exists'); +} + +const pluginJsPath = path.join(PLUGIN_DIR, 'plugin', 'index.js'); +if (!fs.existsSync(pluginJsPath)) { + error('plugin/index.js not found'); +} else { + success('plugin/index.js exists'); +} + +// Check property inspectors +const piDir = path.join(PLUGIN_DIR, 'propertyinspector'); +if (fs.existsSync(piDir)) { + const piFiles = fs.readdirSync(piDir); + success(`Found ${piFiles.length} property inspector file(s)`); +} else { + warn('propertyinspector directory not found'); +} + +// Check images directory +const imagesDir = path.join(PLUGIN_DIR, 'images'); +if (fs.existsSync(imagesDir)) { + const imageFiles = fs.readdirSync(imagesDir); + success(`Found ${imageFiles.length} image file(s)`); + + if (imageFiles.length === 0) { + warn('images directory is empty'); + } +} else { + error('images directory not found'); +} + +// Summary +console.log('\n' + '='.repeat(50)); +if (errors === 0 && warnings === 0) { + console.log('✓ Plugin validation passed!'); + process.exit(0); +} else { + console.log(`Found ${errors} error(s) and ${warnings} warning(s)`); + if (errors > 0) { + console.log('\nPlease fix errors before building the plugin.'); + process.exit(1); + } else { + console.log('\nPlugin can be built, but consider fixing warnings.'); + process.exit(0); + } +} diff --git a/test-electron.js b/test-electron.js new file mode 100644 index 0000000..3157977 --- /dev/null +++ b/test-electron.js @@ -0,0 +1,26 @@ +const { app, BrowserWindow } = require('electron'); + +console.log('TEST: Electron starting...'); + +app.whenReady().then(() => { + console.log('TEST: App ready, creating window...'); + + const win = new BrowserWindow({ + width: 800, + height: 600, + title: 'TEST WINDOW', + show: true, + center: true + }); + + console.log('TEST: Window created, loading content...'); + win.loadURL('https://www.google.com'); + + win.on('ready-to-show', () => { + console.log('TEST: Window ready-to-show fired'); + }); + + win.on('show', () => { + console.log('TEST: Window shown!'); + }); +});