mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
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:
parent
b7d0c94313
commit
e2879fff58
|
|
@ -461,10 +461,10 @@ function renderPlaylist(assignments) {
|
|||
<div class="playlist-item-info">
|
||||
<div class="playlist-item-name">${a.filename || a.widget_name || 'Unknown'}</div>
|
||||
<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 ? ` · <span style="color:var(--accent)">Zone: ${a.zone_id.slice(0,8)}</span>` : ''}
|
||||
${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}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue