Fix YouTube playback: use IFrame API, fix playlist change detection, network-first caching

- Replace raw iframe YouTube embeds with official YT IFrame Player API for proper
  error handling (150/153/100/101) and unmute support
- Fix playlist not updating when single item changes by comparing full content
  fingerprint (id + url + filepath + filename) instead of just content_id
- Add click-to-unmute overlay for YouTube since iframe swallows click events
- Remove hardcoded origin param from server-side YouTube URLs (caused Error 153
  when player domain differs from server)
- Switch service worker to network-first for player assets so deploys take effect
  without hard refresh; keep cache-first for uploaded content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-08 14:56:49 -05:00
parent af371b9d89
commit 8a84923d72
3 changed files with 150 additions and 53 deletions

View file

@ -107,13 +107,17 @@
['click', 'touchstart', 'keydown'].forEach(evt => {
document.addEventListener(evt, () => {
userHasInteracted = true;
// Try to unmute any playing video
// Try to unmute any playing HTML5 video
const video = document.querySelector('#playerContainer video');
if (video && video.muted) {
video.muted = false;
video.play().catch(() => {});
console.log('Unmuted video after user interaction');
}
// Unmute YouTube player if active
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
try { activeYtPlayer.unMute(); console.log('Unmuted YouTube player'); } catch {}
}
}, { once: false });
});
@ -422,8 +426,10 @@
}
const newItems = data.assignments || [];
const newIds = newItems.map(a => a.content_id).join(',');
const oldIds = playlist.map(a => a.content_id).join(',');
// Build fingerprint from id + url + filename to detect any content change
const fingerprint = (items) => items.map(a => `${a.content_id}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
// Apply orientation
if (data.orientation) {
@ -438,11 +444,12 @@
layout = data.layout || null;
if (newIds === oldIds && playlist.length > 0) {
if (newFp === oldFp && playlist.length > 0) {
console.log('Playlist unchanged');
return;
}
console.log('Playlist changed, updating');
playlist = newItems;
if (playlist.length === 0) {
@ -453,15 +460,10 @@
document.getElementById('setupScreen').style.display = 'none';
if (!isPlaying) {
currentIndex = 0;
isPlaying = true;
playCurrentItem();
} else {
// Check if current item still exists
const curId = playlist[currentIndex]?.content_id;
if (!curId) { currentIndex = 0; playCurrentItem(); }
}
// Always restart playback when content changes
currentIndex = 0;
isPlaying = true;
playCurrentItem();
}
function playCurrentItem() {
@ -504,14 +506,121 @@
}
// ==================== Content Rendering ====================
function fixYoutubeUrl(url) {
// Extract YouTube video ID from embed URL
function extractVideoId(url) {
try {
const m = url.match(/\/embed\/([a-zA-Z0-9_-]{11})/);
if (m) return m[1];
const u = new URL(url);
if (!u.searchParams.has('mute')) u.searchParams.set('mute', '1');
if (!u.searchParams.has('enablejsapi')) u.searchParams.set('enablejsapi', '1');
if (!u.searchParams.has('origin')) u.searchParams.set('origin', window.location.origin);
return u.toString();
} catch { return url; }
return u.searchParams.get('v') || url.match(/([a-zA-Z0-9_-]{11})/)?.[1] || null;
} catch { return null; }
}
// Load YouTube IFrame API once
let ytApiReady = false;
let ytApiCallbacks = [];
function loadYoutubeApi(cb) {
if (ytApiReady) { cb(); return; }
ytApiCallbacks.push(cb);
if (!document.getElementById('yt-api-script')) {
const tag = document.createElement('script');
tag.id = 'yt-api-script';
tag.src = 'https://www.youtube.com/iframe_api';
document.head.appendChild(tag);
window.onYouTubeIframeAPIReady = () => {
ytApiReady = true;
ytApiCallbacks.forEach(fn => fn());
ytApiCallbacks = [];
};
}
}
let activeYtPlayer = null;
function createYoutubeEmbed(src, item, container) {
const videoId = extractVideoId(src);
if (!videoId) {
console.error('Could not extract YouTube video ID from:', src);
if (playlist.length > 1) setTimeout(nextItem, 2000);
return null;
}
// Create a div for the YT player to replace
const playerDiv = document.createElement('div');
playerDiv.id = 'yt-player-' + Date.now();
playerDiv.style.cssText = 'width:100%;height:100%;background:#000';
container.appendChild(playerDiv);
// Add a click-to-unmute overlay on top of the YouTube iframe
if (!userHasInteracted) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:absolute;inset:0;z-index:10;cursor:pointer;display:flex;align-items:end;justify-content:center;padding-bottom:40px;';
overlay.innerHTML = '<div style="background:rgba(0,0,0,0.7);color:#fff;padding:10px 24px;border-radius:8px;font:14px sans-serif;pointer-events:none">Click to unmute</div>';
overlay.onclick = (e) => {
e.stopPropagation();
userHasInteracted = true;
unlockAudio();
if (activeYtPlayer && typeof activeYtPlayer.unMute === 'function') {
activeYtPlayer.unMute();
activeYtPlayer.setVolume(100);
console.log('Unmuted YouTube player via overlay');
}
overlay.remove();
};
container.style.position = 'relative';
container.appendChild(overlay);
}
loadYoutubeApi(() => {
if (activeYtPlayer) { try { activeYtPlayer.destroy(); } catch {} }
activeYtPlayer = new YT.Player(playerDiv.id, {
videoId: videoId,
width: '100%',
height: '100%',
playerVars: {
autoplay: 1,
mute: userHasInteracted ? 0 : 1,
controls: 0,
rel: 0,
modestbranding: 1,
loop: 1,
playlist: videoId,
enablejsapi: 1,
origin: window.location.origin,
},
events: {
onReady: (event) => {
console.log('YouTube player ready:', item.filename);
event.target.playVideo();
if (userHasInteracted) {
event.target.unMute();
event.target.setVolume(100);
}
},
onError: (event) => {
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) {
nextItem();
}
},
},
});
});
// Fallback: advance after duration if set
if (playlist.length > 1 && item.duration_sec) {
setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
return playerDiv;
}
function renderContent(item) {
@ -531,16 +640,7 @@
} else {
// Fullscreen
if (isYoutube) {
const iframe = document.createElement('iframe');
iframe.src = fixYoutubeUrl(src);
iframe.allow = 'autoplay; encrypted-media';
iframe.allowFullscreen = true;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
container.appendChild(iframe);
// YouTube videos loop via playlist param — advance after duration or loop indefinitely
if (playlist.length > 1 && item.duration_sec) {
setTimeout(nextItem, (item.duration_sec || 30) * 1000);
}
createYoutubeEmbed(src, item, container);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
@ -593,12 +693,7 @@
iframe.src = `${config.serverUrl}/api/widgets/${assignment.widget_id}/render`;
div.appendChild(iframe);
} else if (isYoutubeZone) {
const iframe = document.createElement('iframe');
iframe.src = fixYoutubeUrl(src);
iframe.allow = 'autoplay; encrypted-media';
iframe.allowFullscreen = true;
iframe.style.cssText = 'width:100%;height:100%;border:none';
div.appendChild(iframe);
createYoutubeEmbed(src, assignment, div);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;

View file

@ -1,37 +1,33 @@
const CACHE_NAME = 'rd-player-v2';
const STATIC_ASSETS = ['/player/', '/player/index.html', '/socket.io/socket.io.js'];
const CACHE_NAME = 'rd-player-v3';
const CONTENT_CACHE = 'rd-content-v1';
// Install: cache static assets
// Install: skip waiting to activate immediately
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
// Activate: clean old caches
// Activate: clean old caches, claim clients
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))
))
keys.filter(k => k !== CACHE_NAME && k !== CONTENT_CACHE).map(k => caches.delete(k))
)).then(() => self.clients.claim())
);
self.clients.claim();
});
// Fetch: cache content files for offline playback
// Fetch handler
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Cache content files (videos, images) on first fetch
// Content files (videos, images): cache on first fetch for offline playback
if (url.pathname.startsWith('/uploads/content/')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok && response.status === 200) {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
caches.open(CONTENT_CACHE).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => new Response('Offline', { status: 503 }));
@ -40,10 +36,16 @@ self.addEventListener('fetch', (event) => {
return;
}
// For static assets, try cache first
if (STATIC_ASSETS.some(a => url.pathname === a || url.pathname.endsWith(a))) {
// Player page and static assets: network-first, fall back to cache
if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') {
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
fetch(event.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => caches.match(event.request).then(cached => cached || new Response('Offline', { status: 503 })))
);
return;
}

View file

@ -150,7 +150,7 @@ router.post('/youtube', (req, res) => {
if (!videoId) return res.status(400).json({ error: 'Invalid YouTube URL' });
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&origin=${encodeURIComponent(req.protocol + '://' + req.get('host'))}`;
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}`;