feat: node canvas grid
This commit is contained in:
234
MULTI_NODE_UPDATE.md
Normal file
234
MULTI_NODE_UPDATE.md
Normal file
@@ -0,0 +1,234 @@
|
||||
# Multi-Node Canvas Grid Update
|
||||
|
||||
## Overview
|
||||
|
||||
The SPORE LEDLab interface has been redesigned to display all SPORE nodes simultaneously with individual canvases in a responsive grid layout. Each node can be controlled independently with its own preset and parameters.
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. UI Layout Redesign
|
||||
|
||||
**Before:**
|
||||
- Single canvas display in center
|
||||
- Node list in right sidebar
|
||||
- Controls always visible in right sidebar
|
||||
|
||||
**After:**
|
||||
- Full-width grid layout displaying all nodes
|
||||
- Each node has its own canvas card
|
||||
- Floating control panel appears when a node is selected
|
||||
- Maximizes screen space for displaying multiple nodes
|
||||
|
||||
### 2. New Components
|
||||
|
||||
#### `NodeCanvasGrid` Component
|
||||
- **Location:** `/public/scripts/node-canvas-grid.js`
|
||||
- **Purpose:** Manages the grid of node canvases
|
||||
- **Features:**
|
||||
- Displays all discovered SPORE nodes in a responsive grid
|
||||
- Each node card shows:
|
||||
- Node IP address
|
||||
- Connection status (connected/streaming)
|
||||
- Live canvas with streaming visualization
|
||||
- Click on any node to select it and show controls
|
||||
- Supports both individual node selection and broadcast mode
|
||||
- Real-time frame rendering for each node
|
||||
|
||||
#### Updated `PresetControls` Component
|
||||
- **Location:** `/public/scripts/preset-controls.js`
|
||||
- **Changes:**
|
||||
- Now works with floating control panel
|
||||
- Tracks parameters per node (using Map)
|
||||
- Restores node-specific settings when switching between nodes
|
||||
- Sends node-specific commands to server
|
||||
|
||||
### 3. CSS Styling
|
||||
|
||||
#### New Classes
|
||||
- `.matrix-grid-section` - Full-width container for node grid
|
||||
- `.node-canvas-grid` - CSS grid layout for node cards
|
||||
- `.node-canvas-item` - Individual node card styling
|
||||
- Hover effects
|
||||
- Selected state (green border glow)
|
||||
- Streaming animation
|
||||
- `.floating-controls` - Floating control panel
|
||||
- Appears when node is selected
|
||||
- Slides in from right (desktop) or bottom (mobile)
|
||||
- Closes when clicking X or deselecting node
|
||||
|
||||
#### Responsive Design
|
||||
- Desktop: 3-4 columns based on screen width
|
||||
- Tablet: 2-3 columns
|
||||
- Mobile: 1-2 columns with controls sliding from bottom
|
||||
|
||||
### 4. Server-Side Multi-Node Streaming
|
||||
|
||||
#### Updated `LEDLabServer` Class
|
||||
- **Location:** `/server/index.js`
|
||||
- **New Features:**
|
||||
- `nodeStreams` Map: Tracks active streams per node
|
||||
- `nodeConfigurations` Map: Stores per-node settings
|
||||
- Simultaneous streaming to multiple nodes
|
||||
- Per-node preset instances and parameters
|
||||
|
||||
#### Key Methods Updated
|
||||
- `startPreset(presetName, width, height, nodeIp, parameters)`
|
||||
- Now accepts nodeIp to target specific node
|
||||
- Supports initial parameter values
|
||||
|
||||
- `stopStreaming(nodeIp)`
|
||||
- Stops streaming for specific node or all nodes
|
||||
|
||||
- `updatePresetParameter(parameter, value, nodeIp)`
|
||||
- Updates parameters for specific node or broadcast to all
|
||||
|
||||
- `streamFrameForNode(nodeIp)`
|
||||
- Generates and sends frames for specific node
|
||||
- Multiple intervals running simultaneously
|
||||
|
||||
### 5. HTML Structure
|
||||
|
||||
#### Stream View (Main View)
|
||||
```html
|
||||
<section class="matrix-grid-section">
|
||||
<div class="node-canvas-grid">
|
||||
<!-- Node cards dynamically created here -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="floating-controls">
|
||||
<!-- Control panel content -->
|
||||
</aside>
|
||||
```
|
||||
|
||||
### 6. User Workflow
|
||||
|
||||
1. **Node Discovery**
|
||||
- Nodes automatically discovered via UDP
|
||||
- Each node appears as a card in the grid
|
||||
|
||||
2. **Node Selection**
|
||||
- Click any node card to select it
|
||||
- Floating control panel appears
|
||||
- Node card highlights with green border
|
||||
|
||||
3. **Configure Node**
|
||||
- Select preset from dropdown
|
||||
- Adjust parameters with sliders
|
||||
- Changes apply only to selected node
|
||||
|
||||
4. **Start Streaming**
|
||||
- Click "Start Streaming" button
|
||||
- Node card shows "streaming" status
|
||||
- Canvas displays live animation
|
||||
|
||||
5. **Multi-Node Operation**
|
||||
- Select different node
|
||||
- Configure with different preset/parameters
|
||||
- Start streaming independently
|
||||
- Multiple nodes can stream simultaneously
|
||||
|
||||
6. **Broadcast Mode**
|
||||
- Click "Broadcast to All" button
|
||||
- Control panel shows "Broadcast to All"
|
||||
- Preset and parameters apply to all nodes
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Per-Node State Management
|
||||
|
||||
#### Client-Side (PresetControls)
|
||||
```javascript
|
||||
this.nodeParameters = new Map(); // nodeIp -> {presetName, parameters}
|
||||
```
|
||||
- Stores preset selection and parameters for each node
|
||||
- Restores settings when switching between nodes
|
||||
- Maintains independent state per node
|
||||
|
||||
#### Server-Side (LEDLabServer)
|
||||
```javascript
|
||||
this.nodeStreams = new Map(); // nodeIp -> {preset, presetName, interval, matrixSize}
|
||||
this.nodeConfigurations = new Map(); // nodeIp -> {presetName, parameters, matrixSize}
|
||||
```
|
||||
- Active streams run independently with separate intervals
|
||||
- Configurations saved per node
|
||||
- Multiple presets can run simultaneously
|
||||
|
||||
### WebSocket Messages
|
||||
|
||||
#### Enhanced Messages
|
||||
All streaming-related messages now include optional `nodeIp` field:
|
||||
```javascript
|
||||
{
|
||||
type: 'startPreset',
|
||||
presetName: 'lava-lamp',
|
||||
width: 16,
|
||||
height: 16,
|
||||
nodeIp: '192.168.1.100', // or null for broadcast
|
||||
parameters: {speed: 1.5, scale: 2.0}
|
||||
}
|
||||
```
|
||||
|
||||
### Frame Rendering
|
||||
|
||||
Each node has its own canvas with:
|
||||
- Independent rendering context
|
||||
- Automatic sizing based on container
|
||||
- Serpentine pixel order support
|
||||
- Glow effects for bright pixels
|
||||
- Smooth animations
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Visual Overview** - See all nodes at once
|
||||
2. **Independent Control** - Each node can run different animations
|
||||
3. **Space Efficient** - Responsive grid maximizes screen usage
|
||||
4. **Scalable** - Works with 1-20+ nodes
|
||||
5. **Intuitive** - Click to select, visual feedback
|
||||
6. **Flexible** - Individual or broadcast mode
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
- Old single-stream API still supported
|
||||
- Settings view unchanged
|
||||
- Existing presets work without modification
|
||||
- UDP discovery unchanged
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
### New Files
|
||||
- `/public/scripts/node-canvas-grid.js` - New component
|
||||
|
||||
### Modified Files
|
||||
- `/public/index.html` - Updated layout
|
||||
- `/public/styles/main.css` - New grid and floating control styles
|
||||
- `/public/scripts/preset-controls.js` - Per-node parameter tracking
|
||||
- `/public/scripts/ledlab-app.js` - Initialize new component
|
||||
- `/server/index.js` - Multi-node streaming support
|
||||
|
||||
### Unchanged Files
|
||||
- All preset files (`/presets/*.js`)
|
||||
- `/server/udp-discovery.js`
|
||||
- `/public/scripts/framework.js`
|
||||
- `/public/scripts/theme-manager.js`
|
||||
- `/public/scripts/navigation.js`
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
1. Test with single node
|
||||
2. Test with multiple nodes (2-4)
|
||||
3. Test node selection and switching
|
||||
4. Test broadcast mode
|
||||
5. Test parameter independence
|
||||
6. Test starting/stopping individual streams
|
||||
7. Test responsive layout on different screen sizes
|
||||
8. Test node disconnection/reconnection
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Drag to reorder nodes
|
||||
- Group nodes for collective control
|
||||
- Save/load multi-node scenes
|
||||
- Node status graphs
|
||||
- Performance metrics per node
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# SPORE LEDLab
|
||||
|
||||
LEDLab is a tool for streaming animations to SPORE nodes that implement the PixelStreamController.
|
||||
SPORE LEDLab is a powerful tool for streaming animations to LED matrices connected to SPORE nodes. Create stunning visual effects with real-time parameter control and multi-node streaming capabilities.
|
||||
|
||||

