diff --git a/MULTI_NODE_UPDATE.md b/MULTI_NODE_UPDATE.md new file mode 100644 index 0000000..8045364 --- /dev/null +++ b/MULTI_NODE_UPDATE.md @@ -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 +
+
+ +
+
+ + +``` + +### 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 + diff --git a/README.md b/README.md index 4a189d9..56199b2 100644 --- a/README.md +++ b/README.md @@ -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. + +![ledlab](assets/ledlab.png) ## Components diff --git a/assets/ledlab.png b/assets/ledlab.png new file mode 100644 index 0000000..ecd592f Binary files /dev/null and b/assets/ledlab.png differ diff --git a/public/index.html b/public/index.html index 403e04b..092d90a 100644 --- a/public/index.html +++ b/public/index.html @@ -27,80 +27,70 @@
- -
-
-

Matrix Display

-
- 16x16 | - Stopped + +
+
+

SPORE Nodes

+
+ +
-
-
- -
-
- - -
- -
-

SPORE Nodes

-
- -
-
+
Discovering nodes...
-
+
- -
-

Global Settings

-
-
- -
- - 20 + +
- -
-

Animation Presets

-
- -
-
- -
-
- -
-
- - -
+ +
@@ -148,8 +138,8 @@ + - diff --git a/public/scripts/ledlab-app.js b/public/scripts/ledlab-app.js index f037a1e..e1aed85 100644 --- a/public/scripts/ledlab-app.js +++ b/public/scripts/ledlab-app.js @@ -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 diff --git a/public/scripts/node-canvas-grid.js b/public/scripts/node-canvas-grid.js new file mode 100644 index 0000000..cd75dd1 --- /dev/null +++ b/public/scripts/node-canvas-grid.js @@ -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 = '
No nodes discovered. Waiting for SPORE nodes...
'; + 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 = `
${this.escapeHtml(message)}
`; + } + } + + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } +} + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = NodeCanvasGrid; +} + diff --git a/public/scripts/node-discovery.js b/public/scripts/node-discovery.js deleted file mode 100644 index 8c7bd81..0000000 --- a/public/scripts/node-discovery.js +++ /dev/null @@ -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 = '
No nodes discovered
'; - return; - } - - const html = this.nodes.map(node => ` -
-
-
-
${this.escapeHtml(node.ip === 'broadcast' ? 'Broadcast' : node.ip)}
-
${node.status} • Port ${node.port}
-
-
- `).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 = `
${this.escapeHtml(message)}
`; - } - } - - // Public method to select broadcast (called from outside) - selectBroadcastTarget() { - this.selectBroadcast(); - } - - escapeHtml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } -} - -// Export for use in other modules -if (typeof module !== 'undefined' && module.exports) { - module.exports = NodeDiscovery; -} diff --git a/public/scripts/preset-controls.js b/public/scripts/preset-controls.js index f928283..3d48daa 100644 --- a/public/scripts/preset-controls.js +++ b/public/scripts/preset-controls.js @@ -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'); diff --git a/public/styles/main.css b/public/styles/main.css index c861c68..2aa9523 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -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 { diff --git a/server/index.js b/server/index.js index 4fc8d05..fe27855 100644 --- a/server/index.js +++ b/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) { diff --git a/server/udp-discovery.js b/server/udp-discovery.js index 0c60eee..0fd56fc 100644 --- a/server/udp-discovery.js +++ b/server/udp-discovery.js @@ -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; }