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.
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);
|
||||
}
|
||||
}
|
||||