|
||||
|
||||
## Components
|
||||
|
||||
|
||||
BIN
assets/ledlab.png
Normal file
BIN
assets/ledlab.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
@@ -27,80 +27,70 @@
|
||||
<!-- Stream View -->
|
||||
<div id="stream-view" class="view-content active">
|
||||
<main class="ledlab-main">
|
||||
<!-- Matrix Display Section -->
|
||||
<section class="matrix-section">
|
||||
<div class="matrix-header">
|
||||
<h2 class="matrix-title">Matrix Display</h2>
|
||||
<div class="matrix-info">
|
||||
<span id="matrix-size">16x16</span> |
|
||||
<span id="streaming-status" class="status-indicator status-disconnected">Stopped</span>
|
||||
<!-- Full Width Matrix Grid Section -->
|
||||
<section class="matrix-grid-section">
|
||||
<div class="matrix-grid-header">
|
||||
<h2 class="matrix-title">SPORE Nodes</h2>
|
||||
<div class="matrix-grid-controls">
|
||||
<button class="btn btn-secondary btn-small" id="settings-toggle-btn">⚙️ Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="matrix-container">
|
||||
<canvas class="matrix-canvas" id="matrix-canvas"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Control Panel Section -->
|
||||
<section class="control-section">
|
||||
<!-- Node Discovery -->
|
||||
<div class="control-group">
|
||||
<h3 class="control-group-title">SPORE Nodes</h3>
|
||||
<div class="node-controls">
|
||||
<button class="btn btn-secondary" id="broadcast-btn">Broadcast to All</button>
|
||||
</div>
|
||||
<div class="node-list" id="node-list">
|
||||
<div class="node-canvas-grid" id="node-canvas-grid">
|
||||
<div class="loading">Discovering nodes...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Global Controls -->
|
||||
<div class="control-group">
|
||||
<h3 class="control-group-title">Global Settings</h3>
|
||||
<div class="global-controls">
|
||||
<div class="preset-control">
|
||||
<label class="preset-label">Frame Rate (FPS)</label>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="range" class="preset-slider" id="fps-slider" min="1" max="60" step="1" value="20">
|
||||
<span class="preset-value" id="fps-value">20</span>
|
||||
<!-- Floating Control Panel (appears when node is selected) -->
|
||||
<aside class="floating-controls" id="floating-controls" style="display: none;">
|
||||
<div class="floating-controls-header">
|
||||
<h3 class="control-title">
|
||||
<span class="node-indicator"></span>
|
||||
<span id="selected-node-name">No node selected</span>
|
||||
</h3>
|
||||
<button class="btn-close" id="close-controls-btn">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="floating-controls-content">
|
||||
<!-- Global Controls -->
|
||||
<div class="control-group">
|
||||
<h4 class="control-group-subtitle">Global Settings</h4>
|
||||
<div class="preset-control">
|
||||
<label class="preset-label">Frame Rate (FPS)</label>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="range" class="preset-slider" id="fps-slider" min="1" max="60" step="1" value="20">
|
||||
<span class="preset-value" id="fps-value">20</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preset Selection -->
|
||||
<div class="control-group">
|
||||
<h4 class="control-group-subtitle">Animation Preset</h4>
|
||||
<div class="preset-selector-wrapper">
|
||||
<select class="preset-select" id="preset-select">
|
||||
<option value="">Select a preset...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="preset-controls" id="preset-controls">
|
||||
<!-- Dynamic controls will be inserted here -->
|
||||
</div>
|
||||
<div class="btn-container">
|
||||
<button class="btn btn-primary" id="toggle-stream-btn" data-streaming="false">
|
||||
<span class="btn-icon">▶</span>
|
||||
<span class="btn-text">Start Streaming</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preset Selection -->
|
||||
<div class="control-group">
|
||||
<h3 class="control-group-title">Animation Presets</h3>
|
||||
<div class="preset-selector-wrapper">
|
||||
<select class="preset-select" id="preset-select">
|
||||
<option value="">Select a preset...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="preset-controls" id="preset-controls">
|
||||
<!-- Dynamic controls will be inserted here -->
|
||||
</div>
|
||||
<div class="btn-container">
|
||||
<button class="btn btn-primary" id="toggle-stream-btn" data-streaming="false">
|
||||
<span class="btn-icon">▶</span>
|
||||
<span class="btn-text">Start Streaming</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden Matrix Configuration (used by navigation.js) -->
|
||||
<div class="control-group" style="display: none;">
|
||||
<div class="matrix-config">
|
||||
<div class="matrix-input">
|
||||
<input type="number" id="matrix-width" min="1" max="32" value="16">
|
||||
</div>
|
||||
<div class="matrix-input">
|
||||
<input type="number" id="matrix-height" min="1" max="32" value="16">
|
||||
</div>
|
||||
<!-- Hidden Matrix Configuration -->
|
||||
<div style="display: none;">
|
||||
<input type="number" id="matrix-width" min="1" max="32" value="16">
|
||||
<input type="number" id="matrix-height" min="1" max="32" value="16">
|
||||
<button id="apply-matrix-btn">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Settings View -->
|
||||
@@ -148,8 +138,8 @@
|
||||
<script src="scripts/framework.js"></script>
|
||||
<script src="scripts/navigation.js"></script>
|
||||
<script src="scripts/matrix-display.js"></script>
|
||||
<script src="scripts/node-canvas-grid.js"></script>
|
||||
<script src="scripts/preset-controls.js"></script>
|
||||
<script src="scripts/node-discovery.js"></script>
|
||||
<script src="scripts/ledlab-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -33,26 +33,26 @@ class LEDLabApp {
|
||||
}
|
||||
|
||||
initComponents() {
|
||||
// Initialize Matrix Display component
|
||||
// Initialize Node Canvas Grid component (new multi-canvas view)
|
||||
const gridSection = document.querySelector('.matrix-grid-section');
|
||||
if (gridSection) {
|
||||
this.nodeCanvasGrid = new NodeCanvasGrid(gridSection, this.viewModel, this.eventBus);
|
||||
this.nodeCanvasGrid.mount();
|
||||
}
|
||||
|
||||
// Initialize Preset Controls component (now works with floating controls)
|
||||
const floatingControls = document.querySelector('#floating-controls');
|
||||
if (floatingControls) {
|
||||
this.presetControls = new PresetControls(floatingControls, this.viewModel, this.eventBus);
|
||||
this.presetControls.mount();
|
||||
}
|
||||
|
||||
// Keep old Matrix Display component for backwards compatibility with settings view
|
||||
const matrixContainer = document.querySelector('.matrix-section');
|
||||
if (matrixContainer) {
|
||||
this.matrixDisplay = new MatrixDisplay(matrixContainer, this.viewModel, this.eventBus);
|
||||
this.matrixDisplay.mount();
|
||||
}
|
||||
|
||||
// Initialize Preset Controls component
|
||||
const controlsContainer = document.querySelector('.control-section');
|
||||
if (controlsContainer) {
|
||||
this.presetControls = new PresetControls(controlsContainer, this.viewModel, this.eventBus);
|
||||
this.presetControls.mount();
|
||||
}
|
||||
|
||||
// Initialize Node Discovery component
|
||||
const nodeContainer = document.querySelector('#node-list').parentElement;
|
||||
if (nodeContainer) {
|
||||
this.nodeDiscovery = new NodeDiscovery(nodeContainer, this.viewModel, this.eventBus);
|
||||
this.nodeDiscovery.mount();
|
||||
}
|
||||
}
|
||||
|
||||
connectWebSocket() {
|
||||
@@ -180,13 +180,6 @@ class LEDLabApp {
|
||||
});
|
||||
});
|
||||
|
||||
this.eventBus.subscribe('broadcastToAll', (data) => {
|
||||
this.sendWebSocketMessage({
|
||||
type: 'broadcastToAll',
|
||||
...data
|
||||
});
|
||||
});
|
||||
|
||||
this.eventBus.subscribe('selectNode', (data) => {
|
||||
this.sendWebSocketMessage({
|
||||
type: 'selectNode',
|
||||
@@ -194,13 +187,6 @@ class LEDLabApp {
|
||||
});
|
||||
});
|
||||
|
||||
this.eventBus.subscribe('selectBroadcast', (data) => {
|
||||
this.sendWebSocketMessage({
|
||||
type: 'selectBroadcast',
|
||||
...data
|
||||
});
|
||||
});
|
||||
|
||||
this.eventBus.subscribe('updateFrameRate', (data) => {
|
||||
this.sendWebSocketMessage({
|
||||
type: 'updateFrameRate',
|
||||
@@ -235,10 +221,6 @@ class LEDLabApp {
|
||||
sendToNode(nodeIp, message) {
|
||||
this.viewModel.publish('sendToNode', { nodeIp, message });
|
||||
}
|
||||
|
||||
broadcastToAll(message) {
|
||||
this.viewModel.publish('broadcastToAll', { message });
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the app when DOM is loaded
|
||||
|
||||
486
public/scripts/node-canvas-grid.js
Normal file
486
public/scripts/node-canvas-grid.js
Normal file
@@ -0,0 +1,486 @@
|
||||
// Node Canvas Grid Component - Displays multiple SPORE nodes with individual canvases
|
||||
|
||||
class NodeCanvasGrid extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
this.nodes = [];
|
||||
this.selectedNode = null;
|
||||
this.nodeCanvases = new Map(); // Store canvas contexts for each node
|
||||
this.nodeFrameData = new Map(); // Store frame data for each node
|
||||
this.matrixWidth = 16;
|
||||
this.matrixHeight = 16;
|
||||
this.pendingRenders = new Set(); // Track which nodes need rendering
|
||||
this.animationFrameId = null; // Track animation frame
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.loadNodes();
|
||||
this.startPeriodicRefresh();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Settings toggle button
|
||||
const settingsToggleBtn = document.getElementById('settings-toggle-btn');
|
||||
if (settingsToggleBtn) {
|
||||
this.addEventListener(settingsToggleBtn, 'click', () => {
|
||||
if (window.navigationManager) {
|
||||
window.navigationManager.switchView('settings');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Close controls button
|
||||
const closeControlsBtn = document.getElementById('close-controls-btn');
|
||||
if (closeControlsBtn) {
|
||||
this.addEventListener(closeControlsBtn, 'click', () => {
|
||||
this.deselectNode();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
this.subscribeToEvent('nodeDiscovered', (data) => {
|
||||
this.addOrUpdateNode(data.node);
|
||||
});
|
||||
|
||||
this.subscribeToEvent('nodeLost', (data) => {
|
||||
this.removeNode(data.node.ip);
|
||||
});
|
||||
|
||||
this.subscribeToEvent('frame', (data) => {
|
||||
// Update frame data for specific node
|
||||
if (data.nodeIp) {
|
||||
this.nodeFrameData.set(data.nodeIp, data.data);
|
||||
this.scheduleRender(data.nodeIp);
|
||||
}
|
||||
});
|
||||
|
||||
this.subscribeToEvent('matrixSizeChanged', (data) => {
|
||||
this.matrixWidth = data.size.width;
|
||||
this.matrixHeight = data.size.height;
|
||||
this.renderAllCanvases();
|
||||
});
|
||||
|
||||
this.subscribeToEvent('streamingStarted', (data) => {
|
||||
if (data.nodeIp) {
|
||||
this.updateNodeStreamingStatus(data.nodeIp, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.subscribeToEvent('streamingStopped', (data) => {
|
||||
if (data.nodeIp) {
|
||||
this.updateNodeStreamingStatus(data.nodeIp, false);
|
||||
}
|
||||
});
|
||||
|
||||
this.subscribeToEvent('status', (data) => {
|
||||
// Update UI to reflect current server state
|
||||
if (data.data.nodes) {
|
||||
this.nodes = data.data.nodes;
|
||||
this.renderNodeGrid();
|
||||
}
|
||||
if (data.data.matrixSize) {
|
||||
this.matrixWidth = data.data.matrixSize.width;
|
||||
this.matrixHeight = data.data.matrixSize.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadNodes() {
|
||||
try {
|
||||
const response = await fetch('/api/nodes');
|
||||
const data = await response.json();
|
||||
|
||||
// Filter out any broadcast nodes
|
||||
this.nodes = (data.nodes || []).filter(node => node.ip !== 'broadcast');
|
||||
this.renderNodeGrid();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading nodes:', error);
|
||||
this.showError('Failed to load nodes');
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicRefresh() {
|
||||
// Refresh node list every 5 seconds
|
||||
setInterval(() => {
|
||||
this.loadNodes();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
addOrUpdateNode(node) {
|
||||
// Never add broadcast nodes
|
||||
if (node.ip === 'broadcast') {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIndex = this.nodes.findIndex(n => n.ip === node.ip);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing node
|
||||
this.nodes[existingIndex] = { ...node, lastSeen: Date.now() };
|
||||
} else {
|
||||
// Add new node
|
||||
this.nodes.push({ ...node, lastSeen: Date.now() });
|
||||
}
|
||||
|
||||
this.renderNodeGrid();
|
||||
}
|
||||
|
||||
removeNode(nodeIp) {
|
||||
// Don't process broadcast nodes
|
||||
if (nodeIp === 'broadcast') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.nodes = this.nodes.filter(node => node.ip !== nodeIp);
|
||||
this.nodeCanvases.delete(nodeIp);
|
||||
this.nodeFrameData.delete(nodeIp);
|
||||
|
||||
// If the removed node was selected, deselect it
|
||||
if (this.selectedNode === nodeIp) {
|
||||
this.deselectNode();
|
||||
}
|
||||
|
||||
this.renderNodeGrid();
|
||||
}
|
||||
|
||||
renderNodeGrid() {
|
||||
const gridContainer = document.getElementById('node-canvas-grid');
|
||||
if (!gridContainer) return;
|
||||
|
||||
// Clear existing content
|
||||
gridContainer.innerHTML = '';
|
||||
|
||||
// Filter out broadcast nodes one more time for safety
|
||||
const validNodes = this.nodes.filter(node => node.ip !== 'broadcast');
|
||||
|
||||
if (validNodes.length === 0) {
|
||||
gridContainer.innerHTML = '<div class="empty-state">No nodes discovered. Waiting for SPORE nodes...</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
validNodes.forEach(node => {
|
||||
const nodeItem = this.createNodeCanvasItem(node);
|
||||
gridContainer.appendChild(nodeItem);
|
||||
});
|
||||
|
||||
// Update visual selection
|
||||
this.updateNodeSelection();
|
||||
}
|
||||
|
||||
createNodeCanvasItem(node) {
|
||||
const nodeItem = document.createElement('div');
|
||||
nodeItem.className = `node-canvas-item ${node.status || 'connected'}`;
|
||||
nodeItem.dataset.nodeIp = node.ip;
|
||||
|
||||
// Create header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'node-canvas-header';
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'node-canvas-title';
|
||||
|
||||
const ip = document.createElement('div');
|
||||
ip.className = 'node-canvas-ip';
|
||||
ip.textContent = node.ip;
|
||||
|
||||
const status = document.createElement('div');
|
||||
status.className = `node-canvas-status ${node.status || 'connected'}`;
|
||||
status.textContent = node.status || 'connected';
|
||||
|
||||
title.appendChild(ip);
|
||||
header.appendChild(title);
|
||||
header.appendChild(status);
|
||||
|
||||
// Create canvas container
|
||||
const canvasContainer = document.createElement('div');
|
||||
canvasContainer.className = 'node-canvas-container';
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'node-canvas';
|
||||
canvas.dataset.nodeIp = node.ip;
|
||||
|
||||
canvasContainer.appendChild(canvas);
|
||||
|
||||
// Assemble node item
|
||||
nodeItem.appendChild(header);
|
||||
nodeItem.appendChild(canvasContainer);
|
||||
|
||||
// Setup canvas
|
||||
this.setupNodeCanvas(node.ip, canvas);
|
||||
|
||||
// Add click handler for node selection
|
||||
nodeItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.selectNode(node.ip);
|
||||
});
|
||||
|
||||
return nodeItem;
|
||||
}
|
||||
|
||||
setupNodeCanvas(nodeIp, canvas) {
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
if (!ctx) {
|
||||
console.error('Failed to get canvas context for node:', nodeIp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create off-screen canvas for double buffering
|
||||
const offscreenCanvas = document.createElement('canvas');
|
||||
const offscreenCtx = offscreenCanvas.getContext('2d', { alpha: false });
|
||||
|
||||
// Set initial size for offscreen canvas
|
||||
offscreenCanvas.width = canvas.width || 320;
|
||||
offscreenCanvas.height = canvas.height || 320;
|
||||
|
||||
// Configure both contexts
|
||||
[ctx, offscreenCtx].forEach(context => {
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.webkitImageSmoothingEnabled = false;
|
||||
context.mozImageSmoothingEnabled = false;
|
||||
context.msImageSmoothingEnabled = false;
|
||||
});
|
||||
|
||||
// Store both canvas contexts
|
||||
this.nodeCanvases.set(nodeIp, {
|
||||
canvas,
|
||||
ctx,
|
||||
offscreenCanvas,
|
||||
offscreenCtx,
|
||||
resizeTimer: null
|
||||
});
|
||||
|
||||
// Function to update canvas size
|
||||
const updateCanvasSize = () => {
|
||||
const container = canvas.parentElement;
|
||||
const containerWidth = container.clientWidth;
|
||||
const containerHeight = container.clientHeight;
|
||||
|
||||
// Calculate pixel size to maximize canvas space
|
||||
const maxPixelWidth = Math.floor(containerWidth / this.matrixWidth);
|
||||
const maxPixelHeight = Math.floor(containerHeight / this.matrixHeight);
|
||||
const pixelSize = Math.min(maxPixelWidth, maxPixelHeight);
|
||||
|
||||
const newWidth = this.matrixWidth * pixelSize;
|
||||
const newHeight = this.matrixHeight * pixelSize;
|
||||
|
||||
// Only update if size actually changed
|
||||
if (canvas.width !== newWidth || canvas.height !== newHeight) {
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
offscreenCanvas.width = newWidth;
|
||||
offscreenCanvas.height = newHeight;
|
||||
this.renderNodeCanvas(nodeIp);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial size - call immediately and wait for next frame
|
||||
requestAnimationFrame(() => {
|
||||
updateCanvasSize();
|
||||
// Render initial frame if we have data
|
||||
if (this.nodeFrameData.has(nodeIp)) {
|
||||
this.renderNodeCanvas(nodeIp);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize with debouncing
|
||||
let resizeTimer = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
// Clear existing timer
|
||||
if (resizeTimer) {
|
||||
clearTimeout(resizeTimer);
|
||||
}
|
||||
|
||||
// Set new timer with debounce
|
||||
resizeTimer = setTimeout(() => {
|
||||
updateCanvasSize();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
resizeObserver.observe(canvas.parentElement);
|
||||
}
|
||||
|
||||
scheduleRender(nodeIp) {
|
||||
// Add node to pending renders
|
||||
this.pendingRenders.add(nodeIp);
|
||||
|
||||
// Schedule animation frame if not already scheduled
|
||||
if (!this.animationFrameId) {
|
||||
this.animationFrameId = requestAnimationFrame(() => {
|
||||
this.renderPendingFrames();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderPendingFrames() {
|
||||
// Render all pending nodes
|
||||
this.pendingRenders.forEach(nodeIp => {
|
||||
this.renderNodeCanvas(nodeIp);
|
||||
});
|
||||
|
||||
// Clear pending renders and animation frame ID
|
||||
this.pendingRenders.clear();
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
|
||||
renderNodeCanvas(nodeIp) {
|
||||
const canvasData = this.nodeCanvases.get(nodeIp);
|
||||
if (!canvasData) return;
|
||||
|
||||
const { canvas, ctx, offscreenCanvas, offscreenCtx } = canvasData;
|
||||
const frameData = this.nodeFrameData.get(nodeIp);
|
||||
|
||||
// Check if canvases are properly sized
|
||||
if (!canvas.width || !canvas.height || !offscreenCanvas.width || !offscreenCanvas.height) {
|
||||
console.warn(`Canvas not sized for ${nodeIp}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no frame data to prevent unnecessary clears
|
||||
if (!frameData || !frameData.startsWith('RAW:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pixelData = frameData.substring(4);
|
||||
const pixelSize = offscreenCanvas.width / this.matrixWidth;
|
||||
|
||||
// Render to off-screen canvas (not visible, no flicker)
|
||||
offscreenCtx.fillStyle = '#000000';
|
||||
offscreenCtx.fillRect(0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
|
||||
// Batch render all pixels to off-screen canvas
|
||||
for (let row = 0; row < this.matrixHeight; row++) {
|
||||
for (let col = 0; col < this.matrixWidth; col++) {
|
||||
// Calculate serpentine index
|
||||
const hardwareIndex = (row % 2 === 0) ?
|
||||
(row * this.matrixWidth + col) :
|
||||
(row * this.matrixWidth + (this.matrixWidth - 1 - col));
|
||||
const pixelStart = hardwareIndex * 6;
|
||||
|
||||
if (pixelStart + 5 < pixelData.length) {
|
||||
const hexColor = pixelData.substring(pixelStart, pixelStart + 6);
|
||||
|
||||
// Quick parse RGB
|
||||
const r = parseInt(hexColor.substring(0, 2), 16);
|
||||
const g = parseInt(hexColor.substring(2, 4), 16);
|
||||
const b = parseInt(hexColor.substring(4, 6), 16);
|
||||
|
||||
// Skip black pixels for performance
|
||||
if (r === 0 && g === 0 && b === 0) continue;
|
||||
|
||||
// Draw pixel to off-screen canvas
|
||||
const x = col * pixelSize;
|
||||
const y = row * pixelSize;
|
||||
offscreenCtx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
offscreenCtx.fillRect(x, y, pixelSize, pixelSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy the complete off-screen canvas to visible canvas in one operation (smooth!)
|
||||
ctx.drawImage(offscreenCanvas, 0, 0);
|
||||
}
|
||||
|
||||
|
||||
renderAllCanvases() {
|
||||
this.nodes.forEach(node => {
|
||||
this.renderNodeCanvas(node.ip);
|
||||
});
|
||||
}
|
||||
|
||||
selectNode(nodeIp) {
|
||||
this.selectedNode = nodeIp;
|
||||
this.updateNodeSelection();
|
||||
this.showFloatingControls(nodeIp);
|
||||
|
||||
// Notify other components
|
||||
this.viewModel.publish('selectNode', { nodeIp });
|
||||
}
|
||||
|
||||
deselectNode() {
|
||||
this.selectedNode = null;
|
||||
this.updateNodeSelection();
|
||||
this.hideFloatingControls();
|
||||
}
|
||||
|
||||
updateNodeSelection() {
|
||||
const gridContainer = document.getElementById('node-canvas-grid');
|
||||
if (!gridContainer) return;
|
||||
|
||||
// Remove selected class from all items
|
||||
gridContainer.querySelectorAll('.node-canvas-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
|
||||
// Add selected class to the selected node
|
||||
if (this.selectedNode) {
|
||||
const selectedItem = gridContainer.querySelector(`[data-node-ip="${this.selectedNode}"]`);
|
||||
if (selectedItem) {
|
||||
selectedItem.classList.add('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showFloatingControls(nodeIp) {
|
||||
const floatingControls = document.getElementById('floating-controls');
|
||||
const selectedNodeName = document.getElementById('selected-node-name');
|
||||
|
||||
if (floatingControls && selectedNodeName) {
|
||||
selectedNodeName.textContent = nodeIp;
|
||||
floatingControls.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
hideFloatingControls() {
|
||||
const floatingControls = document.getElementById('floating-controls');
|
||||
if (floatingControls) {
|
||||
floatingControls.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updateNodeStreamingStatus(nodeIp, isStreaming) {
|
||||
const gridContainer = document.getElementById('node-canvas-grid');
|
||||
if (!gridContainer) return;
|
||||
|
||||
const nodeItem = gridContainer.querySelector(`[data-node-ip="${nodeIp}"]`);
|
||||
if (nodeItem) {
|
||||
if (isStreaming) {
|
||||
nodeItem.classList.add('streaming');
|
||||
} else {
|
||||
nodeItem.classList.remove('streaming');
|
||||
}
|
||||
|
||||
const statusElement = nodeItem.querySelector('.node-canvas-status');
|
||||
if (statusElement) {
|
||||
statusElement.textContent = isStreaming ? 'streaming' : 'connected';
|
||||
statusElement.className = `node-canvas-status ${isStreaming ? 'streaming' : 'connected'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const gridContainer = document.getElementById('node-canvas-grid');
|
||||
if (gridContainer) {
|
||||
gridContainer.innerHTML = `<div class="error">${this.escapeHtml(message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = NodeCanvasGrid;
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
// Node Discovery Component
|
||||
|
||||
class NodeDiscovery extends Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
super(container, viewModel, eventBus);
|
||||
this.nodes = [];
|
||||
this.currentTarget = null;
|
||||
}
|
||||
|
||||
mount() {
|
||||
super.mount();
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.loadNodes();
|
||||
this.startPeriodicRefresh();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Broadcast button
|
||||
const broadcastBtn = this.findElement('#broadcast-btn');
|
||||
if (broadcastBtn) {
|
||||
this.addEventListener(broadcastBtn, 'click', () => {
|
||||
this.selectBroadcastTarget();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
this.subscribeToEvent('nodeDiscovered', (data) => {
|
||||
this.addOrUpdateNode(data.node);
|
||||
});
|
||||
|
||||
this.subscribeToEvent('nodeLost', (data) => {
|
||||
this.removeNode(data.node.ip);
|
||||
});
|
||||
|
||||
this.subscribeToEvent('status', (data) => {
|
||||
// Update UI to reflect current server state
|
||||
if (data.data.nodes) {
|
||||
this.nodes = data.data.nodes;
|
||||
this.currentTarget = data.data.currentTarget;
|
||||
this.renderNodeList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadNodes() {
|
||||
try {
|
||||
const response = await fetch('/api/nodes');
|
||||
const data = await response.json();
|
||||
|
||||
this.nodes = data.nodes || [];
|
||||
this.renderNodeList();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading nodes:', error);
|
||||
this.showError('Failed to load nodes');
|
||||
}
|
||||
}
|
||||
|
||||
startPeriodicRefresh() {
|
||||
// Refresh node list every 5 seconds
|
||||
setInterval(() => {
|
||||
this.loadNodes();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
addOrUpdateNode(node) {
|
||||
const existingIndex = this.nodes.findIndex(n => n.ip === node.ip);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing node
|
||||
this.nodes[existingIndex] = { ...node, lastSeen: Date.now() };
|
||||
} else {
|
||||
// Add new node
|
||||
this.nodes.push({ ...node, lastSeen: Date.now() });
|
||||
}
|
||||
|
||||
this.renderNodeList();
|
||||
}
|
||||
|
||||
removeNode(nodeIp) {
|
||||
this.nodes = this.nodes.filter(node => node.ip !== nodeIp);
|
||||
this.renderNodeList();
|
||||
}
|
||||
|
||||
renderNodeList() {
|
||||
const nodeListContainer = this.findElement('#node-list');
|
||||
if (!nodeListContainer) return;
|
||||
|
||||
if (this.nodes.length === 0) {
|
||||
nodeListContainer.innerHTML = '<div class="empty-state">No nodes discovered</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = this.nodes.map(node => `
|
||||
<div class="node-item ${node.status} ${node.ip === this.currentTarget ? 'selected' : ''}" data-ip="${this.escapeHtml(node.ip)}" style="cursor: pointer;">
|
||||
<div class="node-indicator ${node.status}"></div>
|
||||
<div class="node-info">
|
||||
<div class="node-ip">${this.escapeHtml(node.ip === 'broadcast' ? 'Broadcast' : node.ip)}</div>
|
||||
<div class="node-status">${node.status} • Port ${node.port}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
nodeListContainer.innerHTML = html;
|
||||
|
||||
// Add click handlers for node selection
|
||||
this.nodes.forEach(node => {
|
||||
const nodeElement = nodeListContainer.querySelector(`[data-ip="${node.ip}"]`);
|
||||
if (nodeElement) {
|
||||
this.addEventListener(nodeElement, 'click', () => {
|
||||
this.selectNode(node.ip);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectNode(nodeIp) {
|
||||
this.currentTarget = nodeIp;
|
||||
this.viewModel.publish('selectNode', { nodeIp });
|
||||
|
||||
// Update visual selection
|
||||
const nodeListContainer = this.findElement('#node-list');
|
||||
if (nodeListContainer) {
|
||||
nodeListContainer.querySelectorAll('.node-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
const selectedNode = nodeListContainer.querySelector(`[data-ip="${nodeIp}"]`);
|
||||
if (selectedNode) {
|
||||
selectedNode.classList.add('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectBroadcast() {
|
||||
this.currentTarget = 'broadcast';
|
||||
this.viewModel.publish('selectBroadcast', {});
|
||||
|
||||
// Update visual selection
|
||||
const nodeListContainer = this.findElement('#node-list');
|
||||
if (nodeListContainer) {
|
||||
nodeListContainer.querySelectorAll('.node-item').forEach(item => {
|
||||
item.classList.remove('selected');
|
||||
});
|
||||
const broadcastNode = nodeListContainer.querySelector(`[data-ip="broadcast"]`);
|
||||
if (broadcastNode) {
|
||||
broadcastNode.classList.add('selected');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const nodeListContainer = this.findElement('#node-list');
|
||||
if (nodeListContainer) {
|
||||
nodeListContainer.innerHTML = `<div class="error">${this.escapeHtml(message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Public method to select broadcast (called from outside)
|
||||
selectBroadcastTarget() {
|
||||
this.selectBroadcast();
|
||||
}
|
||||
|
||||
escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = NodeDiscovery;
|
||||
}
|
||||
@@ -6,6 +6,8 @@ class PresetControls extends Component {
|
||||
this.presets = {};
|
||||
this.currentPreset = null;
|
||||
this.presetControls = new Map();
|
||||
this.selectedNode = null; // Track which node is currently selected
|
||||
this.nodeParameters = new Map(); // Store parameters per node: nodeIp -> { presetName, parameters }
|
||||
}
|
||||
|
||||
mount() {
|
||||
@@ -80,28 +82,36 @@ class PresetControls extends Component {
|
||||
}
|
||||
|
||||
setupViewModelListeners() {
|
||||
this.subscribeToEvent('streamingStarted', (data) => {
|
||||
this.updateStreamingState(true, data.preset);
|
||||
// Listen for node selection
|
||||
this.subscribeToEvent('selectNode', (data) => {
|
||||
this.selectedNode = data.nodeIp;
|
||||
this.loadNodeSettings();
|
||||
});
|
||||
|
||||
this.subscribeToEvent('streamingStopped', () => {
|
||||
this.updateStreamingState(false);
|
||||
this.subscribeToEvent('streamingStarted', (data) => {
|
||||
this.updateStreamingState(true, data.preset, data.nodeIp);
|
||||
});
|
||||
|
||||
this.subscribeToEvent('streamingStopped', (data) => {
|
||||
this.updateStreamingState(false, null, data.nodeIp);
|
||||
});
|
||||
|
||||
this.subscribeToEvent('presetParameterUpdated', (data) => {
|
||||
// Update control display without triggering another update
|
||||
const control = this.presetControls.get(data.parameter);
|
||||
if (control) {
|
||||
if (control.type === 'range') {
|
||||
control.value = data.value;
|
||||
const valueDisplay = control.parentElement.querySelector('.preset-value');
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = parseFloat(data.value).toFixed(2);
|
||||
// Only update if this is for the currently selected node
|
||||
if (data.nodeIp === this.selectedNode) {
|
||||
const control = this.presetControls.get(data.parameter);
|
||||
if (control) {
|
||||
if (control.type === 'range') {
|
||||
control.value = data.value;
|
||||
const valueDisplay = control.parentElement.querySelector('.preset-value');
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = parseFloat(data.value).toFixed(2);
|
||||
}
|
||||
} else if (control.type === 'color') {
|
||||
control.value = this.hexToColorValue(data.value);
|
||||
} else {
|
||||
control.value = data.value;
|
||||
}
|
||||
} else if (control.type === 'color') {
|
||||
control.value = this.hexToColorValue(data.value);
|
||||
} else {
|
||||
control.value = data.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -202,9 +212,60 @@ class PresetControls extends Component {
|
||||
}
|
||||
|
||||
this.currentPreset = this.presets[presetName];
|
||||
|
||||
// Store preset selection for current node
|
||||
if (this.selectedNode) {
|
||||
const nodeParams = this.nodeParameters.get(this.selectedNode) || {};
|
||||
nodeParams.presetName = presetName;
|
||||
this.nodeParameters.set(this.selectedNode, nodeParams);
|
||||
}
|
||||
|
||||
this.createPresetControls();
|
||||
}
|
||||
|
||||
loadNodeSettings() {
|
||||
if (!this.selectedNode) return;
|
||||
|
||||
// Get stored parameters for this node
|
||||
const nodeParams = this.nodeParameters.get(this.selectedNode);
|
||||
|
||||
if (nodeParams && nodeParams.presetName) {
|
||||
// Load the preset and restore parameters
|
||||
const presetSelect = this.findElement('#preset-select');
|
||||
if (presetSelect && presetSelect.value !== nodeParams.presetName) {
|
||||
presetSelect.value = nodeParams.presetName;
|
||||
this.selectPreset(nodeParams.presetName);
|
||||
}
|
||||
|
||||
// Restore parameter values
|
||||
if (nodeParams.parameters) {
|
||||
Object.entries(nodeParams.parameters).forEach(([param, value]) => {
|
||||
const control = this.presetControls.get(param);
|
||||
if (control) {
|
||||
if (control.type === 'range') {
|
||||
control.value = value;
|
||||
const valueDisplay = control.parentElement.querySelector('.preset-value');
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = parseFloat(value).toFixed(2);
|
||||
}
|
||||
} else if (control.type === 'color') {
|
||||
control.value = this.hexToColorValue(value);
|
||||
} else {
|
||||
control.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Reset to default
|
||||
const presetSelect = this.findElement('#preset-select');
|
||||
if (presetSelect) {
|
||||
presetSelect.value = '';
|
||||
this.clearPresetControls();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createPresetControls() {
|
||||
const controlsContainer = this.findElement('#preset-controls');
|
||||
if (!controlsContainer) return;
|
||||
@@ -313,13 +374,24 @@ class PresetControls extends Component {
|
||||
}
|
||||
|
||||
updatePresetParameter(parameter, value) {
|
||||
// Store parameter for current node
|
||||
if (this.selectedNode) {
|
||||
const nodeParams = this.nodeParameters.get(this.selectedNode) || {};
|
||||
if (!nodeParams.parameters) {
|
||||
nodeParams.parameters = {};
|
||||
}
|
||||
nodeParams.parameters[parameter] = value;
|
||||
this.nodeParameters.set(this.selectedNode, nodeParams);
|
||||
}
|
||||
|
||||
// Send parameter update to server immediately (real-time)
|
||||
this.viewModel.publish('updatePresetParameter', {
|
||||
parameter,
|
||||
value
|
||||
value,
|
||||
nodeIp: this.selectedNode
|
||||
});
|
||||
|
||||
console.log(`Parameter updated: ${parameter} = ${value}`);
|
||||
console.log(`Parameter updated for ${this.selectedNode}: ${parameter} = ${value}`);
|
||||
}
|
||||
|
||||
clearPresetControls() {
|
||||
@@ -355,21 +427,39 @@ class PresetControls extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedNode) {
|
||||
alert('Please select a node first');
|
||||
return;
|
||||
}
|
||||
|
||||
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
|
||||
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
|
||||
|
||||
// Get current parameters for this node
|
||||
const nodeParams = this.nodeParameters.get(this.selectedNode);
|
||||
const parameters = nodeParams?.parameters || {};
|
||||
|
||||
this.viewModel.publish('startPreset', {
|
||||
presetName: presetSelect.value,
|
||||
width,
|
||||
height
|
||||
height,
|
||||
nodeIp: this.selectedNode,
|
||||
parameters
|
||||
});
|
||||
}
|
||||
|
||||
stopStreaming() {
|
||||
this.viewModel.publish('stopStreaming', {});
|
||||
this.viewModel.publish('stopStreaming', {
|
||||
nodeIp: this.selectedNode
|
||||
});
|
||||
}
|
||||
|
||||
sendTestFrame() {
|
||||
if (!this.selectedNode) {
|
||||
alert('Please select a node first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a test frame with a simple pattern in serpentine order
|
||||
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
|
||||
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
|
||||
@@ -390,12 +480,18 @@ class PresetControls extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
this.viewModel.publish('broadcastToAll', {
|
||||
this.viewModel.publish('sendToNode', {
|
||||
nodeIp: this.selectedNode,
|
||||
message: frameData
|
||||
});
|
||||
}
|
||||
|
||||
clearMatrix() {
|
||||
if (!this.selectedNode) {
|
||||
alert('Please select a node first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send a frame with all black pixels in serpentine order
|
||||
const width = parseInt(this.findElement('#matrix-width')?.value) || 16;
|
||||
const height = parseInt(this.findElement('#matrix-height')?.value) || 16;
|
||||
@@ -411,7 +507,8 @@ class PresetControls extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
this.viewModel.publish('broadcastToAll', {
|
||||
this.viewModel.publish('sendToNode', {
|
||||
nodeIp: this.selectedNode,
|
||||
message: frameData
|
||||
});
|
||||
}
|
||||
@@ -425,7 +522,12 @@ class PresetControls extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
updateStreamingState(isStreaming, preset) {
|
||||
updateStreamingState(isStreaming, preset, nodeIp) {
|
||||
// Only update UI if this is for the currently selected node
|
||||
if (nodeIp !== this.selectedNode && nodeIp !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleBtn = this.findElement('#toggle-stream-btn');
|
||||
const btnIcon = toggleBtn?.querySelector('.btn-icon');
|
||||
const btnText = toggleBtn?.querySelector('.btn-text');
|
||||
|
||||
@@ -146,10 +146,10 @@ body {
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--accent-primary);
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2);
|
||||
box-shadow: 0 4px 20px rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
@@ -207,12 +207,152 @@ body {
|
||||
.ledlab-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 1.5rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Matrix display section */
|
||||
/* Matrix Grid Section - Full Width */
|
||||
.matrix-grid-section {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
box-shadow: var(--shadow-primary);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
}
|
||||
|
||||
.matrix-grid-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.matrix-grid-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Node Canvas Grid */
|
||||
.node-canvas-grid {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-auto-rows: minmax(min(200px, calc((100vh - 10rem) / 3)), max(200px, calc((100vh - 10rem) / 2)));
|
||||
gap: 0.75rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.node-canvas-item {
|
||||
background: var(--matrix-bg);
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--border-secondary);
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.node-canvas-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.node-canvas-item.selected {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.2), 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.node-canvas-item.streaming {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.node-canvas-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.node-canvas-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.node-canvas-ip {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
|
||||
.node-canvas-status {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.node-canvas-status.connected {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--node-connected);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.node-canvas-status.streaming {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: var(--accent-primary);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.node-canvas-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--matrix-bg);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node-canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Old Matrix display section - kept for backwards compatibility */
|
||||
.matrix-section {
|
||||
flex: 1;
|
||||
background: var(--bg-secondary);
|
||||
@@ -310,7 +450,92 @@ body {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Control panel section */
|
||||
/* Floating Control Panel */
|
||||
.floating-controls {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 4rem);
|
||||
max-height: calc(100vh - 12rem);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: var(--backdrop-blur);
|
||||
z-index: 100;
|
||||
animation: slideInFromRight 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideInFromRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-controls-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.control-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.control-title .node-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--node-connected);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.floating-controls-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Old Control panel section - kept for backwards compatibility */
|
||||
.control-section {
|
||||
width: 380px;
|
||||
min-width: 320px;
|
||||
@@ -358,6 +583,24 @@ body {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.control-group-subtitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-group-subtitle::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Node list */
|
||||
.node-controls {
|
||||
margin-bottom: 1rem;
|
||||
@@ -819,34 +1062,121 @@ body {
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.control-section::-webkit-scrollbar {
|
||||
.control-section::-webkit-scrollbar,
|
||||
.floating-controls-content::-webkit-scrollbar,
|
||||
.node-canvas-grid::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.control-section::-webkit-scrollbar-track {
|
||||
.control-section::-webkit-scrollbar-track,
|
||||
.floating-controls-content::-webkit-scrollbar-track,
|
||||
.node-canvas-grid::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.control-section::-webkit-scrollbar-thumb {
|
||||
.control-section::-webkit-scrollbar-thumb,
|
||||
.floating-controls-content::-webkit-scrollbar-thumb,
|
||||
.node-canvas-grid::-webkit-scrollbar-thumb {
|
||||
background: var(--border-primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.control-section::-webkit-scrollbar-thumb:hover {
|
||||
.control-section::-webkit-scrollbar-thumb:hover,
|
||||
.floating-controls-content::-webkit-scrollbar-thumb:hover,
|
||||
.node-canvas-grid::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (min-width: 1600px) {
|
||||
.node-canvas-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
grid-auto-rows: minmax(220px, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.node-canvas-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
grid-auto-rows: minmax(180px, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.node-canvas-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
grid-auto-rows: minmax(min(160px, calc((100vh - 8rem) / 3)), max(160px, calc((100vh - 8rem) / 2)));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-canvas-item {
|
||||
padding: 0.625rem;
|
||||
}
|
||||
|
||||
.node-canvas-ip {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
gap: 1rem;
|
||||
padding: 0 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ledlab-main {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.node-canvas-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
grid-auto-rows: minmax(min(140px, calc((100vh - 7rem) / 3)), max(140px, calc((100vh - 7rem) / 2)));
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.node-canvas-item {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.node-canvas-header {
|
||||
margin-bottom: 0.375rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node-canvas-ip {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.node-canvas-status {
|
||||
font-size: 0.55rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
}
|
||||
|
||||
.floating-controls {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
animation: slideInFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideInFromBottom {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.control-section {
|
||||
|
||||
283
server/index.js
283
server/index.js
@@ -24,14 +24,16 @@ class LEDLabServer {
|
||||
this.udpDiscovery = new UdpDiscovery(this.udpPort);
|
||||
this.presetRegistry = new PresetRegistry();
|
||||
|
||||
// Legacy single-stream support (kept for backwards compatibility)
|
||||
this.currentPreset = null;
|
||||
this.currentPresetName = null;
|
||||
this.streamingInterval = null;
|
||||
this.connectedClients = new Set();
|
||||
|
||||
// Per-node configurations and current target
|
||||
// Multi-node streaming support
|
||||
this.nodeStreams = new Map(); // ip -> {preset, presetName, interval, matrixSize, parameters}
|
||||
this.nodeConfigurations = new Map(); // ip -> {presetName, parameters, matrixSize}
|
||||
this.currentTarget = null; // 'broadcast' or specific IP
|
||||
this.currentTarget = null; // Currently selected node IP
|
||||
|
||||
this.setupExpress();
|
||||
this.setupWebSocket();
|
||||
@@ -85,7 +87,7 @@ class LEDLabServer {
|
||||
});
|
||||
|
||||
// Save updated configuration for current target
|
||||
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||
if (this.currentTarget) {
|
||||
this.saveCurrentConfiguration(this.currentTarget);
|
||||
}
|
||||
|
||||
@@ -136,15 +138,15 @@ class LEDLabServer {
|
||||
handleWebSocketMessage(ws, data) {
|
||||
switch (data.type) {
|
||||
case 'startPreset':
|
||||
this.startPreset(data.presetName, data.width, data.height);
|
||||
this.startPreset(data.presetName, data.width, data.height, data.nodeIp, data.parameters);
|
||||
break;
|
||||
|
||||
case 'stopStreaming':
|
||||
this.stopStreaming();
|
||||
this.stopStreaming(data.nodeIp);
|
||||
break;
|
||||
|
||||
case 'updatePresetParameter':
|
||||
this.updatePresetParameter(data.parameter, data.value);
|
||||
this.updatePresetParameter(data.parameter, data.value, data.nodeIp);
|
||||
break;
|
||||
|
||||
case 'setMatrixSize':
|
||||
@@ -155,18 +157,10 @@ class LEDLabServer {
|
||||
this.selectNode(data.nodeIp);
|
||||
break;
|
||||
|
||||
case 'selectBroadcast':
|
||||
this.selectBroadcast();
|
||||
break;
|
||||
|
||||
case 'sendToNode':
|
||||
this.sendToSpecificNode(data.nodeIp, data.message);
|
||||
break;
|
||||
|
||||
case 'broadcastToAll':
|
||||
this.broadcastToAllNodes(data.message);
|
||||
break;
|
||||
|
||||
case 'updateFrameRate':
|
||||
this.updateFrameRate(data.fps);
|
||||
break;
|
||||
@@ -199,34 +193,66 @@ class LEDLabServer {
|
||||
}
|
||||
|
||||
setupPresetManager() {
|
||||
// Start with no active preset
|
||||
// Start with no active presets
|
||||
this.currentPreset = null;
|
||||
this.nodeStreams.clear();
|
||||
}
|
||||
|
||||
startPreset(presetName, width = this.matrixWidth, height = this.matrixHeight) {
|
||||
startPreset(presetName, width = this.matrixWidth, height = this.matrixHeight, nodeIp = null, parameters = null) {
|
||||
try {
|
||||
// Stop current streaming if active
|
||||
if (this.currentPreset) {
|
||||
this.stopStreaming();
|
||||
const targetIp = nodeIp || this.currentTarget;
|
||||
|
||||
if (!targetIp) {
|
||||
console.warn('No target specified for streaming');
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop current streaming for this node if active
|
||||
this.stopStreaming(targetIp);
|
||||
|
||||
// Create new preset instance
|
||||
this.currentPreset = this.presetRegistry.createPreset(presetName, width, height);
|
||||
this.currentPresetName = presetName; // Store the registry key
|
||||
this.currentPreset.start();
|
||||
const preset = this.presetRegistry.createPreset(presetName, width, height);
|
||||
preset.start();
|
||||
|
||||
console.log(`Started preset: ${presetName} (${width}x${height})`);
|
||||
// Apply parameters if provided
|
||||
if (parameters) {
|
||||
Object.entries(parameters).forEach(([param, value]) => {
|
||||
preset.setParameter(param, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Start streaming interval
|
||||
console.log(`Started preset: ${presetName} (${width}x${height}) for ${targetIp}`);
|
||||
|
||||
// Start streaming interval for this node
|
||||
const intervalMs = Math.floor(1000 / this.fps);
|
||||
this.streamingInterval = setInterval(() => {
|
||||
this.streamFrame();
|
||||
const interval = setInterval(() => {
|
||||
this.streamFrameForNode(targetIp);
|
||||
}, intervalMs);
|
||||
|
||||
// Store stream information
|
||||
this.nodeStreams.set(targetIp, {
|
||||
preset,
|
||||
presetName,
|
||||
interval,
|
||||
matrixSize: { width, height },
|
||||
parameters: preset.getParameters()
|
||||
});
|
||||
|
||||
// Update legacy support
|
||||
if (targetIp === this.currentTarget) {
|
||||
this.currentPreset = preset;
|
||||
this.currentPresetName = presetName;
|
||||
this.streamingInterval = interval;
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
this.saveCurrentConfiguration(targetIp);
|
||||
|
||||
// Notify clients
|
||||
this.broadcastToClients({
|
||||
type: 'streamingStarted',
|
||||
preset: this.currentPreset.getMetadata()
|
||||
preset: preset.getMetadata(),
|
||||
nodeIp: targetIp
|
||||
});
|
||||
|
||||
// Also send updated state to keep all clients in sync
|
||||
@@ -234,58 +260,85 @@ class LEDLabServer {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting preset:', error);
|
||||
this.sendToClient(ws, {
|
||||
this.broadcastToClients({
|
||||
type: 'error',
|
||||
message: `Failed to start preset: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopStreaming() {
|
||||
if (this.streamingInterval) {
|
||||
clearInterval(this.streamingInterval);
|
||||
this.streamingInterval = null;
|
||||
stopStreaming(nodeIp = null) {
|
||||
const targetIp = nodeIp || this.currentTarget;
|
||||
|
||||
if (targetIp) {
|
||||
this.stopNodeStream(targetIp);
|
||||
} else {
|
||||
// Legacy: stop current streaming
|
||||
if (this.streamingInterval) {
|
||||
clearInterval(this.streamingInterval);
|
||||
this.streamingInterval = null;
|
||||
}
|
||||
|
||||
if (this.currentPreset) {
|
||||
this.currentPreset.stop();
|
||||
this.currentPreset = null;
|
||||
this.currentPresetName = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.currentPreset) {
|
||||
this.currentPreset.stop();
|
||||
this.currentPreset = null;
|
||||
this.currentPresetName = null;
|
||||
}
|
||||
|
||||
console.log('Streaming stopped');
|
||||
console.log(`Streaming stopped for ${targetIp || 'current target'}`);
|
||||
|
||||
this.broadcastToClients({
|
||||
type: 'streamingStopped'
|
||||
type: 'streamingStopped',
|
||||
nodeIp: targetIp
|
||||
});
|
||||
|
||||
// Save current configuration for the current target if it exists
|
||||
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||
this.saveCurrentConfiguration(this.currentTarget);
|
||||
}
|
||||
|
||||
// Also send updated state to keep all clients in sync
|
||||
this.broadcastCurrentState();
|
||||
}
|
||||
|
||||
updatePresetParameter(parameter, value) {
|
||||
if (this.currentPreset) {
|
||||
this.currentPreset.setParameter(parameter, value);
|
||||
stopNodeStream(nodeIp) {
|
||||
const stream = this.nodeStreams.get(nodeIp);
|
||||
if (stream) {
|
||||
clearInterval(stream.interval);
|
||||
stream.preset.stop();
|
||||
this.nodeStreams.delete(nodeIp);
|
||||
|
||||
this.broadcastToClients({
|
||||
// Update legacy support if this was the current target
|
||||
if (nodeIp === this.currentTarget) {
|
||||
this.currentPreset = null;
|
||||
this.currentPresetName = null;
|
||||
this.streamingInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePresetParameter(parameter, value, nodeIp = null) {
|
||||
const targetIp = nodeIp || this.currentTarget;
|
||||
|
||||
if (targetIp) {
|
||||
const stream = this.nodeStreams.get(targetIp);
|
||||
if (stream) {
|
||||
stream.preset.setParameter(parameter, value);
|
||||
stream.parameters = stream.preset.getParameters();
|
||||
this.saveCurrentConfiguration(targetIp);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy support
|
||||
if (this.currentPreset && targetIp === this.currentTarget) {
|
||||
this.currentPreset.setParameter(parameter, value);
|
||||
}
|
||||
|
||||
this.broadcastToClients({
|
||||
type: 'presetParameterUpdated',
|
||||
parameter,
|
||||
value
|
||||
});
|
||||
value,
|
||||
nodeIp: targetIp
|
||||
});
|
||||
|
||||
// Save updated configuration for current target
|
||||
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||
this.saveCurrentConfiguration(this.currentTarget);
|
||||
}
|
||||
|
||||
// Don't broadcast full state on every parameter change to avoid UI flickering
|
||||
// State is already updated via presetParameterUpdated event
|
||||
}
|
||||
// Don't broadcast full state on every parameter change to avoid UI flickering
|
||||
// State is already updated via presetParameterUpdated event
|
||||
}
|
||||
|
||||
setMatrixSize(width, height) {
|
||||
@@ -303,7 +356,7 @@ class LEDLabServer {
|
||||
});
|
||||
|
||||
// Save updated configuration for current target
|
||||
if (this.currentTarget && this.currentTarget !== 'broadcast') {
|
||||
if (this.currentTarget) {
|
||||
this.saveCurrentConfiguration(this.currentTarget);
|
||||
}
|
||||
}
|
||||
@@ -316,10 +369,8 @@ class LEDLabServer {
|
||||
|
||||
const frameData = this.currentPreset.generateFrame();
|
||||
if (frameData) {
|
||||
// Send to specific target or broadcast
|
||||
if (this.currentTarget === 'broadcast') {
|
||||
this.udpDiscovery.broadcastToAll(frameData);
|
||||
} else if (this.currentTarget) {
|
||||
// Send to specific target
|
||||
if (this.currentTarget) {
|
||||
this.udpDiscovery.sendToNode(this.currentTarget, frameData);
|
||||
}
|
||||
|
||||
@@ -332,12 +383,29 @@ class LEDLabServer {
|
||||
}
|
||||
}
|
||||
|
||||
sendToSpecificNode(nodeIp, message) {
|
||||
return this.udpDiscovery.sendToNode(nodeIp, message);
|
||||
streamFrameForNode(nodeIp) {
|
||||
const stream = this.nodeStreams.get(nodeIp);
|
||||
if (!stream || !stream.preset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const frameData = stream.preset.generateFrame();
|
||||
if (frameData) {
|
||||
// Send to specific node
|
||||
this.udpDiscovery.sendToNode(nodeIp, frameData);
|
||||
|
||||
// Send frame data to WebSocket clients for preview
|
||||
this.broadcastToClients({
|
||||
type: 'frame',
|
||||
data: frameData,
|
||||
nodeIp: nodeIp,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
broadcastToAllNodes(message) {
|
||||
return this.udpDiscovery.broadcastToAll(message);
|
||||
sendToSpecificNode(nodeIp, message) {
|
||||
return this.udpDiscovery.sendToNode(nodeIp, message);
|
||||
}
|
||||
|
||||
broadcastCurrentState() {
|
||||
@@ -366,10 +434,21 @@ class LEDLabServer {
|
||||
this.fps = fps;
|
||||
console.log(`Frame rate updated to ${fps} FPS`);
|
||||
|
||||
// If streaming is active, restart the interval with new frame rate
|
||||
const intervalMs = Math.floor(1000 / this.fps);
|
||||
|
||||
// Update all active node streams
|
||||
this.nodeStreams.forEach((stream, nodeIp) => {
|
||||
if (stream.interval) {
|
||||
clearInterval(stream.interval);
|
||||
stream.interval = setInterval(() => {
|
||||
this.streamFrameForNode(nodeIp);
|
||||
}, intervalMs);
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy: If streaming is active, restart the interval with new frame rate
|
||||
if (this.currentPreset && this.streamingInterval) {
|
||||
clearInterval(this.streamingInterval);
|
||||
const intervalMs = Math.floor(1000 / this.fps);
|
||||
this.streamingInterval = setInterval(() => {
|
||||
this.streamFrame();
|
||||
}, intervalMs);
|
||||
@@ -386,50 +465,54 @@ class LEDLabServer {
|
||||
selectNode(nodeIp) {
|
||||
this.currentTarget = nodeIp;
|
||||
|
||||
// Load configuration for this node if it exists, otherwise use current settings
|
||||
const nodeConfig = this.nodeConfigurations.get(nodeIp);
|
||||
if (nodeConfig) {
|
||||
this.loadNodeConfiguration(nodeConfig);
|
||||
// Check if this node already has an active stream
|
||||
const stream = this.nodeStreams.get(nodeIp);
|
||||
if (stream) {
|
||||
// Node is already streaming, update legacy references
|
||||
this.currentPreset = stream.preset;
|
||||
this.currentPresetName = stream.presetName;
|
||||
this.matrixWidth = stream.matrixSize.width;
|
||||
this.matrixHeight = stream.matrixSize.height;
|
||||
} else {
|
||||
// Save current configuration for this node
|
||||
this.saveCurrentConfiguration(nodeIp);
|
||||
// Load configuration for this node if it exists
|
||||
const nodeConfig = this.nodeConfigurations.get(nodeIp);
|
||||
if (nodeConfig) {
|
||||
this.matrixWidth = nodeConfig.matrixSize.width;
|
||||
this.matrixHeight = nodeConfig.matrixSize.height;
|
||||
// Don't auto-start streaming, just load the configuration
|
||||
}
|
||||
}
|
||||
|
||||
this.broadcastCurrentState();
|
||||
}
|
||||
|
||||
selectBroadcast() {
|
||||
this.currentTarget = 'broadcast';
|
||||
this.broadcastCurrentState();
|
||||
}
|
||||
|
||||
saveCurrentConfiguration(nodeIp) {
|
||||
if (this.currentPreset && this.currentPresetName) {
|
||||
const stream = this.nodeStreams.get(nodeIp);
|
||||
if (stream && stream.preset && stream.presetName) {
|
||||
this.nodeConfigurations.set(nodeIp, {
|
||||
presetName: this.currentPresetName, // Use registry key, not display name
|
||||
presetName: stream.presetName,
|
||||
parameters: stream.preset.getParameters(),
|
||||
matrixSize: stream.matrixSize
|
||||
});
|
||||
} else if (this.currentPreset && this.currentPresetName) {
|
||||
// Legacy fallback
|
||||
this.nodeConfigurations.set(nodeIp, {
|
||||
presetName: this.currentPresetName,
|
||||
parameters: this.currentPreset.getParameters(),
|
||||
matrixSize: { width: this.matrixWidth, height: this.matrixHeight }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
loadNodeConfiguration(config) {
|
||||
// Stop current streaming
|
||||
this.stopStreaming();
|
||||
|
||||
// Load the node's configuration
|
||||
this.matrixWidth = config.matrixSize.width;
|
||||
this.matrixHeight = config.matrixSize.height;
|
||||
|
||||
// Start the preset with saved parameters
|
||||
this.startPreset(config.presetName, config.matrixSize.width, config.matrixSize.height);
|
||||
|
||||
// Set the parameters after preset is created
|
||||
if (this.currentPreset) {
|
||||
Object.entries(config.parameters).forEach(([param, value]) => {
|
||||
this.currentPreset.setParameter(param, value);
|
||||
});
|
||||
}
|
||||
loadNodeConfiguration(config, nodeIp) {
|
||||
// Start the preset with saved parameters for this specific node
|
||||
this.startPreset(
|
||||
config.presetName,
|
||||
config.matrixSize.width,
|
||||
config.matrixSize.height,
|
||||
nodeIp,
|
||||
config.parameters
|
||||
);
|
||||
}
|
||||
|
||||
broadcastToClients(message) {
|
||||
|
||||
@@ -157,15 +157,6 @@ class UdpDiscovery extends EventEmitter {
|
||||
...node
|
||||
}));
|
||||
|
||||
// Add broadcast option
|
||||
nodes.unshift({
|
||||
ip: 'broadcast',
|
||||
status: 'broadcast',
|
||||
address: '255.255.255.255',
|
||||
port: this.port,
|
||||
isBroadcast: true
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user