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