mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
* PiP overlay MVP: push image/web overlays to a device or group (#109) Implements the #109 MVP from docs proposal: a floating overlay PUSHED to a device or group in real time, rendered above the playlist without disturbing it. Scope is the MVP only — video/RTSP, MQTT, offline-queue, and the priority/stacking system are deferred to follow-up PRs as the proposal specifies. Protocol (/device socket, player-agnostic): - device:pip-show { pip_id, type:image|web, uri, position, width, height, duration, title?, title_color?, background_color?, opacity?, border_radius?, close_button? } - device:pip-clear { pip_id? } The player fetches uri itself (same trust model as remote_url content; server never proxies). type:web is full-trust by design, hence the 'full' token scope. Server (server/routes/pip.js, new; mounted in config/api-surface.js PUBLIC_ROUTERS): - POST /api/pip and POST /api/pip/clear + DELETE /api/pip, all requireScope('full'). - Resolves device_id to a device OR a group, expands a group to members, and emits per-device — reusing the group command route's room-size online check and {device_id, name, status: sent|offline} result shape. Generates pip_id. - Validates type/position allowlists, uri http(s), numeric bounds on width/height/duration/opacity/border_radius, colors via the existing VALID_COLOR (#RRGGBB; transparency is the separate opacity field). - Workspace-isolated: every target query is scoped to req.workspaceId, so a token bound to workspace A can't address workspace B (404). Offline devices are reported, never queued (PiP is ephemeral). Player overlay layer (Tizen; tizen/js/pip-overlay.js, new): - A #pip sibling ABOVE #stage that PlaylistPlayer/ZoneRenderer never touch. - applyOrientation now applies the SAME transform to #pip as #stage, so corner positions track the visible CONTENT in all four orientations. - image -> <img>, web -> <iframe> (muted by default: empty allow= denies autoplay), sized/positioned/styled per payload, optional title bar. - Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear (id-aware) or timer tears down; teardown wrapped so a malformed payload can't wedge the layer. Reports show/clear over device:log (tag 'pip'). Dashboard: a minimal "Send overlay" / "Clear overlay" tester on the device-detail controls (device/group via the open device, type, uri, position, duration), calling POST /api/pip through the api helper. Tests (server suite green, 161/161): - api.test.js: PiP tier — authz (read/write 403, full passes), workspace isolation (wsA token -> wsB device 404), payload validation, device + group targeting, clear; plus the PUBLIC_ROUTERS snapshot-firewall updated for /api/pip. - pip-overlay.test.js: loads the real player.js + pip-overlay.js in a vm with a DOM shim; proves the overlay shows, auto-dismisses on the duration timer, and never changes the playlist signature / touches #stage; web->iframe, last-show-wins, id-aware clear, malformed-payload safety. Not in this PR (intentional): - Android player overlay — fast-follow. Protocol + server are player-agnostic; the Android layer (an overlay View above the player, orientation-matched to MainActivity's rootView rotation) is the same shape and lands next. - OpenAPI docs for POST /api/pip — the contract test's scope heuristic only treats 'command' paths as full-scope, so documenting a full-scope non-command route there needs that heuristic extended first; deferred with the docs item (proposal §8.6). - video/rtsp types, MQTT, offline queue-on-reconnect, priority/stacking, arbitrary (x,y)/selector positioning (proposal §6). Refs #109 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * PiP overlay: add Android + web players (#109) Extends the #109 PiP MVP to the other two players so the protocol (device:pip-show / device:pip-clear) is honored fleet-wide, not just on Tizen. No server/protocol changes — the route and socket messages are player-agnostic; these are the two missing surfaces. Web player (server/player/index.html): - New #pipContainer layer above #playerContainer, pointer-transparent, that the playlist render never touches. The same orientation transform is applied to it as to #playerContainer (extended to also reset width/height on landscape so a portrait->landscape switch realigns), so corner positions track the visible content. - Inline PiP logic mirroring tizen/js/pip-overlay.js: image -> <img>, web -> <iframe> (muted by default via empty allow=), position/size/bg/opacity/radius/title, single slot last-show-wins, duration timer (0 = until cleared), id-aware clear, wrapped teardown. - device:pip-show/clear handlers; reports show/clear over device:log (tag "pip"). Android player: - activity_main.xml: a pipLayout FrameLayout as the LAST child of rootLayout — it draws above the content AND inherits rootView's orientation rotation/translation, so corner positioning is orientation-matched for free. - PipOverlay.kt (new): builds the overlay box into pipLayout. image -> ImageView (decoded off-thread via ImageLoader, dropped if torn down mid-decode); web -> WebView with mediaPlaybackRequiresUserGesture=true (mute-by-default). Gravity-based corner/center placement with a 4% inset, GradientDrawable bg + corner radius, alpha=opacity, optional title bar. Single slot last-show-wins; duration timer; id-aware clear; teardown wrapped and also run on activity destroy (WebView cleanup). - WebSocketService: onPipShow/onPipClear callbacks + safeOn handlers posted to the main thread (they build Views) + a sendLog(tag, level, message) emitter for device:log. - MainActivity: instantiate PipOverlay (log -> wsService.sendLog("pip", ...)), wire the callbacks, tear down on destroy. Verified: Android assembleDebug builds clean; web player inline JS parses; server suite still 161/161 (no server changes this commit). Not yet validated on real hardware — four-orientation corner positioning mirrors the player container/rootView transform but should be eyeballed on a panel. Refs #109 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e2ff8f47b7
commit
965920cd17
|
|
@ -26,6 +26,7 @@ import com.remotedisplay.player.data.ServerConfig
|
||||||
import com.remotedisplay.player.player.MediaPlayerManager
|
import com.remotedisplay.player.player.MediaPlayerManager
|
||||||
import com.remotedisplay.player.player.PlaylistController
|
import com.remotedisplay.player.player.PlaylistController
|
||||||
import com.remotedisplay.player.player.PlaylistItem
|
import com.remotedisplay.player.player.PlaylistItem
|
||||||
|
import com.remotedisplay.player.player.PipOverlay
|
||||||
import com.remotedisplay.player.player.WallController
|
import com.remotedisplay.player.player.WallController
|
||||||
import com.remotedisplay.player.player.ZoneManager
|
import com.remotedisplay.player.player.ZoneManager
|
||||||
import com.remotedisplay.player.remote.ScreenshotCapture
|
import com.remotedisplay.player.remote.ScreenshotCapture
|
||||||
|
|
@ -49,6 +50,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
private lateinit var updateChecker: UpdateChecker
|
private lateinit var updateChecker: UpdateChecker
|
||||||
private var zoneManager: ZoneManager? = null
|
private var zoneManager: ZoneManager? = null
|
||||||
private lateinit var wallController: WallController
|
private lateinit var wallController: WallController
|
||||||
|
private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
|
||||||
|
|
||||||
private lateinit var playerView: PlayerView
|
private lateinit var playerView: PlayerView
|
||||||
private lateinit var imageView: ImageView
|
private lateinit var imageView: ImageView
|
||||||
|
|
@ -132,6 +134,12 @@ class MainActivity : AppCompatActivity() {
|
||||||
// Hide player controls
|
// Hide player controls
|
||||||
playerView.useController = false
|
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
|
// Setup zone manager for multi-zone layouts
|
||||||
zoneManager = ZoneManager(this, rootView as FrameLayout) {
|
zoneManager = ZoneManager(this, rootView as FrameLayout) {
|
||||||
playlistController.onVideoComplete()
|
playlistController.onVideoComplete()
|
||||||
|
|
@ -550,6 +558,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
|
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
|
||||||
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(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 = { _ ->
|
wsService?.onRegistered = { _ ->
|
||||||
hideStatus()
|
hideStatus()
|
||||||
}
|
}
|
||||||
|
|
@ -734,6 +746,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
remoteStreaming = false
|
remoteStreaming = false
|
||||||
zoneManager?.cleanup()
|
zoneManager?.cleanup()
|
||||||
|
if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView
|
||||||
if (::mediaPlayer.isInitialized) {
|
if (::mediaPlayer.isInitialized) {
|
||||||
stopScreenshotStreaming()
|
stopScreenshotStreaming()
|
||||||
mediaPlayer.release()
|
mediaPlayer.release()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,8 @@ class WebSocketService : Service() {
|
||||||
var onCommand: ((String, JSONObject?) -> Unit)? = null
|
var onCommand: ((String, JSONObject?) -> Unit)? = null
|
||||||
var onWallSync: ((JSONObject) -> Unit)? = null
|
var onWallSync: ((JSONObject) -> Unit)? = null
|
||||||
var onWallSyncRequest: ((JSONObject) -> Unit)? = null
|
var onWallSyncRequest: ((JSONObject) -> Unit)? = null
|
||||||
|
var onPipShow: ((JSONObject) -> Unit)? = null
|
||||||
|
var onPipClear: ((JSONObject) -> Unit)? = null
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
fun getService(): WebSocketService = this@WebSocketService
|
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}") } }
|
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 ->
|
safeOn("device:command") { args ->
|
||||||
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
|
||||||
val type = data.optString("type", "")
|
val type = data.optString("type", "")
|
||||||
|
|
@ -527,6 +539,20 @@ class WebSocketService : Service() {
|
||||||
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
|
} 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) {
|
fun sendPlaybackState(contentId: String, positionSec: Float) {
|
||||||
if (socket?.connected() != true) return
|
if (socket?.connected() != true) return
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -76,4 +76,14 @@
|
||||||
android:indeterminate="true" />
|
android:indeterminate="true" />
|
||||||
</LinearLayout>
|
</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>
|
</FrameLayout>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ export const api = {
|
||||||
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
|
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
|
// Provisioning
|
||||||
pairDevice: (pairing_code, name) => request('/provision/pair', {
|
pairDevice: (pairing_code, name) => request('/provision/pair', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,29 @@ async function loadDevice(deviceId, activeTab = null) {
|
||||||
${t('device.ctl.shutdown')}
|
${t('device.ctl.shutdown')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Remote Control Tab -->
|
<!-- Remote Control Tab -->
|
||||||
|
|
@ -868,6 +891,25 @@ async function setupActions(device) {
|
||||||
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
|
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
|
||||||
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
|
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) {
|
function setupRemote(device) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const PUBLIC_ROUTERS = [
|
||||||
{ path: '/api/playlists', mod: './routes/playlists' },
|
{ path: '/api/playlists', mod: './routes/playlists' },
|
||||||
{ path: '/api/activity', mod: './routes/activity' },
|
{ path: '/api/activity', mod: './routes/activity' },
|
||||||
{ path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true },
|
{ path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true },
|
||||||
|
{ path: '/api/pip', mod: './routes/pip' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const JWT_ONLY_ROUTERS = [
|
const JWT_ONLY_ROUTERS = [
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,16 @@
|
||||||
.wall-mode #playerContainer > iframe,
|
.wall-mode #playerContainer > iframe,
|
||||||
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
|
.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 */
|
/* Status overlay */
|
||||||
#statusOverlay {
|
#statusOverlay {
|
||||||
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
|
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
|
||||||
|
|
@ -210,6 +220,9 @@
|
||||||
<!-- Player Container -->
|
<!-- Player Container -->
|
||||||
<div id="playerContainer" style="display:none"></div>
|
<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 -->
|
<!-- Status Overlay -->
|
||||||
<div id="statusOverlay" style="display:none">
|
<div id="statusOverlay" style="display:none">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|
@ -918,6 +931,88 @@
|
||||||
if (data.type === 'screen_off') toggleScreenOff();
|
if (data.type === 'screen_off') toggleScreenOff();
|
||||||
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
|
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() {
|
function register() {
|
||||||
|
|
@ -1134,15 +1229,23 @@
|
||||||
const newFp = fingerprint(newItems);
|
const newFp = fingerprint(newItems);
|
||||||
const oldFp = fingerprint(playlist);
|
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) {
|
if (data.orientation) {
|
||||||
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
|
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
|
||||||
document.getElementById('playerContainer').style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
|
const portrait = data.orientation.includes('portrait');
|
||||||
if (data.orientation.includes('portrait')) {
|
[document.getElementById('playerContainer'), document.getElementById('pipContainer')].forEach((el) => {
|
||||||
document.getElementById('playerContainer').style.transformOrigin = 'center center';
|
if (!el) return;
|
||||||
document.getElementById('playerContainer').style.width = '100vh';
|
el.style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
|
||||||
document.getElementById('playerContainer').style.height = '100vw';
|
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
|
// Apply (or clear) wall mode. Force re-render when wall config changes
|
||||||
|
|
|
||||||
159
server/routes/pip.js
Normal file
159
server/routes/pip.js
Normal 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;
|
||||||
|
|
@ -88,6 +88,12 @@ before(async () => {
|
||||||
S.deviceToken = 'devtok_' + crypto.randomBytes(16).toString('hex');
|
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'))")
|
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);
|
.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();
|
db.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,7 +139,7 @@ test('partition: the public token surface is exactly the reviewed set (snapshot
|
||||||
const EXPECTED_PUBLIC = [
|
const EXPECTED_PUBLIC = [
|
||||||
'/api/devices', '/api/content', '/api/folders', '/api/assignments', '/api/layouts',
|
'/api/devices', '/api/content', '/api/folders', '/api/assignments', '/api/layouts',
|
||||||
'/api/widgets', '/api/schedules', '/api/walls', '/api/reports', '/api/groups',
|
'/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();
|
].sort();
|
||||||
assert.deepEqual(PUBLIC_ROUTERS.map(r => r.path).sort(), EXPECTED_PUBLIC);
|
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);
|
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');
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
135
server/test/pip-overlay.test.js
Normal file
135
server/test/pip-overlay.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
|
@ -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. */
|
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; }
|
.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 */
|
||||||
.toast {
|
.toast {
|
||||||
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@
|
||||||
<!-- Playback stage -->
|
<!-- Playback stage -->
|
||||||
<div id="stage" class="screen stage hidden"></div>
|
<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 -->
|
<!-- Tiny on-screen status (offline / errors), auto-hides -->
|
||||||
<div id="toast" class="toast hidden"></div>
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
|
@ -47,6 +51,7 @@
|
||||||
<script src="js/schedule-eval.js"></script>
|
<script src="js/schedule-eval.js"></script>
|
||||||
<script src="js/player.js"></script>
|
<script src="js/player.js"></script>
|
||||||
<script src="js/device-control.js"></script>
|
<script src="js/device-control.js"></script>
|
||||||
|
<script src="js/pip-overlay.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@
|
||||||
var elSetup = document.getElementById('setup');
|
var elSetup = document.getElementById('setup');
|
||||||
var elPairing = document.getElementById('pairing');
|
var elPairing = document.getElementById('pairing');
|
||||||
var elStage = document.getElementById('stage');
|
var elStage = document.getElementById('stage');
|
||||||
|
var elPip = document.getElementById('pip'); // #109: PiP overlay layer (above #stage)
|
||||||
var elUrl = document.getElementById('serverUrl');
|
var elUrl = document.getElementById('serverUrl');
|
||||||
var elConnect = document.getElementById('connectBtn');
|
var elConnect = document.getElementById('connectBtn');
|
||||||
var elSetupStatus = document.getElementById('setupStatus');
|
var elSetupStatus = document.getElementById('setupStatus');
|
||||||
|
|
@ -242,6 +243,11 @@
|
||||||
// Leader broadcasts position; followers align index + drift-correct their video.
|
// Leader broadcasts position; followers align index + drift-correct their video.
|
||||||
socket.on('wall:sync', function (d) { wallController.onSync(d); });
|
socket.on('wall:sync', function (d) { wallController.onSync(d); });
|
||||||
socket.on('wall:sync-request', function (d) { wallController.onSyncRequest(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() {
|
function register() {
|
||||||
|
|
@ -285,6 +291,14 @@
|
||||||
var o = document.getElementById('screenOffOverlay');
|
var o = document.getElementById('screenOffOverlay');
|
||||||
if (o && o.parentNode) o.parentNode.removeChild(o);
|
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
|
// #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
|
// dashboard:device-log on the open device-detail screen; device:command-result is
|
||||||
// a structured echo (harmless if the server doesn't handle it).
|
// a structured echo (harmless if the server doesn't handle it).
|
||||||
|
|
@ -361,26 +375,34 @@
|
||||||
function () { return deviceId; },
|
function () { return deviceId; },
|
||||||
function () { return authenticated && !!socket && socket.connected; }
|
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
|
// 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
|
// are fixed-landscape, so we rotate the CONTENT (not the panel). Values mirror the
|
||||||
// dashboard: landscape / portrait / landscape-flipped / portrait-flipped.
|
// dashboard: landscape / portrait / landscape-flipped / portrait-flipped.
|
||||||
function applyOrientation(o) {
|
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') {
|
if (!o || o === 'landscape') {
|
||||||
s.style.position = ''; s.style.top = ''; s.style.left = '';
|
s.position = ''; s.top = ''; s.left = '';
|
||||||
s.style.width = ''; s.style.height = ''; s.style.transform = ''; s.style.transformOrigin = '';
|
s.width = ''; s.height = ''; s.transform = ''; s.transformOrigin = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var deg = o === 'portrait' ? 90 : o === 'portrait-flipped' ? 270 : o === 'landscape-flipped' ? 180 : 0;
|
var deg = o === 'portrait' ? 90 : o === 'portrait-flipped' ? 270 : o === 'landscape-flipped' ? 180 : 0;
|
||||||
var swap = (deg === 90 || deg === 270);
|
var swap = (deg === 90 || deg === 270);
|
||||||
s.style.position = 'absolute';
|
s.position = 'absolute';
|
||||||
s.style.top = '50%';
|
s.top = '50%';
|
||||||
s.style.left = '50%';
|
s.left = '50%';
|
||||||
s.style.width = swap ? '100vh' : '100vw';
|
s.width = swap ? '100vh' : '100vw';
|
||||||
s.style.height = swap ? '100vw' : '100vh';
|
s.height = swap ? '100vw' : '100vh';
|
||||||
s.style.transformOrigin = 'center center';
|
s.transformOrigin = 'center center';
|
||||||
s.style.transform = 'translate(-50%, -50%) rotate(' + deg + 'deg)';
|
s.transform = 'translate(-50%, -50%) rotate(' + deg + 'deg)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPlaylist(payload) {
|
function onPlaylist(payload) {
|
||||||
|
|
|
||||||
135
tizen/js/pip-overlay.js
Normal file
135
tizen/js/pip-overlay.js
Normal 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 };
|
||||||
Loading…
Reference in a new issue