fix(player-web): independent per-zone rotation in multi-zone layouts

Mirror of the Android fix. The web player showed only the FIRST assignment per
zone (playlist.find) and an image zone set the GLOBAL advanceTimer->nextItem, so
the whole layout re-rendered on one global tick instead of each zone cycling its
own content. Now each zone groups its assignments (by zone_id, sorted), renders
the first, and advances on its OWN timer (images/widgets/youtube: duration;
videos: on end; single-item zones loop). Cleared in teardown. Also render zones
before the single-item 'renderable?' bail so an empty current item can't blank
the screen.
This commit is contained in:
ScreenTinker 2026-06-08 23:12:29 -05:00
parent d4f71bbf3a
commit 546fcdc105

View file

@ -329,6 +329,9 @@
// playback muted).
let userHasInteracted = false;
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
// (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
@ -1297,8 +1300,14 @@
// 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
// 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() {
if (advanceTimer) { clearTimeout(advanceTimer); advanceTimer = null; }
clearZoneTimers();
const container = document.getElementById('playerContainer');
if (container) {
container.querySelectorAll('video').forEach(v => {
@ -1320,6 +1329,14 @@
const container = document.getElementById('playerContainer');
container.style.display = 'block';
// Multi-zone: each zone pulls + rotates its own assignments by zone_id,
// independent of the "current item". Render zones here (before the single-item
// bail) so an empty/placeholder current item can't blank the whole screen.
if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) {
renderZones(container, item);
return;
}
// Defense in depth: bail to waiting state on missing/malformed item rather
// than fall through every branch and leave a blank container.
const hasRenderableType = item && (
@ -1444,52 +1461,81 @@
}
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 => {
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');
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}`;
// 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);
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 ====================
function captureAndSend() {
if (!socket?.connected) return;