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.
365
.gitea/workflows/build-windows.yml
Normal file
@@ -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"
|
||||||
2
cinny
@@ -183,7 +183,7 @@ if (!gotTheLock) {
|
|||||||
|
|
||||||
// Development mode detection
|
// Development mode detection
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
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;
|
const PORT = 44548;
|
||||||
|
|
||||||
// Helper to get correct icon path in dev vs packaged app
|
// Helper to get correct icon path in dev vs packaged app
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
||||||
"dev:vite": "cd cinny && npm start",
|
"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": "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:linux": "npm run build -- --linux",
|
||||||
"build:win": "npm run build -- --win",
|
"build:win": "npm run build -- --win",
|
||||||
|
|||||||
26
streamdeck-plugin/.gitignore
vendored
Normal file
@@ -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
|
||||||
207
streamdeck-plugin/QUICKSTART.md
Normal file
@@ -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
|
||||||
263
streamdeck-plugin/README.md
Normal file
@@ -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
|
||||||
69
streamdeck-plugin/build.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
5634
streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/images/paarrot.svg
Normal file
|
After Width: | Height: | Size: 889 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
113
streamdeck-plugin/com.paarrot.streamdeck.sdPlugin/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
18
streamdeck-plugin/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
156
streamdeck-plugin/validate.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
test-electron.js
Normal file
@@ -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!');
|
||||||
|
});
|
||||||
|
});
|
||||||