diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
index 771a868..1d86f93 100644
--- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
+++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
@@ -26,6 +26,7 @@ import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
+import com.remotedisplay.player.player.PipOverlay
import com.remotedisplay.player.player.WallController
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
@@ -49,6 +50,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var wallController: WallController
+ private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
private lateinit var playerView: PlayerView
private lateinit var imageView: ImageView
@@ -132,6 +134,12 @@ class MainActivity : AppCompatActivity() {
// Hide player controls
playerView.useController = false
+ // #109: PiP overlay layer (top child of rootLayout; inherits the orientation
+ // transform). Reports show/clear over device:log (tag "pip").
+ pipOverlay = PipOverlay(this, findViewById(R.id.pipLayout)) { level, message ->
+ wsService?.sendLog("pip", level, message)
+ }
+
// Setup zone manager for multi-zone layouts
zoneManager = ZoneManager(this, rootView as FrameLayout) {
playlistController.onVideoComplete()
@@ -550,6 +558,10 @@ class MainActivity : AppCompatActivity() {
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(data) }
+ // #109: PiP overlay show/clear (posted to the main thread by the service).
+ wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) }
+ wsService?.onPipClear = { data -> if (::pipOverlay.isInitialized) pipOverlay.clearFrom(data) }
+
wsService?.onRegistered = { _ ->
hideStatus()
}
@@ -734,6 +746,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
remoteStreaming = false
zoneManager?.cleanup()
+ if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView
if (::mediaPlayer.isInitialized) {
stopScreenshotStreaming()
mediaPlayer.release()
diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PipOverlay.kt b/android/app/src/main/java/com/remotedisplay/player/player/PipOverlay.kt
new file mode 100644
index 0000000..40a8802
--- /dev/null
+++ b/android/app/src/main/java/com/remotedisplay/player/player/PipOverlay.kt
@@ -0,0 +1,158 @@
+package com.remotedisplay.player.player
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.webkit.WebView
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.remotedisplay.player.util.ImageLoader
+import com.remotedisplay.player.util.WebViewSupport
+import org.json.JSONObject
+
+/**
+ * PiP overlay layer (#109). Renders an image or web (WebView) overlay into [pipLayout],
+ * which is the top child of rootLayout — so it draws above the playlist AND inherits the
+ * orientation rotation/translation applied to rootView (corner positions track the visible
+ * content). The playlist renderers never touch pipLayout.
+ *
+ * MVP semantics (mirrors the web/Tizen players): single overlay slot, last-show-wins;
+ * duration timer (0 = until cleared); device:pip-clear (id-aware) or the timer tears it
+ * down; teardown is wrapped so a malformed payload can't wedge the layer. Reports show/clear
+ * via [log] (device:log tag "pip").
+ *
+ * All methods must run on the main thread (WebSocketService posts the socket events there).
+ */
+class PipOverlay(
+ private val context: Context,
+ private val pipLayout: FrameLayout,
+ private val log: (level: String, message: String) -> Unit = { _, _ -> }
+) {
+ private val handler = Handler(Looper.getMainLooper())
+ private var timer: Runnable? = null
+ private var current: String? = null
+ private var webView: WebView? = null
+
+ fun show(p: JSONObject) {
+ try {
+ teardown() // single slot, last-show-wins
+ val type = p.optString("type", "image")
+ val uri = p.optString("uri", "")
+ if (uri.isEmpty()) { log("warn", "pip show ignored: empty uri"); return }
+
+ val dm = context.resources.displayMetrics
+ val w = p.optInt("width", 480).coerceIn(1, dm.widthPixels * 4)
+ val h = p.optInt("height", 360).coerceIn(1, dm.heightPixels * 4)
+
+ val box = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ clipToOutline = true
+ val radius = p.optInt("border_radius", 0).toFloat()
+ background = GradientDrawable().apply {
+ setColor(parseColor(p.optString("background_color", ""), Color.BLACK))
+ cornerRadius = radius
+ }
+ alpha = p.optDouble("opacity", 1.0).toFloat().coerceIn(0f, 1f)
+ }
+
+ val title = p.optString("title", "")
+ if (title.isNotEmpty()) {
+ box.addView(TextView(context).apply {
+ text = title
+ setTextColor(parseColor(p.optString("title_color", ""), Color.WHITE))
+ setBackgroundColor(Color.argb(115, 0, 0, 0))
+ textSize = 14f
+ maxLines = 1
+ setPadding(20, 12, 20, 12)
+ }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
+ }
+
+ // Media fills the remaining box height (weight 1).
+ val mediaLp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
+ if (type == "web") {
+ val wv = WebView(context)
+ WebViewSupport.configure(wv, "PiP")
+ // Mute web audio by default: deny autoplay-with-gesture so audio can't start.
+ try { wv.settings.mediaPlaybackRequiresUserGesture = true } catch (_: Throwable) {}
+ wv.setBackgroundColor(Color.TRANSPARENT)
+ wv.loadUrl(uri)
+ webView = wv
+ box.addView(wv, mediaLp)
+ } else {
+ val img = ImageView(context).apply { scaleType = ImageView.ScaleType.CENTER_CROP }
+ box.addView(img, mediaLp)
+ loadImageInto(img, uri, w, h)
+ }
+
+ // Corner/center placement; 4% inset off the edges (matches web/Tizen).
+ val lp = FrameLayout.LayoutParams(w, h)
+ val mx = (dm.widthPixels * 0.04f).toInt()
+ val my = (dm.heightPixels * 0.04f).toInt()
+ lp.gravity = when (p.optString("position", "top-right")) {
+ "top-left" -> { lp.leftMargin = mx; lp.topMargin = my; Gravity.TOP or Gravity.START }
+ "bottom-right" -> { lp.rightMargin = mx; lp.bottomMargin = my; Gravity.BOTTOM or Gravity.END }
+ "bottom-left" -> { lp.leftMargin = mx; lp.bottomMargin = my; Gravity.BOTTOM or Gravity.START }
+ "center" -> Gravity.CENTER
+ else -> { lp.rightMargin = mx; lp.topMargin = my; Gravity.TOP or Gravity.END } // top-right
+ }
+
+ pipLayout.addView(box, lp)
+ pipLayout.visibility = View.VISIBLE
+ current = p.optString("pip_id", "(anon)")
+
+ val dur = p.optInt("duration", 0)
+ if (dur > 0) {
+ val id = current
+ timer = Runnable { clear(id) }.also { handler.postDelayed(it, dur * 1000L) }
+ }
+ log("info", "pip show $type ${p.optString("pip_id", "")} pos=${p.optString("position", "top-right")} dur=$dur")
+ } catch (e: Throwable) {
+ // A malformed payload must never wedge the layer.
+ teardown()
+ log("warn", "pip show failed: ${e.message}")
+ }
+ }
+
+ /** Clear a showing overlay. A pip_id only clears if it matches; an empty id clears any. */
+ fun clear(pipId: String?) {
+ if (!pipId.isNullOrEmpty() && current != null && pipId != current) return
+ val had = current != null
+ teardown()
+ if (had) log("info", "pip cleared" + (if (!pipId.isNullOrEmpty()) " $pipId" else ""))
+ }
+
+ /** Convenience for the device:pip-clear payload ({ pip_id? }). */
+ fun clearFrom(data: JSONObject) = clear(if (data.has("pip_id")) data.optString("pip_id") else null)
+
+ private fun teardown() {
+ try { timer?.let { handler.removeCallbacks(it) } } catch (_: Throwable) {}
+ timer = null
+ current = null
+ try { webView?.apply { stopLoading(); loadUrl("about:blank"); destroy() } } catch (_: Throwable) {}
+ webView = null
+ try { pipLayout.removeAllViews(); pipLayout.visibility = View.GONE } catch (_: Throwable) {}
+ }
+
+ private fun loadImageInto(img: ImageView, url: String, w: Int, h: Int) {
+ val token = current
+ Thread {
+ val bmp = try { ImageLoader.decodeUrl(url, w, h) } catch (e: Throwable) { null }
+ img.post {
+ // Drop the result if this overlay was torn down / replaced while decoding.
+ if (img.parent == null || token != current) return@post
+ if (bmp != null) img.setImageBitmap(bmp)
+ else { log("warn", "pip image failed to load"); clear(token) }
+ }
+ }.start()
+ }
+
+ private fun parseColor(hex: String, fallback: Int): Int =
+ if (hex.matches(Regex("^#[0-9A-Fa-f]{6}$"))) try { Color.parseColor(hex) } catch (e: Throwable) { fallback } else fallback
+}
diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
index c013831..c64da3a 100644
--- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
+++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
@@ -42,6 +42,8 @@ class WebSocketService : Service() {
var onCommand: ((String, JSONObject?) -> Unit)? = null
var onWallSync: ((JSONObject) -> Unit)? = null
var onWallSyncRequest: ((JSONObject) -> Unit)? = null
+ var onPipShow: ((JSONObject) -> Unit)? = null
+ var onPipClear: ((JSONObject) -> Unit)? = null
inner class LocalBinder : Binder() {
fun getService(): WebSocketService = this@WebSocketService
@@ -236,6 +238,16 @@ class WebSocketService : Service() {
handler.post { try { onWallSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onWallSyncRequest cb: ${e.message}") } }
}
+ // #109: PiP overlay. Post to the main thread — the handlers build Views.
+ safeOn("device:pip-show") { args ->
+ val data = args.firstOrNull() as? JSONObject ?: return@safeOn
+ handler.post { try { onPipShow?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipShow cb: ${e.message}") } }
+ }
+ safeOn("device:pip-clear") { args ->
+ val data = (args.firstOrNull() as? JSONObject) ?: JSONObject()
+ handler.post { try { onPipClear?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipClear cb: ${e.message}") } }
+ }
+
safeOn("device:command") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val type = data.optString("type", "")
@@ -527,6 +539,20 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
}
+ // #109: surface a log line to the dashboard device-detail screen (dashboard:device-log).
+ // Used for PiP show/clear (tag "pip"); guarded like the other emitters.
+ fun sendLog(tag: String, level: String, message: String) {
+ if (socket?.connected() != true) return
+ try {
+ socket?.emit("device:log", JSONObject().apply {
+ put("device_id", config.deviceId)
+ put("tag", tag)
+ put("level", level)
+ put("message", message)
+ })
+ } catch (e: Throwable) { Log.w("WebSocketService", "sendLog: ${e.message}") }
+ }
+
fun sendPlaybackState(contentId: String, positionSec: Float) {
if (socket?.connected() != true) return
try {
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 3c284a2..5d18d0c 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -76,4 +76,14 @@
android:indeterminate="true" />
+
+
+
diff --git a/frontend/js/api.js b/frontend/js/api.js
index a1ebecb..786516b 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -33,6 +33,11 @@ export const api = {
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
+ // #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
+ // device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
+ sendPip: (id, opts) => request('/pip', { method: 'POST', body: JSON.stringify({ device_id: id, ...opts }) }),
+ clearPip: (id, pipId) => request('/pip/clear', { method: 'POST', body: JSON.stringify({ device_id: id, pip_id: pipId || undefined }) }),
+
// Provisioning
pairDevice: (pairing_code, name) => request('/provision/pair', {
method: 'POST',
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index 0cb65df..94570d8 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -410,6 +410,29 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.ctl.shutdown')}
+
+
+
@@ -918,6 +931,88 @@
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
});
+
+ // #109: PiP overlay — a pushed floating layer above the playlist. The player
+ // fetches uri itself (same trust model as remote_url content).
+ socket.on('device:pip-show', (data) => pipShow(data));
+ socket.on('device:pip-clear', (data) => pipClear(data && data.pip_id));
+ }
+
+ // ==================== PiP overlay (#109) ====================
+ // Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
+ // (id-aware) or timer tears down. Renders into #pipContainer, never the player. Mirrors
+ // the Tizen PipOverlay (tizen/js/pip-overlay.js). Teardown is wrapped so a malformed
+ // payload can't wedge the layer.
+ let pipTimer = null, pipCurrent = null;
+ const PIP_POS = {
+ '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%)' },
+ };
+ const pipColor = (c) => (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? c : null;
+ const pipPx = (v, d) => { const n = Number(v); return (isFinite(n) && n > 0 ? n : d) + 'px'; };
+ function pipReport(level, msg) {
+ try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
+ }
+ function pipTeardown() {
+ try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
+ pipTimer = null; pipCurrent = null;
+ const c = document.getElementById('pipContainer'); if (c) c.innerHTML = '';
+ }
+ function pipShow(p) {
+ const container = document.getElementById('pipContainer');
+ if (!p || !container) return;
+ try {
+ pipTeardown(); // single slot, last-show-wins
+ const box = document.createElement('div');
+ box.className = 'pip-box';
+ box.style.width = pipPx(p.width, 480);
+ box.style.height = pipPx(p.height, 360);
+ box.style.background = pipColor(p.background_color) || '#000000';
+ if (p.opacity != null && isFinite(Number(p.opacity))) box.style.opacity = String(Math.max(0, Math.min(1, Number(p.opacity))));
+ if (p.border_radius != null && isFinite(Number(p.border_radius))) box.style.borderRadius = pipPx(p.border_radius, 0);
+ const pos = PIP_POS[p.position] || PIP_POS['top-right'];
+ Object.keys(pos).forEach((k) => { box.style[k] = pos[k]; });
+
+ const hasTitle = p.title != null && p.title !== '';
+ if (hasTitle) {
+ const bar = document.createElement('div');
+ bar.className = 'pip-title';
+ bar.textContent = String(p.title);
+ bar.style.color = pipColor(p.title_color) || '#ffffff';
+ bar.style.background = 'rgba(0,0,0,0.45)';
+ box.appendChild(bar);
+ }
+ let media;
+ if (p.type === 'web') {
+ media = document.createElement('iframe');
+ media.setAttribute('frameborder', '0');
+ media.setAttribute('scrolling', 'no');
+ media.setAttribute('allow', ''); // mute web audio by default (deny autoplay)
+ media.src = p.uri;
+ } else {
+ media = document.createElement('img');
+ media.src = p.uri;
+ }
+ media.style.height = hasTitle ? 'calc(100% - 32px)' : '100%';
+ media.style.objectFit = 'cover';
+ box.appendChild(media);
+ container.appendChild(box);
+ pipCurrent = p.pip_id || '(anon)';
+ const dur = Number(p.duration);
+ if (isFinite(dur) && dur > 0) pipTimer = setTimeout(() => pipClear(pipCurrent), dur * 1000);
+ pipReport('info', `pip show ${p.type || '?'} ${p.pip_id || ''} pos=${p.position || 'top-right'} dur=${isFinite(dur) ? dur : 0}`);
+ } catch (e) {
+ pipTeardown();
+ pipReport('warn', 'pip show failed: ' + (e && e.message ? e.message : e));
+ }
+ }
+ function pipClear(pipId) {
+ // A clear carrying a pip_id only clears if it matches the showing overlay.
+ if (pipId && pipCurrent && pipId !== pipCurrent) return;
+ const had = !!pipCurrent;
+ pipTeardown();
+ if (had) pipReport('info', 'pip cleared' + (pipId ? ' ' + pipId : ''));
}
function register() {
@@ -1134,15 +1229,23 @@
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
- // Apply orientation
+ // Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
+ // corner overlay tracks the visible content (not the physical panel) in every orientation.
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
- document.getElementById('playerContainer').style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
- if (data.orientation.includes('portrait')) {
- document.getElementById('playerContainer').style.transformOrigin = 'center center';
- document.getElementById('playerContainer').style.width = '100vh';
- document.getElementById('playerContainer').style.height = '100vw';
- }
+ const portrait = data.orientation.includes('portrait');
+ [document.getElementById('playerContainer'), document.getElementById('pipContainer')].forEach((el) => {
+ if (!el) return;
+ el.style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
+ if (portrait) {
+ el.style.transformOrigin = 'center center';
+ el.style.width = '100vh';
+ el.style.height = '100vw';
+ } else {
+ el.style.width = '';
+ el.style.height = '';
+ }
+ });
}
// Apply (or clear) wall mode. Force re-render when wall config changes
diff --git a/server/routes/pip.js b/server/routes/pip.js
new file mode 100644
index 0000000..68a29e5
--- /dev/null
+++ b/server/routes/pip.js
@@ -0,0 +1,159 @@
+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;
diff --git a/server/test/api.test.js b/server/test/api.test.js
index 6ed3221..13d3291 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -88,6 +88,12 @@ before(async () => {
S.deviceToken = 'devtok_' + crypto.randomBytes(16).toString('hex');
db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))")
.run(S.deviceId, 'WS-dev', S.user1, S.wsA, S.deviceToken);
+ // #109 PiP fixtures: a device in workspace B (cross-tenant isolation) and the wsA
+ // device as a member of the wsA group (group-targeting expansion).
+ S.deviceIdB = crypto.randomUUID();
+ db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))")
+ .run(S.deviceIdB, 'WS-dev-B', S.user1, S.wsB, 'devtok_' + crypto.randomBytes(16).toString('hex'));
+ db.prepare('INSERT INTO device_group_members (group_id, device_id) VALUES (?, ?)').run(S.groupId, S.deviceId);
db.close();
});
@@ -133,7 +139,7 @@ test('partition: the public token surface is exactly the reviewed set (snapshot
const EXPECTED_PUBLIC = [
'/api/devices', '/api/content', '/api/folders', '/api/assignments', '/api/layouts',
'/api/widgets', '/api/schedules', '/api/walls', '/api/reports', '/api/groups',
- '/api/playlists', '/api/activity', '/api/kiosk',
+ '/api/playlists', '/api/activity', '/api/kiosk', '/api/pip',
].sort();
assert.deepEqual(PUBLIC_ROUTERS.map(r => r.path).sort(), EXPECTED_PUBLIC);
});
@@ -312,3 +318,59 @@ test('token-auth: last_used_at is stamped on first use', async () => {
const after = (await jfetch('/api/tokens', auth(S.jwt))).body.find(t => t.id === created.body.id);
assert.ok(after.last_used_at, 'last_used_at is set after first use');
});
+
+// ───────────────────────── TIER 5: #109 PiP OVERLAY (POST /api/pip) ─────────────────────────
+// MVP: image/web overlay pushed to a device/group, full-scope, workspace-isolated.
+const pipBody = (over = {}) => ({ device_id: S.deviceId, type: 'image', uri: 'https://example.com/x.png', position: 'top-right', width: 480, height: 360, duration: 30, ...over });
+
+// authz: requireScope('full')
+test('pip: read/write tokens are rejected (403, needs full)', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.read, pipBody()))).status, 403);
+ assert.equal((await jfetch('/api/pip', post(S.tok.write, pipBody()))).status, 403);
+});
+test('pip: full token is accepted (offline device reported, not queued)', async () => {
+ const res = await jfetch('/api/pip', post(S.tok.full, pipBody()));
+ assert.equal(res.status, 200);
+ assert.equal(res.body.target, 'device');
+ assert.ok(res.body.pip_id, 'server generates a pip_id');
+ assert.equal(res.body.total, 1);
+ assert.equal(res.body.offline, 1, 'the offline device is reported offline');
+ assert.equal(res.body.sent, 0);
+});
+
+// workspace isolation: a wsA token cannot address a wsB device, nor a non-existent id.
+test('pip: workspace isolation — wsA token cannot target a wsB device (404)', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: S.deviceIdB }))).then(r => r.status)), 404);
+});
+test('pip: unknown device/group id is 404', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: crypto.randomUUID() })))).status, 404);
+});
+
+// group targeting: device_id resolves to a group and expands to its members.
+test('pip: group id expands to members (group with 1 member)', async () => {
+ const res = await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: S.groupId })));
+ assert.equal(res.status, 200);
+ assert.equal(res.body.target, 'group');
+ assert.equal(res.body.total, 1, 'the group has one member device');
+});
+
+// payload validation
+test('pip: payload validation (type / uri / position / bounds / color)', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ type: 'video' })))).status, 400); // not in allowlist
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ uri: 'ftp://x/y' })))).status, 400); // bad scheme
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ uri: 'not a url' })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ position: 'middle' })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ width: 5 })))).status, 400); // below DIM_MIN
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ duration: -1 })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ opacity: 2 })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ background_color: 'red' })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: '' })))).status, 400);
+});
+
+// clear: POST /api/pip/clear and DELETE /api/pip, both full-scope.
+test('pip clear: full token clears (POST /clear and DELETE), read token rejected', async () => {
+ assert.equal((await jfetch('/api/pip/clear', post(S.tok.full, { device_id: S.deviceId }))).status, 200);
+ assert.equal((await jfetch('/api/pip/clear', post(S.tok.read, { device_id: S.deviceId }))).status, 403);
+ const del = await jfetch('/api/pip', { method: 'DELETE', ...auth(S.tok.full), body: JSON.stringify({ device_id: S.deviceId }) });
+ assert.equal(del.status, 200);
+});
diff --git a/server/test/pip-overlay.test.js b/server/test/pip-overlay.test.js
new file mode 100644
index 0000000..0b78626
--- /dev/null
+++ b/server/test/pip-overlay.test.js
@@ -0,0 +1,135 @@
+'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 = '
'; 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; } });
+ 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