From 6e56807271204482d6bd01253f23c7cc48945899 Mon Sep 17 00:00:00 2001 From: TylerCG <117808427+TylerCG@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:36:13 -0400 Subject: [PATCH] good progress --- .vscode/settings.json | 5 + control-service/Dockerfile | 3 + control-service/public/cms.html | 597 ++++++++++++++++-- control-service/src/server.js | 40 +- frontend/src/App.svelte | 132 +++- frontend/src/components/HomeScreen.svelte | 29 +- frontend/src/components/IdleScreen.svelte | 14 +- frontend/src/lib/config.js | 2 +- frontend/src/lib/configStore.js | 35 +- media/background_audio/pop_century.mp3 | Bin 0 -> 2605929 bytes .../when_you_wish_upon_a_star.mp3 | Bin 0 -> 4274940 bytes settings.json | 21 +- 12 files changed, 792 insertions(+), 86 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 media/background_audio/pop_century.mp3 create mode 100755 media/background_audio/when_you_wish_upon_a_star.mp3 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..072a9f0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "chat.tools.terminal.autoApprove": { + "docker-compose": true + } +} \ No newline at end of file diff --git a/control-service/Dockerfile b/control-service/Dockerfile index da102a9..d8e48bb 100644 --- a/control-service/Dockerfile +++ b/control-service/Dockerfile @@ -21,6 +21,9 @@ COPY control-service/public ./public # Copy media files COPY media ./media +# Copy settings file +COPY settings.json ./settings.json + EXPOSE 3001 CMD ["node", "src/server.js"] diff --git a/control-service/public/cms.html b/control-service/public/cms.html index 6fdc6af..90ee9dd 100644 --- a/control-service/public/cms.html +++ b/control-service/public/cms.html @@ -19,6 +19,7 @@ align-items: center; justify-content: center; padding: 20px; + overflow-x: hidden; } .container { @@ -187,6 +188,7 @@ gap: 20px; align-items: center; margin-top: 15px; + flex-wrap: wrap; } .location-mode label { @@ -197,6 +199,98 @@ margin-top: 10px; } + .color-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 15px; + margin-top: 15px; + width: 100%; + } + + .color-picker-item { + display: flex; + flex-direction: column; + gap: 8px; + } + + .color-picker-item label { + font-size: 12px; + font-weight: 600; + color: #555; + margin: 0; + word-break: break-word; + } + + .color-preview { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + background: #f9f9f9; + border-radius: 6px; + border: 1px solid #eee; + } + + .color-preview input[type="color"] { + width: 50px; + height: 50px; + border: none; + cursor: pointer; + padding: 0; + } + + .color-hex { + font-size: 12px; + font-family: monospace; + color: #666; + flex: 1; + } + + .theme-buttons { + display: flex; + gap: 10px; + margin-top: 20px; + flex-wrap: wrap; + } + + .btn-secondary { + padding: 10px 16px; + background: #4a90e2; + color: white; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .btn-secondary:hover { + background: #3a7bc8; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(74, 144, 226, 0.3); + } + + .btn-secondary.export { + background: #10b981; + } + + .btn-secondary.export:hover { + background: #059669; + } + + .btn-secondary.import { + background: #f59e0b; + } + + .btn-secondary.import:hover { + background: #d97706; + } + + #themeFileInput { + display: none; + } + @media (max-width: 600px) { .container { padding: 20px; @@ -238,8 +332,8 @@
- -

Shown after the guest name on idle screen

+ +

Appended after guest name (e.g., " Family" displays as "Guest Family")

@@ -254,11 +348,7 @@

Displayed on hero page top bar

-
- - -

Primary accent color for UI

-
+
@@ -288,66 +378,203 @@

Guest name displayed below welcome text

+ +
+ + +

Audio file to loop with 1 second crossfade on loop

+
+ +
+ + +

Space above the video in pixels (0-500)

+
+ +
+ + +

Color Themes

-
- +
+

Hero Page Colors

+
+
+ +
+ +
#1B4965
+
+
+ +
+ +
+ +
#1E8E9F
+
+
+ +
+ +
+ +
#FF6F61
+
+
+ +
+ +
+ +
#FF6F61
+
+
+ +
+ +
+ +
#FF6F61
+
+
+ +
+ +
+ +
#ffffff
+
+
+ +
+ +
+ +
#1B4965
+
+
+ +
+ +
+ + +
+
+
-
- - -

Top bar background color

+
+

Hero Background Gradient

