PiP overlay MVP: push image/web overlays to a device or group (#109) (#127)

* 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>
This commit is contained in:
screentinker 2026-06-18 14:54:44 -05:00 committed by GitHub
parent e2ff8f47b7
commit 965920cd17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 906 additions and 18 deletions

View file

@ -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()

View file

@ -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
}

View file

@ -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 {

View file

@ -76,4 +76,14 @@
android:indeterminate="true" />
</LinearLayout>
<!-- #109: PiP overlay layer. Last child = drawn on top of the playlist content.
It lives INSIDE rootLayout, so the orientation rotation/translation applied to
rootView covers it too — corner positions track the visible content. PipOverlay
renders the overlay box into this (otherwise-transparent, empty) layer; the
playlist renderers never touch it. -->
<FrameLayout
android:id="@+id/pipLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View file

@ -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',

View file

@ -410,6 +410,29 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.ctl.shutdown')}
</button>
</div>
<!-- #109: PiP overlay tester. Pushes device:pip-show/clear via POST /api/pip
(real triggers are external via the API token; this is for testing). -->
<div style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
<div style="font-weight:600;margin-bottom:8px">Overlay (PiP) test</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<select id="pipType" class="btn btn-secondary btn-sm" style="min-width:90px">
<option value="image">image</option>
<option value="web">web</option>
</select>
<input id="pipUri" type="url" placeholder="https://… (image or page URL)" style="flex:1;min-width:240px;padding:6px 8px;background:#0b0f1a;border:1px solid var(--border);border-radius:6px;color:var(--text)">
<select id="pipPosition" class="btn btn-secondary btn-sm" style="min-width:120px">
<option value="top-right">top-right</option>
<option value="top-left">top-left</option>
<option value="bottom-right">bottom-right</option>
<option value="bottom-left">bottom-left</option>
<option value="center">center</option>
</select>
<input id="pipDuration" type="number" min="0" value="30" title="seconds (0 = until cleared)" style="width:90px;padding:6px 8px;background:#0b0f1a;border:1px solid var(--border);border-radius:6px;color:var(--text)">
<button class="btn btn-primary btn-sm" id="sendPipBtn">Send overlay</button>
<button class="btn btn-secondary btn-sm" id="clearPipBtn">Clear overlay</button>
</div>
</div>
</div>
<!-- Remote Control Tab -->
@ -868,6 +891,25 @@ async function setupActions(device) {
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
});
// #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
const uri = (document.getElementById('pipUri')?.value || '').trim();
if (!uri) { showToast('Enter an overlay URL', 'error'); return; }
try {
const res = await api.sendPip(device.id, {
type: document.getElementById('pipType').value,
uri,
position: document.getElementById('pipPosition').value,
duration: Number(document.getElementById('pipDuration').value) || 0,
});
showToast(`Overlay sent (${res.sent} sent, ${res.offline} offline)`, res.sent ? 'success' : 'warning');
} catch (err) { showToast(err.message, 'error'); }
});
document.getElementById('clearPipBtn')?.addEventListener('click', async () => {
try { await api.clearPip(device.id); showToast('Overlay cleared', 'success'); }
catch (err) { showToast(err.message, 'error'); }
});
}
function setupRemote(device) {

View file

@ -36,6 +36,7 @@ const PUBLIC_ROUTERS = [
{ path: '/api/playlists', mod: './routes/playlists' },
{ path: '/api/activity', mod: './routes/activity' },
{ path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true },
{ path: '/api/pip', mod: './routes/pip' },
];
const JWT_ONLY_ROUTERS = [

View file

@ -179,6 +179,16 @@
.wall-mode #playerContainer > iframe,
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
/* #109: PiP overlay layer — a fixed full-viewport layer ABOVE #playerContainer that
the playlist never touches. The same orientation transform is applied to it as to
#playerContainer, so corner positions track the visible content in every orientation.
Pointer-transparent and empty (invisible) until an overlay is shown. */
#pipContainer { position: fixed; inset: 0; pointer-events: none; z-index: 9000; }
#pipContainer > .pip-box { position: absolute; overflow: hidden; box-sizing: border-box; box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
#pipContainer > .pip-box > .pip-title { font: 600 16px sans-serif; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#pipContainer > .pip-box > img,
#pipContainer > .pip-box > iframe { display: block; width: 100%; border: 0; }
/* Status overlay */
#statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
@ -210,6 +220,9 @@
<!-- Player Container -->
<div id="playerContainer" style="display:none"></div>
<!-- #109: PiP overlay layer (above the player; never touched by playlist render) -->
<div id="pipContainer"></div>
<!-- Status Overlay -->
<div id="statusOverlay" style="display:none">
<div class="spinner"></div>
@ -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

159
server/routes/pip.js Normal file
View file

@ -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;

View file

@ -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);
});

View file

@ -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 = '<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; } });
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');
});

View file

