/* 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', portrait_video_unsupported: 'Portrait video isn’t supported on this TV — use landscape, or pre-rotate the file and mark it Landscape.' },
  es: { nothing_scheduled: 'No hay nada programado en este momento', no_content: 'Aún no hay contenido asignado', portrait_video_unsupported: 'El vídeo en vertical no es compatible con este televisor: usa horizontal o rota el archivo y márcalo como Horizontal.' },
  fr: { nothing_scheduled: 'Rien de programmé pour le moment', no_content: 'Aucun contenu attribué pour l’instant', portrait_video_unsupported: 'La vidéo en portrait n’est pas prise en charge sur ce téléviseur — utilisez le paysage, ou faites pivoter le fichier et marquez-le Paysage.' },
  de: { nothing_scheduled: 'Derzeit ist nichts geplant', no_content: 'Noch kein Inhalt zugewiesen', portrait_video_unsupported: 'Hochformat-Video wird auf diesem TV nicht unterstützt – nutze Querformat oder drehe die Datei und markiere sie als Querformat.' },
  pt: { nothing_scheduled: 'Nada programado no momento', no_content: 'Nenhum conteúdo atribuído ainda', portrait_video_unsupported: 'Vídeo em retrato não é suportado nesta TV — use paisagem ou gire o arquivo e marque-o como Paisagem.' }
};
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, getDeviceId) {
  this.stage = stageEl;
  this.getBase = getBase;
  this.getDeviceId = getDeviceId || function () { return ''; };
  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
  // #170: current device orientation. Portrait/flipped VIDEO must rotate the Tizen hardware video
  // plane via AVPlay (CSS rotate can't touch it -> black screen). Set by app.js applyOrientation.
  this.orientation = 'landscape';
  this.avActive = false;       // an AVPlay session is live (portrait video)
  this.DEFAULT_DURATION = 10;
  this.MIN_DURATION = 3;
  this.preloadEl = null;       // #group-sync double buffer: pre-buffered next <video>
  this.preloadIdx = -1;
  // #187 image double buffer: pre-DECODED next <img> (detached — decode() warms the bitmap without
  // the DOM, so clearStage can't wipe it). Mirrors preloadEl/preloadIdx. Swapped in decode-gated so
  // the image path never clears the stage to black before the new frame is paint-ready.
  this.preloadImgEl = null;
  this.preloadImgIdx = -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;
  // Proof-of-play (parity with the web/Android players): onPlayEvent is a hook set by app.js that
  // forwards device:play-event to the server (populates play_logs / Reports). _loggedItem is the item
  // we last emitted play_start for, so we can close it with play_end on the next show.
  this.onPlayEvent = null;
  this._loggedItem = 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;
};

// #187 image double buffer (mirrors preloadVideo/_takePreload for the IMAGE path). Only the NEXT
// item, only when it's an image, ONE-ahead. Builds a DETACHED <img>, sets src, and warms the decoded
// bitmap via HTMLImageElement.decode() (feature-detected — Tizen 5.0 / SSSP6 may lack it, so the
// element still loads and the swap path falls back to onload/complete). At the boundary renderImage
// _takePreloadImage()s it and swaps it in with no black hold.
PlaylistPlayer.prototype.preloadImage = function (idx) {
  if (this.preloadImgIdx === idx) return;   // already handled this boundary
  this._releasePreloadImage();              // index moved off the warmed image -> drop the stale one
  var item = this.items[idx];
  if (!item) { this.preloadImgIdx = -1; return; }
  // One-ahead ONLY, images only. A non-image next: mark this boundary handled, nothing to warm.
  if ((item.mime_type || '').indexOf('image/') !== 0) { this.preloadImgIdx = idx; return; }
  try {
    var img = document.createElement('img');
    this.fit(img, item);
    img.src = this.contentUrl(item);
    // Warm the decoded bitmap. A decode() rejection (broken URL) is swallowed here and re-surfaces at
    // the swap (renderImage re-runs decode()/onerror -> skipSoon), so a bad image still skips.
    if (typeof img.decode === 'function') { img.decode().catch(function () {}); }
    this.preloadImgEl = img; this.preloadImgIdx = idx;
  } catch (e) { this._releasePreloadImage(); }
};
PlaylistPlayer.prototype._takePreloadImage = function (idx) {
  if (this.preloadImgIdx === idx && this.preloadImgEl) {
    var el = this.preloadImgEl; this.preloadImgEl = null; this.preloadImgIdx = -1;
    if (el.parentNode) el.parentNode.removeChild(el);   // detached in practice; defensive parity
    return el;
  }
  return null;
};
PlaylistPlayer.prototype._releasePreloadImage = function () {
  if (this.preloadImgEl) {
    try { this.preloadImgEl.onload = this.preloadImgEl.onerror = null; this.preloadImgEl.src = ''; } catch (e) {}
  }
  this.preloadImgEl = null; this.preloadImgIdx = -1;
};

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.
    // transition-engine: include the per-item transition, or a transition change keeps the same
    // signature -> "unchanged" -> the player never applies the new transitions.
    // duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
      // widget_rev for the same reason as schedules and transition above: a widget's IDENTITY
      // is unchanged when it is EDITED, so a content edit produced an identical signature, the
      // update was treated as unchanged, and the screen kept the old render until a restart.
      return [a.content_id, a.widget_id, a.widget_rev || 0, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
  }));
  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;
  // #187: the warmed image is cached BY INDEX; a structural change can repoint that index at a
  // different item, so drop it (the next dwell re-warms the correct successor).
  this._releasePreloadImage();
  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 && !this._forceRender) {
    var stay = this.indexOfIdentity(items, curId);
    if (stay >= 0 && this.hasContentOnScreen()) { this.index = stay; return; }
  }
  this._forceRender = false;

  // 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.
  // ...but only when an advance is actually coming. Single-item playback here deliberately has
  // none: `single` makes renderImage, renderVideo and renderWidget all skip their timer (a solo
  // item is meant to sit there), so replacing the one item of a one-item playlist deferred forever
  // and the old content stayed on the screen. On Tizen this strands IMAGES too, not just video and
  // widgets as on the web player, because the timer is skipped for every type.
  var outgoingNeverAdvances = !this.items || this.items.length <= 1;
  if (this.hasContentOnScreen() && !this.wallFollower && !this.scheduleDriven && !outgoingNeverAdvances) {
    this._deferredRotation = true;
    this._deferredSuccessorId = this.itemIdentity(items[nextIdx]);
    // Safety net: a deferral is a bet that an advance will arrive. If it does not, apply the
    // change anyway rather than leave the screen on content the operator has replaced.
    var self = this;
    if (this._deferredDeadline) clearTimeout(this._deferredDeadline);
    this._deferredDeadline = setTimeout(function () {
      if (!self._deferredRotation) return;
      self._deferredRotation = false;
      var di = -1;
      for (var k = 0; k < self.items.length; k++) {
        if (self.itemIdentity(self.items[k]) === self._deferredSuccessorId) { di = k; break; }
      }
      self.startPlaybackAt(di === -1 ? 0 : di);
    }, 60000);
    return;
  }

  this.startPlaybackAt(nextIdx);
};

PlaylistPlayer.prototype.stop = function () {
  if (this.timer) { clearTimeout(this.timer); this.timer = null; }
  this._releasePreloadImage();   // #187: drop any warmed next-image bitmap on teardown
  this.clearStage();
  // Proof-of-play: close the open row so its duration is recorded on teardown.
  if (this._loggedItem) { this._logPlay('play_end', this._loggedItem, true); this._loggedItem = null; }
};

PlaylistPlayer.prototype.clearStage = function () {
  if (this.avActive) this.avStop(); // #170: tear down any AVPlay session (portrait video)
  // 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 null;
  // A cached copy is preferred whenever we hold the revision this item asks for. That is what keeps
  // the screen alive through an outage — and the revision check is what stops it keeping the screen
  // alive with the WRONG asset after somebody replaced it in the dashboard.
  var local = window.MediaCache && window.__stMediaCache
    && window.__stMediaCache.localUrl(item.content_id, item.content_rev);
  if (local) return local;
  return this.getBase() + '/api/content/' + item.content_id + '/file'
    + (item.content_rev ? '?rev=' + encodeURIComponent(item.content_rev) : '');
};

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;
    if (this._deferredDeadline) { clearTimeout(this._deferredDeadline); this._deferredDeadline = null; }
    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 = '';
  // Clearing the signature alone was not enough: load() returns at the continuity check ("current
  // item survives -> keep playing it, just retarget the index") BEFORE reaching any render, so the
  // invalidate had no effect and the item kept the semantics of the mode we had just left. Leaving
  // a sync group or a wall therefore froze the screen on one clip — rendered with `single`, so
  // looping with no timer — and every later refresh took the unchanged path because the element
  // was attached and playing, i.e. healthy. This flag makes the next load actually re-render.
  this._forceRender = true;
};
PlaylistPlayer.prototype.getIndex = function () { return this.index; };
PlaylistPlayer.prototype.getItemCount = function () { return this.items.length; };
PlaylistPlayer.prototype.isWallFollower = function () { return !!this.wallFollower; };
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);
};

// Proof-of-play: build + forward a device:play-event payload via the onPlayEvent hook (set by app.js).
// Widgets carry no content_id, so key on widget_id — keeping play_start/play_end consistent so the
// row's duration closes. Mirrors server/player/index.html and the Android WebSocketService.
PlaylistPlayer.prototype._logPlay = function (event, item, completed) {
  if (typeof this.onPlayEvent !== 'function' || !item) return;
  var cid = item.content_id || item.widget_id || '';
  var payload = {
    device_id: this.getDeviceId(),
    event: event,
    content_id: cid || null,
    content_name: item.filename || 'Unknown'
  };
  if (event === 'play_start') payload.duration_sec = (item.duration_sec > 0 ? item.duration_sec : null);
  else payload.completed = !!completed;
  try { this.onPlayEvent(payload); } catch (e) {}
};

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];

  // Proof-of-play (parity with the web/Android players): close the outgoing item and open this one.
  // Wall followers don't log — the leader's single row represents the whole wall.
  if (!this.wallFollower) {
    if (this._loggedItem && this._loggedItem !== item) this._logPlay('play_end', this._loggedItem, true);
    this._logPlay('play_start', item, false);
    this._loggedItem = item;
  }

  // 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 || '';
  // #187: the IMAGE path decode-gates and SWAPS (it clears the stage only after the new frame is
  // paint-ready, inside renderImage) so slow Tizen decode HW no longer black-flashes between images.
  // Every OTHER type keeps the pre-dispatch clearStage() exactly as before — this mirrors the same
  // branch order used in the dispatch below, so an item routed to renderImage is the only one skipped.
  var isImage = mime !== 'video/youtube'
    && !(item.widget_id && !item.content_id)
    && mime.indexOf('video/') !== 0
    && mime.indexOf('image/') === 0;
  // Widgets also buffer-swap (renderWidget reveals the new iframe on load, then clears), so they skip
  // the pre-dispatch clearStage too — kills the black flash on directory-board/widget reloads.
  var isWidget = !!(item.widget_id && !item.content_id);
  // Skip the pre-dispatch clearStage for an image (it decode-gates + swaps inside renderImage) AND for a
  // landscape video that will composite into a wipe (renderVideoBuffered needs the outgoing frame to
  // capture as `from`, then clears inside its own mount). Everything else clears up front as before.
  if (!isImage && !isWidget && !this._videoWillComposite(item)) 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';
};

// #187: decode-gated one-ahead double buffer for images (mirrors the video preloader). The stage is
// NEVER cleared to black before the new image is paint-ready: we take the warmed pre-decoded <img>
// (or decode a fresh one), and ONLY THEN clearStage()+append — a swap, never clear-then-load.
PlaylistPlayer.prototype.renderImage = function (item, single) {
  var self = this;
  var targetIdx = this.index;
  var img = this._takePreloadImage(targetIdx);   // pre-decoded from the previous item's dwell?
  if (!img) {
    this._releasePreloadImage();                 // any warmed image is for a different index now — drop it
    img = document.createElement('img');
    this.fit(img, item);
    img.src = this.contentUrl(item);
  }
  var settled = false;
  var stale = function () {
    // A next()/gotoIndex/playlist change mid-decode must not mount a now-stale image over the current
    // item (mirrors how renderVideo's _takePreload only fires for the still-current index).
    return self.index !== targetIdx || self.items[targetIdx] !== item;
  };
  var mount = function () {
    if (settled) return; settled = true;
    if (stale()) { try { img.src = ''; } catch (e) {} return; }
    // transition-engine: if this item carries a transition and the outgoing frame is a live image,
    // composite instead of hard-swapping. _runImageTransition owns clear+append+schedule+preload, and
    // falls back to the plain swap on any failure (never blank). Video is unaffected (AVPlay plane).
    var t = item.transition;
    if (self._glTxAbort) self._glTxAbort(); // settle any in-flight wipe first — one at a time on the shared renderer
    var from = self._texturableStageFrame(); // may snapshot an outgoing <video> -> video→image wipes
    if (t && t.effects && t.effects.length && from && self._transitionRuntimeReady()) {
      self._runImageTransition(from, img, t, item, targetIdx, single);
      return;
    }
    self.clearStage();                 // SWAP: clear only now, with the decoded image ready to paint
    self.stage.appendChild(img);
    if (!single) {
      self.schedule(self.durationMs(item));
      self.preloadImage(self.nextActiveIndex(targetIdx));   // warm the NEXT image while THIS one dwells
    }
  };
  var fail = function () {
    if (settled) return; settled = true;
    if (stale()) return;
    self.skipSoon();                   // broken URL / decode reject -> skip (A1 self-heals a single item)
  };
  img.onerror = fail;
  // Feature-detect decode() (Tizen 5.0 / SSSP6 may lack it) -> onload/complete fallback.
  if (typeof img.decode === 'function') {
    img.decode().then(mount).catch(fail);
  } else if (img.complete) {
    if (img.naturalWidth > 0) mount(); else fail();   // warmed element already loaded (ok) or errored
  } else {
    img.onload = mount;                // onerror set above routes a load error to fail
  }
};

// ---- transition-engine: GL image->image transition on the Tizen player ----
// Images only (video lives on the AVPlay hardware plane and can't be textured). Every failure path
// falls back to the plain clear+append swap, so a transition never leaves the screen blank.
PlaylistPlayer.prototype._transitionRuntimeReady = function () {
  return !!(window.TransitionRenderer && window.TransitionParams && window.__TRANSITION_SHADERS);
};
PlaylistPlayer.prototype._texturableStageImage = function () {
  var img = this.stage.querySelector('img');
  return (img && img.complete && img.naturalWidth > 0) ? img : null;
};
// Is there a paintable frame on the stage right now (img OR video)? Cheap existence check (no snapshot),
// used to decide whether a video will composite before we skip the pre-dispatch clearStage.
PlaylistPlayer.prototype._hasStageFrame = function () {
  var img = this.stage.querySelector('img');
  if (img && img.complete && img.naturalWidth > 0) return true;
  var v = this.stage.querySelector('video');
  return !!(v && v.readyState >= 2 && v.videoWidth > 0);
};
// The on-stage frame as a texturable source: the live <img>, or — so a wipe can start FROM a playing
// clip (video→image / video→video) — a snapshot canvas of the outgoing <video>'s current frame. A
// cross-origin video with no CORS taints the snapshot; that surfaces later as a texImage2D SecurityError
// in _runGlWipe and hard-cuts (never blank). Returns null if nothing on stage is paintable yet.
PlaylistPlayer.prototype._texturableStageFrame = function () {
  var img = this.stage.querySelector('img');
  if (img && img.complete && img.naturalWidth > 0) return img;
  var v = this.stage.querySelector('video');
  if (v && v.readyState >= 2 && v.videoWidth > 0) {
    try {
      var w = this.stage.clientWidth || 1280, h = this.stage.clientHeight || 720;
      var mode = v.className === 'contain' ? 'contain' : (v.className === 'fill' ? 'fill' : 'cover');
      return this._fitToCanvas(v, w, h, mode);
    } catch (e) { return null; }
  }
  return null;
};
PlaylistPlayer.prototype._fitMode = function (item) {
  var f = (item.fit || item.scale || 'cover').toLowerCase();
  if (f === 'contain' || f === 'fit') return 'contain';
  if (f === 'fill' || f === 'stretch') return 'fill';
  return 'cover';
};
// draw a source image onto a stage-sized canvas honoring the item's fit mode, so the transition frames
// match the static <img>'s object-fit (no pop). Stays texturable iff the source is (else texImage2D throws).
PlaylistPlayer.prototype._fitToCanvas = function (src, w, h, mode) {
  var c = document.createElement('canvas'); c.width = w; c.height = h;
  var cx = c.getContext('2d');
  // a <video> exposes its intrinsic size as videoWidth/Height, not naturalWidth/width — check both,
  // else the divisor is 0 and drawImage paints NaN/blank.
  var iw = src.naturalWidth || src.videoWidth || src.width, ih = src.naturalHeight || src.videoHeight || src.height;
  var dw, dh;
  if (mode === 'fill') { dw = w; dh = h; }
  else { var s = (mode === 'cover') ? Math.max(w / iw, h / ih) : Math.min(w / iw, h / ih); dw = iw * s; dh = ih * s; }
  cx.drawImage(src, (w - dw) / 2, (h - dh) / 2, dw, dh);
  return c;
};
// ONE persistent WebGL renderer, reused for every transition. A context per transition leaks GPU
// contexts and chokes real hardware after a few; detaching the canvas between transitions keeps the
// context alive, and only a genuine context loss forces a rebuild.
PlaylistPlayer.prototype._getGlTransition = function () {
  if (this._glTx && !this._glTx.renderer.lost) return this._glTx;
  try {
    var self = this;
    var canvas = document.createElement('canvas');
    canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%';
    var renderer = window.TransitionRenderer.createRenderer(canvas, window.TransitionParams, {
      onContextLost: function () { var a = self._glTxAbort; self._glTx = null; self._glTxAbort = null; if (a) a(); },
    });
    this._glTx = { canvas: canvas, renderer: renderer };
    return this._glTx;
  } catch (e) { this._glTx = null; return null; }
};
// Shared GL-wipe core for EVERY Tizen transition — image OR (landscape) video target. Renders
// `fromFrame` (img|video|canvas) -> `toTex` on the persistent canvas; on completion (rAF p>=1, the
// deadline, or context loss) it detaches the canvas (Tizen keeps the context alive by detaching, not
// hiding) and calls mount(), which swaps in the real incoming element (img, or video+play) and owns its
// schedule/preload. onStart() runs once the wipe is committed (the image path schedules its dwell there
// for overlap timing; video advances on 'ended'). Any setup failure / missing runtime / tainted source
// calls hardCut() — never a blank stage. dwellMs bounds the wipe so it can't outlast an image's dwell.
PlaylistPlayer.prototype._runGlWipe = function (fromFrame, toTex, t, item, dwellMs, onStart, mount, hardCut) {
  var self = this, stage = this.stage;
  var effect = t.effects[Math.floor(Math.random() * t.effects.length)];   // vary among the chosen effects
  var src = effect && window.__TRANSITION_SHADERS[effect.shader];
  var gl = src ? this._getGlTransition() : null;
  if (!gl) { hardCut(); return; }                            // no shader / no WebGL -> hard cut
  var canvas = gl.canvas, renderer = gl.renderer;
  var mode = this._fitMode(item);
  var w = stage.clientWidth || 1280, h = stage.clientHeight || 720;
  var raf = 0, startTs = 0, done = false, deadline = 0;
  var finish = function () {
    if (done) return; done = true;
    if (raf) cancelAnimationFrame(raf);
    if (deadline) clearTimeout(deadline);
    if (self._glTxAbort === finish) self._glTxAbort = null;
    try { if (canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (e) {} // detach, keep context
    mount();                                                  // swap in the incoming element + own its schedule/preload
  };
  try {
    renderer.resize(w, h);                                    // size the persistent canvas to the stage
    renderer.setFrom(self._fitToCanvas(fromFrame, w, h, mode)); // throws (SecurityError) on a tainted source
    renderer.setTo(self._fitToCanvas(toTex, w, h, mode));
    renderer.setShader(src);                                  // throws on a bad shader
    renderer.render(0, effect.params);
  } catch (e) { try { if (canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (x) {} hardCut(); return; }
  self._glTxAbort = finish;                                   // context lost mid-transition -> finish (hard cut)
  stage.appendChild(canvas);                                  // over the outgoing frame, both live
  onStart();                                                  // OVERLAP: image dwell starts now (video: no-op)
  var durMs = Math.min(t.durationMs, Math.max(150, dwellMs - 100));
  // safety net: mount the target from a timer too, not only rAF — survives rAF throttling/freeze
  deadline = setTimeout(finish, durMs + 80);
  var frame = function (ts) {
    if (done) return;
    if (renderer.lost) { finish(); return; }
    if (!startTs) startTs = ts;
    var p = Math.min(1, (ts - startTs) / durMs);
    if (!renderer.render(p, effect.params)) { finish(); return; }
    if (p >= 1) { finish(); return; }
    raf = requestAnimationFrame(frame);
  };
  raf = requestAnimationFrame(frame);
};
// Image target: wipe fromImg -> toImg, then swap in the plain <img>. Dwell is scheduled at wipe START
// (overlap timing); the mount just swaps the frame (no re-schedule). hardCut = the plain swap.
PlaylistPlayer.prototype._runImageTransition = function (fromImg, toImg, t, item, targetIdx, single) {
  var self = this, stage = this.stage;
  var dwellMs = this.durationMs(item);
  var swapPlain = function () {                               // plain hard-cut swap (schedules the dwell)
    self.clearStage(); stage.appendChild(toImg);
    if (!single) { self.schedule(self.durationMs(item)); self.preloadImage(self.nextActiveIndex(targetIdx)); }
  };
  this._runGlWipe(fromImg, toImg, t, item, dwellMs,
    function () { if (!single) self.schedule(dwellMs); },     // onStart: schedule the dwell for overlap
    function () {                                             // mount: swap in the image (dwell already scheduled)
      self.clearStage(); stage.appendChild(toImg);
      if (!single) self.preloadImage(self.nextActiveIndex(targetIdx));
    },
    swapPlain);                                               // hardCut
};
// Landscape VIDEO target (image→video / video→video): warm-play the incoming clip MUTED offscreen until
// its first frame is PRESENTED (a just-'loadeddata' <video> won't reliably drawImage), freeze it,
// snapshot that frame as the wipe's `to`, run the wipe, then swap in + resume the real <video> from that
// same frame. Every failure path hard-cuts to a plain video mount. Only reached for landscape (portrait
// video rides the AVPlay hardware plane, which can't be textured — always a hard cut there).
PlaylistPlayer.prototype.renderVideoBuffered = function (item, single) {
  var self = this, stage = this.stage;
  var from = this._texturableStageFrame();   // capture the outgoing frame NOW (playCurrent skipped clearStage)
  var t = item.transition;
  // The warm-play (first-frame wait + wipe) is async; if the current item changes mid-window (playlist
  // push / advance), a stale clip must NOT clear the stage + mount over the newer content. Mirrors the
  // stale() guard renderImage already uses.
  var targetIdx = this.index;
  var stale = function () { return self.index !== targetIdx || self.items[targetIdx] !== item; };
  var abandon = function () { try { v.pause(); v.removeAttribute('src'); v.load(); } catch (e) {} };
  var v = document.createElement('video');
  this.fit(v, item);
  v.muted = true; v.setAttribute('playsinline', '');         // warm-play MUST be muted (autoplay policy)
  v.loop = single;                                           // single item loops; multi advances on end
  v.style.cssText = '';
  var done = false, watchdog = null;
  var mountVideo = function () {                             // full clear + append + resume from the snapshot frame
    if (stale()) { abandon(); return; }                     // a newer item took over during the wipe — don't clobber it
    self.clearStage();
    self.currentVideoEl = v;                                 // wall/group drift-correct this (only after clearStage)
    stage.appendChild(v);
    v.onended = function () { if (!single) self.advance(); };
    v.onerror = function () { self.skipSoon(); };
    var p = v.play(); if (p && p.catch) p.catch(function () {});
    if (!single) {
      var secs = Number(item.content_duration || item.duration_sec) || self.DEFAULT_DURATION;
      self.schedule((secs + 5) * 1000);                      // safety net if 'ended' never fires
    }
  };
  var onFirstFrame = function () {
    if (done) return; done = true;
    if (watchdog) clearTimeout(watchdog);
    if (stale()) { abandon(); return; }                     // superseded before the wipe even started
    try { v.pause(); } catch (e) {}                          // hold at the snapshot frame; mountVideo resumes here
    var w = stage.clientWidth || 1280, h = stage.clientHeight || 720;
    var to = null;
    if (v.readyState >= 2 && v.videoWidth > 0) { try { to = self._fitToCanvas(v, w, h, self._fitMode(item)); } catch (e) { to = null; } }
    if (from && to && t && t.effects && t.effects.length && self._transitionRuntimeReady()) {
      self._runGlWipe(from, to, t, item, t.durationMs + 200, function () {}, mountVideo, mountVideo);
    } else {
      mountVideo();
    }
  };
  var armFrame = function () {
    if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(function () { onFirstFrame(); });
    else setTimeout(onFirstFrame, 150);
  };
  v.addEventListener('loadeddata', function () { var p = v.play(); if (p && p.then) p.then(armFrame).catch(armFrame); else armFrame(); }, { once: true });
  v.addEventListener('error', function () { if (done) return; done = true; if (watchdog) clearTimeout(watchdog); self.skipSoon(); });
  watchdog = setTimeout(function () { if (done) return; done = true; if (stale()) { abandon(); return; } mountVideo(); }, 800); // cold/slow clip -> hard cut
  v.src = this.contentUrl(item);
  v.load();
};
// A landscape video will composite into the wipe iff it carries a transition, the runtime is ready, and
// there's a texturable frame on stage. Portrait/flipped video uses the AVPlay hardware plane (untexturable)
// -> never composites. playCurrent uses this to skip the pre-dispatch clearStage (keep the outgoing frame);
// renderVideo uses it to route here. Both are synchronous with an unchanged stage, so they always agree.
PlaylistPlayer.prototype._videoWillComposite = function (item) {
  var mime = item.mime_type || '';
  if (mime.indexOf('video/') !== 0) return false;
  if (this.orientation === 'portrait' || this.orientation === 'portrait-flipped') return false;
  var t = item.transition;
  if (!(t && t.effects && t.effects.length)) return false;
  if (!this._transitionRuntimeReady()) return false;
  return this._hasStageFrame();
};

PlaylistPlayer.prototype.setOrientation = function (o) { this.orientation = o || 'landscape'; };
PlaylistPlayer.prototype.avAvailable = function () { return !!(window.webapis && webapis.avplay); };

// #170: on Tizen the HTML5 <video> is composited on a hardware plane that ignores CSS rotate,
// so portrait/portrait-flipped orientation blacks the video out. AVPlay's setDisplayRotation
// rotates the hardware plane itself. We use it ONLY for portrait/flipped video — landscape keeps
// the proven <video> path (double-buffer + group-sync drift). Any AVPlay failure degrades to an
// honest note, never a silent black screen.
PlaylistPlayer.prototype.renderVideoAv = function (item, single) {
  var self = this;
  var url = this.contentUrl(item);
  if (!url) { this.skipSoon(); return; }
  var deg = this.orientation === 'portrait-flipped' ? 270 : 90;
  var rot = deg === 270 ? 'PLAYER_DISPLAY_ROTATION_270' : 'PLAYER_DISPLAY_ROTATION_90';
  var obj = document.getElementById('avPlayer');
  var w = window.innerWidth || (window.screen && screen.width) || 1920;
  var h = window.innerHeight || (window.screen && screen.height) || 1080;
  // currentVideoEl stays null: wall/group-sync per-frame drift needs an HTML5 element; portrait
  // signage is solo-first, and the schedule engine still drives index/position via gotoIndex.
  this.currentVideoEl = null;
  try { webapis.avplay.close(); } catch (e) {}            // reset any prior session
  try {
    webapis.avplay.open(url);
    if (obj) obj.style.display = 'block';
    webapis.avplay.setDisplayRect(0, 0, w, h);
    try { webapis.avplay.setDisplayMethod('PLAYER_DISPLAY_MODE_LETTER_BOX'); } catch (e) {}
    webapis.avplay.setDisplayRotation(rot);
    webapis.avplay.setListener({
      onstreamcompleted: function () {
        if (single) { try { webapis.avplay.seekTo(0); webapis.avplay.play(); } catch (e) { self.avStop(); self.skipSoon(); } }
        else { self.advance(); }
      },
      onerror: function () { self.avFallback(item); }
    });
    this.avActive = true;
    webapis.avplay.prepareAsync(
      function () { try { webapis.avplay.play(); } catch (e) { self.avFallback(item); } },
      function () { self.avFallback(item); }
    );
    // Safety net (mirrors the <video> path): advance after the known duration if
    // onstreamcompleted never fires.
    if (!single) {
      var secs = Number(item.content_duration || item.duration_sec) || this.DEFAULT_DURATION;
      this.schedule((secs + 5) * 1000);
    }
  } catch (e) { this.avFallback(item); }
};

// AVPlay missing/failed on this device -> honest note (never a silent black), then move on.
PlaylistPlayer.prototype.avFallback = function (item) {
  this.avStop();
  this.stage.innerHTML =
    '<div class="card" style="position:relative"><h1>ScreenTinker</h1>' +
    '<p class="sub">' + tzt('portrait_video_unsupported') + '</p></div>';
  // Don't wedge on a single looping item; re-check after a beat (or the item's duration).
  this.schedule(Math.max(this.durationMs(item), 30000));
};

PlaylistPlayer.prototype.avStop = function () {
  if (!this.avActive) return;
  try { webapis.avplay.stop(); } catch (e) {}
  try { webapis.avplay.close(); } catch (e) {}
  var obj = document.getElementById('avPlayer');
  if (obj) obj.style.display = 'none';
  this.avActive = false;
};

PlaylistPlayer.prototype.renderVideo = function (item, single) {
  // #170: portrait/flipped video must rotate the Tizen HARDWARE video plane, which a CSS transform
  // on #stage can't do (black screen). Route it through AVPlay; landscape stays on <video>.
  if ((this.orientation === 'portrait' || this.orientation === 'portrait-flipped') && this.avAvailable()) {
    return this.renderVideoAv(item, single);
  }
  // Landscape: if this video has a transition and there's a texturable outgoing frame, wipe INTO it
  // (image→video / video→video). playCurrent skipped the pre-dispatch clearStage for exactly this case,
  // so the outgoing frame is still on stage to capture. Plain videos fall through to the proven path.
  if (this._videoWillComposite(item)) { return this.renderVideoBuffered(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', ''); // warm muted so autoplay is guaranteed
  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 () {});
  // Audio parity (#129): honor per-item mute. Warm-play stays muted so autoplay can't be blocked,
  // then apply the real state once playing (Tizen is a privileged app, so unmuted playback is fine).
  // Wall followers stay muted — only the audio leader is unmuted by the dashboard/remote.
  var applyMute = function () { try { v.muted = self.wallFollower ? true : !!item.muted; } catch (e) {} };
  v.addEventListener('playing', applyMute, { once: true });
  if (!v.paused && v.readyState >= 2) applyMute(); // a reused preload may already be playing
  // 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);
  }
};

// Flip an already-playing YouTube embed without reloading it. The IFrame API accepts commands by
// postMessage when enablejsapi=1, which is the only handle on a cross-origin iframe — setting
// `muted` on the element reaches nothing, which is why the toggle appeared to do nothing here.
PlaylistPlayer.prototype.setYouTubeMuted = function (muted) {
  try {
    var f = document.querySelector('iframe[src*="youtube.com/embed"]');
    if (!f || !f.contentWindow) return false;
    f.contentWindow.postMessage(JSON.stringify({
      event: 'command', func: muted ? 'mute' : 'unMute', args: []
    }), 'https://www.youtube.com');
    return true;
  } catch (e) { return false; }
};

PlaylistPlayer.prototype.renderYouTube = function (item, single) {
  var id = this.youtubeId(item.remote_url);
  if (!id) { this.skipSoon(); return; }
  var vertical = /st_aspect=vertical/.test(item.remote_url || '');
  // Audio parity (#129) — this URL hardcoded `mute=1`, so YouTube on Tizen was permanently silent:
  // the per-item mute flag was never consulted and nothing could ever unmute it. Same feature as
  // the `<video>` path a few lines up, which honoured the flag correctly.
  //
  // The rule is the one in server/lib/media-mute.js, mirrored rather than imported because this
  // file ships inside the .wgt: a wall follower is always silent (one wall, one audio source),
  // otherwise the item's flag decides. Tizen is a privileged app with no autoplay-gesture
  // requirement, so there is no user-gesture term here.
  var muted = this.wallFollower ? true : !!item.muted;
  // enablejsapi lets a live mute toggle reach the embed by postMessage without reloading it —
  // reloading would restart the video from zero every time an operator touched the control.
  var src = 'https://www.youtube.com/embed/' + id +
    '?autoplay=1&mute=' + (muted ? 1 : 0) +
    '&controls=0&rel=0&modestbranding=1&loop=1&playlist=' + id + '&playsinline=1&enablejsapi=1';
  this.renderFrame(src, single ? 0 : this.durationMs(item), 'autoplay; encrypted-media', vertical);
};

PlaylistPlayer.prototype.renderWidget = function (item, single) {
  var self = this;
  var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (item.widget_rev || 0);
  // Anti-flash (#directory-board, parity with the web player): build the new iframe hidden ON TOP of the
  // current content and reveal it on load, THEN drop everything else — so a widget/directory-board
  // reload never black-flashes the stage (playCurrent skipped the pre-clear for widgets).
  var f = document.createElement('iframe');
  f.setAttribute('frameborder', '0');
  f.setAttribute('allowfullscreen', '');
  f.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;border:0;opacity:0';
  var revealed = false;
  var reveal = function () {
    if (revealed) return; revealed = true;
    var kids = self.stage.children;
    for (var i = kids.length - 1; i >= 0; i--) { if (kids[i] !== f) self.stage.removeChild(kids[i]); }
    f.style.opacity = '1';
  };
  f.addEventListener('load', reveal, { once: true });
  setTimeout(reveal, 4000); // fallback: reveal even if a blocked widget never fires load
  f.src = src;
  this.stage.appendChild(f);
  if (!single) this.schedule(this.durationMs(item));
};

PlaylistPlayer.prototype.renderFrame = function (src, advanceMs, allow, vertical) {
  var f = document.createElement('iframe');
  f.setAttribute('frameborder', '0');
  f.setAttribute('allowfullscreen', '');
  if (allow) f.setAttribute('allow', allow);
  // Vertical (Shorts): center a 9:16 iframe on the black stage instead of the
  // stylesheet's full-bleed 100%x100% (which pillarboxes vertical video badly).
  if (vertical) f.style.cssText = 'position:absolute;top:0;bottom:0;left:0;right:0;margin:auto;height:100%;width:auto;aspect-ratio:9/16;max-width:100%;border:0';
  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, getDeviceId) {
  this.stage = stageEl;
  this.getBase = getBase;
  this.getDeviceId = getDeviceId || function () { return ''; };
  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 null;
  // A cached copy is preferred whenever we hold the revision this item asks for. That is what keeps
  // the screen alive through an outage — and the revision check is what stops it keeping the screen
  // alive with the WRONG asset after somebody replaced it in the dashboard.
  var local = window.MediaCache && window.__stMediaCache
    && window.__stMediaCache.localUrl(item.content_id, item.content_rev);
  if (local) return local;
  return this.getBase() + '/api/content/' + item.content_id + '/file'
    + (item.content_rev ? '?rev=' + encodeURIComponent(item.content_rev) : '');
};

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 yvert = /st_aspect=vertical/.test(a.remote_url || '');
      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', yvert));
      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' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (a.widget_rev || 0)));
      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, vertical) {
  var f = document.createElement('iframe');
  f.setAttribute('frameborder', '0');
  f.setAttribute('allowfullscreen', '');
  if (allow) f.setAttribute('allow', allow);
  if (vertical) f.style.cssText = 'position:absolute;top:0;bottom:0;left:0;right:0;margin:auto;height:100%;width:auto;aspect-ratio:9/16;max-width:100%;border:0';
  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';

  // #236: per-panel mounting rotation. Ported by hand from server/lib/wall-geometry.js, which is the
  // canonical rule and the only place it is tested — the .wgt is packaged, so it cannot pull the
  // shared script the web player loads. Any change there has to be mirrored here or a mixed wall
  // grows a seam. rotation is degrees CLOCKWISE the content is turned inside the framebuffer, the
  // same convention as the device orientation setting.
  var rot = [0, 90, 180, 270].indexOf(Number(config.rotation)) >= 0 ? Number(config.rotation) : 0;
  if (rot === 0) {
    // Left byte-identical to the pre-#236 expression on purpose: every wall in the field is
    // rotation 0 and must not shift by a float's worth after an update.
    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 = '';
    return;
  }
  var nx = (p.x + p.w / 2 - s.x) / s.w;
  var ny = (p.y + p.h / 2 - s.y) / s.h;
  var quarter = (rot === 90 || rot === 270);
  var cx, cy;
  if (rot === 90) { cx = 1 - ny; cy = nx; }
  else if (rot === 180) { cx = 1 - nx; cy = 1 - ny; }
  else { cx = ny; cy = 1 - nx; }
  st.left = (cx * 100) + 'vw';
  st.top = (cy * 100) + 'vh';
  // A quarter turn measures the wall's horizontal against the framebuffer's VERTICAL.
  st.width = ((p.w / s.w) * 100) + (quarter ? 'vh' : 'vw');
  st.height = ((p.h / s.h) * 100) + (quarter ? 'vw' : 'vh');
  // translate BEFORE rotate, or the -50% offset is rotated too and the tile lands on the wrong side.
  st.transform = 'translate(-50%, -50%) rotate(' + rot + 'deg)';
  st.transformOrigin = 'center center';
};

// #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;
  // No target means every item is currently outside its daypart. Returning here left the whole
  // group displaying (or looping) whatever was in-window last, out of hours — while an identical
  // ungrouped screen correctly showed the idle card. Group members are schedule-driven, so no
  // renderer arms a timer and nothing else was watching for this.
  var t = this.target();
  if (!t) {
    if (this.player.hasContentOnScreen()) this.player.nothingScheduled();
    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);   // warm next clip (video-only; no-ops otherwise)
    this.player.preloadImage(t.nextIndex);   // #187: warm next image (image-only; separate buffer)
  }
  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
  this.player._releasePreloadImage();                 // #187: drop any warmed next-image bitmap
  if (this.report) this.report('info', 'group-sync exited');
};
