diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
index 771a868..1d86f93 100644
--- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
+++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt
@@ -26,6 +26,7 @@ import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
+import com.remotedisplay.player.player.PipOverlay
import com.remotedisplay.player.player.WallController
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
@@ -49,6 +50,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var wallController: WallController
+ private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
private lateinit var playerView: PlayerView
private lateinit var imageView: ImageView
@@ -132,6 +134,12 @@ class MainActivity : AppCompatActivity() {
// Hide player controls
playerView.useController = false
+ // #109: PiP overlay layer (top child of rootLayout; inherits the orientation
+ // transform). Reports show/clear over device:log (tag "pip").
+ pipOverlay = PipOverlay(this, findViewById(R.id.pipLayout)) { level, message ->
+ wsService?.sendLog("pip", level, message)
+ }
+
// Setup zone manager for multi-zone layouts
zoneManager = ZoneManager(this, rootView as FrameLayout) {
playlistController.onVideoComplete()
@@ -550,6 +558,10 @@ class MainActivity : AppCompatActivity() {
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(data) }
+ // #109: PiP overlay show/clear (posted to the main thread by the service).
+ wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) }
+ wsService?.onPipClear = { data -> if (::pipOverlay.isInitialized) pipOverlay.clearFrom(data) }
+
wsService?.onRegistered = { _ ->
hideStatus()
}
@@ -734,6 +746,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
remoteStreaming = false
zoneManager?.cleanup()
+ if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView
if (::mediaPlayer.isInitialized) {
stopScreenshotStreaming()
mediaPlayer.release()
diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PipOverlay.kt b/android/app/src/main/java/com/remotedisplay/player/player/PipOverlay.kt
new file mode 100644
index 0000000..40a8802
--- /dev/null
+++ b/android/app/src/main/java/com/remotedisplay/player/player/PipOverlay.kt
@@ -0,0 +1,158 @@
+package com.remotedisplay.player.player
+
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.Gravity
+import android.view.View
+import android.webkit.WebView
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import com.remotedisplay.player.util.ImageLoader
+import com.remotedisplay.player.util.WebViewSupport
+import org.json.JSONObject
+
+/**
+ * PiP overlay layer (#109). Renders an image or web (WebView) overlay into [pipLayout],
+ * which is the top child of rootLayout — so it draws above the playlist AND inherits the
+ * orientation rotation/translation applied to rootView (corner positions track the visible
+ * content). The playlist renderers never touch pipLayout.
+ *
+ * MVP semantics (mirrors the web/Tizen players): single overlay slot, last-show-wins;
+ * duration timer (0 = until cleared); device:pip-clear (id-aware) or the timer tears it
+ * down; teardown is wrapped so a malformed payload can't wedge the layer. Reports show/clear
+ * via [log] (device:log tag "pip").
+ *
+ * All methods must run on the main thread (WebSocketService posts the socket events there).
+ */
+class PipOverlay(
+ private val context: Context,
+ private val pipLayout: FrameLayout,
+ private val log: (level: String, message: String) -> Unit = { _, _ -> }
+) {
+ private val handler = Handler(Looper.getMainLooper())
+ private var timer: Runnable? = null
+ private var current: String? = null
+ private var webView: WebView? = null
+
+ fun show(p: JSONObject) {
+ try {
+ teardown() // single slot, last-show-wins
+ val type = p.optString("type", "image")
+ val uri = p.optString("uri", "")
+ if (uri.isEmpty()) { log("warn", "pip show ignored: empty uri"); return }
+
+ val dm = context.resources.displayMetrics
+ val w = p.optInt("width", 480).coerceIn(1, dm.widthPixels * 4)
+ val h = p.optInt("height", 360).coerceIn(1, dm.heightPixels * 4)
+
+ val box = LinearLayout(context).apply {
+ orientation = LinearLayout.VERTICAL
+ clipToOutline = true
+ val radius = p.optInt("border_radius", 0).toFloat()
+ background = GradientDrawable().apply {
+ setColor(parseColor(p.optString("background_color", ""), Color.BLACK))
+ cornerRadius = radius
+ }
+ alpha = p.optDouble("opacity", 1.0).toFloat().coerceIn(0f, 1f)
+ }
+
+ val title = p.optString("title", "")
+ if (title.isNotEmpty()) {
+ box.addView(TextView(context).apply {
+ text = title
+ setTextColor(parseColor(p.optString("title_color", ""), Color.WHITE))
+ setBackgroundColor(Color.argb(115, 0, 0, 0))
+ textSize = 14f
+ maxLines = 1
+ setPadding(20, 12, 20, 12)
+ }, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
+ }
+
+ // Media fills the remaining box height (weight 1).
+ val mediaLp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
+ if (type == "web") {
+ val wv = WebView(context)
+ WebViewSupport.configure(wv, "PiP")
+ // Mute web audio by default: deny autoplay-with-gesture so audio can't start.
+ try { wv.settings.mediaPlaybackRequiresUserGesture = true } catch (_: Throwable) {}
+ wv.setBackgroundColor(Color.TRANSPARENT)
+ wv.loadUrl(uri)
+ webView = wv
+ box.addView(wv, mediaLp)
+ } else {
+ val img = ImageView(context).apply { scaleType = ImageView.ScaleType.CENTER_CROP }
+ box.addView(img, mediaLp)
+ loadImageInto(img, uri, w, h)
+ }
+
+ // Corner/center placement; 4% inset off the edges (matches web/Tizen).
+ val lp = FrameLayout.LayoutParams(w, h)
+ val mx = (dm.widthPixels * 0.04f).toInt()
+ val my = (dm.heightPixels * 0.04f).toInt()
+ lp.gravity = when (p.optString("position", "top-right")) {
+ "top-left" -> { lp.leftMargin = mx; lp.topMargin = my; Gravity.TOP or Gravity.START }
+ "bottom-right" -> { lp.rightMargin = mx; lp.bottomMargin = my; Gravity.BOTTOM or Gravity.END }
+ "bottom-left" -> { lp.leftMargin = mx; lp.bottomMargin = my; Gravity.BOTTOM or Gravity.START }
+ "center" -> Gravity.CENTER
+ else -> { lp.rightMargin = mx; lp.topMargin = my; Gravity.TOP or Gravity.END } // top-right
+ }
+
+ pipLayout.addView(box, lp)
+ pipLayout.visibility = View.VISIBLE
+ current = p.optString("pip_id", "(anon)")
+
+ val dur = p.optInt("duration", 0)
+ if (dur > 0) {
+ val id = current
+ timer = Runnable { clear(id) }.also { handler.postDelayed(it, dur * 1000L) }
+ }
+ log("info", "pip show $type ${p.optString("pip_id", "")} pos=${p.optString("position", "top-right")} dur=$dur")
+ } catch (e: Throwable) {
+ // A malformed payload must never wedge the layer.
+ teardown()
+ log("warn", "pip show failed: ${e.message}")
+ }
+ }
+
+ /** Clear a showing overlay. A pip_id only clears if it matches; an empty id clears any. */
+ fun clear(pipId: String?) {
+ if (!pipId.isNullOrEmpty() && current != null && pipId != current) return
+ val had = current != null
+ teardown()
+ if (had) log("info", "pip cleared" + (if (!pipId.isNullOrEmpty()) " $pipId" else ""))
+ }
+
+ /** Convenience for the device:pip-clear payload ({ pip_id? }). */
+ fun clearFrom(data: JSONObject) = clear(if (data.has("pip_id")) data.optString("pip_id") else null)
+
+ private fun teardown() {
+ try { timer?.let { handler.removeCallbacks(it) } } catch (_: Throwable) {}
+ timer = null
+ current = null
+ try { webView?.apply { stopLoading(); loadUrl("about:blank"); destroy() } } catch (_: Throwable) {}
+ webView = null
+ try { pipLayout.removeAllViews(); pipLayout.visibility = View.GONE } catch (_: Throwable) {}
+ }
+
+ private fun loadImageInto(img: ImageView, url: String, w: Int, h: Int) {
+ val token = current
+ Thread {
+ val bmp = try { ImageLoader.decodeUrl(url, w, h) } catch (e: Throwable) { null }
+ img.post {
+ // Drop the result if this overlay was torn down / replaced while decoding.
+ if (img.parent == null || token != current) return@post
+ if (bmp != null) img.setImageBitmap(bmp)
+ else { log("warn", "pip image failed to load"); clear(token) }
+ }
+ }.start()
+ }
+
+ private fun parseColor(hex: String, fallback: Int): Int =
+ if (hex.matches(Regex("^#[0-9A-Fa-f]{6}$"))) try { Color.parseColor(hex) } catch (e: Throwable) { fallback } else fallback
+}
diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
index c013831..c64da3a 100644
--- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
+++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt
@@ -42,6 +42,8 @@ class WebSocketService : Service() {
var onCommand: ((String, JSONObject?) -> Unit)? = null
var onWallSync: ((JSONObject) -> Unit)? = null
var onWallSyncRequest: ((JSONObject) -> Unit)? = null
+ var onPipShow: ((JSONObject) -> Unit)? = null
+ var onPipClear: ((JSONObject) -> Unit)? = null
inner class LocalBinder : Binder() {
fun getService(): WebSocketService = this@WebSocketService
@@ -236,6 +238,16 @@ class WebSocketService : Service() {
handler.post { try { onWallSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onWallSyncRequest cb: ${e.message}") } }
}
+ // #109: PiP overlay. Post to the main thread — the handlers build Views.
+ safeOn("device:pip-show") { args ->
+ val data = args.firstOrNull() as? JSONObject ?: return@safeOn
+ handler.post { try { onPipShow?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipShow cb: ${e.message}") } }
+ }
+ safeOn("device:pip-clear") { args ->
+ val data = (args.firstOrNull() as? JSONObject) ?: JSONObject()
+ handler.post { try { onPipClear?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipClear cb: ${e.message}") } }
+ }
+
safeOn("device:command") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val type = data.optString("type", "")
@@ -527,6 +539,20 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
}
+ // #109: surface a log line to the dashboard device-detail screen (dashboard:device-log).
+ // Used for PiP show/clear (tag "pip"); guarded like the other emitters.
+ fun sendLog(tag: String, level: String, message: String) {
+ if (socket?.connected() != true) return
+ try {
+ socket?.emit("device:log", JSONObject().apply {
+ put("device_id", config.deviceId)
+ put("tag", tag)
+ put("level", level)
+ put("message", message)
+ })
+ } catch (e: Throwable) { Log.w("WebSocketService", "sendLog: ${e.message}") }
+ }
+
fun sendPlaybackState(contentId: String, positionSec: Float) {
if (socket?.connected() != true) return
try {
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 3c284a2..5d18d0c 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -76,4 +76,14 @@
android:indeterminate="true" />
+
+