diff --git a/public/index.html b/public/index.html index 6ad48fb..403e04b 100644 --- a/public/index.html +++ b/public/index.html @@ -8,16 +8,25 @@
-
-

SPORE LEDLab

- -
+ + -
+ +
+
@@ -45,53 +54,99 @@
+ +
+

Global Settings

+
+
+ +
+ + 20 +
+
+
+
+

Animation Presets

- +
+ +
- - +
- -
-

Matrix Configuration

+ + + +
+
+ + +
+
+

⚙️ Settings

+ +
+

Matrix Configuration

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

Manual Control

+
+

Test

- - + +
+

About

+

+ 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. +

+
+
+
+ diff --git a/public/scripts/ledlab-app.js b/public/scripts/ledlab-app.js index ac7f61a..f037a1e 100644 --- a/public/scripts/ledlab-app.js +++ b/public/scripts/ledlab-app.js @@ -201,6 +201,13 @@ class LEDLabApp { }); }); + this.eventBus.subscribe('updateFrameRate', (data) => { + this.sendWebSocketMessage({ + type: 'updateFrameRate', + ...data + }); + }); + // Handle theme changes window.addEventListener('themeChanged', (event) => { console.log('Theme changed to:', event.detail.theme); diff --git a/public/scripts/navigation.js b/public/scripts/navigation.js new file mode 100644 index 0000000..86b70e1 --- /dev/null +++ b/public/scripts/navigation.js @@ -0,0 +1,139 @@ +// Navigation Manager for SPORE LEDLab + +class NavigationManager { + constructor() { + this.currentView = 'stream'; + this.init(); + } + + init() { + this.setupNavigationListeners(); + // Sync settings values on init + this.syncSettingsValues(); + } + + setupNavigationListeners() { + // Navigation tab listeners + const navTabs = document.querySelectorAll('.nav-tab'); + navTabs.forEach(tab => { + tab.addEventListener('click', () => { + const view = tab.dataset.view; + this.switchView(view); + }); + }); + + // Settings apply button + const settingsApplyBtn = document.getElementById('settings-apply-matrix-btn'); + if (settingsApplyBtn) { + settingsApplyBtn.addEventListener('click', () => { + this.applySettingsFromView(); + }); + } + + // Sync settings when stream view matrix config changes + const streamWidthInput = document.getElementById('matrix-width'); + const streamHeightInput = document.getElementById('matrix-height'); + + if (streamWidthInput) { + streamWidthInput.addEventListener('change', () => this.syncSettingsValues()); + } + if (streamHeightInput) { + streamHeightInput.addEventListener('change', () => this.syncSettingsValues()); + } + } + + switchView(viewName) { + // Hide all views + const allViews = document.querySelectorAll('.view-content'); + allViews.forEach(view => view.classList.remove('active')); + + // Deactivate all tabs + const allTabs = document.querySelectorAll('.nav-tab'); + allTabs.forEach(tab => tab.classList.remove('active')); + + // Show selected view + const targetView = document.getElementById(`${viewName}-view`); + if (targetView) { + targetView.classList.add('active'); + } + + // Activate selected tab + const targetTab = document.querySelector(`.nav-tab[data-view="${viewName}"]`); + if (targetTab) { + targetTab.classList.add('active'); + } + + this.currentView = viewName; + + // Sync values when switching to settings + if (viewName === 'settings') { + this.syncSettingsValues(); + } + + console.log(`Switched to ${viewName} view`); + } + + syncSettingsValues() { + // Sync matrix configuration values from stream to settings + const streamWidth = document.getElementById('matrix-width'); + const streamHeight = document.getElementById('matrix-height'); + const settingsWidth = document.getElementById('settings-matrix-width'); + const settingsHeight = document.getElementById('settings-matrix-height'); + + if (streamWidth && settingsWidth) { + settingsWidth.value = streamWidth.value; + } + if (streamHeight && settingsHeight) { + settingsHeight.value = streamHeight.value; + } + } + + applySettingsFromView() { + const settingsWidth = document.getElementById('settings-matrix-width'); + const settingsHeight = document.getElementById('settings-matrix-height'); + const streamWidth = document.getElementById('matrix-width'); + const streamHeight = document.getElementById('matrix-height'); + + if (settingsWidth && streamWidth) { + streamWidth.value = settingsWidth.value; + } + if (settingsHeight && streamHeight) { + streamHeight.value = settingsHeight.value; + } + + // Trigger the apply button in the stream view + const applyBtn = document.getElementById('apply-matrix-btn'); + if (applyBtn) { + applyBtn.click(); + } + + // Show success feedback + const btn = document.getElementById('settings-apply-matrix-btn'); + if (btn) { + const originalText = btn.textContent; + btn.textContent = '✓ Applied!'; + btn.style.background = 'linear-gradient(135deg, var(--accent-success) 0%, #22c55e 100%)'; + + setTimeout(() => { + btn.textContent = originalText; + btn.style.background = ''; + }, 2000); + } + } + + getCurrentView() { + return this.currentView; + } +} + +// Initialize navigation when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + window.navigationManager = new NavigationManager(); + console.log('Navigation manager initialized'); +}); + +// Export for use in other modules +if (typeof module !== 'undefined' && module.exports) { + module.exports = NavigationManager; +} + diff --git a/public/scripts/preset-controls.js b/public/scripts/preset-controls.js index 7e6cae6..f928283 100644 --- a/public/scripts/preset-controls.js +++ b/public/scripts/preset-controls.js @@ -16,11 +16,29 @@ class PresetControls extends Component { } setupEventListeners() { - // Preset selection + // FPS slider + const fpsSlider = this.findElement('#fps-slider'); + const fpsValue = this.findElement('#fps-value'); + if (fpsSlider && fpsValue) { + this.addEventListener(fpsSlider, 'input', (e) => { + const fps = parseInt(e.target.value); + fpsValue.textContent = fps; + this.updateFrameRate(fps); + }); + } + + // Preset selection - immediate switching const presetSelect = this.findElement('#preset-select'); if (presetSelect) { this.addEventListener(presetSelect, 'change', (e) => { - this.selectPreset(e.target.value); + const presetName = e.target.value; + this.selectPreset(presetName); + + // If currently streaming, automatically restart with new preset + const toggleBtn = this.findElement('#toggle-stream-btn'); + if (toggleBtn && toggleBtn.dataset.streaming === 'true' && presetName) { + this.startStreaming(); + } }); } @@ -32,18 +50,16 @@ class PresetControls extends Component { }); } - // Start/Stop buttons - const startBtn = this.findElement('#start-btn'); - if (startBtn) { - this.addEventListener(startBtn, 'click', () => { - this.startStreaming(); - }); - } - - const stopBtn = this.findElement('#stop-btn'); - if (stopBtn) { - this.addEventListener(stopBtn, 'click', () => { - this.stopStreaming(); + // Toggle stream button + const toggleStreamBtn = this.findElement('#toggle-stream-btn'); + if (toggleStreamBtn) { + this.addEventListener(toggleStreamBtn, 'click', () => { + const isStreaming = toggleStreamBtn.dataset.streaming === 'true'; + if (isStreaming) { + this.stopStreaming(); + } else { + this.startStreaming(); + } }); } @@ -73,7 +89,21 @@ class PresetControls extends Component { }); this.subscribeToEvent('presetParameterUpdated', (data) => { - this.updatePresetParameter(data.parameter, data.value); + // 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); + } + } else if (control.type === 'color') { + control.value = this.hexToColorValue(data.value); + } else { + control.value = data.value; + } + } }); @@ -82,15 +112,28 @@ class PresetControls extends Component { const isStreaming = data.data.streaming; const currentPreset = data.data.currentPreset; const presetParameters = data.data.presetParameters; + const fps = data.data.fps; this.updateStreamingState(isStreaming, currentPreset ? { name: currentPreset } : null); - if (currentPreset) { - this.selectPreset(currentPreset.toLowerCase().replace('-preset', '')); + // Update FPS display + if (fps !== undefined) { + const fpsSlider = this.findElement('#fps-slider'); + const fpsValue = this.findElement('#fps-value'); + if (fpsSlider && fpsValue) { + fpsSlider.value = fps; + fpsValue.textContent = fps; + } + } + + // Only select preset if it's different from current (avoid recreating controls) + const presetSelect = this.findElement('#preset-select'); + if (currentPreset && presetSelect && presetSelect.value !== currentPreset) { + this.selectPreset(currentPreset); } if (presetParameters && this.currentPreset) { - // Update parameter controls with current values + // Update parameter controls with current values without triggering events Object.entries(presetParameters).forEach(([param, value]) => { const control = this.presetControls.get(param); if (control) { @@ -109,6 +152,15 @@ class PresetControls extends Component { }); } }); + + this.subscribeToEvent('frameRateUpdated', (data) => { + const fpsSlider = this.findElement('#fps-slider'); + const fpsValue = this.findElement('#fps-value'); + if (fpsSlider && fpsValue && data.fps) { + fpsSlider.value = data.fps; + fpsValue.textContent = data.fps; + } + }); } async loadPresets() { @@ -201,8 +253,15 @@ class PresetControls extends Component { valueDisplay.textContent = defaultValue; sliderInput.addEventListener('input', (e) => { - valueDisplay.textContent = parseFloat(e.target.value).toFixed(2); - this.updatePresetParameter(paramName, parseFloat(e.target.value)); + const value = parseFloat(e.target.value); + valueDisplay.textContent = value.toFixed(2); + this.updatePresetParameter(paramName, value); + + // Visual feedback for real-time update + valueDisplay.style.color = 'var(--accent-primary)'; + setTimeout(() => { + valueDisplay.style.color = ''; + }, 200); }); const container = document.createElement('div'); @@ -223,6 +282,12 @@ class PresetControls extends Component { colorInput.addEventListener('input', (e) => { const hexValue = this.colorValueToHex(e.target.value); this.updatePresetParameter(paramName, hexValue); + + // Visual feedback for real-time update + colorInput.style.borderColor = 'var(--accent-primary)'; + setTimeout(() => { + colorInput.style.borderColor = ''; + }, 200); }); return colorInput; @@ -235,6 +300,12 @@ class PresetControls extends Component { textInput.addEventListener('input', (e) => { this.updatePresetParameter(paramName, e.target.value); + + // Visual feedback for real-time update + textInput.style.borderColor = 'var(--accent-primary)'; + setTimeout(() => { + textInput.style.borderColor = ''; + }, 200); }); return textInput; @@ -242,11 +313,13 @@ class PresetControls extends Component { } updatePresetParameter(parameter, value) { - // Send parameter update to server + // Send parameter update to server immediately (real-time) this.viewModel.publish('updatePresetParameter', { parameter, value }); + + console.log(`Parameter updated: ${parameter} = ${value}`); } clearPresetControls() { @@ -353,17 +426,30 @@ class PresetControls extends Component { } updateStreamingState(isStreaming, preset) { - const startBtn = this.findElement('#start-btn'); - const stopBtn = this.findElement('#stop-btn'); + const toggleBtn = this.findElement('#toggle-stream-btn'); + const btnIcon = toggleBtn?.querySelector('.btn-icon'); + const btnText = toggleBtn?.querySelector('.btn-text'); - if (isStreaming) { - startBtn.disabled = true; - stopBtn.disabled = false; - } else { - startBtn.disabled = false; - stopBtn.disabled = true; + if (toggleBtn) { + toggleBtn.dataset.streaming = isStreaming ? 'true' : 'false'; + + if (isStreaming) { + if (btnIcon) btnIcon.textContent = '⏸'; + if (btnText) btnText.textContent = 'Stop Streaming'; + toggleBtn.classList.remove('btn-primary'); + toggleBtn.classList.add('btn-stop'); + } else { + if (btnIcon) btnIcon.textContent = '▶'; + if (btnText) btnText.textContent = 'Start Streaming'; + toggleBtn.classList.remove('btn-stop'); + toggleBtn.classList.add('btn-primary'); + } } } + + updateFrameRate(fps) { + this.viewModel.publish('updateFrameRate', { fps }); + } } // Export for use in other modules diff --git a/public/styles/main.css b/public/styles/main.css index a141a0a..c861c68 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -5,63 +5,88 @@ } :root { - /* Dark theme colors */ - --bg-primary: #0f0f0f; - --bg-secondary: #1a1a1a; - --bg-tertiary: #2d2d2d; - --text-primary: #ffffff; - --text-secondary: #b3b3b3; - --text-tertiary: #888888; + /* Dark Theme - Matching SPORE UI */ + --bg-primary: linear-gradient(135deg, #2c3e50 0%, #34495e 50%, #1a252f 100%); + --bg-secondary: rgba(0, 0, 0, 0.3); + --bg-tertiary: rgba(0, 0, 0, 0.2); + --bg-hover: rgba(0, 0, 0, 0.15); + --bg-overlay: rgba(0, 0, 0, 0.7); + + --text-primary: #ecf0f1; + --text-secondary: rgba(255, 255, 255, 0.8); + --text-tertiary: rgba(255, 255, 255, 0.7); + --text-muted: rgba(255, 255, 255, 0.6); + + --border-primary: rgba(255, 255, 255, 0.1); + --border-secondary: rgba(255, 255, 255, 0.15); + --border-hover: rgba(255, 255, 255, 0.2); + --accent-primary: #4ade80; - --accent-secondary: #22d3ee; + --accent-secondary: #60a5fa; --accent-warning: #fbbf24; --accent-error: #f87171; - --border-primary: #333333; - --border-secondary: #444444; - --shadow-primary: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - --backdrop-blur: blur(12px); + --accent-success: #4caf50; + + --shadow-primary: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-secondary: 0 4px 16px rgba(0, 0, 0, 0.2); + --shadow-hover: 0 8px 32px rgba(0, 0, 0, 0.6); + + --backdrop-blur: blur(10px); /* LEDLab specific colors */ --matrix-bg: #000000; --pixel-on: #4ade80; - --pixel-off: #1a1a1a; + --pixel-off: rgba(0, 0, 0, 0.3); --pixel-dim: #22c55e; - --control-bg: #1e1e1e; + --control-bg: rgba(0, 0, 0, 0.2); --preset-active: #4ade80; --node-connected: #22c55e; --node-disconnected: #ef4444; } [data-theme="light"] { - --bg-primary: #ffffff; - --bg-secondary: #f8fafc; - --bg-tertiary: #e2e8f0; + --bg-primary: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 50%, #e2e8f0 100%); + --bg-secondary: rgba(255, 255, 255, 0.1); + --bg-tertiary: rgba(255, 255, 255, 0.05); + --bg-hover: rgba(255, 255, 255, 0.15); + --bg-overlay: rgba(0, 0, 0, 0.3); + --text-primary: #1e293b; - --text-secondary: #64748b; - --text-tertiary: #94a3b8; - --accent-primary: #16a34a; - --accent-secondary: #0891b2; - --accent-warning: #d97706; + --text-secondary: #475569; + --text-tertiary: #64748b; + --text-muted: #94a3b8; + + --border-primary: rgba(148, 163, 184, 0.2); + --border-secondary: rgba(148, 163, 184, 0.3); + --border-hover: rgba(148, 163, 184, 0.4); + + --accent-primary: #3b82f6; + --accent-secondary: #1d4ed8; + --accent-warning: #f59e0b; --accent-error: #dc2626; - --border-primary: #e2e8f0; - --border-secondary: #cbd5e1; - --shadow-primary: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); - --backdrop-blur: blur(12px); + --accent-success: #059669; + + --shadow-primary: 0 8px 32px rgba(148, 163, 184, 0.1); + --shadow-secondary: 0 4px 16px rgba(148, 163, 184, 0.05); + --shadow-hover: 0 12px 40px rgba(148, 163, 184, 0.15); + + --backdrop-blur: blur(20px); /* Light theme LEDLab colors */ --matrix-bg: #f8fafc; - --pixel-on: #16a34a; + --pixel-on: #3b82f6; --pixel-off: #f1f5f9; - --pixel-dim: #22c55e; - --control-bg: #f8fafc; - --preset-active: #16a34a; - --node-connected: #16a34a; + --pixel-dim: #60a5fa; + --control-bg: rgba(255, 255, 255, 0.1); + --preset-active: #3b82f6; + --node-connected: #059669; --node-disconnected: #dc2626; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: var(--bg-primary); + background-attachment: fixed; height: 100vh; padding: 1rem; color: var(--text-primary); @@ -77,53 +102,114 @@ body { display: flex; flex-direction: column; flex: 1; - padding: 0 2rem; max-height: calc(100vh - 2rem); overflow: hidden; + gap: 1rem; } -/* LEDLab specific styles */ -.ledlab-header { +/* Main Navigation - Matching SPORE UI */ +.main-navigation { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 2rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-primary); + padding: 0.75rem 1.5rem; + background: var(--bg-secondary); + border-radius: 16px; + border: 1px solid var(--border-primary); + backdrop-filter: var(--backdrop-blur); + box-shadow: var(--shadow-primary); + margin-bottom: 1rem; } -.ledlab-title { - font-size: 2rem; - font-weight: 700; - color: var(--accent-primary); +.nav-left { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.nav-tab { + background: transparent; + border: 1px solid transparent; + color: var(--text-secondary); + padding: 0.625rem 1.25rem; + border-radius: 10px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: none; +} + +.nav-tab:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.nav-tab.active { + background: var(--bg-tertiary); + border: 1px solid var(--accent-primary); + color: var(--text-primary); + box-shadow: 0 4px 12px rgba(74, 222, 128, 0.2); +} + +.nav-right { + display: flex; + gap: 1rem; + align-items: center; } .theme-toggle { - background: var(--bg-secondary); - border: 1px solid var(--border-primary); - border-radius: 8px; - padding: 0.5rem; + background: none; + border: none; + color: var(--text-secondary); cursor: pointer; + padding: 0.5rem; + border-radius: 8px; transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; } .theme-toggle:hover { - background: var(--bg-tertiary); + background: var(--bg-hover); + color: var(--text-primary); transform: scale(1.05); } .theme-toggle svg { width: 20px; height: 20px; - fill: currentColor; + stroke: currentColor; + stroke-width: 2; + transition: transform 0.3s ease; +} + +.theme-toggle:hover svg { + transform: rotate(15deg); +} + +/* View Content */ +.view-content { + display: none; + flex: 1; + overflow: hidden; + min-height: 0; +} + +.view-content.active { + display: flex; + flex-direction: row; + gap: 1.5rem; } /* Main content layout */ .ledlab-main { display: flex; flex: 1; - gap: 2rem; + gap: 1.5rem; overflow: hidden; + min-height: 0; } /* Matrix display section */ @@ -135,6 +221,52 @@ body { padding: 1.5rem; display: flex; flex-direction: column; + min-height: 0; + box-shadow: var(--shadow-primary); + backdrop-filter: var(--backdrop-blur); +} + +/* Settings Section */ +.settings-section { + flex: 1; + background: var(--bg-secondary); + border-radius: 16px; + border: 1px solid var(--border-primary); + padding: 2rem; + overflow-y: auto; + box-shadow: var(--shadow-primary); + backdrop-filter: var(--backdrop-blur); +} + +.settings-section h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-primary); +} + +.settings-group { + margin-bottom: 2rem; +} + +.settings-group h3 { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.settings-group h3::before { + content: ''; + width: 4px; + height: 18px; + background: var(--accent-primary); + border-radius: 2px; } .matrix-header { @@ -166,6 +298,7 @@ body { border: 1px solid var(--border-secondary); position: relative; overflow: hidden; + box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3); } .matrix-canvas { @@ -179,7 +312,8 @@ body { /* Control panel section */ .control-section { - width: 320px; + width: 380px; + min-width: 320px; background: var(--bg-secondary); border-radius: 16px; border: 1px solid var(--border-primary); @@ -188,13 +322,22 @@ body { flex-direction: column; gap: 1.5rem; overflow-y: auto; + max-height: calc(100vh - 8rem); + box-shadow: var(--shadow-primary); + backdrop-filter: var(--backdrop-blur); } .control-group { background: var(--control-bg); border-radius: 12px; - padding: 1rem; + padding: 1.25rem; border: 1px solid var(--border-secondary); + transition: all 0.2s ease; +} + +.control-group:hover { + border-color: var(--border-tertiary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .control-group-title { @@ -223,20 +366,24 @@ body { .node-list { max-height: 200px; overflow-y: auto; + overflow-x: hidden; } .node-item { display: flex; align-items: center; gap: 0.75rem; - padding: 0.5rem; + padding: 0.75rem; border-radius: 8px; cursor: pointer; - transition: background-color 0.2s ease; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + border: 1px solid transparent; + margin-bottom: 0.5rem; } .node-item:hover { background: var(--bg-tertiary); + border-color: var(--border-secondary); } .node-item.connected { @@ -250,6 +397,8 @@ body { .node-item.selected { background: rgba(74, 222, 128, 0.1); border-left: 3px solid var(--accent-primary); + border-color: rgba(74, 222, 128, 0.3); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.2); } .node-indicator { @@ -280,125 +429,277 @@ body { } /* Preset controls */ +.preset-selector-wrapper { + margin-bottom: 1.25rem; +} + .preset-select { width: 100%; background: var(--bg-tertiary); - border: 1px solid var(--border-primary); + border: 1px solid var(--border-secondary); border-radius: 8px; - padding: 0.5rem; + padding: 0.75rem; color: var(--text-primary); font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + cursor: pointer; +} + +.preset-select option { + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0.5rem; +} + +.preset-select:focus { + outline: none; + border-color: var(--accent-primary); + box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15); + background: var(--bg-hover); +} + +.preset-select:hover { + border-color: var(--border-tertiary); + background: var(--bg-hover); } .preset-controls { display: flex; flex-direction: column; gap: 0.75rem; + margin-bottom: 1.25rem; + min-height: 2rem; + position: relative; +} + +.preset-controls:empty::before { + content: 'Select a preset to see parameters'; + color: var(--text-tertiary); + font-size: 0.875rem; + font-style: italic; + position: absolute; + top: 0; + left: 0; +} + +.global-controls { + display: flex; + flex-direction: column; + gap: 0.75rem; } .preset-control { display: flex; flex-direction: column; - gap: 0.25rem; + gap: 0.4rem; } .preset-label { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; - font-weight: 500; + font-weight: 600; } .preset-input { width: 100%; background: var(--bg-tertiary); - border: 1px solid var(--border-primary); + border: 1px solid var(--border-secondary); border-radius: 6px; - padding: 0.5rem; + padding: 0.625rem; color: var(--text-primary); font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; } .preset-input:focus { outline: none; border-color: var(--accent-primary); - box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2); + box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15); + background: var(--bg-hover); +} + +.preset-input:hover { + border-color: var(--border-tertiary); + background: var(--bg-hover); } .preset-slider { width: 100%; - height: 4px; + height: 6px; background: var(--bg-tertiary); - border-radius: 2px; + border-radius: 3px; outline: none; -webkit-appearance: none; + appearance: none; + transition: background 0.2s ease; +} + +.preset-slider:hover { + background: var(--bg-hover); } .preset-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 16px; - height: 16px; + width: 18px; + height: 18px; background: var(--accent-primary); border-radius: 50%; cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.preset-slider::-webkit-slider-thumb:hover { + transform: scale(1.15); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.4); } .preset-slider::-moz-range-thumb { - width: 16px; - height: 16px; + width: 18px; + height: 18px; background: var(--accent-primary); border-radius: 50%; cursor: pointer; border: none; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } -/* Buttons */ +.preset-slider::-moz-range-thumb:hover { + transform: scale(1.15); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.4); +} + +.preset-value { + min-width: 3rem; + text-align: right; + font-weight: 600; + color: var(--text-primary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; + transition: color 0.2s ease; +} + +/* Buttons - Spore UI inspired styling */ .btn { - background: var(--accent-primary); - color: white; - border: none; + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border-secondary); border-radius: 8px; - padding: 0.75rem 1rem; + padding: 0.625rem 1.25rem; font-size: 0.875rem; font-weight: 600; cursor: pointer; - transition: all 0.2s ease; - display: flex; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; + min-height: 2.75rem; + white-space: nowrap; + backdrop-filter: var(--backdrop-blur); } .btn:hover { - background: var(--accent-secondary); + background: var(--bg-hover); + border-color: var(--border-tertiary); + color: var(--text-primary); transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .btn:active { + background: var(--bg-tertiary); transform: translateY(0); + box-shadow: none; } .btn:disabled { opacity: 0.5; cursor: not-allowed; + background: transparent; + border-color: var(--border-primary); + color: var(--text-secondary); transform: none; + box-shadow: none; +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent-primary) 0%, #22c55e 100%); + color: white; + border-color: var(--accent-primary); + box-shadow: 0 2px 8px rgba(74, 222, 128, 0.25); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #22c55e 0%, var(--accent-primary) 100%); + border-color: #22c55e; + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(74, 222, 128, 0.35); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(74, 222, 128, 0.25); +} + +.btn-stop { + background: linear-gradient(135deg, var(--accent-error) 0%, #ef4444 100%); + color: white; + border-color: var(--accent-error); + box-shadow: 0 2px 8px rgba(239, 68, 68, 0.25); +} + +.btn-stop:hover { + background: linear-gradient(135deg, #ef4444 0%, var(--accent-error) 100%); + border-color: #ef4444; + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.35); +} + +.btn-stop:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(239, 68, 68, 0.25); +} + +.btn-icon { + font-size: 1rem; + line-height: 1; +} + +.btn-text { + line-height: 1; } .btn-secondary { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-primary); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + color: var(--text-secondary); + border-color: var(--border-secondary); } .btn-secondary:hover { - background: var(--bg-primary); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%); + color: var(--text-primary); + border-color: var(--border-tertiary); } .btn-small { - padding: 0.5rem 0.75rem; - font-size: 0.75rem; + padding: 0.5rem 1rem; + font-size: 0.8rem; + min-height: 2.25rem; +} + +.btn-container { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; } /* Matrix configuration */ @@ -424,18 +725,26 @@ body { .matrix-input input { background: var(--bg-tertiary); - border: 1px solid var(--border-primary); + border: 1px solid var(--border-secondary); border-radius: 6px; - padding: 0.5rem; + padding: 0.625rem; color: var(--text-primary); font-size: 0.875rem; + font-weight: 500; width: 80px; + transition: all 0.2s ease; } .matrix-input input:focus { outline: none; border-color: var(--accent-primary); - box-shadow: 0 0 0 2px rgba(74, 222, 128, 0.2); + box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.15); + background: var(--bg-hover); +} + +.matrix-input input:hover { + border-color: var(--border-tertiary); + background: var(--bg-hover); } @@ -444,28 +753,41 @@ body { display: inline-flex; align-items: center; gap: 0.5rem; - font-size: 0.875rem; - padding: 0.25rem 0.75rem; - border-radius: 12px; - font-weight: 500; + font-size: 0.75rem; + padding: 0.375rem 0.875rem; + border-radius: 16px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + transition: all 0.2s ease; } .status-connected { - background: rgba(34, 197, 94, 0.1); + background: rgba(34, 197, 94, 0.15); color: var(--node-connected); - border: 1px solid rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.3); } .status-disconnected { - background: rgba(239, 68, 68, 0.1); + background: rgba(239, 68, 68, 0.15); color: var(--node-disconnected); - border: 1px solid rgba(239, 68, 68, 0.2); + border: 1px solid rgba(239, 68, 68, 0.3); } .status-streaming { - background: rgba(74, 222, 128, 0.1); + background: rgba(74, 222, 128, 0.15); color: var(--accent-primary); - border: 1px solid rgba(74, 222, 128, 0.2); + border: 1px solid rgba(74, 222, 128, 0.3); + animation: pulse-glow 2s ease-in-out infinite; +} + +@keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 0 5px rgba(74, 222, 128, 0.3); + } + 50% { + box-shadow: 0 0 15px rgba(74, 222, 128, 0.5); + } } /* Loading and error states */ @@ -517,6 +839,11 @@ body { /* Responsive adjustments */ @media (max-width: 768px) { + .container { + padding: 0 1rem; + gap: 1rem; + } + .ledlab-main { flex-direction: column; gap: 1rem; @@ -524,11 +851,50 @@ body { .control-section { width: 100%; - max-height: 300px; + min-width: unset; + max-height: 50vh; } .matrix-section { - min-height: 400px; + min-height: 50vh; + } + + .btn-container { + gap: 0.5rem; + } + + .matrix-config { + flex-wrap: wrap; + gap: 0.75rem; + } + + .matrix-input { + min-width: 100px; + } +} + +@media (max-width: 480px) { + .ledlab-header { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .matrix-config { + flex-direction: column; + align-items: stretch; + } + + .matrix-input { + width: 100%; + } + + .btn-container { + flex-direction: column; + } + + .btn { + width: 100%; } } diff --git a/server/index.js b/server/index.js index 4abb710..4fc8d05 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,7 @@ class LEDLabServer { this.udpPort = options.udpPort || 4210; this.matrixWidth = options.matrixWidth || 16; this.matrixHeight = options.matrixHeight || 16; + this.fps = options.fps || 20; this.app = express(); this.server = http.createServer(this.app); @@ -56,10 +57,11 @@ class LEDLabServer { this.app.get('/api/status', (req, res) => { res.json({ streaming: this.currentPreset !== null, - currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null, + currentPreset: this.currentPresetName || null, matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, nodeCount: this.udpDiscovery.getNodeCount(), currentTarget: this.currentTarget, + fps: this.fps, }); }); @@ -116,11 +118,12 @@ class LEDLabServer { // Send current status to new client const currentState = { streaming: this.currentPreset !== null, - currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null, + currentPreset: this.currentPresetName || null, matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, nodes: this.udpDiscovery.getNodes(), presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, currentTarget: this.currentTarget, + fps: this.fps, }; this.sendToClient(ws, { @@ -164,6 +167,10 @@ class LEDLabServer { this.broadcastToAllNodes(data.message); break; + case 'updateFrameRate': + this.updateFrameRate(data.fps); + break; + default: console.warn('Unknown WebSocket message type:', data.type); } @@ -211,9 +218,10 @@ class LEDLabServer { console.log(`Started preset: ${presetName} (${width}x${height})`); // Start streaming interval + const intervalMs = Math.floor(1000 / this.fps); this.streamingInterval = setInterval(() => { this.streamFrame(); - }, 50); // 20 FPS + }, intervalMs); // Notify clients this.broadcastToClients({ @@ -275,8 +283,8 @@ class LEDLabServer { this.saveCurrentConfiguration(this.currentTarget); } - // Also send updated state to keep all clients in sync - this.broadcastCurrentState(); + // Don't broadcast full state on every parameter change to avoid UI flickering + // State is already updated via presetParameterUpdated event } } @@ -335,11 +343,12 @@ class LEDLabServer { broadcastCurrentState() { const currentState = { streaming: this.currentPreset !== null, - currentPreset: this.currentPreset ? this.currentPreset.getMetadata().name : null, + currentPreset: this.currentPresetName || null, matrixSize: { width: this.matrixWidth, height: this.matrixHeight }, nodes: this.udpDiscovery.getNodes(), presetParameters: this.currentPreset ? this.currentPreset.getParameters() : null, currentTarget: this.currentTarget, + fps: this.fps, }; this.broadcastToClients({ @@ -348,6 +357,31 @@ class LEDLabServer { }); } + updateFrameRate(fps) { + if (fps < 1 || fps > 60) { + console.warn('Invalid FPS value:', fps); + return; + } + + this.fps = fps; + console.log(`Frame rate updated to ${fps} FPS`); + + // 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); + } + + // Notify clients + this.broadcastToClients({ + type: 'frameRateUpdated', + fps: this.fps + }); + } + // Node selection and configuration management selectNode(nodeIp) { this.currentTarget = nodeIp;