mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
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>
136 lines
5.6 KiB
JavaScript
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 };
|