Instant playlist push, fix YouTube looping, auto-fetch video titles

- Push playlist updates to devices instantly via WebSocket on all
  assignment mutations (add, update, delete, reorder, copy)
- Fix YouTube videos skipping early: remove duration_sec timeout (was
  defaulting to 10s), use generation counter to ignore stale player
  callbacks, disable YouTube loop param for multi-item playlists
- Auto-fetch YouTube video title via oEmbed API when no name provided
- Show actual video duration in M:SS format in playlist instead of
  misleading assignment duration_sec
- Pre-fill server URL from origin on web player setup
- Bump playlist poll interval to 5min (fallback only, push is primary)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-08 15:42:41 -05:00
parent b7d0c94313
commit e2879fff58
6 changed files with 76 additions and 18 deletions

View file

@ -461,10 +461,10 @@ function renderPlaylist(assignments) {
<div class="playlist-item-info"> <div class="playlist-item-info">
<div class="playlist-item-name">${a.filename || a.widget_name || 'Unknown'}</div> <div class="playlist-item-name">${a.filename || a.widget_name || 'Unknown'}</div>
<div class="playlist-item-meta"> <div class="playlist-item-meta">
${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 ? ` &middot; <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''} ${a.zone_id ? ` &middot; <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''}
${a.duration_sec ? ` &middot; Duration: ${a.duration_sec}s` : ''} ${a.content_duration ? ` &middot; ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''}
${a.content_duration ? ` &middot; Length: ${Math.round(a.content_duration)}s` : ''} ${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` &middot; ${a.duration_sec}s` : ''}
${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''} ${a.schedule_start ? ` &middot; ${a.schedule_start}-${a.schedule_end}` : ''}
</div> </div>
</div> </div>

View file

@ -155,6 +155,12 @@
} }
// ==================== Boot ==================== // ==================== 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) { if (config.serverUrl && config.deviceId && config.paired) {
// Show tap-to-start overlay to unlock audio on auto-reconnect // Show tap-to-start overlay to unlock audio on auto-reconnect
const tapOverlay = document.createElement('div'); const tapOverlay = document.createElement('div');
@ -183,9 +189,10 @@
} }
// ==================== Setup UI ==================== // ==================== Setup UI ====================
const savedUrl = config.serverUrl || ''; const savedUrl = config.serverUrl || window.location.origin;
document.getElementById('serverUrl').value = savedUrl; document.getElementById('serverUrl').value = savedUrl;
// Unlock audio on any user interaction // Unlock audio on any user interaction
function unlockAudio() { function unlockAudio() {
userHasInteracted = true; userHasInteracted = true;
@ -397,12 +404,14 @@
function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } }
function startPlaylistRefresh() { 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); if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(() => { refreshTimer = setInterval(() => {
if (socket?.connected && config.deviceId && config.paired) { if (socket?.connected && config.deviceId && config.paired) {
socket.emit('device:register', { device_id: config.deviceId, device_info: {} }); socket.emit('device:register', { device_id: config.deviceId, device_info: {} });
} }
}, PLAYLIST_REFRESH_INTERVAL); }, 300000); // 5 minutes fallback
} }
// ==================== Playlist ==================== // ==================== Playlist ====================
@ -536,6 +545,7 @@
} }
let activeYtPlayer = null; let activeYtPlayer = null;
let ytGeneration = 0; // Incremented on each new YouTube embed to ignore stale callbacks
function createYoutubeEmbed(src, item, container) { function createYoutubeEmbed(src, item, container) {
const videoId = extractVideoId(src); const videoId = extractVideoId(src);
@ -545,6 +555,12 @@
return null; 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 // Create a div for the YT player to replace
const playerDiv = document.createElement('div'); const playerDiv = document.createElement('div');
playerDiv.id = 'yt-player-' + Date.now(); playerDiv.id = 'yt-player-' + Date.now();
@ -572,7 +588,11 @@
} }
loadYoutubeApi(() => { 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, { activeYtPlayer = new YT.Player(playerDiv.id, {
videoId: videoId, videoId: videoId,
width: '100%', width: '100%',
@ -583,13 +603,14 @@
controls: 0, controls: 0,
rel: 0, rel: 0,
modestbranding: 1, modestbranding: 1,
loop: 1, loop: shouldLoop ? 1 : 0,
playlist: videoId, playlist: shouldLoop ? videoId : undefined,
enablejsapi: 1, enablejsapi: 1,
origin: window.location.origin, origin: window.location.origin,
}, },
events: { events: {
onReady: (event) => { onReady: (event) => {
if (myGeneration !== ytGeneration) return;
console.log('YouTube player ready:', item.filename); console.log('YouTube player ready:', item.filename);
event.target.playVideo(); event.target.playVideo();
if (userHasInteracted) { if (userHasInteracted) {
@ -598,16 +619,21 @@
} }
}, },
onError: (event) => { onError: (event) => {
if (myGeneration !== ytGeneration) return;
console.error('YouTube error', event.data, 'for:', item.filename); 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) { if (playlist.length > 1) {
console.log('Skipping unplayable YouTube video'); console.log('Skipping unplayable YouTube video');
setTimeout(nextItem, 2000); setTimeout(nextItem, 2000);
} }
}, },
onStateChange: (event) => { onStateChange: (event) => {
// YT.PlayerState.ENDED = 0 if (myGeneration !== ytGeneration) return;
if (event.data === 0 && playlist.length > 1) { // 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(); nextItem();
} }
}, },
@ -615,10 +641,9 @@
}); });
}); });
// Fallback: advance after duration if set // Note: YouTube advancement is handled by onStateChange ENDED event.
if (playlist.length > 1 && item.duration_sec) { // Do NOT use duration_sec timeout here — it defaults to 10s for assignments
setTimeout(nextItem, (item.duration_sec || 30) * 1000); // and would cut videos short. The YouTube player tells us when it's done.
}
return playerDiv; return playerDiv;
} }

View file

@ -2,6 +2,20 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../db/database'); 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 // Check device ownership for device-scoped routes
function checkDeviceAccess(req, res) { function checkDeviceAccess(req, res) {
const device = db.prepare('SELECT user_id FROM devices WHERE id = ?').get(req.params.deviceId); 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 = ? WHERE a.id = ?
`).get(result.lastInsertRowid); `).get(result.lastInsertRowid);
pushPlaylistToDevice(req, req.params.deviceId);
res.status(201).json(assignment); res.status(201).json(assignment);
} catch (err) { } catch (err) {
if (err.message.includes('UNIQUE')) { 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 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 = ? WHERE a.id = ?
`).get(req.params.id); `).get(req.params.id);
pushPlaylistToDevice(req, assignment.device_id);
res.json(updated); res.json(updated);
}); });
@ -120,6 +136,7 @@ router.delete('/:id', (req, res) => {
if (!assignment) return res.status(404).json({ error: 'Assignment not found' }); if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id); 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 }); 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 = ? WHERE a.device_id = ?
ORDER BY a.sort_order ASC ORDER BY a.sort_order ASC
`).all(req.params.deviceId); `).all(req.params.deviceId);
pushPlaylistToDevice(req, req.params.deviceId);
res.json(assignments); res.json(assignments);
}); });
@ -169,6 +187,7 @@ router.post('/device/:deviceId/copy-to/:targetDeviceId', (req, res) => {
}); });
transaction(); transaction();
pushPlaylistToDevice(req, req.params.targetDeviceId);
res.json({ success: true, copied: source.length }); res.json({ success: true, copied: source.length });
}); });

View file

@ -140,7 +140,7 @@ router.post('/remote', checkRemoteUrl, (req, res) => {
}); });
// Add YouTube content (available to all plans - no storage used) // Add YouTube content (available to all plans - no storage used)
router.post('/youtube', (req, res) => { router.post('/youtube', async (req, res) => {
try { try {
const { url, name } = req.body; const { url, name } = req.body;
if (!url) return res.status(400).json({ error: 'url is required' }); if (!url) return res.status(400).json({ error: 'url is required' });
@ -149,10 +149,22 @@ router.post('/youtube', (req, res) => {
const videoId = extractYoutubeId(url); const videoId = extractYoutubeId(url);
if (!videoId) return res.status(400).json({ error: 'Invalid YouTube 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 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 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 thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const filename = name || `YouTube: ${videoId}`;
db.prepare(` db.prepare(`
INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path) INSERT INTO content (id, user_id, filename, filepath, mime_type, file_size, remote_url, thumbnail_path)

View file

@ -276,6 +276,7 @@ app.use('/uploads/content', (req, res, next) => {
// Setup WebSockets // Setup WebSockets
const setupWebSockets = require('./ws'); const setupWebSockets = require('./ws');
const { deviceNs, dashboardNs } = setupWebSockets(io); const { deviceNs, dashboardNs } = setupWebSockets(io);
app.set('io', io);
// Start heartbeat checker // Start heartbeat checker
const { startHeartbeatChecker } = require('./services/heartbeat'); const { startHeartbeatChecker } = require('./services/heartbeat');

View file

@ -85,8 +85,9 @@ function checkDeviceAccess(deviceId) {
} }
module.exports = function setupDeviceSocket(io) { 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.lastScreenshots = lastScreenshots;
module.exports.buildPlaylistPayload = buildPlaylistPayload;
const deviceNs = io.of('/device'); const deviceNs = io.of('/device');
const dashboardNs = io.of('/dashboard'); const dashboardNs = io.of('/dashboard');