diff --git a/server/player/index.html b/server/player/index.html
index 190004a..1556c44 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -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 = '
Click to unmute
';
+ 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;
diff --git a/server/player/sw.js b/server/player/sw.js
index 7e4f70a..b71f7cb 100644
--- a/server/player/sw.js
+++ b/server/player/sw.js
@@ -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;
}
diff --git a/server/routes/content.js b/server/routes/content.js
index b86d854..0deb466 100644
--- a/server/routes/content.js
+++ b/server/routes/content.js
@@ -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}`;