mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
* 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> * PiP overlay: add Android + web players (#109) Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show / device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes — the route and socket messages are player-agnostic; these are the two missing surfaces. Web player (server/player/index.html): - New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist render never touches. The same orientation transform is applied to it as to #playerContainer (extended to also reset width/height on landscape so a portrait->landscape switch realigns), so corner positions track the visible content. - Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe> (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown. - device:pip-show/clear handlers; reports show/clear over device:log (tag "pip"). Android player: - activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws above the content AND inherits rootView's orientation rotation/translation, so corner positioning is orientation-matched for free. - PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped and also run on activity destroy (WebView cleanup). - WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main thread (they build Views) + a sendLog(tag, level, message) emitter for device:log. - MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the callbacks, tear down on destroy. Verified: Android assembleDebug builds clean; web player inline JS parses; server suite still 161/161 (no server changes this commit). Not yet validated on real hardware — four-orientation corner positioning mirrors the player container/rootView transform but should be eyeballed on a panel. Refs #109 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
7.5 KiB
JavaScript
160 lines
7.5 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { db } = require('../db/database');
|
|
// #109 PiP: a real-time floating overlay PUSHED to a device/group. Fleet-affecting,
|
|
// full-trust (a `web` overlay renders an arbitrary page in the player), so — like the
|
|
// group command route — it requires the 'full' token scope. No-op for JWT sessions.
|
|
const { requireScope } = require('../middleware/apiToken');
|
|
|
|
// Reuse the existing 6-hex color contract (#RRGGBB). Overlay transparency is expressed
|
|
// via the separate `opacity` field, so no alpha channel is accepted here.
|
|
const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
|
const PIP_TYPES = ['image', 'web'];
|
|
const PIP_POSITIONS = ['top-right', 'top-left', 'bottom-right', 'bottom-left', 'center'];
|
|
|
|
// Numeric bounds (px / seconds). MVP keeps these conservative; sizes are clamped by
|
|
// validation, not silently coerced.
|
|
const DIM_MIN = 40, DIM_MAX = 3840; // overlay box px
|
|
const DUR_MIN = 0, DUR_MAX = 86400; // seconds; 0 = until explicitly cleared
|
|
const RADIUS_MAX = 512; // border-radius px
|
|
|
|
function intInRange(v, def, lo, hi) {
|
|
if (v === undefined || v === null || v === '') return { ok: true, val: def };
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return { ok: false };
|
|
const r = Math.round(n);
|
|
if (r < lo || r > hi) return { ok: false };
|
|
return { ok: true, val: r };
|
|
}
|
|
|
|
function floatInRange(v, def, lo, hi) {
|
|
if (v === undefined || v === null || v === '') return { ok: true, val: def };
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n) || n < lo || n > hi) return { ok: false };
|
|
return { ok: true, val: n };
|
|
}
|
|
|
|
// Resolve a target id to its online/offline device list within the CALLER'S workspace.
|
|
// A device first, then a group; null if neither exists in this workspace (the handler
|
|
// 404s). Scoping every query by req.workspaceId is the workspace-isolation guarantee:
|
|
// a token bound to workspace A can never address a device/group in workspace B.
|
|
function resolveTargets(req, id) {
|
|
const wsId = req.workspaceId;
|
|
if (!wsId || !id) return null;
|
|
const device = db.prepare('SELECT id, name, status FROM devices WHERE id = ? AND workspace_id = ?').get(id, wsId);
|
|
if (device) return { kind: 'device', devices: [device] };
|
|
const group = db.prepare('SELECT id, name FROM device_groups WHERE id = ? AND workspace_id = ?').get(id, wsId);
|
|
if (group) {
|
|
const devices = db.prepare(`
|
|
SELECT d.id, d.name, d.status FROM devices d
|
|
JOIN device_group_members dgm ON d.id = dgm.device_id
|
|
WHERE dgm.group_id = ? AND d.workspace_id = ?
|
|
`).all(id, wsId);
|
|
return { kind: 'group', devices };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Emit `event` to each online target, mirroring the group command route's room-size
|
|
// online check and {device_id, name, status: sent|offline} result shape. Offline
|
|
// devices are reported, never queued — PiP is ephemeral (a stale flash on reconnect
|
|
// is worse than a miss; see the proposal §6).
|
|
function emitToTargets(req, devices, event, payload) {
|
|
const deviceNs = req.app.get('io').of('/device');
|
|
const results = [];
|
|
for (const device of devices) {
|
|
const room = deviceNs.adapter.rooms.get(device.id);
|
|
if (room && room.size > 0) {
|
|
deviceNs.to(device.id).emit(event, payload);
|
|
results.push({ device_id: device.id, name: device.name, status: 'sent' });
|
|
} else {
|
|
results.push({ device_id: device.id, name: device.name, status: 'offline' });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
function summarize(results) {
|
|
const sent = results.filter(r => r.status === 'sent').length;
|
|
const offline = results.filter(r => r.status === 'offline').length;
|
|
return { sent, offline, total: results.length, results };
|
|
}
|
|
|
|
// POST /api/pip — show an overlay on a device or group.
|
|
router.post('/', requireScope('full'), (req, res) => {
|
|
const b = req.body || {};
|
|
|
|
if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
|
|
if (!PIP_TYPES.includes(b.type)) return res.status(400).json({ error: `invalid type, use one of: ${PIP_TYPES.join(', ')}` });
|
|
|
|
// uri must be an absolute http(s) URL — the PLAYER fetches it directly (no server
|
|
// proxy), same trust model as remote_url content.
|
|
let parsed;
|
|
try { parsed = new URL(b.uri); } catch { return res.status(400).json({ error: 'uri must be a valid absolute URL' }); }
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
return res.status(400).json({ error: 'uri scheme must be http or https' });
|
|
}
|
|
|
|
const position = b.position == null || b.position === '' ? 'top-right' : b.position;
|
|
if (!PIP_POSITIONS.includes(position)) return res.status(400).json({ error: `invalid position, use one of: ${PIP_POSITIONS.join(', ')}` });
|
|
|
|
const width = intInRange(b.width, 480, DIM_MIN, DIM_MAX);
|
|
if (!width.ok) return res.status(400).json({ error: `width must be ${DIM_MIN}-${DIM_MAX}` });
|
|
const height = intInRange(b.height, 360, DIM_MIN, DIM_MAX);
|
|
if (!height.ok) return res.status(400).json({ error: `height must be ${DIM_MIN}-${DIM_MAX}` });
|
|
const duration = intInRange(b.duration, 0, DUR_MIN, DUR_MAX);
|
|
if (!duration.ok) return res.status(400).json({ error: `duration must be ${DUR_MIN}-${DUR_MAX} seconds (0 = until cleared)` });
|
|
const opacity = floatInRange(b.opacity, 1, 0, 1);
|
|
if (!opacity.ok) return res.status(400).json({ error: 'opacity must be between 0 and 1' });
|
|
const borderRadius = intInRange(b.border_radius, 0, 0, RADIUS_MAX);
|
|
if (!borderRadius.ok) return res.status(400).json({ error: `border_radius must be 0-${RADIUS_MAX}` });
|
|
|
|
if (b.title_color != null && b.title_color !== '' && !VALID_COLOR.test(b.title_color)) {
|
|
return res.status(400).json({ error: 'invalid title_color, use #RRGGBB' });
|
|
}
|
|
if (b.background_color != null && b.background_color !== '' && !VALID_COLOR.test(b.background_color)) {
|
|
return res.status(400).json({ error: 'invalid background_color, use #RRGGBB' });
|
|
}
|
|
|
|
const targets = resolveTargets(req, b.device_id);
|
|
if (!targets) return res.status(404).json({ error: 'device or group not found in this workspace' });
|
|
|
|
const pip_id = uuidv4();
|
|
const payload = {
|
|
pip_id,
|
|
type: b.type,
|
|
uri: b.uri,
|
|
position,
|
|
width: width.val,
|
|
height: height.val,
|
|
duration: duration.val,
|
|
opacity: opacity.val,
|
|
border_radius: borderRadius.val,
|
|
close_button: b.close_button === true,
|
|
};
|
|
if (b.title != null && b.title !== '') payload.title = String(b.title).slice(0, 200);
|
|
if (b.title_color) payload.title_color = b.title_color;
|
|
if (b.background_color) payload.background_color = b.background_color;
|
|
|
|
const results = emitToTargets(req, targets.devices, 'device:pip-show', payload);
|
|
res.json({ success: true, pip_id, target: targets.kind, ...summarize(results) });
|
|
});
|
|
|
|
// Clear an overlay. DELETE /api/pip and POST /api/pip/clear are equivalent; an omitted
|
|
// pip_id clears whatever is showing.
|
|
function handleClear(req, res) {
|
|
const b = req.body || {};
|
|
if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
|
|
const targets = resolveTargets(req, b.device_id);
|
|
if (!targets) return res.status(404).json({ error: 'device or group not found in this workspace' });
|
|
const payload = b.pip_id ? { pip_id: String(b.pip_id) } : {};
|
|
const results = emitToTargets(req, targets.devices, 'device:pip-clear', payload);
|
|
res.json({ success: true, target: targets.kind, ...summarize(results) });
|
|
}
|
|
|
|
router.post('/clear', requireScope('full'), handleClear);
|
|
router.delete('/', requireScope('full'), handleClear);
|
|
|
|
module.exports = router;
|