mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
884 lines
40 KiB
JavaScript
884 lines
40 KiB
JavaScript
/* PlaylistPlayer — fullscreen single-zone renderer for the Tizen player.
|
||
* Mirrors the Android player's content rules:
|
||
* image -> shown for duration_sec (min 3s), then advance
|
||
* video -> plays to end then advance; single item loops
|
||
* video/youtube-> iframe embed; single item loops, multi advances after duration
|
||
* remote_url -> same as image/video but src = remote_url
|
||
* widget -> iframe of {server}/api/widgets/{id}/render for duration_sec
|
||
* Content file URL: {server}/api/content/{content_id}/file (public)
|
||
*/
|
||
// Minimal i18n for the Tizen player (no shared i18n module here). Falls back to en.
|
||
var TIZEN_I18N = {
|
||
en: { nothing_scheduled: 'Nothing scheduled right now', no_content: 'No content assigned yet' },
|
||
es: { nothing_scheduled: 'No hay nada programado en este momento', no_content: 'Aún no hay contenido asignado' },
|
||
fr: { nothing_scheduled: 'Rien de programmé pour le moment', no_content: 'Aucun contenu attribué pour l’instant' },
|
||
de: { nothing_scheduled: 'Derzeit ist nichts geplant', no_content: 'Noch kein Inhalt zugewiesen' },
|
||
pt: { nothing_scheduled: 'Nada programado no momento', no_content: 'Nenhum conteúdo atribuído ainda' }
|
||
};
|
||
var TZ_LANG = (function () { try { return (localStorage.getItem('rd_lang') || navigator.language || 'en').split('-')[0]; } catch (e) { return 'en'; } })();
|
||
function tzt(k) { return (TIZEN_I18N[TZ_LANG] && TIZEN_I18N[TZ_LANG][k]) || TIZEN_I18N.en[k] || k; }
|
||
|
||
function PlaylistPlayer(stageEl, getBase) {
|
||
this.stage = stageEl;
|
||
this.getBase = getBase;
|
||
this.items = [];
|
||
this.index = 0;
|
||
this.timer = null;
|
||
this.sig = '';
|
||
this.timezone = null; // #74/#75: device-effective IANA tz for schedule eval
|
||
this.wallFollower = false; // video-wall: a follower holds the leader's item, no auto-advance
|
||
this.currentVideoEl = null; // current <video> (wall leader reads position; follower drift-corrects)
|
||
this.itemStartedAt = 0; // wall position fallback for non-video items
|
||
this.DEFAULT_DURATION = 10;
|
||
this.MIN_DURATION = 3;
|
||
this.preloadEl = null; // #group-sync double buffer: pre-buffered next <video>
|
||
this.preloadIdx = -1;
|
||
// #157: schedule-driven (group-sync) mode — set by app.js. Group-sync advances via its own tick,
|
||
// not the solo timer, and Tizen group-sync doesn't use wallFollower, so this gates the deferral.
|
||
this.scheduleDriven = false;
|
||
// #157 deferred rotation-out: when a removed-but-live item should finish before we swap in the list.
|
||
this._deferredRotation = false;
|
||
this._deferredSuccessorId = null;
|
||
}
|
||
|
||
// #157 continuity helpers (mirror the web/Android players).
|
||
PlaylistPlayer.prototype.setScheduleDriven = function (b) { this.scheduleDriven = !!b; };
|
||
PlaylistPlayer.prototype.itemIdentity = function (x) {
|
||
return x ? [x.content_id || '', x.widget_id || '', x.remote_url || '', x.filepath || ''].join('|') : '';
|
||
};
|
||
PlaylistPlayer.prototype.indexOfIdentity = function (arr, id) {
|
||
for (var i = 0; i < arr.length; i++) if (this.itemIdentity(arr[i]) === id) return i;
|
||
return -1;
|
||
};
|
||
PlaylistPlayer.prototype.hasContentOnScreen = function () {
|
||
return !!(this.stage && this.stage.querySelector &&
|
||
(this.stage.querySelector('video') || this.stage.querySelector('img') || this.stage.querySelector('iframe')));
|
||
};
|
||
|
||
// Double buffer: build a hidden, buffering <video> for the next clip (in document.body so clearStage
|
||
// won't wipe it) so renderVideo can mount it instantly at the boundary (no black hold). Videos only.
|
||
PlaylistPlayer.prototype.preloadVideo = function (idx) {
|
||
if (this.preloadIdx === idx) return; // already handled this boundary
|
||
var item = this.items[idx];
|
||
if (!item) return;
|
||
if ((item.mime_type || '').indexOf('video/') !== 0) { this.preloadIdx = idx; this.preloadEl = null; return; }
|
||
try {
|
||
if (this.preloadEl && this.preloadEl.parentNode) this.preloadEl.parentNode.removeChild(this.preloadEl);
|
||
var v = document.createElement('video');
|
||
v.muted = true; v.setAttribute('playsinline', ''); v.preload = 'auto';
|
||
v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;left:-9999px';
|
||
v.src = this.contentUrl(item);
|
||
v.load();
|
||
document.body.appendChild(v);
|
||
this.preloadEl = v; this.preloadIdx = idx;
|
||
} catch (e) { this.preloadEl = null; this.preloadIdx = -1; }
|
||
};
|
||
PlaylistPlayer.prototype._takePreload = function (idx) {
|
||
if (this.preloadIdx === idx && this.preloadEl) {
|
||
var el = this.preloadEl; this.preloadEl = null; this.preloadIdx = -1;
|
||
if (el.parentNode) el.parentNode.removeChild(el);
|
||
return el;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
PlaylistPlayer.prototype.load = function (assignments) {
|
||
// B3: a malformed device:playlist-update with a non-array `assignments` used to throw
|
||
// (.filter is not a function) out of the socket handler; coerce to [] instead.
|
||
var items = (Array.isArray(assignments) ? assignments : []).filter(function (a) {
|
||
return a && (a.content_id || a.widget_id || a.remote_url);
|
||
});
|
||
// Stable order
|
||
items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); });
|
||
|
||
var sig = JSON.stringify(items.map(function (a) {
|
||
// STRUCTURAL only. #74/#75: include schedules so a schedule edit (same content) re-renders.
|
||
// duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
|
||
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || []];
|
||
}));
|
||
if (sig === this.sig && this.items.length) {
|
||
// In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect
|
||
// (group schedule tick re-anchors; solo advance uses it next) WITHOUT restarting playback.
|
||
for (var k = 0; k < this.items.length && k < items.length; k++) {
|
||
if (this.items[k].duration_sec !== items[k].duration_sec) this.items[k].duration_sec = items[k].duration_sec;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Structural change. Preserve continuity like the web/Android players instead of always
|
||
// restarting from the top: if the on-screen item survives, keep playing it; if it was removed
|
||
// while live in SOLO playback (#157 e.g. an expiry), let it finish then rotate to the successor.
|
||
var oldItems = this.items;
|
||
var oldIndex = this.index;
|
||
var curId = this.itemIdentity(oldItems[oldIndex]);
|
||
this.sig = sig;
|
||
this.items = items;
|
||
this._deferredRotation = false;
|
||
this._deferredSuccessorId = null;
|
||
|
||
if (!items.length) { this.index = 0; this.startPlayback(); return; }
|
||
|
||
// Current item survives -> keep playing it, just retarget the index (no restart).
|
||
if (curId) {
|
||
var stay = this.indexOfIdentity(items, curId);
|
||
if (stay >= 0 && this.hasContentOnScreen()) { this.index = stay; return; }
|
||
}
|
||
|
||
// Anchor gone: walk forward from the OLD position to the first item that still exists.
|
||
var nextIdx = 0;
|
||
if (oldItems.length) {
|
||
for (var w = 1; w <= oldItems.length; w++) {
|
||
var pid = this.itemIdentity(oldItems[(oldIndex + w) % oldItems.length]);
|
||
if (!pid || pid === curId) continue;
|
||
var f = this.indexOfIdentity(items, pid);
|
||
if (f >= 0) { nextIdx = f; break; }
|
||
}
|
||
}
|
||
|
||
// #157: removed-but-live in solo playback -> don't interrupt; rotate out on the next advance
|
||
// (the current item's video onended / image timer still fires advance()). Group-sync (schedule-
|
||
// driven) and wall followers reconcile via their own tick, so play through immediately as before.
|
||
if (this.hasContentOnScreen() && !this.wallFollower && !this.scheduleDriven) {
|
||
this._deferredRotation = true;
|
||
this._deferredSuccessorId = this.itemIdentity(items[nextIdx]);
|
||
return;
|
||
}
|
||
|
||
this.startPlaybackAt(nextIdx);
|
||
};
|
||
|
||
PlaylistPlayer.prototype.stop = function () {
|
||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||
this.clearStage();
|
||
};
|
||
|
||
PlaylistPlayer.prototype.clearStage = function () {
|
||
// Pause any video before removing so audio doesn't linger.
|
||
var v = this.stage.querySelector('video');
|
||
if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} }
|
||
this.stage.innerHTML = '';
|
||
};
|
||
|
||
PlaylistPlayer.prototype.idle = function () {
|
||
this.clearStage();
|
||
this.stage.innerHTML =
|
||
'<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
|
||
'<p class="sub">' + tzt('no_content') + '</p></div>';
|
||
};
|
||
|
||
PlaylistPlayer.prototype.durationMs = function (item) {
|
||
// B3: a non-numeric duration_sec ("abc") used to yield NaN -> schedule(NaN) -> fire-ASAP spin.
|
||
// Coerce; any non-positive/NaN falls back to the default.
|
||
var d = Number(item.duration_sec);
|
||
if (!(d > 0)) d = this.DEFAULT_DURATION;
|
||
if (d < this.MIN_DURATION) d = this.MIN_DURATION;
|
||
return d * 1000;
|
||
};
|
||
|
||
PlaylistPlayer.prototype.contentUrl = function (item) {
|
||
if (item.remote_url) return item.remote_url;
|
||
if (item.content_id) return this.getBase() + '/api/content/' + item.content_id + '/file';
|
||
return null;
|
||
};
|
||
|
||
PlaylistPlayer.prototype.advance = function () {
|
||
// #157: apply a deferred rotation-out — the removed-but-live item just finished, so swap in the
|
||
// stashed list and continue at the preserved successor instead of interrupting/restarting.
|
||
if (this._deferredRotation) {
|
||
this._deferredRotation = false;
|
||
var sid = this._deferredSuccessorId; this._deferredSuccessorId = null;
|
||
var to = sid ? this.indexOfIdentity(this.items, sid) : -1;
|
||
this.startPlaybackAt(to >= 0 ? to : 0);
|
||
return;
|
||
}
|
||
if (!this.items.length) return;
|
||
// #74/#75: advance to the next schedule-active item; idle if none.
|
||
var idx = this.nextActiveIndex(this.index);
|
||
if (idx < 0) { this.nothingScheduled(); return; }
|
||
this.index = idx;
|
||
this.playCurrent();
|
||
};
|
||
|
||
// Play a specific index (schedule-permitting); mirrors the web player's startPlaybackAt.
|
||
PlaylistPlayer.prototype.startPlaybackAt = function (idx) {
|
||
if (!this.items.length) { this.idle(); return; }
|
||
if (idx < 0 || idx >= this.items.length) idx = 0;
|
||
if (this.scheduleAllows(this.items[idx])) { this.index = idx; this.playCurrent(); return; }
|
||
var a = this.nextActiveIndex(idx);
|
||
if (a >= 0) { this.index = a; this.playCurrent(); } else this.nothingScheduled();
|
||
};
|
||
|
||
PlaylistPlayer.prototype.schedule = function (ms) {
|
||
var self = this;
|
||
if (this.timer) clearTimeout(this.timer);
|
||
this.timer = setTimeout(function () { self.advance(); }, ms);
|
||
};
|
||
|
||
// #74/#75: per-item schedule gating (mirrors the web/Android players). No blocks =
|
||
// always on. Fails open: any evaluator error means the item plays.
|
||
PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || null; };
|
||
|
||
// ---- video-wall support (used by WallController) ----
|
||
// A follower holds the leader's current item and never auto-advances; entering or
|
||
// leaving wall mode (or a role flip) calls invalidate() so the next load re-renders
|
||
// with the right semantics instead of being de-duped by the unchanged signature.
|
||
PlaylistPlayer.prototype.setWallFollower = function (b) { this.wallFollower = !!b; };
|
||
PlaylistPlayer.prototype.invalidate = function () { this.sig = ''; };
|
||
PlaylistPlayer.prototype.getIndex = function () { return this.index; };
|
||
PlaylistPlayer.prototype.getCurrentItem = function () { return this.items[this.index] || null; };
|
||
PlaylistPlayer.prototype.getCurrentVideo = function () { return this.currentVideoEl; };
|
||
PlaylistPlayer.prototype.getItemStartedAt = function () { return this.itemStartedAt; };
|
||
// Follower jumps to the leader's index. No-op if already there (avoids a needless
|
||
// restart that would re-buffer the same item).
|
||
PlaylistPlayer.prototype.gotoIndex = function (idx) {
|
||
if (!this.items.length) return;
|
||
var n = this.items.length;
|
||
idx = ((idx % n) + n) % n;
|
||
if (idx === this.index) return;
|
||
this.index = idx;
|
||
this.playCurrent();
|
||
};
|
||
|
||
PlaylistPlayer.prototype.scheduleAllows = function (item) {
|
||
if (!item || !item.schedules || !item.schedules.length) return true;
|
||
try {
|
||
return (typeof ScheduleEval !== 'undefined')
|
||
? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), this.timezone) : true;
|
||
} catch (e) { return true; }
|
||
};
|
||
|
||
PlaylistPlayer.prototype.anyScheduled = function () {
|
||
for (var i = 0; i < this.items.length; i++) {
|
||
if (this.items[i].schedules && this.items[i].schedules.length) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
PlaylistPlayer.prototype.firstActiveIndex = function () {
|
||
for (var i = 0; i < this.items.length; i++) if (this.scheduleAllows(this.items[i])) return i;
|
||
return -1;
|
||
};
|
||
|
||
PlaylistPlayer.prototype.nextActiveIndex = function (from) {
|
||
if (!this.items.length) return -1;
|
||
for (var i = 1; i <= this.items.length; i++) {
|
||
var idx = (from + i) % this.items.length;
|
||
if (this.scheduleAllows(this.items[idx])) return idx;
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
PlaylistPlayer.prototype.startPlayback = function () {
|
||
if (!this.items.length) { this.idle(); return; }
|
||
var idx = this.firstActiveIndex();
|
||
if (idx < 0) { this.nothingScheduled(); return; }
|
||
this.index = idx;
|
||
this.playCurrent();
|
||
};
|
||
|
||
// Every item filtered out: idle and re-check shortly (a daypart may open).
|
||
PlaylistPlayer.prototype.nothingScheduled = function () {
|
||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||
this.clearStage();
|
||
this.stage.innerHTML =
|
||
'<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
|
||
'<p class="sub">' + tzt('nothing_scheduled') + '</p></div>';
|
||
var self = this;
|
||
this.timer = setTimeout(function () { self.startPlayback(); }, 30000);
|
||
};
|
||
|
||
PlaylistPlayer.prototype.playCurrent = function () {
|
||
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||
if (!this.items.length) { this.idle(); return; }
|
||
|
||
this.itemStartedAt = Date.now(); // wall position fallback for non-video items
|
||
this.currentVideoEl = null; // set by renderVideo when applicable
|
||
|
||
var item = this.items[this.index];
|
||
// Scheduled playlists cycle even with one active item so windows re-evaluate.
|
||
// A wall FOLLOWER also behaves "single": it holds the leader's current item
|
||
// (looping, no auto-advance) and only switches when wall:sync says the index moved.
|
||
var single = this.wallFollower || (this.items.length === 1 && !this.anyScheduled());
|
||
var mime = item.mime_type || '';
|
||
this.clearStage();
|
||
|
||
try {
|
||
if (mime === 'video/youtube') return this.renderYouTube(item, single);
|
||
if (item.widget_id && !item.content_id) return this.renderWidget(item, single);
|
||
if (mime.indexOf('video/') === 0) return this.renderVideo(item, single);
|
||
if (mime.indexOf('image/') === 0) return this.renderImage(item, single);
|
||
// Fallback: a remote_url with unknown mime -> try iframe
|
||
if (item.remote_url) return this.renderFrame(item.remote_url, single ? 0 : this.durationMs(item));
|
||
} catch (e) {
|
||
this.skipSoon();
|
||
return;
|
||
}
|
||
// Unknown item -> skip
|
||
this.skipSoon();
|
||
};
|
||
|
||
// Give a broken item ~2s then move on so the loop never wedges.
|
||
PlaylistPlayer.prototype.skipSoon = function () {
|
||
if (this.items.length > 1) { this.schedule(2000); return; }
|
||
// A1: a SINGLE-item playlist used to WEDGE on a broken item — skipSoon did nothing, so a transient
|
||
// failure (CDN blip, brief network loss, a 404 that later resolves) left a permanent black screen
|
||
// while the heartbeat still reported the device online. Retry the SAME item after a backoff so it
|
||
// self-heals instead of going dark forever.
|
||
var self = this;
|
||
if (this.timer) clearTimeout(this.timer);
|
||
this.timer = setTimeout(function () { self.playCurrent(); }, 5000);
|
||
};
|
||
|
||
PlaylistPlayer.prototype.fit = function (el, item) {
|
||
// assignment may carry a fit hint; default cover (matches Android default)
|
||
var f = (item.fit || item.scale || 'cover').toLowerCase();
|
||
if (f === 'contain' || f === 'fit') el.className = 'contain';
|
||
else if (f === 'fill' || f === 'stretch') el.className = 'fill';
|
||
else el.className = 'cover';
|
||
};
|
||
|
||
PlaylistPlayer.prototype.renderImage = function (item, single) {
|
||
var self = this;
|
||
var img = document.createElement('img');
|
||
this.fit(img, item);
|
||
img.onerror = function () { self.skipSoon(); };
|
||
img.src = this.contentUrl(item);
|
||
this.stage.appendChild(img);
|
||
if (!single) this.schedule(this.durationMs(item));
|
||
};
|
||
|
||
PlaylistPlayer.prototype.renderVideo = function (item, single) {
|
||
var self = this;
|
||
// Double buffer: reuse the pre-buffered element for this index if warmed (no black hold); its src
|
||
// is already set + buffering, so playback starts near-instantly.
|
||
var pre = this._takePreload(this.index);
|
||
var v = pre || document.createElement('video');
|
||
this.currentVideoEl = v; // wall: leader reads currentTime; follower drift-corrects this
|
||
this.fit(v, item);
|
||
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
|
||
v.loop = single; // single item loops; multi advances on end
|
||
v.onended = function () { if (!single) self.advance(); };
|
||
v.onerror = function () { self.skipSoon(); };
|
||
if (!pre) v.src = this.contentUrl(item);
|
||
v.style.cssText = ''; // clear the offscreen-hide style if reused
|
||
this.stage.appendChild(v);
|
||
var p = v.play(); if (p && p.catch) p.catch(function () {});
|
||
// Safety net: if 'ended' never fires (rare), advance after the known
|
||
// content duration (or the assignment duration) + a buffer.
|
||
if (!single) {
|
||
var secs = Number(item.content_duration || item.duration_sec) || this.DEFAULT_DURATION; // B3: numeric
|
||
this.schedule((secs + 5) * 1000);
|
||
}
|
||
};
|
||
|
||
PlaylistPlayer.prototype.renderYouTube = function (item, single) {
|
||
var id = this.youtubeId(item.remote_url);
|
||
if (!id) { this.skipSoon(); return; }
|
||
var src = 'https://www.youtube.com/embed/' + id +
|
||
'?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=' + id + '&playsinline=1';
|
||
this.renderFrame(src, single ? 0 : this.durationMs(item), 'autoplay; encrypted-media');
|
||
};
|
||
|
||
PlaylistPlayer.prototype.renderWidget = function (item, single) {
|
||
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render';
|
||
this.renderFrame(src, single ? 0 : this.durationMs(item));
|
||
};
|
||
|
||
PlaylistPlayer.prototype.renderFrame = function (src, advanceMs, allow) {
|
||
var f = document.createElement('iframe');
|
||
f.setAttribute('frameborder', '0');
|
||
f.setAttribute('allowfullscreen', '');
|
||
if (allow) f.setAttribute('allow', allow);
|
||
f.src = src;
|
||
this.stage.appendChild(f);
|
||
if (advanceMs > 0) this.schedule(advanceMs);
|
||
};
|
||
|
||
PlaylistPlayer.prototype.youtubeId = function (url) {
|
||
if (!url) return null;
|
||
var m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
|
||
if (m) return m[1];
|
||
if (/^[A-Za-z0-9_-]{11}$/.test(url)) return url; // bare id
|
||
return null;
|
||
};
|
||
|
||
/* ZoneRenderer — multi-zone layout renderer for the Tizen player.
|
||
* Ports the Android player's ZoneManager (player/ZoneManager.kt). A layout is a set of
|
||
* absolutely-positioned zones (percent geometry + z-index + fit_mode + background), and
|
||
* EACH zone rotates its own list of assignments independently: images/widgets advance on
|
||
* a duration timer, videos advance on 'ended' (a single-item zone loops). The same
|
||
* per-item schedule gating (#74/#75) used in single-zone applies per zone. Assignments are
|
||
* grouped by zone_id and sorted by sort_order; unassigned content (zone_id null) goes to
|
||
* the FIRST zone only. Single-zone playback stays in PlaylistPlayer; app.js chooses the
|
||
* renderer from payload.layout.
|
||
*/
|
||
function ZoneRenderer(stageEl, getBase) {
|
||
this.stage = stageEl;
|
||
this.getBase = getBase;
|
||
this.timezone = null;
|
||
this.zones = [];
|
||
this.timers = {}; // zoneId -> timeout id
|
||
this.videos = {}; // zoneId -> <video> (pause before removal)
|
||
this.sig = '';
|
||
this.DEFAULT_DURATION = 10;
|
||
this.MIN_DURATION = 3;
|
||
}
|
||
|
||
ZoneRenderer.prototype.setTimezone = function (tz) { this.timezone = tz || null; };
|
||
ZoneRenderer.prototype.active = function () { return this.zones.length > 0; };
|
||
// #162: drop the cached signature so the next render() repaints even if the layout is
|
||
// unchanged — used when the shared #stage was blanked by the other (single-zone) renderer.
|
||
ZoneRenderer.prototype.invalidate = function () { this.sig = ''; };
|
||
|
||
ZoneRenderer.prototype.cancelAll = function () {
|
||
for (var k in this.timers) { if (this.timers.hasOwnProperty(k) && this.timers[k]) clearTimeout(this.timers[k]); }
|
||
this.timers = {};
|
||
};
|
||
|
||
ZoneRenderer.prototype.clear = function () {
|
||
this.cancelAll();
|
||
for (var k in this.videos) {
|
||
if (this.videos.hasOwnProperty(k) && this.videos[k]) {
|
||
try { this.videos[k].pause(); this.videos[k].removeAttribute('src'); this.videos[k].load(); } catch (e) {}
|
||
}
|
||
}
|
||
this.videos = {};
|
||
this.zones = [];
|
||
this.sig = '';
|
||
this.stage.innerHTML = '';
|
||
};
|
||
|
||
ZoneRenderer.prototype.signature = function (layout, assignments) {
|
||
var zsig = (layout.zones || []).map(function (z) {
|
||
return [z.id, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index, z.fit_mode, z.background_color];
|
||
});
|
||
var asig = (assignments || []).map(function (a) {
|
||
return [a.zone_id || '', a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type, a.sort_order, a.schedules || []];
|
||
});
|
||
return JSON.stringify([layout.id || '', zsig, asig]);
|
||
};
|
||
|
||
ZoneRenderer.prototype.render = function (layout, assignments) {
|
||
if (!layout || !layout.zones || !layout.zones.length) { this.clear(); return; }
|
||
var sig = this.signature(layout, assignments);
|
||
if (sig === this.sig && this.zones.length) return; // unchanged — keep zones playing
|
||
this.clear();
|
||
this.sig = sig;
|
||
|
||
// The stage must be a positioned containing block so zone % geometry resolves against
|
||
// it (applyOrientation leaves the stage static in landscape).
|
||
if (!this.stage.style.position) this.stage.style.position = 'relative';
|
||
|
||
this.zones = layout.zones.map(function (z) {
|
||
return {
|
||
id: z.id, name: z.name || 'Zone',
|
||
x: zrNum(z.x_percent, 0), y: zrNum(z.y_percent, 0),
|
||
w: zrNum(z.width_percent, 100), h: zrNum(z.height_percent, 100),
|
||
z: zrNum(z.z_index, 0),
|
||
fit: z.fit_mode || 'cover',
|
||
bg: z.background_color || '#000000'
|
||
};
|
||
});
|
||
|
||
// Group assignments by zone_id (sorted by sort_order); zone_id null -> first zone only.
|
||
var byZone = {}, unassigned = [];
|
||
(assignments || []).forEach(function (a) {
|
||
if (!a || !(a.content_id || a.widget_id || a.remote_url)) return;
|
||
if (a.zone_id == null || a.zone_id === '') unassigned.push(a);
|
||
else (byZone[a.zone_id] = byZone[a.zone_id] || []).push(a);
|
||
});
|
||
function bySort(a, b) { return (a.sort_order || 0) - (b.sort_order || 0); }
|
||
for (var zid in byZone) if (byZone.hasOwnProperty(zid)) byZone[zid].sort(bySort);
|
||
unassigned.sort(bySort);
|
||
|
||
var self = this, unassignedUsed = false;
|
||
this.zones.slice().sort(function (a, b) { return a.z - b.z; }).forEach(function (zone) {
|
||
var list = byZone[zone.id];
|
||
if (!list && !unassignedUsed) { unassignedUsed = true; list = unassigned; }
|
||
list = list || [];
|
||
|
||
var div = document.createElement('div');
|
||
div.style.position = 'absolute';
|
||
div.style.left = zone.x + '%'; div.style.top = zone.y + '%';
|
||
div.style.width = zone.w + '%'; div.style.height = zone.h + '%';
|
||
div.style.overflow = 'hidden';
|
||
div.style.zIndex = String(zone.z);
|
||
div.style.background = zone.bg;
|
||
self.stage.appendChild(div);
|
||
zone.el = div;
|
||
|
||
if (list.length) self.showItem(zone, list, 0);
|
||
});
|
||
};
|
||
|
||
ZoneRenderer.prototype.scheduleAdvance = function (zone, ms, fn) {
|
||
if (this.timers[zone.id]) clearTimeout(this.timers[zone.id]);
|
||
this.timers[zone.id] = setTimeout(fn, ms);
|
||
};
|
||
|
||
// Per-item schedule gating, mirrors PlaylistPlayer / Android. No blocks = always on;
|
||
// fails open (any evaluator error means the item plays).
|
||
ZoneRenderer.prototype.allows = function (item) {
|
||
if (!item || !item.schedules || !item.schedules.length) return true;
|
||
try {
|
||
return (typeof ScheduleEval !== 'undefined')
|
||
? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), this.timezone) : true;
|
||
} catch (e) { return true; }
|
||
};
|
||
|
||
ZoneRenderer.prototype.nextActive = function (list, from) {
|
||
for (var i = 0; i < list.length; i++) {
|
||
var idx = (from + i) % list.length;
|
||
if (this.allows(list[idx])) return idx;
|
||
}
|
||
return -1;
|
||
};
|
||
|
||
ZoneRenderer.prototype.durationMs = function (item) {
|
||
var d = item.duration_sec || this.DEFAULT_DURATION;
|
||
if (d < this.MIN_DURATION) d = this.MIN_DURATION;
|
||
return d * 1000;
|
||
};
|
||
|
||
ZoneRenderer.prototype.contentUrl = function (item) {
|
||
if (item.remote_url) return item.remote_url;
|
||
if (item.content_id) return this.getBase() + '/api/content/' + item.content_id + '/file';
|
||
return null;
|
||
};
|
||
|
||
ZoneRenderer.prototype.showItem = function (zone, list, index) {
|
||
if (this.timers[zone.id]) { clearTimeout(this.timers[zone.id]); this.timers[zone.id] = null; }
|
||
if (this.videos[zone.id]) { try { this.videos[zone.id].pause(); } catch (e) {} this.videos[zone.id] = null; }
|
||
zone.el.innerHTML = '';
|
||
|
||
var self = this;
|
||
// #74/#75: skip items whose schedule excludes them now; blank-idle the zone and
|
||
// re-check shortly (a daypart may open) if none are active.
|
||
var activeIdx = this.nextActive(list, index);
|
||
if (activeIdx < 0) { this.scheduleAdvance(zone, 30000, function () { self.showItem(zone, list, 0); }); return; }
|
||
|
||
var a = list[activeIdx];
|
||
// Scheduled zones cycle even with one active item so windows re-evaluate.
|
||
var multi = list.length > 1 || list.some(function (x) { return x.schedules && x.schedules.length; });
|
||
var advance = function () { self.showItem(zone, list, activeIdx + 1); };
|
||
var dur = this.durationMs(a);
|
||
var mime = a.mime_type || '';
|
||
|
||
try {
|
||
if (mime === 'video/youtube') {
|
||
var yid = zrYoutubeId(a.remote_url);
|
||
if (!yid) { if (multi) this.scheduleAdvance(zone, 2000, advance); return; }
|
||
var ysrc = 'https://www.youtube.com/embed/' + yid +
|
||
'?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=' + yid + '&playsinline=1';
|
||
zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media'));
|
||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||
} else if (a.widget_type || (a.widget_id && !a.content_id)) {
|
||
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render'));
|
||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||
} else if (mime.indexOf('video/') === 0) {
|
||
var v = document.createElement('video');
|
||
v.className = zrFitClass(zone.fit);
|
||
// Zone videos are muted: TV web autoplay needs muted, and overlapping zone audio
|
||
// is rarely intended. (Single-zone fullscreen handles audio in PlaylistPlayer.)
|
||
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
|
||
v.loop = !multi; // single-item zone loops; multi advances on end
|
||
v.onended = function () { if (multi) advance(); };
|
||
v.onerror = function () { if (multi) self.scheduleAdvance(zone, 2000, advance); };
|
||
v.src = this.contentUrl(a);
|
||
zone.el.appendChild(v);
|
||
this.videos[zone.id] = v;
|
||
var p = v.play(); if (p && p.catch) p.catch(function () {});
|
||
if (multi) {
|
||
var secs = a.content_duration || a.duration_sec || this.DEFAULT_DURATION;
|
||
this.scheduleAdvance(zone, (secs + 5) * 1000, advance); // safety net if 'ended' never fires
|
||
}
|
||
} else if (mime.indexOf('image/') === 0) {
|
||
var img = document.createElement('img');
|
||
img.className = zrFitClass(zone.fit);
|
||
img.onerror = function () { if (multi) self.scheduleAdvance(zone, 2000, advance); };
|
||
img.src = this.contentUrl(a);
|
||
zone.el.appendChild(img);
|
||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||
} else if (a.remote_url) {
|
||
zone.el.appendChild(zrFrame(a.remote_url));
|
||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||
} else {
|
||
if (multi) this.scheduleAdvance(zone, dur, advance);
|
||
}
|
||
} catch (e) {
|
||
if (multi) this.scheduleAdvance(zone, 2000, advance);
|
||
}
|
||
};
|
||
|
||
// --- ZoneRenderer helpers ---
|
||
function zrNum(v, d) { var n = parseFloat(v); return isNaN(n) ? d : n; }
|
||
function zrFitClass(fit) {
|
||
var f = String(fit || 'cover').toLowerCase();
|
||
if (f === 'contain' || f === 'fit') return 'contain';
|
||
if (f === 'fill' || f === 'stretch') return 'fill';
|
||
return 'cover';
|
||
}
|
||
function zrFrame(src, allow) {
|
||
var f = document.createElement('iframe');
|
||
f.setAttribute('frameborder', '0');
|
||
f.setAttribute('allowfullscreen', '');
|
||
if (allow) f.setAttribute('allow', allow);
|
||
f.src = src;
|
||
return f;
|
||
}
|
||
function zrYoutubeId(url) {
|
||
if (!url) return null;
|
||
var m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
|
||
if (m) return m[1];
|
||
if (/^[A-Za-z0-9_-]{11}$/.test(url)) return url;
|
||
return null;
|
||
}
|
||
|
||
/* WallController — video-wall sync for the Tizen player.
|
||
* Mirrors the WEB player's wall logic (server/player/index.html); the Android player
|
||
* has no wall support, so the web player is the reference. A wall maps one playlist
|
||
* across several screens: each screen renders the FULL content (player_rect) but the
|
||
* stage is positioned (in vw/vh) so only this screen's slice (screen_rect) is on-view,
|
||
* with object-fit:fill so a given source row lands on the same physical line on every
|
||
* screen sharing a viewport height. The LEADER plays normally and broadcasts wall:sync
|
||
* at 4Hz; FOLLOWERS hold the leader's item (PlaylistPlayer.wallFollower) and keep their
|
||
* video locked to the leader's clock with a latency-compensated drift controller.
|
||
* (Tizen video is always muted, so the "followers stay silent" rule is automatic.)
|
||
*/
|
||
function WallController(stageEl, player, getSocket, getDeviceId, canEmit) {
|
||
this.stage = stageEl;
|
||
this.player = player;
|
||
this.getSocket = getSocket;
|
||
this.getDeviceId = getDeviceId;
|
||
this.canEmit = canEmit; // () -> authenticated && socket connected (don't emit pre-register)
|
||
this.config = null;
|
||
this.timer = null;
|
||
}
|
||
|
||
WallController.prototype.active = function () { return !!this.config; };
|
||
|
||
// Map this screen's slice. left/top/width/height in vw/vh so the viewport fills
|
||
// edge-to-edge (no pillarbox at the seam between adjacent screens).
|
||
WallController.prototype.styleStage = function (config) {
|
||
var s = config.screen_rect, p = config.player_rect;
|
||
if (!s || !p || !s.w || !s.h) return;
|
||
this.stage.classList.add('wall-mode');
|
||
var st = this.stage.style;
|
||
st.position = 'absolute';
|
||
st.left = (((p.x - s.x) / s.w) * 100) + 'vw';
|
||
st.top = (((p.y - s.y) / s.h) * 100) + 'vh';
|
||
st.width = ((p.w / s.w) * 100) + 'vw';
|
||
st.height = ((p.h / s.h) * 100) + 'vh';
|
||
st.transform = ''; st.transformOrigin = '';
|
||
};
|
||
|
||
// #group-sync: the sync id is wall_id (WALL) or group_id (GROUP mode).
|
||
WallController.prototype.syncId = function (c) { return c && (c.mode === 'group' ? c.group_id : c.wall_id); };
|
||
WallController.prototype.clearStageStyle = function () {
|
||
this.stage.classList.remove('wall-mode');
|
||
var st = this.stage.style;
|
||
st.position = ''; st.left = ''; st.top = ''; st.width = ''; st.height = '';
|
||
st.transform = ''; st.transformOrigin = '';
|
||
};
|
||
|
||
WallController.prototype.apply = function (config) {
|
||
var isGroup = config.mode === 'group';
|
||
var id = this.syncId(config);
|
||
var roleChanged = !this.config ||
|
||
this.config.is_leader !== config.is_leader ||
|
||
this.syncId(this.config) !== id;
|
||
this.config = config;
|
||
|
||
// WALL: map this screen's slice (transform). GROUP: full-screen — clear any wall styling; the
|
||
// normal render path honors per-item mute (no forced follower mute).
|
||
if (isGroup) this.clearStageStyle(); else this.styleStage(config);
|
||
this.player.setWallFollower(!config.is_leader);
|
||
// Entering sync mode or flipping role: force a fresh render so leader/follower
|
||
// semantics take effect (otherwise an unchanged signature de-dupes the load).
|
||
if (roleChanged) this.player.invalidate();
|
||
|
||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||
var self = this;
|
||
if (config.is_leader) {
|
||
// 4Hz so followers nudge playbackRate instead of jerk-seeking; immediate first
|
||
// tick so any already-up follower aligns now (and on leader-reclaim after reconnect).
|
||
this.timer = setInterval(function () { self.emitSync(); }, 250);
|
||
setTimeout(function () { self.emitSync(); }, 100);
|
||
} else {
|
||
// Follower: ask the leader for its position now so we don't show the item start
|
||
// until the next periodic tick (up to ~250ms of visible drift on a fresh join).
|
||
var s = this.getSocket();
|
||
if (s && this.canEmit()) {
|
||
s.emit(isGroup ? 'group:sync-request' : 'wall:sync-request', isGroup ? { group_id: id } : { wall_id: id });
|
||
}
|
||
}
|
||
};
|
||
|
||
WallController.prototype.exit = function () {
|
||
var wasActive = !!this.config || !!this.timer || this.stage.classList.contains('wall-mode');
|
||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||
this.config = null;
|
||
this.player.setWallFollower(false);
|
||
if (wasActive) {
|
||
this.clearStageStyle();
|
||
this.player.invalidate(); // re-render cleanly back into normal (non-sync) mode
|
||
}
|
||
};
|
||
|
||
WallController.prototype.emitSync = function () {
|
||
if (!this.config || !this.config.is_leader || !this.canEmit()) return;
|
||
var s = this.getSocket(); if (!s) return;
|
||
var item = this.player.getCurrentItem();
|
||
if (!item) return;
|
||
var v = this.player.getCurrentVideo();
|
||
var pos = v ? (v.currentTime || 0)
|
||
: Math.max(0, (Date.now() - this.player.getItemStartedAt()) / 1000);
|
||
var msg = {
|
||
device_id: this.getDeviceId(),
|
||
current_index: this.player.getIndex(),
|
||
content_id: item.content_id || null,
|
||
position_sec: pos,
|
||
sent_at: Date.now()
|
||
};
|
||
if (this.config.mode === 'group') { msg.group_id = this.config.group_id; s.emit('group:sync', msg); }
|
||
else { msg.wall_id = this.config.wall_id; s.emit('wall:sync', msg); }
|
||
};
|
||
|
||
WallController.prototype.onSync = function (data) {
|
||
var c = this.config;
|
||
if (!c || c.is_leader || !data) return;
|
||
var isG = c.mode === 'group';
|
||
if ((isG ? data.group_id : data.wall_id) !== this.syncId(c)) return;
|
||
// Align to the leader's current item.
|
||
if (typeof data.current_index === 'number' && data.current_index !== this.player.getIndex()) {
|
||
this.player.gotoIndex(data.current_index);
|
||
}
|
||
// Hold close to the leader's clock, latency-compensated (mirrors the web player):
|
||
// > 0.3s -> hard seek + reset rate
|
||
// > 0.05s -> nudge playbackRate +/-3% to converge gently
|
||
// else -> ride at 1.0x
|
||
var v = this.player.getCurrentVideo();
|
||
if (v && typeof data.position_sec === 'number') {
|
||
var latency = data.sent_at ? Math.max(0, (Date.now() - data.sent_at) / 1000) : 0;
|
||
var target = data.position_sec + latency;
|
||
var drift = (v.currentTime || 0) - target;
|
||
var ad = Math.abs(drift);
|
||
try {
|
||
if (ad > 0.3 && isFinite(v.duration) && target < v.duration) { v.currentTime = target; v.playbackRate = 1.0; }
|
||
else if (ad > 0.05) { v.playbackRate = drift > 0 ? 0.97 : 1.03; }
|
||
else if (v.playbackRate !== 1.0) { v.playbackRate = 1.0; }
|
||
} catch (e) {}
|
||
}
|
||
};
|
||
|
||
WallController.prototype.onSyncRequest = function (data) {
|
||
var c = this.config;
|
||
if (!c || !c.is_leader) return;
|
||
var isG = c.mode === 'group';
|
||
var dataId = data && (isG ? data.group_id : data.wall_id);
|
||
if (dataId && dataId !== this.syncId(c)) return;
|
||
this.emitSync();
|
||
};
|
||
|
||
/* GroupSyncController — clock/schedule group sync for the Tizen player. Mirrors the WEB player's
|
||
* groupScheduleTick (server/player/index.html). Unlike WallController there is NO leader and NO
|
||
* server relay: every same-playlist member lays the deterministic playlist schedule (each item
|
||
* occupies durationMs, in order, dayparted items skipped) on a server-DISCIPLINED clock and derives
|
||
* the identical (index, position) locally. That is offline-native (no server at play-time) and
|
||
* cannot go split-brain. Reuses the player's wallFollower mode (loop + no auto-advance).
|
||
*/
|
||
function GroupSyncController(player, getOffsetMs, report) {
|
||
this.player = player;
|
||
this.getOffsetMs = getOffsetMs; // () -> smoothed clock offset in ms (server is the time authority)
|
||
this.report = report; // (level, msg) -> dashboard live-log
|
||
this.groupId = null;
|
||
this.timer = null;
|
||
this.dbgLast = 0;
|
||
// A fresh item snaps ONCE to the exact schedule position (load-and-hold) instead of nudging away
|
||
// the ~0.3s load offset over ~10s. Steady-state drift rides the gentle nudge afterward.
|
||
this.alignPending = true;
|
||
this.lastAlignedIndex = -1;
|
||
}
|
||
GroupSyncController.prototype.active = function () { return !!this.groupId; };
|
||
GroupSyncController.prototype.syncedNow = function () { return Date.now() + (this.getOffsetMs() || 0); };
|
||
GroupSyncController.prototype.slots = function () {
|
||
var p = this.player, items = p.items, acc = 0, s = [];
|
||
for (var i = 0; i < items.length; i++) {
|
||
if (!p.scheduleAllows(items[i])) continue; // same daypart filter as solo playback
|
||
// CANONICAL slot length — MUST match the web + Android engines exactly (max(1,dur||10)*1000).
|
||
// Deliberately NOT durationMs() (its MIN_DURATION=3 clamp would diverge from the other players).
|
||
var d = Math.max(1, Number(items[i].duration_sec) || 10) * 1000;
|
||
s.push({ index: i, start: acc, dur: d }); acc += d;
|
||
}
|
||
return { slots: s, period: acc };
|
||
};
|
||
GroupSyncController.prototype.target = function () {
|
||
var r = this.slots();
|
||
if (!r.slots.length || r.period <= 0) return null;
|
||
var phase = ((this.syncedNow() % r.period) + r.period) % r.period;
|
||
var ci = -1;
|
||
for (var i = 0; i < r.slots.length; i++) { var x = r.slots[i]; if (phase >= x.start && phase < x.start + x.dur) { ci = i; break; } }
|
||
if (ci < 0) ci = r.slots.length - 1;
|
||
var s = r.slots[ci], nx = r.slots[(ci + 1) % r.slots.length];
|
||
// nextIndex + secToBoundary drive the double buffer (preload the upcoming clip a few s early).
|
||
return { index: s.index, posSec: (phase - s.start) / 1000, nextIndex: nx.index, secToBoundary: (s.start + s.dur - phase) / 1000 };
|
||
};
|
||
GroupSyncController.prototype.tick = function () {
|
||
if (!this.groupId || !this.player.items.length) return;
|
||
var t = this.target(); if (!t) return;
|
||
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
|
||
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6) this.player.preloadVideo(t.nextIndex);
|
||
var action = 'hold';
|
||
if (t.index !== this.player.getIndex()) {
|
||
this.player.gotoIndex(t.index); action = 'jump>' + t.index;
|
||
} else {
|
||
var v = this.player.getCurrentVideo();
|
||
if (v && isFinite(v.duration) && v.duration > 0) {
|
||
var target = t.posSec % v.duration; // loop-safe when slot > clip length
|
||
var drift = (v.currentTime || 0) - target, ad = Math.abs(drift);
|
||
if (this.player.getIndex() !== this.lastAlignedIndex) this.alignPending = true;
|
||
try {
|
||
if (this.alignPending) {
|
||
if (ad > 0.05) { v.currentTime = target; this.lastSeekAt = Date.now(); }
|
||
v.playbackRate = 1.0; this.alignPending = false; this.lastAlignedIndex = this.player.getIndex();
|
||
action = 'align ' + drift.toFixed(2);
|
||
}
|
||
// Seek cooldown: don't hard-seek every tick (decoder-thrash guard); nudge within the window.
|
||
else if (ad > 0.3 && Date.now() - (this.lastSeekAt || 0) > 1200) { v.currentTime = target; v.playbackRate = 1.0; this.lastSeekAt = Date.now(); action = 'seek ' + drift.toFixed(2); }
|
||
else if (ad > 0.05) { v.playbackRate = drift > 0 ? 0.97 : 1.03; action = 'nudge ' + drift.toFixed(2); }
|
||
else if (v.playbackRate !== 1.0) { v.playbackRate = 1.0; }
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
// Log discrete corrections (jump/align/seek) immediately so the transition is visible; only the
|
||
// routine steady-state line (hold/nudge) is throttled — else the one-tick "align" on load reads
|
||
// misleadingly (sampled over by a later hold/nudge).
|
||
var now = Date.now();
|
||
var discrete = action.indexOf('jump') === 0 || action.indexOf('align') === 0 || action.indexOf('seek') === 0;
|
||
if ((discrete || now - this.dbgLast > 1000) && this.report) {
|
||
this.dbgLast = now;
|
||
this.report('info', 'idx=' + this.player.getIndex() + ' tgt=' + t.index + ' pos=' + t.posSec.toFixed(2) + ' off=' + (this.getOffsetMs() || 0) + 'ms ' + action);
|
||
}
|
||
};
|
||
GroupSyncController.prototype.apply = function (groupId) {
|
||
var first = !this.groupId;
|
||
this.groupId = groupId;
|
||
this.alignPending = true; this.lastAlignedIndex = -1; // snap the first item into sync on entry
|
||
this.player.setWallFollower(true); // group member: loop + no local auto-advance (schedule drives)
|
||
this.player.invalidate(); // force a clean re-render into follower semantics
|
||
this.tick(); // align immediately from the cached clock offset
|
||
if (this.timer) clearInterval(this.timer);
|
||
var self = this;
|
||
this.timer = setInterval(function () { self.tick(); }, 250); // 4Hz local correction
|
||
if (this.report) this.report('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(groupId).slice(0, 8) + ' off=' + (this.getOffsetMs() || 0) + 'ms');
|
||
};
|
||
GroupSyncController.prototype.exit = function () {
|
||
if (!this.groupId && !this.timer) return;
|
||
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
||
this.groupId = null;
|
||
this.player.setWallFollower(false);
|
||
this.player.invalidate();
|
||
this.player._takePreload(this.player.preloadIdx); // drop any warmed next-clip element
|
||
if (this.report) this.report('info', 'group-sync exited');
|
||
};
|