mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-21 05:32:34 -06:00
Merge pull request #42 from screentinker/fix/sw-video-passthrough
fix(web): service worker video passthrough + independent per-zone rotation
This commit is contained in:
commit
ac1b24fe43
|
|
@ -4,9 +4,14 @@
|
||||||
// v2 - first network-first version (replaced a cache-first SW that shipped stale JS)
|
// v2 - first network-first version (replaced a cache-first SW that shipped stale JS)
|
||||||
// v3 - force returning clients to drop the old bucket so the "Add user" admin
|
// v3 - force returning clients to drop the old bucket so the "Add user" admin
|
||||||
// button (and any client still on a pre-v2 cache-first SW) lands.
|
// button (and any client still on a pre-v2 cache-first SW) lands.
|
||||||
|
// v4 - stop intercepting media/content/player + range requests. The old handler
|
||||||
|
// clone+cache+respond'd every non-API request; a video Range request gets a
|
||||||
|
// 206 (uncacheable) which broke the handler ("ServiceWorker encountered an
|
||||||
|
// unexpected error"), so videos never loaded on pages this SW controls
|
||||||
|
// (e.g. the web player, since this SW's scope is '/').
|
||||||
// Changing this string is what makes the browser detect a new SW + run activate,
|
// Changing this string is what makes the browser detect a new SW + run activate,
|
||||||
// which deletes every cache key != CACHE below.
|
// which deletes every cache key != CACHE below.
|
||||||
const CACHE = 'rd-admin-v3';
|
const CACHE = 'rd-admin-v4';
|
||||||
|
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll([
|
e.waitUntil(caches.open(CACHE).then(c => c.addAll([
|
||||||
|
|
@ -23,18 +28,31 @@ self.addEventListener('activate', e => {
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', e => {
|
self.addEventListener('fetch', e => {
|
||||||
// Don't intercept API or socket.io traffic - those need to hit the network unmediated.
|
const req = e.request;
|
||||||
if (e.request.url.includes('/api/') || e.request.url.includes('/socket.io/')) return;
|
// Only handle same-origin GET navigations/assets. Everything else hits the
|
||||||
// Network-first: respect the server's Cache-Control: no-cache + ETag (304s
|
// network unmediated:
|
||||||
// stay fast); fall back to cache only when offline. Re-populate the cache
|
// - non-GET: cache.put() rejects on them anyway.
|
||||||
// on every successful fetch so the offline fallback stays current.
|
// - Range requests (video seeking): the response is 206 Partial Content, which
|
||||||
|
// is uncacheable and breaks clone+cache+respond -> "ServiceWorker encountered
|
||||||
|
// an unexpected error", stalling video playback.
|
||||||
|
// - /uploads/ (content/media), /player (the web player), /api/, /socket.io/:
|
||||||
|
// not ours to cache; the player + server set their own cache headers.
|
||||||
|
if (req.method !== 'GET' || req.headers.has('range')) return;
|
||||||
|
const url = req.url;
|
||||||
|
if (url.includes('/api/') || url.includes('/socket.io/') ||
|
||||||
|
url.includes('/uploads/') || url.includes('/player')) return;
|
||||||
|
|
||||||
|
// Network-first: respect the server's Cache-Control: no-cache + ETag (304s stay
|
||||||
|
// fast); fall back to cache only when offline. Only cache full, same-origin 200s.
|
||||||
e.respondWith(
|
e.respondWith(
|
||||||
fetch(e.request)
|
fetch(req)
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
const copy = resp.clone();
|
if (resp && resp.status === 200 && resp.type === 'basic') {
|
||||||
caches.open(CACHE).then(c => c.put(e.request, copy)).catch(() => {});
|
const copy = resp.clone();
|
||||||
|
caches.open(CACHE).then(c => c.put(req, copy)).catch(() => {});
|
||||||
|
}
|
||||||
return resp;
|
return resp;
|
||||||
})
|
})
|
||||||
.catch(() => caches.match(e.request))
|
.catch(() => caches.match(req))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -329,6 +329,9 @@
|
||||||
// playback muted).
|
// playback muted).
|
||||||
let userHasInteracted = false;
|
let userHasInteracted = false;
|
||||||
let advanceTimer = null;
|
let advanceTimer = null;
|
||||||
|
// Per-zone rotation timers (multi-zone). Each zone advances independently on
|
||||||
|
// its own interval, decoupled from the fullscreen advanceTimer/nextItem.
|
||||||
|
let zoneTimers = {};
|
||||||
// Video wall state. wallConfig is the tile assignment from the server
|
// Video wall state. wallConfig is the tile assignment from the server
|
||||||
// (null when this device isn't in a wall). The leader runs the playlist
|
// (null when this device isn't in a wall). The leader runs the playlist
|
||||||
// normally and broadcasts wall:sync every second; followers don't run
|
// normally and broadcasts wall:sync every second; followers don't run
|
||||||
|
|
@ -1297,8 +1300,14 @@
|
||||||
// releases the decoder and kills audio. Null event handlers so a late onended
|
// releases the decoder and kills audio. Null event handlers so a late onended
|
||||||
// can't fire into a stale playlist state. Queries all <video> elements so
|
// can't fire into a stale playlist state. Queries all <video> elements so
|
||||||
// zone-mode (multi-region) videos get cleaned up too, not just currentVideoEl.
|
// zone-mode (multi-region) videos get cleaned up too, not just currentVideoEl.
|
||||||
|
function clearZoneTimers() {
|
||||||
|
for (const k in zoneTimers) clearTimeout(zoneTimers[k]);
|
||||||
|
zoneTimers = {};
|
||||||
|
}
|
||||||
|
|
||||||
function teardownCurrentMedia() {
|
function teardownCurrentMedia() {
|
||||||
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
|
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
|
||||||
|
clearZoneTimers();
|
||||||
const container = document.getElementById('playerContainer');
|
const container = document.getElementById('playerContainer');
|
||||||
if (container) {
|
if (container) {
|
||||||
container.querySelectorAll('video').forEach(v => {
|
container.querySelectorAll('video').forEach(v => {
|
||||||
|
|
@ -1320,9 +1329,9 @@
|
||||||
const container = document.getElementById('playerContainer');
|
const container = document.getElementById('playerContainer');
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
|
|
||||||
// Multi-zone: each zone pulls its own content by zone_id, independent of the
|
// Multi-zone: each zone pulls + rotates its own assignments by zone_id,
|
||||||
// rotating "current item". Render zones here (before the single-item bail) so
|
// independent of the "current item". Render zones here (before the single-item
|
||||||
// an empty/placeholder current item can't blank the whole multi-zone screen.
|
// bail) so an empty/placeholder current item can't blank the whole screen.
|
||||||
if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
|
if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
|
||||||
renderZones(container, item);
|
renderZones(container, item);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1452,52 +1461,81 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderZones(container, defaultItem) {
|
function renderZones(container, defaultItem) {
|
||||||
// Multi-zone layout
|
clearZoneTimers();
|
||||||
|
// Group assignments by zone, ordered by sort_order so each zone rotates its
|
||||||
|
// OWN list independently (images/widgets on a duration timer, videos on end)
|
||||||
|
// rather than every zone re-rendering on a single global tick.
|
||||||
|
const byZone = {};
|
||||||
|
for (const a of playlist) {
|
||||||
|
const zid = a.zone_id || '__none__';
|
||||||
|
(byZone[zid] = byZone[zid] || []).push(a);
|
||||||
|
}
|
||||||
|
for (const k in byZone) byZone[k].sort((x, y) => (x.sort_order || 0) - (y.sort_order || 0));
|
||||||
|
|
||||||
|
let unassignedUsed = false;
|
||||||
layout.zones.forEach(zone => {
|
layout.zones.forEach(zone => {
|
||||||
|
let items = byZone[zone.id];
|
||||||
|
if ((!items || !items.length) && !unassignedUsed && byZone['__none__']) {
|
||||||
|
unassignedUsed = true; items = byZone['__none__'];
|
||||||
|
}
|
||||||
|
if ((!items || !items.length) && defaultItem) items = [defaultItem];
|
||||||
|
if (!items || !items.length) return;
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'zone';
|
div.className = 'zone';
|
||||||
div.style.cssText = `left:${zone.x_percent}%;top:${zone.y_percent}%;width:${zone.width_percent}%;height:${zone.height_percent}%;z-index:${zone.z_index || 0}`;
|
div.style.cssText = `left:${zone.x_percent}%;top:${zone.y_percent}%;width:${zone.width_percent}%;height:${zone.height_percent}%;z-index:${zone.z_index || 0}`;
|
||||||
|
|
||||||
// Find assignment for this zone
|
|
||||||
const assignment = playlist.find(a => a.zone_id === zone.id) || defaultItem;
|
|
||||||
if (!assignment) return;
|
|
||||||
|
|
||||||
const isVideo = assignment.mime_type?.startsWith('video/');
|
|
||||||
const src = assignment.remote_url || `${config.serverUrl}/uploads/content/${assignment.filepath}`;
|
|
||||||
|
|
||||||
const isYoutubeZone = assignment.mime_type === 'video/youtube';
|
|
||||||
|
|
||||||
if (zone.zone_type === 'widget' && assignment.widget_id) {
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.src = `${config.serverUrl}/api/widgets/${assignment.widget_id}/render`;
|
|
||||||
// Sandbox into a unique origin so widget scripts can't read window.parent
|
|
||||||
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
|
|
||||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
|
||||||
div.appendChild(iframe);
|
|
||||||
} else if (isYoutubeZone) {
|
|
||||||
createYoutubeEmbed(src, assignment, div);
|
|
||||||
} else if (isVideo) {
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.src = src;
|
|
||||||
video.autoplay = true;
|
|
||||||
video.muted = (zone.sort_order > 0); // Only first zone has audio
|
|
||||||
video.loop = (playlist.length === 1);
|
|
||||||
video.playsInline = true;
|
|
||||||
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
|
|
||||||
if (!video.loop) video.onended = () => nextItem();
|
|
||||||
div.appendChild(video);
|
|
||||||
} else {
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.src = src;
|
|
||||||
img.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
|
|
||||||
div.appendChild(img);
|
|
||||||
if (playlist.length > 1) advanceTimer = setTimeout(nextItem, (assignment.duration_sec || 10) * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
showZoneItem(zone, div, items, 0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render items[index] in a zone and schedule the next item on the zone's OWN
|
||||||
|
// timer (images/widgets/youtube: duration; videos: on end). Single-item zones
|
||||||
|
// loop / don't advance.
|
||||||
|
function showZoneItem(zone, div, items, index) {
|
||||||
|
if (zoneTimers[zone.id]) { clearTimeout(zoneTimers[zone.id]); delete zoneTimers[zone.id]; }
|
||||||
|
const a = items[index % items.length];
|
||||||
|
const multi = items.length > 1;
|
||||||
|
const advance = () => showZoneItem(zone, div, items, index + 1);
|
||||||
|
// Tear down any prior media in this zone before swapping.
|
||||||
|
div.querySelectorAll('video').forEach(v => { try { v.onended = null; v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} });
|
||||||
|
div.innerHTML = '';
|
||||||
|
|
||||||
|
const isYoutube = a.mime_type === 'video/youtube';
|
||||||
|
const isVideo = !isYoutube && a.mime_type?.startsWith('video/');
|
||||||
|
const src = a.remote_url || `${config.serverUrl}/uploads/content/${a.filepath}`;
|
||||||
|
const dur = (a.duration_sec || 10) * 1000;
|
||||||
|
|
||||||
|
if (zone.zone_type === 'widget' && a.widget_id) {
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render`;
|
||||||
|
// Sandbox into a unique origin so widget scripts can't read window.parent
|
||||||
|
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
|
||||||
|
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||||
|
div.appendChild(iframe);
|
||||||
|
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
|
||||||
|
} else if (isYoutube) {
|
||||||
|
createYoutubeEmbed(src, a, div);
|
||||||
|
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
|
||||||
|
} else if (isVideo) {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.src = src;
|
||||||
|
video.autoplay = true;
|
||||||
|
video.muted = (zone.sort_order > 0); // Only first zone has audio
|
||||||
|
video.loop = !multi; // single-item zone loops; multi advances on end
|
||||||
|
video.playsInline = true;
|
||||||
|
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
|
||||||
|
if (multi) video.onended = advance;
|
||||||
|
div.appendChild(video);
|
||||||
|
} else {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = src;
|
||||||
|
img.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
|
||||||
|
div.appendChild(img);
|
||||||
|
if (multi) zoneTimers[zone.id] = setTimeout(advance, dur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Screenshots ====================
|
// ==================== Screenshots ====================
|
||||||
function captureAndSend() {
|
function captureAndSend() {
|
||||||
if (!socket?.connected) return;
|
if (!socket?.connected) return;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue