fix(tizen): decode-gated image double-buffer to kill the black flash between stills [#187] (#193)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

The Tizen .wgt player black-flashed between IMAGE items on slow decode HW
(Samsung OM55B / SSSP). Root cause: playCurrent() called clearStage() before
renderImage() set img.src, so the stage was empty (black) until the new image
decoded. Images had no decode-gated double-buffer — video gained one in #167
(938a43a), which made the always-present image flash conspicuous by contrast.
renderImage has been byte-identical since the first Tizen commit, so there was
nothing to revert; the buffer had to be ADDED, mirroring the video path.

- preloadImage()/_takePreloadImage()/_releasePreloadImage(): one-ahead,
  image-only decode-gated buffer modeled on preloadVideo()/_takePreload().
  Detached <img>, src set, warmed via HTMLImageElement.decode() (feature-
  detected — onload/complete fallback for Tizen 5.0 / SSSP6). Warmed when the
  current image begins its dwell and from the group-sync boundary tick.
- renderImage now SWAPS: take the pre-decoded <img> (or decode a fresh one) and
  only THEN clearStage()+append, in one synchronous block — the compositor
  never sees an empty stage. Never clear-then-load on the image path.
- Scoped to images only: playCurrent() skips the up-front clearStage() solely
  for image targets (same branch order as the dispatch); video/youtube/widget
  keep their pre-dispatch clear untouched. onerror and decode() rejection route
  to skipSoon(); stale-index guard blocks mounting a stale decode over the
  current item after next()/gotoIndex/load(); one-ahead with stale release on
  index move, load(), stop(), and group-sync exit. #A1 single-item heal intact.

Tests:
- server/test/tizen-image-blackflash.test.js (new, 4/4): loads the real
  player.js in a vm context with a test-controllable decode() Promise and proves
  the invariant — across image->image the #stage is NEVER without a mounted
  <img> (old element held until the new image's decode()/onload resolves, then
  swap). Covers decode() supported, decode() absent (onload fallback), and
  broken-image (decode-reject / onerror) -> skipSoon. Proven to VIOLATE on the
  old clear-then-load ordering and HOLD on the fix.
- server/test/pip-overlay.test.js: the decode-gate makes image mount async,
  which broke its older shim (no decode()/onload/complete). Teach the shim
  element complete/naturalWidth so renderImage takes its synchronous
  complete-fallback branch and mounts. Test-only.

Full server suite 486/486. node -c clean. Headless proves the DOM ordering
invariant (the flash's precondition); final black-frame sign-off needs a real
OM55B panel (manual steps in the test-file header).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-15 18:29:39 -05:00 committed by GitHub
parent 79cf4cac2b
commit 76f072bf4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 396 additions and 8 deletions

View file

@ -25,6 +25,13 @@ function makeEl() {
};
Object.defineProperty(el, 'innerHTML', { get() { return this._html; }, set(v) { this._html = v; if (v === '') this.children = []; } });
Object.defineProperty(el, 'src', { get() { return this._src; }, set(v) { this._src = v; } });
// #187: renderImage is now decode-gated (swap-after-decode, never clear-then-load). This shim has
// no HTMLImageElement.decode() and doesn't fire onload, so mark a src'd <img> already-decoded
// (complete + naturalWidth>0) — renderImage then takes its synchronous complete-fallback branch and
// mounts, exactly as the old clear-then-load path did. The decode()/onload timing itself is proven
// in tizen-image-blackflash.test.js; here we only need the image to land in #stage.
Object.defineProperty(el, 'complete', { get() { return !!this._src; } });
Object.defineProperty(el, 'naturalWidth', { get() { return this._src ? 1 : 0; } });
Object.defineProperty(el, 'textContent', { get() { return this._text; }, set(v) { this._text = v; } });
return el;
}

View file

@ -0,0 +1,288 @@
'use strict';
// #187 Tizen image black-flash verification test. Loads the REAL tizen/js/player.js into a
// node:vm context with a minimal hand-rolled DOM element shim (modeled on pip-overlay.test.js —
// the repo has NO jsdom; CI is plain `node --test --test-concurrency=2`).
//
// THE BUG (#187): the Tizen player black-flashed between IMAGE items because playCurrent() called
// clearStage() (stage.innerHTML='') BEFORE renderImage() set img.src, so #stage was empty (black)
// for the entire decode duration of the incoming image.
//
// THE FIX (already applied to tizen/js/player.js — this test only VERIFIES it): images now get a
// decode-gated one-ahead double buffer (mirrors the video preloader). renderImage takes a
// pre-decoded <img> (preloadImage/_takePreloadImage) or decodes a fresh detached <img>, and ONLY
// THEN clearStage()+appendChild — a SWAP, never clear-then-load. HTMLImageElement.decode() is
// feature-detected with an onload/complete fallback (Tizen 5.0 / SSSP6 may lack decode()). An
// onerror / decode-reject routes to skipSoon().
//
// THE INVARIANT PROVEN HERE: across an image->image transition, #stage is NEVER without a mounted
// <img> — the OLD <img> stays mounted until the NEW image's decode()/onload resolves, then it's
// swapped. A real black flash is a GPU/timing artifact that will not render headlessly, so we assert
// this DOM ordering invariant instead — it is the exact condition whose absence caused the flash.
//
// HOW THE TEST HAS TEETH (distinguishes swap-after-decode from the old clear-then-load): we wrap
// player.clearStage() and record the #stage <img> count at the instant it is called. Under the FIX,
// clearStage() is NOT called at all between "advance issued" and "new image decoded"; it fires
// exactly once, at swap time, with the OLD image (count 1) still mounted. Under the OLD ordering
// clearStage() fired in playCurrent() BEFORE the decode, so (a) clearLog would grow at advance time,
// and (b) the mounted element right after advance would be the undecoded NEW image (or empty), not
// the old one — both of which these assertions reject. (Proven to fail against a reverted copy in a
// /tmp scratch run during development; see the report.)
//
// ---------------------------------------------------------------------------------------------
// MANUAL ON-PANEL SIGN-OFF (Samsung OM55B / SSSP — headless proves the DOM invariant, but real
// decode-HW timing needs the panel):
// 1. Build + sideload the new .wgt onto the OM55B (Tizen CLI 6.1, ScreenTinker signing profile).
// 2. Assign a playlist of SEVERAL LARGE PNG stills (e.g. 4-6 full-res 1080p+ PNGs, short dwell
// ~5-8s each so transitions come often).
// 3. Watch 10+ consecutive image->image transitions closely for ANY black/blank frame between
// stills. Expected: each new still replaces the previous with no intervening black.
// 4. Confirm the OTHER item types still transition normally: add a video, a YouTube item, and a
// widget/remote_url item to the playlist and watch image->video, video->image, image->widget,
// widget->image handoffs — none should regress (only the image path changed; every other type
// keeps the pre-dispatch clearStage()).
// 5. Optional: reboot the panel and re-watch to catch cold-decode timing.
// Note: headless proves "old <img> stays mounted until the new one is decode-ready"; it CANNOT
// prove the GPU actually paints the new frame before the swap — that is what the panel confirms.
// ---------------------------------------------------------------------------------------------
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// Drain the host microtask queue (decode() promises resolve on the real microtask queue, independent
// of the sandbox's captured setTimeout). setImmediate fires after all pending microtasks.
const flush = () => new Promise((res) => setImmediate(res));
// --- minimal DOM element shim: extends pip-overlay's shim with what the fixed renderImage/preloadImage
// use on an <img>: settable src, onload/onerror handler props, complete + naturalWidth, and a
// test-controllable decode() Promise. Tracks parent/child links so appendChild/removeChild/parentNode/
// querySelector and innerHTML='' clearing all behave. ---
function makeEl() {
const el = {
tag: '', style: { cssText: '' }, className: '', attrs: {}, children: [], parentNode: null,
_html: '', _src: '', _text: '',
onload: null, onerror: null, complete: false, naturalWidth: 0,
_decodeDeferreds: [], // pending decode() controllers (test resolves/rejects them on demand)
appendChild(c) { c.parentNode = this; this.children.push(c); this._html = '<children>'; return c; },
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) { this.children.splice(i, 1); c.parentNode = null; } return c; },
querySelector(sel) { return this.children.find(c => c.tag === sel) || null; },
setAttribute(k, v) { this.attrs[k] = v; },
removeAttribute(k) { delete this.attrs[k]; },
addEventListener() {}, removeEventListener() {},
classList: { add() {}, remove() {}, contains() { return false; } },
load() {}, pause() {}, play() { return { catch() {} }; },
};
Object.defineProperty(el, 'innerHTML', {
get() { return this._html; },
set(v) { this._html = v; if (v === '') { this.children.forEach(c => { c.parentNode = null; }); this.children = []; } },
});
Object.defineProperty(el, 'src', { get() { return this._src; }, set(v) { this._src = v; } });
Object.defineProperty(el, 'textContent', { get() { return this._text; }, set(v) { this._text = v; } });
return el;
}
// Give an <img> element a test-controllable decode() (only when decode() is "supported" for the run).
function attachDecode(el) {
el.decode = function () {
return new Promise((resolve, reject) => { el._decodeDeferreds.push({ resolve, reject }); });
};
}
// Settle EVERY pending decode() call recorded on an element (preloadImage and renderImage each call
// decode() on the same warmed element, so there can be more than one). ok=false -> reject.
function settleDecode(el, ok) {
const ds = el._decodeDeferreds.splice(0);
ds.forEach(d => (ok ? d.resolve() : d.reject(new Error('decode failed'))));
}
// Fire an element's onload/onerror handler (the decode()-unsupported fallback path).
function fireLoad(el) { if (typeof el.onload === 'function') el.onload(); }
function fireError(el) { if (typeof el.onerror === 'function') el.onerror(); }
function loadPlayerContext(opts) {
opts = opts || {};
const decodeSupported = opts.decodeSupported !== false; // default: decode() available
const created = []; // every element the player creates
const timers = {};
let seq = 0;
const sandbox = {
console, Date,
setTimeout: (fn) => { const id = ++seq; timers[id] = fn; return id; },
clearTimeout: (id) => { delete timers[id]; },
setInterval: () => 0, clearInterval: () => {},
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
navigator: { language: 'en' },
};
sandbox.document = {
createElement: (tag) => {
const e = makeEl(); e.tag = tag;
if (tag === 'img' && decodeSupported) attachDecode(e);
created.push(e);
return e;
},
getElementById: () => null,
};
sandbox.window = sandbox;
vm.createContext(sandbox);
const read = (p) => fs.readFileSync(path.join(__dirname, '..', '..', 'tizen', 'js', p), 'utf8');
vm.runInContext(read('player.js'), sandbox, { filename: 'player.js' });
return { sandbox, timers, created };
}
// Test helpers -------------------------------------------------------------------------------------
function imgItem(id, order) { return { content_id: id, mime_type: 'image/png', sort_order: order, duration_sec: 10 }; }
// Build a player over a fresh stage, instrument clearStage to log the #stage <img> count at call time,
// and return the handles the cases need.
function newPlayer(ctx) {
const stage = makeEl(); stage.tag = 'div';
const player = new ctx.sandbox.PlaylistPlayer(stage, () => 'http://server');
const clearLog = []; // #stage <img> count captured at each clearStage() call
const stageImgs = () => stage.children.filter(c => c.tag === 'img');
const _clear = player.clearStage.bind(player);
player.clearStage = function () { clearLog.push(stageImgs().length); return _clear(); };
const findImg = (frag) => ctx.created.filter(c => c.tag === 'img').find(c => c.src.indexOf(frag) >= 0);
return { stage, player, clearLog, stageImgs, findImg };
}
// Fire the single pending advance timer (solo playback's duration timer).
function fireOnlyTimer(timers) {
const ids = Object.keys(timers);
assert.equal(ids.length, 1, 'exactly one advance timer pending');
timers[ids[0]]();
}
// CASE 1 — decode() SUPPORTED: image A -> image B swaps only AFTER B decodes; #stage never empties.
test('#187 decode() supported: image->image keeps the old <img> mounted until the new one decodes, then swaps', async () => {
const ctx = loadPlayerContext({ decodeSupported: true });
const { player, clearLog, stageImgs, findImg } = newPlayer(ctx);
player.load([imgItem('A', 0), imgItem('B', 1)]);
// Initial load: A is decode-gated (pending). This is the FIRST image, so an empty stage here is
// expected (there is no previous image to hold) — the invariant is about image->image transitions.
const aImg = findImg('A/file');
assert.ok(aImg, 'image A element created and decode-gated');
assert.equal(stageImgs().length, 0, 'A not mounted until its decode resolves');
settleDecode(aImg, true);
await flush();
assert.equal(stageImgs().length, 1, 'A mounted after its decode resolved');
assert.equal(stageImgs()[0], aImg, 'the mounted <img> is A');
// Mounting A warmed the NEXT image (B) via preloadImage. Advancing to B must NOT clear the stage
// until B is decode-ready.
const clearsBefore = clearLog.length;
fireOnlyTimer(ctx.timers); // duration timer -> advance() -> playCurrent(B) -> renderImage(B)
// *** THE INVARIANT ***: after the advance is issued but BEFORE B's decode resolves, #stage still
// holds A's <img> — not empty, not B. And clearStage() has NOT been called (no clear-then-load).
assert.equal(clearLog.length, clearsBefore, 'clearStage() NOT called before B decodes (no clear-then-load)');
assert.equal(stageImgs().length, 1, '#stage still has exactly one <img> mid-transition (never 0)');
assert.equal(stageImgs()[0], aImg, 'the still-mounted <img> is A, not empty and not the undecoded B');
const bImg = findImg('B/file');
assert.ok(bImg, 'image B was pre-created (warmed) for the transition');
assert.notEqual(bImg, aImg, 'B is a distinct element from A');
settleDecode(bImg, true); // B finished decoding -> swap
await flush();
// Swap happened: clearStage() fired exactly once, and at that instant A (count 1) was still mounted
// — proving the stage was never emptied ahead of the decode.
assert.equal(clearLog.length, clearsBefore + 1, 'clearStage() called exactly once, at swap time');
assert.equal(clearLog[clearsBefore], 1, 'at the swap-clear, A (count 1) was still mounted — stage never emptied early');
assert.equal(stageImgs().length, 1, '#stage has exactly one <img> after the swap');
assert.equal(stageImgs()[0], bImg, 'B is now the mounted <img>');
});
// CASE 2 — decode() UNSUPPORTED (Tizen 5.0 / SSSP6 fallback): same never-empty invariant via onload.
test('#187 decode() unsupported (onload fallback): image->image holds the old <img> until onload, then swaps', async () => {
const ctx = loadPlayerContext({ decodeSupported: false });
const { player, clearLog, stageImgs, findImg } = newPlayer(ctx);
// Sanity: the shim really did NOT expose decode() this run, so we exercise the fallback branch.
const probe = ctx.sandbox.document.createElement('img');
assert.notEqual(typeof probe.decode, 'function', 'decode() is unsupported this run (fallback path exercised)');
player.load([imgItem('A', 0), imgItem('B', 1)]);
const aImg = findImg('A/file');
assert.ok(aImg, 'image A element created');
assert.equal(stageImgs().length, 0, 'A not mounted until onload fires');
fireLoad(aImg); // onload -> mount A (synchronous in the fallback path)
await flush();
assert.equal(stageImgs().length, 1, 'A mounted after onload');
assert.equal(stageImgs()[0], aImg, 'the mounted <img> is A');
const clearsBefore = clearLog.length;
fireOnlyTimer(ctx.timers); // advance -> renderImage(B); B.onload not yet fired
// Invariant on the fallback path: A stays mounted while B is still loading; no clear-then-load.
assert.equal(clearLog.length, clearsBefore, 'clearStage() NOT called before B loads (fallback)');
assert.equal(stageImgs().length, 1, '#stage still has exactly one <img> mid-transition (never 0)');
assert.equal(stageImgs()[0], aImg, 'the still-mounted <img> is A');
const bImg = findImg('B/file');
assert.ok(bImg, 'image B element created for the transition');
fireLoad(bImg); // B loaded -> swap
await flush();
assert.equal(clearLog.length, clearsBefore + 1, 'clearStage() called exactly once, at swap time');
assert.equal(clearLog[clearsBefore], 1, 'at the swap-clear, A was still mounted — stage never emptied early');
assert.equal(stageImgs().length, 1, '#stage has exactly one <img> after the swap');
assert.equal(stageImgs()[0], bImg, 'B is now the mounted <img>');
});
// CASE 3a — a broken incoming image (decode reject) must skipSoon() and must NOT disturb the mounted
// image (fail() never clears the stage), so a bad image causes no black flash either.
test('#187 broken image (decode reject) calls skipSoon and leaves the current image mounted', async () => {
const ctx = loadPlayerContext({ decodeSupported: true });
const { player, stageImgs, findImg } = newPlayer(ctx);
let skips = 0;
const _skip = player.skipSoon.bind(player);
player.skipSoon = function () { skips += 1; return _skip(); };
player.load([imgItem('A', 0), imgItem('B', 1)]);
const aImg = findImg('A/file');
settleDecode(aImg, true);
await flush();
assert.equal(stageImgs()[0], aImg, 'A mounted');
fireOnlyTimer(ctx.timers); // advance to B
const bImg = findImg('B/file');
settleDecode(bImg, false); // B's decode REJECTS (broken URL)
await flush();
assert.equal(skips, 1, 'a decode-reject routed to skipSoon()');
assert.equal(stageImgs().length, 1, '#stage still holds an <img> (no black) after a broken image');
assert.equal(stageImgs()[0], aImg, 'the broken B was NOT mounted; A stays until a good frame is ready');
});
// CASE 3b — same guard on the fallback path: onerror -> skipSoon(), current image untouched.
test('#187 broken image (onerror fallback) calls skipSoon and leaves the current image mounted', async () => {
const ctx = loadPlayerContext({ decodeSupported: false });
const { player, stageImgs, findImg } = newPlayer(ctx);
let skips = 0;
const _skip = player.skipSoon.bind(player);
player.skipSoon = function () { skips += 1; return _skip(); };
player.load([imgItem('A', 0), imgItem('B', 1)]);
const aImg = findImg('A/file');
fireLoad(aImg);
await flush();
assert.equal(stageImgs()[0], aImg, 'A mounted');
fireOnlyTimer(ctx.timers); // advance to B
const bImg = findImg('B/file');
fireError(bImg); // B fails to load
await flush();
assert.equal(skips, 1, 'an onerror routed to skipSoon()');
assert.equal(stageImgs().length, 1, '#stage still holds an <img> (no black) after a broken image');
assert.equal(stageImgs()[0], aImg, 'the broken B was NOT mounted; A stays put');
});

View file

@ -37,6 +37,11 @@ function PlaylistPlayer(stageEl, getBase) {
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;
@ -86,6 +91,43 @@ PlaylistPlayer.prototype._takePreload = function (idx) {
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.
@ -117,6 +159,9 @@ PlaylistPlayer.prototype.load = function (assignments) {
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;
@ -153,6 +198,7 @@ PlaylistPlayer.prototype.load = function (assignments) {
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();
};
@ -305,7 +351,15 @@ PlaylistPlayer.prototype.playCurrent = function () {
// (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();
// #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;
if (!isImage) this.clearStage();
try {
if (mime === 'video/youtube') return this.renderYouTube(item, single);
@ -342,14 +396,49 @@ PlaylistPlayer.prototype.fit = function (el, item) {
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 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));
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; }
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
}
};
PlaylistPlayer.prototype.setOrientation = function (o) { this.orientation = o || 'landscape'; };
@ -910,7 +999,10 @@ 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);
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;
@ -962,5 +1054,6 @@ GroupSyncController.prototype.exit = function () {
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');
};