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');