+
+
+ +
+ +
#1B6B7A
+
+
+
+ +
+ +
#2EC4B6
+
+
+
+ +
+ +
#0F4C5C
+
+
+
-
- - -

Main header gradient base color

+
+

Nav Bar Gradient

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

Divider line between status and header

+
+

Idle Page Status Bar

+
+
+ +
+ +
#113CCF
+
+
+
+ +
+ +
#0f2fa8
+
+
+
-
- - -

Color of "WELCOME" text

+
+

Idle Page Bottom Bar

+
+
+ +
+ +
#113CCF
+
+
+
+ +
+ +
#0f2fa8
+
+
+
-
- - -

Color of guest name text

-
- -
- -
- -
- - -

Idle screen background

-
- -
- - -

Guest name color on idle screen

-
- -
- - -

Family suffix color on idle screen

+ +
+

💾 Saved Themes

+
+ + +
+ +

Export your current theme as a JSON file, or import a previously saved theme file.

@@ -409,11 +636,52 @@ diff --git a/control-service/src/server.js b/control-service/src/server.js index 94854dd..57d157b 100644 --- a/control-service/src/server.js +++ b/control-service/src/server.js @@ -13,7 +13,7 @@ const PORT = parseInt(process.env.PORT || '3001', 10); const CEC_DEVICE = process.env.CEC_DEVICE || '/dev/ttyAMA0'; // Settings file path -const SETTINGS_FILE = path.join(__dirname, '../../settings.json'); +const SETTINGS_FILE = path.join(__dirname, '../settings.json'); // Initialize handlers const cec = new CECHandler(CEC_DEVICE); @@ -54,7 +54,7 @@ function getDefaultSettings() { plex_enabled: true, restaurants_enabled: true, attractions_enabled: true, - brand_color: '#1f2937', + }; } @@ -100,7 +100,21 @@ const server = http.createServer((req, res) => { if (fs.existsSync(mediaPath)) { const stat = fs.statSync(mediaPath); - const contentType = filename.endsWith('.mp4') ? 'video/mp4' : 'application/octet-stream'; + let contentType = 'application/octet-stream'; + + // Determine proper MIME type + if (filename.endsWith('.mp4')) { + contentType = 'video/mp4'; + } else if (filename.endsWith('.mp3')) { + contentType = 'audio/mpeg'; + } else if (filename.endsWith('.wav')) { + contentType = 'audio/wav'; + } else if (filename.endsWith('.m4a')) { + contentType = 'audio/mp4'; + } else if (filename.endsWith('.ogg')) { + contentType = 'audio/ogg'; + } + res.writeHead(200, { 'Content-Type': contentType, 'Content-Length': stat.size }); fs.createReadStream(mediaPath).pipe(res); } else { @@ -150,6 +164,26 @@ const server = http.createServer((req, res) => { return; } + // Audio Files API - List available audio files + if (req.url === '/api/audio-files' && req.method === 'GET') { + try { + const audioDir = path.join(__dirname, '../media/background_audio'); + const files = fs.readdirSync(audioDir).filter(file => { + return file.endsWith('.mp3') || file.endsWith('.wav') || file.endsWith('.m4a') || file.endsWith('.ogg'); + }).map(file => ({ + name: file, + path: `/media/background_audio/${file}` + })); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(files)); + } catch (error) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify([])); // Return empty array if directory doesn't exist + } + return; + } + if (req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(executor.getHealth())); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index a2665e1..5c415a3 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -16,12 +16,16 @@ } from './lib/store.js'; import { fetchRestaurants, fetchAttractions } from './lib/api.js'; import WebSocketManager from './lib/websocket.js'; - import { initConfig, welcomeTitle, idleTimeoutMs, backgroundVideoPath } from './lib/configStore.js'; + import { initConfig, refreshConfig, welcomeTitle, idleTimeoutMs, backgroundVideoPath, backgroundAudioPath, videoTopOffset } from './lib/configStore.js'; let idleTimer; let ws; let IDLE_TIMEOUT = 300000; // Default 5 minutes let welcomeName = 'Guest'; + let audioElement; + const FADE_DURATION = 2000; // 2 second fades + let unsubscribeAudio; + let audioFading = false; // Subscribe to config stores const unsubscribeTitle = welcomeTitle.subscribe(val => { @@ -41,6 +45,7 @@ function handleKeyboardInput(event) { resetIdleTimer(); + triggerAudioPlay(); // Enable audio on user input const key = event.key.toLowerCase(); @@ -95,10 +100,111 @@ } } + function triggerAudioPlay() { + // Called on user input to bypass browser autoplay restrictions + if (audioElement && !audioElement.paused) { + return; // Already playing + } + + if (audioElement && $backgroundAudioPath) { + console.log('🎵 User input detected, attempting audio playback...'); + audioElement.muted = false; + audioElement.play().then(() => { + console.log('✓ Audio playing (triggered by user input)'); + }).catch(err => { + console.error('❌ Play failed on user input:', err.message); + }); + } + } + + function handleAudioLoop() { + if (!audioElement || audioElement.muted || audioFading) return; + + console.log('🔄 Audio ending, starting fade out...'); + audioFading = true; + + const originalVolume = 0.5; + const fadeOutStart = Date.now(); + + const fadeOut = () => { + const elapsed = Date.now() - fadeOutStart; + const progress = Math.min(elapsed / FADE_DURATION, 1); + audioElement.volume = Math.max(0, originalVolume * (1 - progress)); + + if (progress < 1) { + requestAnimationFrame(fadeOut); + } else { + // Fade out complete, now reset and fade in + audioElement.currentTime = 0; + audioElement.volume = 0; + + console.log('✓ Fade out complete, starting fade in...'); + + // Start fade in + const fadeInStart = Date.now(); + const fadeIn = () => { + const elapsed = Date.now() - fadeInStart; + const progress = Math.min(elapsed / FADE_DURATION, 1); + audioElement.volume = originalVolume * progress; + + if (progress < 1) { + requestAnimationFrame(fadeIn); + } else { + audioElement.volume = originalVolume; + audioFading = false; + console.log('✓ Fade in complete, restarting audio...'); + // Restart the audio + audioElement.play().catch(e => console.error('Play failed:', e.message)); + } + }; + + fadeIn(); + } + }; + + fadeOut(); + } + onMount(async () => { // Initialize configuration from CMS await initConfig(); + // Set up audio element with comprehensive event handling + if (audioElement) { + // Event listeners for debugging + audioElement.addEventListener('play', () => console.log('✓ Audio started playing')); + audioElement.addEventListener('pause', () => console.log('Audio paused')); + audioElement.addEventListener('ended', handleAudioLoop); + audioElement.addEventListener('error', (e) => { + console.error('❌ Audio error:', e); + if (audioElement.error) { + console.error('Error code:', audioElement.error.code); + } + }); + audioElement.addEventListener('playing', () => console.log('✓ Audio is now playing (playing event)')); + audioElement.addEventListener('canplaythrough', () => { + console.log('✓ Audio ready to play through (waiting for user input to unmute)'); + }); + audioElement.addEventListener('loadstart', () => console.log('📂 Audio loading started...')); + audioElement.addEventListener('canplay', () => console.log('📂 Audio can play (but not through)')); + audioElement.addEventListener('loadedmetadata', () => console.log('📂 Audio metadata loaded')); + + audioElement.volume = 0.5; + audioElement.preload = 'auto'; + + console.log('✓ Audio element fully initialized'); + + // NOW subscribe to audio path changes (after DOM is ready) + unsubscribeAudio = backgroundAudioPath.subscribe(path => { + if (audioElement && path) { + console.log('🎵 Audio path changed to:', path); + audioElement.src = path; + audioElement.load(); + console.log('📂 Audio loaded and ready, waiting for user input to play'); + } + }); + } + // Initialize WebSocket const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:3001'; ws = new WebSocketManager(wsUrl); @@ -115,6 +221,7 @@ ws.on('input', (data) => { resetIdleTimer(); + triggerAudioPlay(); // Enable audio on any user input if (data.type === 'up' || data.type === 'down') { handleKeyboardInput({ key: `arrow${data.type}` }); } else if (data.type === 'left' || data.type === 'right') { @@ -143,12 +250,17 @@ // Start idle timer resetIdleTimer(); + // Refresh config every 2 seconds to pick up CMS changes + const configRefreshInterval = setInterval(refreshConfig, 2000); + return () => { + clearInterval(configRefreshInterval); clearTimeout(idleTimer); window.removeEventListener('keydown', handleKeyboardInput); ws?.close(); unsubscribeTitle(); unsubscribeTimeout(); + unsubscribeAudio(); }; }); @@ -163,8 +275,20 @@ loop playsinline preload="auto" + style="margin-top: {$videoTopOffset}px;" /> + + {#if $backgroundAudioPath} +