@ -68,6 +68,18 @@ button.ghost { background: transparent; color: #64748b; font-size: 18px; margin-
lands on the same physical line across every screen that shares a viewport height. */
.stage.wall-mode img, .stage.wall-mode video, .stage.wall-mode iframe { object-fit: fill; }
/* #109: PiP overlay layer. Sits above #stage and fills the same viewport box so a
child positioned to a corner lands in the corner of the visible content. app.js
applies the SAME orientation transform here as on #stage (portrait/flipped). It is
pointer-transparent and empty (invisible) until PipOverlay renders into it. */
#pip {
position: fixed; top: 0; left: 0;
width: 100vw; height: 100vh;
pointer-events: none;
z-index: 50;
}
#pip > div { box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
/* Toast */
.toast {
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);

View file

@ -35,6 +35,10 @@
<!-- Playback stage -->
<div id="stage" class="screen stage hidden"></div>
<!-- #109: PiP overlay layer — a sibling ABOVE #stage that the playlist never
touches. app.js mirrors the orientation transform onto it. -->
<div id="pip"></div>
<!-- Tiny on-screen status (offline / errors), auto-hides -->
<div id="toast" class="toast hidden"></div>
@ -47,6 +51,7 @@
<script src="js/schedule-eval.js"></script>
<script src="js/player.js"></script>
<script src="js/device-control.js"></script>
<script src="js/pip-overlay.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View file

@ -61,6 +61,7 @@
var elSetup = document.getElementById('setup');
var elPairing = document.getElementById('pairing');
var elStage = document.getElementById('stage');
var elPip = document.getElementById('pip'); // #109: PiP overlay layer (above #stage)
var elUrl = document.getElementById('serverUrl');
var elConnect = document.getElementById('connectBtn');
var elSetupStatus = document.getElementById('setupStatus');
@ -242,6 +243,11 @@
// Leader broadcasts position; followers align index + drift-correct their video.
socket.on('wall:sync', function (d) { wallController.onSync(d); });
socket.on('wall:sync-request', function (d) { wallController.onSyncRequest(d); });
// #109: PiP overlay — a pushed floating layer above the playlist. The player
// fetches the uri itself (same trust model as remote_url content).
socket.on('device:pip-show', function (d) { pipOverlay.show(d); });
socket.on('device:pip-clear', function (d) { pipOverlay.clear(d && d.pip_id); });
}
function register() {
@ -285,6 +291,14 @@
var o = document.getElementById('screenOffOverlay');
if (o && o.parentNode) o.parentNode.removeChild(o);
}
// #109: report PiP show/clear over the existing device:log channel (tag 'pip') so it
// surfaces in the dashboard device log. Used as the PipOverlay log callback.
function reportPip(level, msg) {
try {
if (socket && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'pip', level: level, message: msg });
} catch (e) {}
}
// #125: report a command outcome to the dashboard. device:log surfaces live as
// dashboard:device-log on the open device-detail screen; device:command-result is
// a structured echo (harmless if the server doesn't handle it).
@ -361,26 +375,34 @@
function () { return deviceId; },
function () { return authenticated && !!socket && socket.connected; }
);
// #109: PiP overlay layer. Renders into #pip (above #stage); never touches the
// playlist. Reports show/clear over device:log (tag 'pip').
var pipOverlay = new PipOverlay(elPip, { log: reportPip });
// Rotate the playback stage in software for portrait / flipped signage. Tizen TVs
// are fixed-landscape, so we rotate the CONTENT (not the panel). Values mirror the
// dashboard: landscape / portrait / landscape-flipped / portrait-flipped.
function applyOrientation(o) {
var s = elStage;
// #109: apply the SAME transform to #stage AND #pip so the overlay's corner
// positions track the visible CONTENT, not the physical panel, in every orientation.
orientEl(elStage.style, o);
if (elPip) orientEl(elPip.style, o);
}
function orientEl(s, o) {
if (!o || o === 'landscape') {
s.style.position = ''; s.style.top = ''; s.style.left = '';
s.style.width = ''; s.style.height = ''; s.style.transform = ''; s.style.transformOrigin = '';
s.position = ''; s.top = ''; s.left = '';
s.width = ''; s.height = ''; s.transform = ''; s.transformOrigin = '';
return;
}
var deg = o === 'portrait' ? 90 : o === 'portrait-flipped' ? 270 : o === 'landscape-flipped' ? 180 : 0;
var swap = (deg === 90 || deg === 270);
s.style.position = 'absolute';
s.style.top = '50%';
s.style.left = '50%';
s.style.width = swap ? '100vh' : '100vw';
s.style.height = swap ? '100vw' : '100vh';
s.style.transformOrigin = 'center center';
s.style.transform = 'translate(-50%, -50%) rotate(' + deg + 'deg)';
s.position = 'absolute';
s.top = '50%';
s.left = '50%';
s.width = swap ? '100vh' : '100vw';
s.height = swap ? '100vw' : '100vh';
s.transformOrigin = 'center center';
s.transform = 'translate(-50%, -50%) rotate(' + deg + 'deg)';
}
function onPlaylist(payload) {

135
tizen/js/pip-overlay.js Normal file
View file

@ -0,0 +1,135 @@
/* 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 };