diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 674fad7..2628cfc 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -461,10 +461,10 @@ function renderPlaylist(assignments) {
${a.filename || a.widget_name || 'Unknown'}
- ${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'} + ${a.widget_id && !a.content_id ? `Widget (${a.widget_type || 'custom'})` : a.mime_type === 'video/youtube' ? 'YouTube' : a.mime_type?.startsWith('video/') ? 'Video' : 'Image'} ${a.zone_id ? ` · Zone: ${a.zone_id.slice(0,8)}` : ''} - ${a.duration_sec ? ` · Duration: ${a.duration_sec}s` : ''} - ${a.content_duration ? ` · Length: ${Math.round(a.content_duration)}s` : ''} + ${a.content_duration ? ` · ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''} + ${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''} ${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
diff --git a/server/player/index.html b/server/player/index.html index 1556c44..f76eb18 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -155,6 +155,12 @@ } // ==================== Boot ==================== + // Auto-detect server URL from origin since player is served from the same server + if (!config.serverUrl) { + config.serverUrl = window.location.origin; + saveConfig(config); + } + if (config.serverUrl && config.deviceId && config.paired) { // Show tap-to-start overlay to unlock audio on auto-reconnect const tapOverlay = document.createElement('div'); @@ -183,9 +189,10 @@ } // ==================== Setup UI ==================== - const savedUrl = config.serverUrl || ''; + const savedUrl = config.serverUrl || window.location.origin; document.getElementById('serverUrl').value = savedUrl; + // Unlock audio on any user interaction function unlockAudio() { userHasInteracted = true; @@ -397,12 +404,14 @@ function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } function startPlaylistRefresh() { + // No longer needed — server pushes playlist updates instantly via WebSocket. + // Kept as a fallback with a long interval in case a push is missed. if (refreshTimer) clearInterval(refreshTimer); refreshTimer = setInterval(() => { if (socket?.connected && config.deviceId && config.paired) { socket.emit('device:register', { device_id: config.deviceId, device_info: {} }); } - }, PLAYLIST_REFRESH_INTERVAL); + }, 300000); // 5 minutes fallback } // ==================== Playlist ==================== @@ -536,6 +545,7 @@ } let activeYtPlayer = null; + let ytGeneration = 0; // Incremented on each new YouTube embed to ignore stale callbacks function createYoutubeEmbed(src, item, container) { const videoId = extractVideoId(src); @@ -545,6 +555,12 @@ return null; } + // Invalidate any previous player's callbacks + const myGeneration = ++ytGeneration; + + // Destroy old player without triggering side effects (callbacks check generation) + if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} activeYtPlayer = null; } + // Create a div for the YT player to replace const playerDiv = document.createElement('div'); playerDiv.id = 'yt-player-' + Date.now(); @@ -572,7 +588,11 @@ } loadYoutubeApi(() => { - if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} } + // Bail if a newer player was created while we waited for the API + if (myGeneration !== ytGeneration) return; + + const shouldLoop = playlist.length <= 1; + let playStartTime = 0; activeYtPlayer = new YT.Player(playerDiv.id, { videoId: videoId, width: '100%', @@ -583,13 +603,14 @@ controls: 0, rel: 0, modestbranding: 1, - loop: 1, - playlist: videoId, + loop: shouldLoop ? 1 : 0, + playlist: shouldLoop ? videoId : undefined, enablejsapi: 1, origin: window.location.origin, }, events: { onReady: (event) => { + if (myGeneration !== ytGeneration) return; console.log('YouTube player ready:', item.filename); event.target.playVideo(); if (userHasInteracted) { @@ -598,16 +619,21 @@ } }, onError: (event) => { + if (myGeneration !== ytGeneration) return; console.error('YouTube error', event.data, 'for:', item.filename); - // 2=invalid param, 5=HTML5 error, 100=not found, 101/150=restricted if (playlist.length > 1) { console.log('Skipping unplayable YouTube video'); setTimeout(nextItem, 2000); } }, onStateChange: (event) => { - // YT.PlayerState.ENDED = 0 - if (event.data === 0 && playlist.length > 1) { + if (myGeneration !== ytGeneration) return; + // Track when video actually starts playing + if (event.data === 1) playStartTime = Date.now(); + // YT.PlayerState.ENDED = 0 — advance to next video + // Ignore ENDED if video played for less than 3 seconds (spurious during init) + if (event.data === 0 && !shouldLoop && (Date.now() - playStartTime) > 3000) { + console.log('YouTube video ended:', item.filename); nextItem(); } }, @@ -615,10 +641,9 @@ }); }); - // Fallback: advance after duration if set - if (playlist.length > 1 && item.duration_sec) { - setTimeout(nextItem, (item.duration_sec || 30) * 1000); - } + // Note: YouTube advancement is handled by onStateChange ENDED event. + // Do NOT use duration_sec timeout here — it defaults to 10s for assignments + // and would cut videos short. The YouTube player tells us when it's done. return playerDiv; } diff --git a/server/routes/assignments.js b/server/routes/assignments.js index 799c36e..518d5d7 100644 --- a/server/routes/assignments.js +++ b/server/routes/assignments.js @@ -2,6 +2,20 @@ const express = require('express'); const router = express.Router(); const { db } = require('../db/database'); +// Push playlist update to a connected device via WebSocket +function pushPlaylistToDevice(req, deviceId) { + try { + const io = req.app.get('io'); + if (!io) return; + const { buildPlaylistPayload } = require('../ws/deviceSocket'); + if (!buildPlaylistPayload) return; + const deviceNs = io.of('/device'); + deviceNs.to(deviceId).emit('device:playlist-update', buildPlaylistPayload(deviceId)); + } catch (e) { + console.warn('Failed to push playlist update:', e.message); + } +} + // Check device ownership for device-scoped routes function checkDeviceAccess(req, res) { const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); @@ -73,6 +87,7 @@ router.post('/device/:deviceId', (req, res) => { WHERE a.id = ? `).get(result.lastInsertRowid); + pushPlaylistToDevice(req, req.params.deviceId); res.status(201).json(assignment); } catch (err) { if (err.message.includes('UNIQUE')) { @@ -111,6 +126,7 @@ router.put('/:id', (req, res) => { FROM assignments a LEFT JOIN content c ON a.content_id = c.id LEFT JOIN widgets w ON a.widget_id = w.id WHERE a.id = ? `).get(req.params.id); + pushPlaylistToDevice(req, assignment.device_id); res.json(updated); }); @@ -120,6 +136,7 @@ router.delete('/:id', (req, res) => { if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id); + pushPlaylistToDevice(req, assignment.device_id); res.json({ success: true, device_id: assignment.device_id, content_id: assignment.content_id }); }); @@ -143,6 +160,7 @@ router.post('/device/:deviceId/reorder', (req, res) => { WHERE a.device_id = ? ORDER BY a.sort_order ASC `).all(req.params.deviceId); + pushPlaylistToDevice(req, req.params.deviceId); res.json(assignments); }); @@ -169,6 +187,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => { }); transaction(); + pushPlaylistToDevice(req, req.params.targetDeviceId); res.json({ success: true, copied: source.length }); }); diff --git a/server/routes/content.js b/server/routes/content.js index 0deb466..e299516 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -140,7 +140,7 @@ router.post('/remote', checkRemoteUrl, (req, res) => { }); // Add YouTube content (available to all plans - no storage used) -router.post('/youtube', (req, res) => { +router.post('/youtube', async (req, res) => { try { const { url, name } = req.body; if (!url) return res.status(400).json({ error: 'url is required' }); @@ -149,10 +149,22 @@ router.post('/youtube', (req, res) => { const videoId = extractYoutubeId(url); if (!videoId) return res.status(400).json({ error: 'Invalid YouTube URL' }); + // Fetch video title from YouTube oEmbed if no name provided + let filename = name; + if (!filename) { + try { + const oembedRes = await fetch(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`); + if (oembedRes.ok) { + const oembed = await oembedRes.json(); + filename = oembed.title; + } + } catch {} + } + if (!filename) filename = `YouTube: ${videoId}`; + const id = uuidv4(); const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=${videoId}&enablejsapi=1`; const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; - const filename = name || `YouTube: ${videoId}`; db.prepare(` INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path) diff --git a/server/server.js b/server/server.js index d5fa271..7b8809e 100644 --- a/server/server.js +++ b/server/server.js @@ -276,6 +276,7 @@ app.use('/uploads/content', (req, res, next) => { // Setup WebSockets const setupWebSockets = require('./ws'); const { deviceNs, dashboardNs } = setupWebSockets(io); +app.set('io', io); // Start heartbeat checker const { startHeartbeatChecker } = require('./services/heartbeat'); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index fc8a83b..0a7c15e 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -85,8 +85,9 @@ function checkDeviceAccess(deviceId) { } module.exports = function setupDeviceSocket(io) { - // Expose lastScreenshots for the screenshot API endpoint + // Expose helpers for use by route handlers module.exports.lastScreenshots = lastScreenshots; + module.exports.buildPlaylistPayload = buildPlaylistPayload; const deviceNs = io.of('/device'); const dashboardNs = io.of('/dashboard');