// Preset Editor for LEDLab class PresetEditor { constructor() { this.configuration = this.getDefaultConfiguration(); this.selectedLayerIndex = null; this.previewActive = false; this.selectedNode = null; this.previewInterval = null; this.canvasRenderer = null; this.matrixWidth = 16; this.matrixHeight = 16; this.fps = 20; this.frameCount = 0; this.lastFpsUpdate = Date.now(); this.init(); } init() { this.setupCanvas(); this.setupEventListeners(); this.loadSavedPresets(); this.renderLayerList(); this.discoverNodes(); this.subscribeToNodeUpdates(); } setupCanvas() { const canvas = document.getElementById('editor-preview-canvas'); if (canvas) { // Set canvas size explicitly canvas.width = this.matrixWidth; canvas.height = this.matrixHeight; this.canvasRenderer = new CanvasRenderer(canvas, this.matrixWidth, this.matrixHeight); this.updateCanvasSize(); // Clear the canvas initially this.canvasRenderer.clear(); } } updateCanvasSize() { const sizeDisplay = document.getElementById('editor-canvas-size'); if (sizeDisplay) { sizeDisplay.textContent = `${this.matrixWidth}×${this.matrixHeight}`; } // Update canvas renderer size if it exists if (this.canvasRenderer) { this.canvasRenderer.setSize(this.matrixWidth, this.matrixHeight); } } getDefaultConfiguration() { return { name: 'New Custom Preset', description: 'A custom configurable preset', layers: [], parameters: { speed: { type: 'range', min: 0.1, max: 2.0, step: 0.1, default: 1.0 }, brightness: { type: 'range', min: 0.1, max: 1.0, step: 0.1, default: 1.0 }, }, }; } setupEventListeners() { // Preset metadata const nameInput = document.getElementById('editor-preset-name'); const descInput = document.getElementById('editor-preset-desc'); if (nameInput) { nameInput.addEventListener('input', (e) => { this.configuration.name = e.target.value; }); } if (descInput) { descInput.addEventListener('input', (e) => { this.configuration.description = e.target.value; }); } // Add layer button const addLayerBtn = document.getElementById('editor-add-layer'); if (addLayerBtn) { addLayerBtn.addEventListener('click', () => this.addLayer()); } // Building block type selector const typeSelector = document.getElementById('editor-layer-type'); if (typeSelector) { typeSelector.addEventListener('change', (e) => { this.showLayerTypeOptions(e.target.value); }); } // Save/Load buttons const saveBtn = document.getElementById('editor-save-preset'); if (saveBtn) { saveBtn.addEventListener('click', () => this.savePreset()); } const newBtn = document.getElementById('editor-new-preset'); if (newBtn) { newBtn.addEventListener('click', () => this.newPreset()); } const loadBtn = document.getElementById('editor-load-preset'); if (loadBtn) { loadBtn.addEventListener('change', (e) => this.loadPreset(e.target.value)); } const exportBtn = document.getElementById('editor-export-json'); if (exportBtn) { exportBtn.addEventListener('click', () => this.exportToJSON()); } const importBtn = document.getElementById('editor-import-json'); if (importBtn) { importBtn.addEventListener('change', (e) => this.importFromJSON(e)); } // Preview toggle button const previewToggle = document.getElementById('editor-preview-toggle'); if (previewToggle) { previewToggle.addEventListener('click', () => this.togglePreview()); } // Node selector const nodeSelect = document.getElementById('editor-node-select'); if (nodeSelect) { nodeSelect.addEventListener('change', (e) => { this.selectedNode = e.target.value || null; this.updatePreviewStatus(); }); } // Delete preset button const deleteBtn = document.getElementById('editor-delete-preset'); if (deleteBtn) { deleteBtn.addEventListener('click', () => this.deleteCurrentPreset()); } } showLayerTypeOptions(type) { const shapeOptions = document.getElementById('editor-shape-options'); const patternOptions = document.getElementById('editor-pattern-options'); if (type === 'shape') { shapeOptions?.classList.remove('hidden'); patternOptions?.classList.add('hidden'); } else if (type === 'pattern') { shapeOptions?.classList.add('hidden'); patternOptions?.classList.remove('hidden'); } } addLayer() { const layerType = document.getElementById('editor-layer-type')?.value || 'shape'; let layer; if (layerType === 'pattern') { layer = this.createPatternLayer(); } else { layer = this.createShapeLayer(); } this.configuration.layers.push(layer); // Automatically expand the newly added layer this.selectedLayerIndex = this.configuration.layers.length - 1; this.renderLayerList(); this.refreshPreviewIfActive(); } refreshPreviewIfActive() { // If preview is active, restart it to reflect changes if (this.previewActive) { // Reset animation states for the new configuration if (this.canvasRenderer) { this.canvasRenderer.reset(); } // Immediately render a frame to show changes if (this.canvasRenderer && this.configuration) { const frame = this.canvasRenderer.renderConfiguration(this.configuration, 1.0, 1.0); this.canvasRenderer.drawFrame(frame); } // If streaming to node, restart streaming with new config if (this.selectedNode) { this.startNodeStreaming(); } } } createShapeLayer() { return { type: 'shape', shape: 'circle', position: { x: 8, y: 8 }, size: { radius: 3, width: 5, height: 5 }, color: { type: 'solid', value: 'ff0000' }, intensity: 1.0 }; } createPatternLayer() { return { type: 'pattern', pattern: 'spiral', color: { type: 'palette', stops: [ { position: 0.0, color: 'ff0000' }, { position: 0.17, color: 'ff8800' }, { position: 0.33, color: 'ffff00' }, { position: 0.5, color: '00ff00' }, { position: 0.67, color: '0088ff' }, { position: 0.83, color: '8800ff' }, { position: 1.0, color: 'ff0088' } ] }, intensity: 1.0, params: { centerX: 8, centerY: 8, arms: 5, rotationSpeed: 1.0 } }; } getPatternParams(pattern) { switch (pattern) { case 'trail': return { decayFactor: 0.8 }; case 'radial': return { centerX: 8, centerY: 8 }; case 'spiral': return { centerX: 8, centerY: 8, arms: 5, rotationSpeed: 1.0 }; default: return {}; } } createAnimation(type) { const baseAnim = { type: type, params: {} }; switch (type) { case 'move': baseAnim.params = { startX: 0, startY: 0, endX: 16, endY: 16, duration: 2.0 }; break; case 'rotate': baseAnim.params = { speed: 1.0 }; break; case 'pulse': baseAnim.params = { minScale: 0.5, maxScale: 1.5, frequency: 1.0 }; break; case 'oscillateX': baseAnim.params = { center: 8, amplitude: 4, frequency: 0.5, phase: 0 }; baseAnim.axis = 'x'; break; case 'oscillateY': baseAnim.params = { center: 8, amplitude: 4, frequency: 0.5, phase: 0 }; baseAnim.axis = 'y'; break; case 'fade': baseAnim.params = { duration: 2.0, fadeIn: true }; break; } return baseAnim; } renderLayerList() { const layerList = document.getElementById('editor-layer-list'); if (!layerList) return; layerList.innerHTML = ''; if (this.configuration.layers.length === 0) { layerList.innerHTML = '
No layers yet. Click "Add Layer" to get started!
'; return; } this.configuration.layers.forEach((layer, index) => { const layerItem = document.createElement('div'); layerItem.className = 'editor-layer-item'; if (index === this.selectedLayerIndex) { layerItem.classList.add('expanded'); } // Layer Header (always visible) const layerHeader = document.createElement('div'); layerHeader.className = 'editor-layer-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'editor-layer-header-left'; // Expand icon const expandIcon = document.createElement('span'); expandIcon.className = 'editor-layer-expand-icon'; expandIcon.textContent = '▶'; // Layer info const layerInfo = document.createElement('div'); layerInfo.className = 'editor-layer-info'; const layerTitle = document.createElement('div'); layerTitle.className = 'editor-layer-title'; layerTitle.textContent = `Layer ${index + 1}: ${this.getLayerDisplayName(layer)}`; const layerSubtitle = document.createElement('div'); layerSubtitle.className = 'editor-layer-subtitle'; layerSubtitle.textContent = this.getLayerDescription(layer); layerInfo.appendChild(layerTitle); layerInfo.appendChild(layerSubtitle); headerLeft.appendChild(expandIcon); headerLeft.appendChild(layerInfo); // Layer buttons const layerButtons = document.createElement('div'); layerButtons.className = 'editor-layer-buttons'; const moveUpBtn = document.createElement('button'); moveUpBtn.className = 'btn-small'; moveUpBtn.textContent = '▲'; moveUpBtn.title = 'Move Up'; moveUpBtn.disabled = index === 0; moveUpBtn.addEventListener('click', (e) => { e.stopPropagation(); this.moveLayer(index, -1); }); const moveDownBtn = document.createElement('button'); moveDownBtn.className = 'btn-small'; moveDownBtn.textContent = '▼'; moveDownBtn.title = 'Move Down'; moveDownBtn.disabled = index === this.configuration.layers.length - 1; moveDownBtn.addEventListener('click', (e) => { e.stopPropagation(); this.moveLayer(index, 1); }); const deleteBtn = document.createElement('button'); deleteBtn.className = 'btn-small btn-danger'; deleteBtn.textContent = '✕'; deleteBtn.title = 'Delete'; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); this.deleteLayer(index); }); layerButtons.appendChild(moveUpBtn); layerButtons.appendChild(moveDownBtn); layerButtons.appendChild(deleteBtn); layerHeader.appendChild(headerLeft); layerHeader.appendChild(layerButtons); // Click handler to toggle expansion layerHeader.addEventListener('click', (e) => { // Only toggle if clicking on the header itself, not the buttons if (!e.target.closest('.editor-layer-buttons')) { this.toggleLayer(index); } }); // Layer Content (expandable) const layerContent = document.createElement('div'); layerContent.className = 'editor-layer-content'; // Render layer configuration inside the content if (layer.type === 'shape') { this.renderShapeEditor(layerContent, layer, index); } else if (layer.type === 'pattern') { this.renderPatternEditor(layerContent, layer, index); } layerItem.appendChild(layerHeader); layerItem.appendChild(layerContent); layerList.appendChild(layerItem); }); } getLayerDisplayName(layer) { if (layer.type === 'shape') { return `${layer.shape.charAt(0).toUpperCase() + layer.shape.slice(1)}`; } else if (layer.type === 'pattern') { return `${layer.pattern.charAt(0).toUpperCase() + layer.pattern.slice(1)} Pattern`; } return layer.type; } getLayerDescription(layer) { const parts = []; if (layer.color && layer.color.type) { parts.push(layer.color.type); } if (layer.animation && layer.animation.type) { parts.push(layer.animation.type); } return parts.length > 0 ? parts.join(' • ') : 'No animation'; } toggleLayer(index) { if (this.selectedLayerIndex === index) { // Collapse if already expanded this.selectedLayerIndex = null; } else { // Expand this layer this.selectedLayerIndex = index; } this.renderLayerList(); } selectLayer(index) { this.selectedLayerIndex = index; this.renderLayerList(); } renderShapeEditor(container, layer, index) { // Shape type this.addSelect(container, 'Shape', layer.shape, ['circle', 'rectangle', 'triangle', 'blob', 'point', 'line'], (value) => { layer.shape = value; this.renderLayerList(); this.refreshPreviewIfActive(); } ); // Position const posGroup = this.addGroup(container, 'Position'); this.addNumberInput(posGroup, 'X', layer.position.x, 0, 32, 0.5, (value) => { layer.position.x = value; this.refreshPreviewIfActive(); }); this.addNumberInput(posGroup, 'Y', layer.position.y, 0, 32, 0.5, (value) => { layer.position.y = value; this.refreshPreviewIfActive(); }); // Size const sizeGroup = this.addGroup(container, 'Size'); if (layer.shape === 'circle' || layer.shape === 'blob' || layer.shape === 'triangle') { this.addNumberInput(sizeGroup, 'Radius', layer.size.radius, 1, 20, 0.5, (value) => { layer.size.radius = value; this.refreshPreviewIfActive(); }); } else if (layer.shape === 'rectangle') { this.addNumberInput(sizeGroup, 'Width', layer.size.width, 1, 32, 1, (value) => { layer.size.width = value; this.refreshPreviewIfActive(); }); this.addNumberInput(sizeGroup, 'Height', layer.size.height, 1, 32, 1, (value) => { layer.size.height = value; this.refreshPreviewIfActive(); }); } // Color this.renderColorEditor(container, layer, index); // Intensity this.addNumberInput(container, 'Intensity', layer.intensity, 0, 1, 0.1, (value) => { layer.intensity = value; this.refreshPreviewIfActive(); }); // Animation this.renderAnimationEditor(container, layer, index); } renderPatternEditor(container, layer, index) { // Pattern type this.addSelect(container, 'Pattern', layer.pattern, ['trail', 'radial', 'spiral'], (value) => { layer.pattern = value; layer.params = this.getPatternParams(value); this.renderLayerList(); this.refreshPreviewIfActive(); } ); // Color this.renderColorEditor(container, layer, index); // Intensity this.addNumberInput(container, 'Intensity', layer.intensity || 1.0, 0, 1, 0.1, (value) => { layer.intensity = value; this.refreshPreviewIfActive(); }); // Pattern-specific params const paramsGroup = this.addGroup(container, 'Parameters'); if (layer.pattern === 'trail') { this.addNumberInput(paramsGroup, 'Decay Factor', layer.params.decayFactor, 0.1, 1.0, 0.05, (value) => { layer.params.decayFactor = value; this.refreshPreviewIfActive(); }); } else if (layer.pattern === 'radial' || layer.pattern === 'spiral') { this.addNumberInput(paramsGroup, 'Center X', layer.params.centerX, 0, 32, 0.5, (value) => { layer.params.centerX = value; this.refreshPreviewIfActive(); }); this.addNumberInput(paramsGroup, 'Center Y', layer.params.centerY, 0, 32, 0.5, (value) => { layer.params.centerY = value; this.refreshPreviewIfActive(); }); if (layer.pattern === 'spiral') { this.addNumberInput(paramsGroup, 'Arms', layer.params.arms, 2, 12, 1, (value) => { layer.params.arms = value; this.refreshPreviewIfActive(); }); this.addNumberInput(paramsGroup, 'Rotation Speed', layer.params.rotationSpeed, 0.1, 5.0, 0.1, (value) => { layer.params.rotationSpeed = value; this.refreshPreviewIfActive(); }); } } } renderColorEditor(container, layer, index) { const colorGroup = this.addGroup(container, 'Color'); this.addSelect(colorGroup, 'Type', layer.color.type, ['solid', 'gradient', 'palette', 'rainbow'], (value) => { layer.color.type = value; this.renderLayerList(); this.refreshPreviewIfActive(); } ); if (layer.color.type === 'solid') { this.addColorInput(colorGroup, 'Color', layer.color.value, (value) => { layer.color.value = value; this.refreshPreviewIfActive(); }); } else if (layer.color.type === 'gradient') { this.addColorInput(colorGroup, 'Color 1', layer.color.color1 || 'ff0000', (value) => { layer.color.color1 = value; this.refreshPreviewIfActive(); }); this.addColorInput(colorGroup, 'Color 2', layer.color.color2 || '0000ff', (value) => { layer.color.color2 = value; this.refreshPreviewIfActive(); }); } else if (layer.color.type === 'palette') { const stopsContainer = document.createElement('div'); stopsContainer.className = 'color-stops-container'; const stops = layer.color.stops || [ { position: 0, color: '000000' }, { position: 1, color: 'ffffff' } ]; stops.forEach((stop, stopIndex) => { const stopDiv = document.createElement('div'); stopDiv.className = 'color-stop'; this.addNumberInput(stopDiv, 'Position', stop.position, 0, 1, 0.05, (value) => { stops[stopIndex].position = value; this.refreshPreviewIfActive(); }); this.addColorInput(stopDiv, 'Color', stop.color, (value) => { stops[stopIndex].color = value; this.refreshPreviewIfActive(); }); stopsContainer.appendChild(stopDiv); }); layer.color.stops = stops; colorGroup.appendChild(stopsContainer); } } renderAnimationEditor(container, layer, index) { const animGroup = this.addGroup(container, 'Animation'); const currentType = layer.animation?.type || 'none'; this.addSelect(animGroup, 'Type', currentType, ['none', 'move', 'rotate', 'pulse', 'oscillateX', 'oscillateY', 'fade'], (value) => { if (value === 'none') { delete layer.animation; } else { layer.animation = this.createAnimation(value); } this.renderLayerList(); this.refreshPreviewIfActive(); } ); if (layer.animation) { const params = layer.animation.params; const animParamsDiv = document.createElement('div'); animParamsDiv.className = 'animation-params'; Object.entries(params).forEach(([key, value]) => { if (typeof value === 'number') { const min = key.includes('Scale') ? 0 : (key.includes('duration') ? 0.1 : -16); const max = key.includes('Scale') ? 3 : (key.includes('duration') ? 10 : 32); const step = key.includes('duration') || key.includes('frequency') ? 0.1 : (key.includes('Scale') ? 0.1 : 0.5); this.addNumberInput(animParamsDiv, key, value, min, max, step, (newValue) => { params[key] = newValue; this.refreshPreviewIfActive(); }); } else if (typeof value === 'boolean') { this.addCheckbox(animParamsDiv, key, value, (newValue) => { params[key] = newValue; this.refreshPreviewIfActive(); }); } }); animGroup.appendChild(animParamsDiv); } } // UI Helper methods addGroup(container, title) { const group = document.createElement('div'); group.className = 'editor-group'; const groupTitle = document.createElement('h4'); groupTitle.textContent = title; group.appendChild(groupTitle); container.appendChild(group); return group; } addNumberInput(container, label, value, min, max, step, onChange) { const wrapper = document.createElement('div'); wrapper.className = 'editor-input-wrapper'; const labelElem = document.createElement('label'); labelElem.textContent = label; const input = document.createElement('input'); input.type = 'number'; input.value = value; input.min = min; input.max = max; input.step = step; input.addEventListener('input', (e) => onChange(parseFloat(e.target.value))); wrapper.appendChild(labelElem); wrapper.appendChild(input); container.appendChild(wrapper); } addColorInput(container, label, value, onChange) { const wrapper = document.createElement('div'); wrapper.className = 'editor-input-wrapper'; const labelElem = document.createElement('label'); labelElem.textContent = label; const input = document.createElement('input'); input.type = 'color'; input.value = '#' + value; input.addEventListener('input', (e) => onChange(e.target.value.substring(1))); wrapper.appendChild(labelElem); wrapper.appendChild(input); container.appendChild(wrapper); } addSelect(container, label, value, options, onChange) { const wrapper = document.createElement('div'); wrapper.className = 'editor-input-wrapper'; const labelElem = document.createElement('label'); labelElem.textContent = label; const select = document.createElement('select'); options.forEach(opt => { const option = document.createElement('option'); option.value = opt; option.textContent = opt; option.selected = opt === value; select.appendChild(option); }); select.addEventListener('change', (e) => onChange(e.target.value)); wrapper.appendChild(labelElem); wrapper.appendChild(select); container.appendChild(wrapper); } addCheckbox(container, label, value, onChange) { const wrapper = document.createElement('div'); wrapper.className = 'editor-input-wrapper'; const labelElem = document.createElement('label'); labelElem.textContent = label; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = value; input.addEventListener('change', (e) => onChange(e.target.checked)); wrapper.appendChild(labelElem); wrapper.appendChild(input); container.appendChild(wrapper); } moveLayer(index, direction) { const newIndex = index + direction; if (newIndex < 0 || newIndex >= this.configuration.layers.length) return; const temp = this.configuration.layers[index]; this.configuration.layers[index] = this.configuration.layers[newIndex]; this.configuration.layers[newIndex] = temp; if (this.selectedLayerIndex === index) { this.selectedLayerIndex = newIndex; } this.renderLayerList(); } deleteLayer(index) { if (confirm('Delete this layer?')) { this.configuration.layers.splice(index, 1); if (this.selectedLayerIndex === index) { this.selectedLayerIndex = null; } else if (this.selectedLayerIndex > index) { this.selectedLayerIndex--; } this.renderLayerList(); this.refreshPreviewIfActive(); } } newPreset() { // Confirm if there are unsaved changes if (this.configuration.layers.length > 0) { if (!confirm('Create a new preset? Any unsaved changes will be lost.')) { return; } } // Stop preview if active if (this.previewActive) { this.togglePreview(); } // Reset to default configuration this.configuration = this.getDefaultConfiguration(); this.selectedLayerIndex = null; // Update UI const nameInput = document.getElementById('editor-preset-name'); const descInput = document.getElementById('editor-preset-desc'); if (nameInput) { nameInput.value = this.configuration.name; } if (descInput) { descInput.value = this.configuration.description; } // Clear and re-render layer list this.renderLayerList(); // Clear canvas if (this.canvasRenderer) { this.canvasRenderer.clear(); } this.showNotification('New preset created', 'success'); } savePreset() { const presetName = this.configuration.name || 'Custom Preset'; const savedPresets = this.getSavedPresets(); savedPresets[presetName] = this.configuration; localStorage.setItem('ledlab_custom_presets', JSON.stringify(savedPresets)); this.loadSavedPresets(); this.showNotification('Preset saved successfully!', 'success'); } loadPreset(presetName) { if (!presetName) return; const savedPresets = this.getSavedPresets(); const preset = savedPresets[presetName]; if (preset) { this.configuration = JSON.parse(JSON.stringify(preset)); document.getElementById('editor-preset-name').value = this.configuration.name; document.getElementById('editor-preset-desc').value = this.configuration.description; this.renderLayerList(); this.refreshPreviewIfActive(); this.showNotification('Preset loaded successfully!', 'success'); } } deleteCurrentPreset() { const presetName = this.configuration.name; if (confirm(`Delete preset "${presetName}"?`)) { const savedPresets = this.getSavedPresets(); delete savedPresets[presetName]; localStorage.setItem('ledlab_custom_presets', JSON.stringify(savedPresets)); this.configuration = this.getDefaultConfiguration(); document.getElementById('editor-preset-name').value = this.configuration.name; document.getElementById('editor-preset-desc').value = this.configuration.description; this.loadSavedPresets(); this.renderLayerList(); this.showNotification('Preset deleted successfully!', 'success'); } } getSavedPresets() { const saved = localStorage.getItem('ledlab_custom_presets'); return saved ? JSON.parse(saved) : {}; } loadSavedPresets() { const savedPresets = this.getSavedPresets(); const selector = document.getElementById('editor-load-preset'); if (selector) { selector.innerHTML = ''; Object.keys(savedPresets).forEach(name => { const option = document.createElement('option'); option.value = name; option.textContent = name; selector.appendChild(option); }); } } exportToJSON() { const json = JSON.stringify(this.configuration, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${this.configuration.name.replace(/\s+/g, '_')}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.showNotification('Configuration exported!', 'success'); } importFromJSON(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const imported = JSON.parse(e.target.result); this.configuration = imported; document.getElementById('editor-preset-name').value = this.configuration.name; document.getElementById('editor-preset-desc').value = this.configuration.description; this.renderLayerList(); this.refreshPreviewIfActive(); this.showNotification('Configuration imported!', 'success'); } catch (error) { this.showNotification('Error importing JSON: ' + error.message, 'error'); } }; reader.readAsText(file); // Reset file input so the same file can be imported again event.target.value = ''; } togglePreview() { this.previewActive = !this.previewActive; const btn = document.getElementById('editor-preview-toggle'); const icon = btn.querySelector('.preview-icon'); const text = btn.querySelector('.preview-text'); if (this.previewActive) { if (icon) icon.textContent = '⏸️'; if (text) text.textContent = 'Stop'; btn.classList.add('active'); this.startPreview(); } else { if (icon) icon.textContent = '▶️'; if (text) text.textContent = 'Start'; btn.classList.remove('active'); this.stopPreview(); } } startPreview() { if (!this.canvasRenderer) { this.showNotification('Canvas not initialized', 'error'); return; } // Reset animation states this.canvasRenderer.reset(); this.frameCount = 0; this.lastFpsUpdate = Date.now(); // Start canvas preview loop const intervalMs = Math.floor(1000 / this.fps); this.previewInterval = setInterval(() => { this.renderPreviewFrame(); }, intervalMs); // If node is selected, also send to server if (this.selectedNode) { this.startNodeStreaming(); } this.updatePreviewStatus(); this.showNotification('Preview started', 'success'); } stopPreview() { if (this.previewInterval) { clearInterval(this.previewInterval); this.previewInterval = null; } // Clear canvas if (this.canvasRenderer) { this.canvasRenderer.clear(); } // Stop node streaming if active if (this.selectedNode && window.ledlabApp) { const message = { type: 'stopStreaming', nodeIp: this.selectedNode }; window.ledlabApp.sendWebSocketMessage(message); } this.updatePreviewStatus(); this.updateFpsDisplay(0); } renderPreviewFrame() { if (!this.canvasRenderer || !this.configuration) return; // Get parameters const speed = 1.0; // Could be made configurable const brightness = 1.0; // Could be made configurable // Render configuration to frame const frame = this.canvasRenderer.renderConfiguration(this.configuration, speed, brightness); // Draw to canvas this.canvasRenderer.drawFrame(frame); // Update FPS counter this.frameCount++; const now = Date.now(); if (now - this.lastFpsUpdate >= 1000) { const actualFps = this.frameCount / ((now - this.lastFpsUpdate) / 1000); this.updateFpsDisplay(actualFps); this.frameCount = 0; this.lastFpsUpdate = now; } } startNodeStreaming() { if (!window.ledlabApp || !this.selectedNode) return; const message = { type: 'startCustomPreset', configuration: this.configuration, nodeIp: this.selectedNode }; window.ledlabApp.sendWebSocketMessage(message); } updatePreviewStatus() { const statusElement = document.getElementById('editor-preview-status'); if (!statusElement) return; if (this.previewActive) { if (this.selectedNode) { statusElement.textContent = `Streaming`; statusElement.className = 'preview-status-compact streaming'; } else { statusElement.textContent = 'Active'; statusElement.className = 'preview-status-compact active'; } } else { statusElement.textContent = 'Ready'; statusElement.className = 'preview-status-compact'; } } updateFpsDisplay(fps) { const fpsElement = document.getElementById('editor-canvas-fps'); if (fpsElement) { fpsElement.textContent = `${Math.round(fps)} fps`; } } discoverNodes() { // Fetch nodes from server API fetch('/api/nodes') .then(response => response.json()) .then(data => { if (data.nodes) { this.updateNodeDropdown(data.nodes); } }) .catch(error => { console.error('Error fetching nodes:', error); }); } subscribeToNodeUpdates() { // Subscribe to node discovery events from the main app if (window.ledlabApp && window.ledlabApp.eventBus) { window.ledlabApp.eventBus.subscribe('nodeDiscovered', (data) => { console.log('Node discovered event received:', data); setTimeout(() => this.discoverNodes(), 100); }); window.ledlabApp.eventBus.subscribe('nodeLost', (data) => { console.log('Node lost event received:', data); setTimeout(() => this.discoverNodes(), 100); }); // Also listen for status updates window.ledlabApp.eventBus.subscribe('status', (data) => { if (data.data && data.data.nodes) { this.updateNodeDropdown(data.data.nodes); } }); } // Poll for nodes initially and periodically this.discoverNodes(); this.nodeDiscoveryInterval = setInterval(() => { this.discoverNodes(); }, 5000); // Refresh every 5 seconds } updateNodeDropdown(nodes) { const select = document.getElementById('editor-node-select'); if (!select) return; // Save current selection const currentSelection = select.value; // Clear existing options except first select.innerHTML = ''; // Add nodes if (nodes && Array.isArray(nodes) && nodes.length > 0) { nodes.forEach(node => { const option = document.createElement('option'); option.value = node.ip; // Format node display text let displayText = node.ip; if (node.hostname) { displayText = `${node.hostname} (${node.ip})`; } else if (node.name) { displayText = `${node.name} (${node.ip})`; } option.textContent = displayText; select.appendChild(option); }); console.log(`Updated node dropdown with ${nodes.length} node(s)`); } else { console.log('No nodes available to populate dropdown'); } // Restore selection if still available if (currentSelection) { const optionExists = Array.from(select.options).some(opt => opt.value === currentSelection); if (optionExists) { select.value = currentSelection; this.selectedNode = currentSelection; } } } showNotification(message, type = 'info') { // Simple notification system const notification = document.createElement('div'); notification.className = `notification notification-${type}`; notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { notification.classList.add('show'); }, 10); setTimeout(() => { notification.classList.remove('show'); setTimeout(() => { document.body.removeChild(notification); }, 300); }, 3000); } } // Initialize editor when DOM is loaded document.addEventListener('DOMContentLoaded', function() { const editorView = document.getElementById('editor-view'); if (editorView) { window.presetEditor = new PresetEditor(); } }); // Export for use in other modules if (typeof module !== 'undefined' && module.exports) { module.exports = PresetEditor; }