mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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>
143 lines
7.3 KiB
JavaScript
143 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
// #109 PiP player-layer test. Loads the REAL tizen/js/player.js + tizen/js/pip-overlay.js
|
|
// into a vm context with a minimal DOM shim (the repo has no jsdom; node --test only).
|
|
// Proves the overlay shows and auto-dismisses WITHOUT changing the playlist signature
|
|
// underneath — i.e. PipOverlay writes only to #pip and never to #stage / PlaylistPlayer.
|
|
|
|
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');
|
|
|
|
// --- minimal DOM element shim: only what PlaylistPlayer.renderImage + PipOverlay use ---
|
|
function makeEl() {
|
|
const el = {
|
|
tag: '', style: {}, className: '', attrs: {}, children: [], _html: '', _src: '', _text: '',
|
|
appendChild(c) { this.children.push(c); this._html = '<children>'; 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 = []; } });
|
|
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;
|
|
}
|
|
|
|
function loadPlayerContext() {
|
|
// Controllable timer so the duration teardown is deterministic (no wall-clock waits).
|
|
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; return e; } };
|
|
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' });
|
|
vm.runInContext(read('pip-overlay.js'), sandbox, { filename: 'pip-overlay.js' });
|
|
return { sandbox, timers };
|
|
}
|
|
|
|
test('pip: overlay shows in #pip and never touches #stage / the playlist signature', () => {
|
|
const { sandbox } = loadPlayerContext();
|
|
const stage = makeEl();
|
|
const pip = makeEl();
|
|
|
|
// A 1-item image playlist; capture the signature the renderer computes.
|
|
const player = new sandbox.PlaylistPlayer(stage, () => 'http://server');
|
|
player.load([{ content_id: 'c1', mime_type: 'image/png', sort_order: 0, duration_sec: 10 }]);
|
|
const sigBefore = player.sig;
|
|
const stageChildrenBefore = stage.children.length;
|
|
assert.ok(sigBefore, 'player computed a playlist signature');
|
|
assert.ok(stageChildrenBefore >= 1, 'playlist rendered into #stage');
|
|
|
|
const logs = [];
|
|
const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document, log: (lvl, msg) => logs.push([lvl, msg]) });
|
|
|
|
overlay.show({ pip_id: 'p1', type: 'image', uri: 'http://img/x.png', position: 'top-right', width: 480, height: 360, duration: 30 });
|
|
assert.equal(pip.children.length, 1, 'overlay box rendered into #pip');
|
|
assert.equal(player.sig, sigBefore, 'playlist signature unchanged by pip show');
|
|
assert.equal(stage.children.length, stageChildrenBefore, '#stage untouched by pip show');
|
|
assert.ok(logs.some(l => l[1].indexOf('pip show') === 0), 'show reported over the log channel');
|
|
});
|
|
|
|
test('pip: duration timer auto-dismisses without disturbing the playlist', () => {
|
|
const { sandbox, timers } = loadPlayerContext();
|
|
const stage = makeEl();
|
|
const pip = makeEl();
|
|
const player = new sandbox.PlaylistPlayer(stage, () => 'http://server');
|
|
player.load([{ content_id: 'c1', mime_type: 'image/png', sort_order: 0, duration_sec: 10 }]);
|
|
const sigBefore = player.sig;
|
|
|
|
const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document });
|
|
overlay.show({ pip_id: 'p1', type: 'image', uri: 'http://img/x.png', duration: 5 });
|
|
assert.equal(pip.children.length, 1, 'overlay shown');
|
|
|
|
// Fire the scheduled duration timer (deterministic: the sandbox setTimeout captured it).
|
|
const ids = Object.keys(timers);
|
|
assert.equal(ids.length, 1, 'a single duration timer was scheduled');
|
|
timers[ids[0]]();
|
|
|
|
assert.equal(pip.children.length, 0, 'overlay auto-dismissed at duration');
|
|
assert.equal(player.sig, sigBefore, 'playlist signature still unchanged after dismiss');
|
|
});
|
|
|
|
test('pip: web type renders an iframe; last-show-wins; targeted clear is id-aware', () => {
|
|
const { sandbox } = loadPlayerContext();
|
|
const pip = makeEl();
|
|
const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document });
|
|
|
|
overlay.show({ pip_id: 'web1', type: 'web', uri: 'https://example.com', duration: 0 });
|
|
assert.equal(pip.children.length, 1);
|
|
const box = pip.children[0];
|
|
assert.ok(box.children.some(c => c.tag === 'iframe'), 'web overlay uses an <iframe>');
|
|
assert.equal(box.children.find(c => c.tag === 'iframe').attrs.allow, '', 'web audio muted by default (empty allow)');
|
|
|
|
// last-show-wins: a second show replaces the first (still a single slot).
|
|
overlay.show({ pip_id: 'web2', type: 'image', uri: 'http://img/y.png', duration: 0 });
|
|
assert.equal(pip.children.length, 1, 'single overlay slot after a replacing show');
|
|
|
|
// a clear for a STALE pip_id is a no-op; the matching id clears.
|
|
overlay.clear('web1');
|
|
assert.equal(pip.children.length, 1, 'stale-id clear ignored');
|
|
overlay.clear('web2');
|
|
assert.equal(pip.children.length, 0, 'matching-id clear tore down the overlay');
|
|
});
|
|
|
|
test('pip: a malformed payload cannot wedge the layer', () => {
|
|
const { sandbox } = loadPlayerContext();
|
|
const pip = makeEl();
|
|
const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document });
|
|
|
|
// doc.createElement throws -> show must swallow it, tear down, and stay usable.
|
|
const boom = { createElement: () => { throw new Error('boom'); } };
|
|
const broken = new sandbox.PipOverlay(pip, { document: boom });
|
|
broken.show({ pip_id: 'x', type: 'image', uri: 'http://img/x.png', duration: 0 });
|
|
assert.equal(pip.children.length, 0, 'no half-built overlay left behind');
|
|
|
|
// the healthy overlay still works afterwards
|
|
overlay.show({ pip_id: 'ok', type: 'image', uri: 'http://img/ok.png', duration: 0 });
|
|
assert.equal(pip.children.length, 1, 'layer still usable after a malformed payload');
|
|
});
|