screentinker/tizen/js/pip-overlay.js
ScreenTinker 7eab9c6092 PiP overlay MVP: push image/web overlays to a device or group (#109)
Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or
group in real time, rendered above the playlist without disturbing it. Scope is the
MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are
deferred to follow-up PRs as the proposal specifies.

Protocol (/device socket, player-agnostic):
- device:pip-show { pip_id, type:image|web, uri, position, width, height, duration,
  title?, title_color?, background_color?, opacity?, border_radius?, close_button? }
- device:pip-clear { pip_id? }
The player fetches uri itself (same trust model as remote_url content; server never
proxies). type:web is full-trust by design, hence the 'full' token scope.

Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS):
- POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full').
- Resolves device_id to a device OR a group, expands a group to members, and emits
  per-device — reusing the group command route's room-size online check and
  {device_id, name, status: sent|offline} result shape. Generates pip_id.
- Validates type/position allowlists, uri http(s), numeric bounds on
  width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR
  (#RRGGBB; transparency is the separate opacity field).
- Workspace-isolated: every target query is scoped to req.workspaceId, so a token
  bound to workspace A can't address workspace B (404). Offline devices are reported,
  never queued (PiP is ephemeral).

Player overlay layer (Tizen; tizen/js/pip-overlay.js, new):
- A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch.
- applyOrientation now applies the SAME transform to #pip as #stage, so corner
  positions track the visible CONTENT in all four orientations.
- image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay),
  sized/positioned/styled per payload, optional title bar.
- Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
  (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge
  the layer. Reports show/clear over device:log (tag 'pip').

Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail
controls (device/group via the open device, type, uri, position, duration), calling
POST /api/pip through the api helper.

Tests (server suite green, 161/161):
- api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation
  (wsA token -> wsB device 404), payload validation, device + group targeting, clear;
  plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip.
- pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM
  shim; proves the overlay shows, auto-dismisses on the duration timer, and never
  changes the playlist signature / touches #stage; web->iframe, last-show-wins,
  id-aware clear, malformed-payload safety.

Not in this PR (intentional):
- Android player overlay — fast-follow. Protocol + server are player-agnostic; the
  Android layer (an overlay View above the player, orientation-matched to MainActivity's
  rootView rotation) is the same shape and lands next.
- OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats
  'command' paths as full-scope, so documenting a full-scope non-command route there
  needs that heuristic extended first; deferred with the docs item (proposal §8.6).
- video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary
  (x,y)/selector positioning (proposal §6).

Refs #109

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 14:42:32 -05:00

136 lines
5.6 KiB
JavaScript

/* PipOverlay — picture-in-picture overlay layer for the Tizen player (#109 MVP).
*
* Renders an image or web (iframe) overlay into a #pip element that sits ABOVE the
* playlist #stage. The playlist renderer (PlaylistPlayer / ZoneRenderer) NEVER touches
* #pip, so showing/clearing an overlay cannot change what's playing underneath.
*
* MVP semantics:
* - single overlay slot, last-show-wins (a new show replaces the current one);
* - duration timer in seconds (0 = until explicitly cleared);
* - device:pip-clear (matching pip_id, or none) or the timer tears it down;
* - teardown is wrapped so a malformed payload can never wedge the layer.
*
* Orientation: app.js applies the SAME orientation transform to #pip as to #stage, so a
* corner position ("top-right") tracks the top-right of the visible CONTENT in every
* orientation. This module only positions the box WITHIN #pip's (already-oriented) box.
*
* Deferred (not MVP): video/rtsp overlay types, priority/stacking, close-button focus.
*/
function PipOverlay(pipEl, opts) {
opts = opts || {};
this.pip = pipEl;
this.doc = opts.document || (typeof document !== 'undefined' ? document : null);
this.log = (typeof opts.log === 'function') ? opts.log : function () {};
// Injectable timers keep teardown deterministic under test; default to the globals.
this._setTimeout = opts.setTimeout || (typeof setTimeout !== 'undefined' ? setTimeout : null);
this._clearTimeout = opts.clearTimeout || (typeof clearTimeout !== 'undefined' ? clearTimeout : null);
this.timer = null;
this.current = null; // pip_id of the overlay currently showing (null when empty)
}
// Corner/center -> inline style offsets. 4% inset keeps the box off the bezel edge.
PipOverlay.POSITIONS = {
'top-left': { top: '4%', left: '4%' },
'top-right': { top: '4%', right: '4%' },
'bottom-left': { bottom: '4%', left: '4%' },
'bottom-right': { bottom: '4%', right: '4%' },
'center': { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }
};
PipOverlay.prototype.show = function (p) {
if (!p || !this.pip || !this.doc) return;
try {
this.teardown(); // single slot, last-show-wins
var box = this._buildBox(p);
this.pip.appendChild(box);
this.current = p.pip_id || '(anon)';
var dur = Number(p.duration);
if (this._setTimeout && isFinite(dur) && dur > 0) {
var self = this;
this.timer = this._setTimeout(function () { self.clear(self.current); }, dur * 1000);
}
this.log('info', 'pip show ' + (p.type || '?') + ' ' + (p.pip_id || '') +
' pos=' + (p.position || 'top-right') + ' dur=' + (isFinite(dur) ? dur : 0));
} catch (e) {
// A malformed payload must never wedge the layer: tear down and stay usable.
this.teardown();
this.log('warn', 'pip show failed: ' + (e && e.message ? e.message : e));
}
};
PipOverlay.prototype.clear = function (pipId) {
// A clear carrying a pip_id only clears if it matches the showing overlay (so a stale
// clear for a replaced overlay is a no-op); an omitted pip_id clears whatever shows.
if (pipId && this.current && pipId !== this.current) return;
var had = !!this.current;
this.teardown();
if (had) this.log('info', 'pip cleared' + (pipId ? ' ' + pipId : ''));
};
PipOverlay.prototype.teardown = function () {
try { if (this.timer && this._clearTimeout) { this._clearTimeout(this.timer); } } catch (e) {}
this.timer = null;
this.current = null;
try { if (this.pip) this.pip.innerHTML = ''; } catch (e) {}
};
PipOverlay.prototype._buildBox = function (p) {
var d = this.doc;
var box = d.createElement('div');
var s = box.style;
s.position = 'absolute';
s.width = pipPx(p.width, 480);
s.height = pipPx(p.height, 360);
s.overflow = 'hidden';
s.boxSizing = 'border-box';
s.background = pipColor(p.background_color) || '#000000';
s.zIndex = '2';
if (p.opacity != null && isFinite(Number(p.opacity))) s.opacity = String(pipClamp(Number(p.opacity), 0, 1));
if (p.border_radius != null && isFinite(Number(p.border_radius))) s.borderRadius = pipPx(p.border_radius, 0);
var pos = PipOverlay.POSITIONS[p.position] || PipOverlay.POSITIONS['top-right'];
for (var k in pos) { if (pos.hasOwnProperty(k)) s[k] = pos[k]; }
var hasTitle = p.title != null && p.title !== '';
if (hasTitle) {
var bar = d.createElement('div');
bar.textContent = String(p.title);
var bs = bar.style;
bs.font = '600 16px sans-serif';
bs.padding = '6px 10px';
bs.color = pipColor(p.title_color) || '#ffffff';
bs.background = 'rgba(0,0,0,0.45)';
bs.whiteSpace = 'nowrap';
bs.overflow = 'hidden';
bs.textOverflow = 'ellipsis';
box.appendChild(bar);
}
var media;
if (p.type === 'web') {
media = d.createElement('iframe');
media.setAttribute('frameborder', '0');
media.setAttribute('scrolling', 'no');
// Mute web audio by default: an empty allow= denies autoplay (incl. audio).
media.setAttribute('allow', '');
media.src = p.uri;
} else { // 'image' (and any non-web MVP type defaults to image render)
media = d.createElement('img');
media.src = p.uri;
}
var ms = media.style;
ms.display = 'block';
ms.border = '0';
ms.width = '100%';
ms.height = hasTitle ? 'calc(100% - 32px)' : '100%';
ms.objectFit = 'cover';
box.appendChild(media);
return box;
};
function pipPx(v, def) { var n = Number(v); if (!isFinite(n) || n <= 0) n = def; return n + 'px'; }
function pipClamp(n, lo, hi) { return n < lo ? lo : (n > hi ? hi : n); }
function pipColor(c) { return (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? c : null; }
if (typeof module !== 'undefined' && module.exports) module.exports = { PipOverlay: PipOverlay };