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>
This commit is contained in:
ScreenTinker 2026-06-18 14:52:10 -05:00
parent 7eab9c6092
commit 1c76fa865c
5 changed files with 317 additions and 7 deletions

View file

@ -26,6 +26,7 @@ import com.remotedisplay.player.data.ServerConfig
import com.remotedisplay.player.player.MediaPlayerManager
import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem
import com.remotedisplay.player.player.PipOverlay
import com.remotedisplay.player.player.WallController
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
@ -49,6 +50,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var wallController: WallController
private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
private lateinit var playerView: PlayerView
private lateinit var imageView: ImageView
@ -132,6 +134,12 @@ class MainActivity : AppCompatActivity() {
// Hide player controls
playerView.useController = false
// #109: PiP overlay layer (top child of rootLayout; inherits the orientation
// transform). Reports show/clear over device:log (tag "pip").
pipOverlay = PipOverlay(this, findViewById(R.id.pipLayout)) { level, message ->
wsService?.sendLog("pip", level, message)
}
// Setup zone manager for multi-zone layouts
zoneManager = ZoneManager(this, rootView as FrameLayout) {
playlistController.onVideoComplete()
@ -550,6 +558,10 @@ class MainActivity : AppCompatActivity() {
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(data) }
// #109: PiP overlay show/clear (posted to the main thread by the service).
wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) }
wsService?.onPipClear = { data -> if (::pipOverlay.isInitialized) pipOverlay.clearFrom(data) }
wsService?.onRegistered = { _ ->
hideStatus()
}
@ -734,6 +746,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
remoteStreaming = false
zoneManager?.cleanup()
if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView
if (::mediaPlayer.isInitialized) {
stopScreenshotStreaming()
mediaPlayer.release()

View file

@ -0,0 +1,158 @@
package com.remotedisplay.player.player
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.View
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.remotedisplay.player.util.ImageLoader
import com.remotedisplay.player.util.WebViewSupport
import org.json.JSONObject
/**
* PiP overlay layer (#109). Renders an image or web (WebView) overlay into [pipLayout],
* which is the top child of rootLayout so it draws above the playlist AND inherits the
* orientation rotation/translation applied to rootView (corner positions track the visible
* content). The playlist renderers never touch pipLayout.
*
* MVP semantics (mirrors the web/Tizen players): single overlay slot, last-show-wins;
* duration timer (0 = until cleared); device:pip-clear (id-aware) or the timer tears it
* down; teardown is wrapped so a malformed payload can't wedge the layer. Reports show/clear
* via [log] (device:log tag "pip").
*
* All methods must run on the main thread (WebSocketService posts the socket events there).
*/
class PipOverlay(
private val context: Context,
private val pipLayout: FrameLayout,
private val log: (level: String, message: String) -> Unit = { _, _ -> }
) {
private val handler = Handler(Looper.getMainLooper())
private var timer: Runnable? = null
private var current: String? = null
private var webView: WebView? = null
fun show(p: JSONObject) {
try {
teardown() // single slot, last-show-wins
val type = p.optString("type", "image")
val uri = p.optString("uri", "")
if (uri.isEmpty()) { log("warn", "pip show ignored: empty uri"); return }
val dm = context.resources.displayMetrics
val w = p.optInt("width", 480).coerceIn(1, dm.widthPixels * 4)
val h = p.optInt("height", 360).coerceIn(1, dm.heightPixels * 4)
val box = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
clipToOutline = true
val radius = p.optInt("border_radius", 0).toFloat()
background = GradientDrawable().apply {
setColor(parseColor(p.optString("background_color", ""), Color.BLACK))
cornerRadius = radius
}
alpha = p.optDouble("opacity", 1.0).toFloat().coerceIn(0f, 1f)
}
val title = p.optString("title", "")
if (title.isNotEmpty()) {
box.addView(TextView(context).apply {
text = title
setTextColor(parseColor(p.optString("title_color", ""), Color.WHITE))
setBackgroundColor(Color.argb(115, 0, 0, 0))
textSize = 14f
maxLines = 1
setPadding(20, 12, 20, 12)
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
}
// Media fills the remaining box height (weight 1).
val mediaLp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
if (type == "web") {
val wv = WebView(context)
WebViewSupport.configure(wv, "PiP")
// Mute web audio by default: deny autoplay-with-gesture so audio can't start.
try { wv.settings.mediaPlaybackRequiresUserGesture = true } catch (_: Throwable) {}
wv.setBackgroundColor(Color.TRANSPARENT)
wv.loadUrl(uri)
webView = wv
box.addView(wv, mediaLp)
} else {
val img = ImageView(context).apply { scaleType = ImageView.ScaleType.CENTER_CROP }
box.addView(img, mediaLp)
loadImageInto(img, uri, w, h)
}
// Corner/center placement; 4% inset off the edges (matches web/Tizen).
val lp = FrameLayout.LayoutParams(w, h)
val mx = (dm.widthPixels * 0.04f).toInt()
val my = (dm.heightPixels * 0.04f).toInt()
lp.gravity = when (p.optString("position", "top-right")) {
"top-left" -> { lp.leftMargin = mx; lp.topMargin = my; Gravity.TOP or Gravity.START }
"bottom-right" -> { lp.rightMargin = mx; lp.bottomMargin = my; Gravity.BOTTOM or Gravity.END }
"bottom-left" -> { lp.leftMargin = mx; lp.bottomMargin = my; Gravity.BOTTOM or Gravity.START }
"center" -> Gravity.CENTER
else -> { lp.rightMargin = mx; lp.topMargin = my; Gravity.TOP or Gravity.END } // top-right
}
pipLayout.addView(box, lp)
pipLayout.visibility = View.VISIBLE
current = p.optString("pip_id", "(anon)")
val dur = p.optInt("duration", 0)
if (dur > 0) {
val id = current
timer = Runnable { clear(id) }.also { handler.postDelayed(it, dur * 1000L) }
}
log("info", "pip show $type ${p.optString("pip_id", "")} pos=${p.optString("position", "top-right")} dur=$dur")
} catch (e: Throwable) {
// A malformed payload must never wedge the layer.
teardown()
log("warn", "pip show failed: ${e.message}")
}
}
/** Clear a showing overlay. A pip_id only clears if it matches; an empty id clears any. */
fun clear(pipId: String?) {
if (!pipId.isNullOrEmpty() && current != null && pipId != current) return
val had = current != null
teardown()
if (had) log("info", "pip cleared" + (if (!pipId.isNullOrEmpty()) " $pipId" else ""))
}
/** Convenience for the device:pip-clear payload ({ pip_id? }). */
fun clearFrom(data: JSONObject) = clear(if (data.has("pip_id")) data.optString("pip_id") else null)
private fun teardown() {
try { timer?.let { handler.removeCallbacks(it) } } catch (_: Throwable) {}
timer = null
current = null
try { webView?.apply { stopLoading(); loadUrl("about:blank"); destroy() } } catch (_: Throwable) {}
webView = null
try { pipLayout.removeAllViews(); pipLayout.visibility = View.GONE } catch (_: Throwable) {}
}
private fun loadImageInto(img: ImageView, url: String, w: Int, h: Int) {
val token = current
Thread {
val bmp = try { ImageLoader.decodeUrl(url, w, h) } catch (e: Throwable) { null }
img.post {
// Drop the result if this overlay was torn down / replaced while decoding.
if (img.parent == null || token != current) return@post
if (bmp != null) img.setImageBitmap(bmp)
else { log("warn", "pip image failed to load"); clear(token) }
}
}.start()
}
private fun parseColor(hex: String, fallback: Int): Int =
if (hex.matches(Regex("^#[0-9A-Fa-f]{6}$"))) try { Color.parseColor(hex) } catch (e: Throwable) { fallback } else fallback
}

View file

@ -42,6 +42,8 @@ class WebSocketService : Service() {
var onCommand: ((String, JSONObject?) -> Unit)? = null
var onWallSync: ((JSONObject) -> Unit)? = null
var onWallSyncRequest: ((JSONObject) -> Unit)? = null
var onPipShow: ((JSONObject) -> Unit)? = null
var onPipClear: ((JSONObject) -> Unit)? = null
inner class LocalBinder : Binder() {
fun getService(): WebSocketService = this@WebSocketService
@ -236,6 +238,16 @@ class WebSocketService : Service() {
handler.post { try { onWallSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onWallSyncRequest cb: ${e.message}") } }
}
// #109: PiP overlay. Post to the main thread — the handlers build Views.
safeOn("device:pip-show") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
handler.post { try { onPipShow?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipShow cb: ${e.message}") } }
}
safeOn("device:pip-clear") { args ->
val data = (args.firstOrNull() as? JSONObject) ?: JSONObject()
handler.post { try { onPipClear?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onPipClear cb: ${e.message}") } }
}
safeOn("device:command") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
val type = data.optString("type", "")
@ -527,6 +539,20 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "sendContentAck: ${e.message}") }
}
// #109: surface a log line to the dashboard device-detail screen (dashboard:device-log).
// Used for PiP show/clear (tag "pip"); guarded like the other emitters.
fun sendLog(tag: String, level: String, message: String) {
if (socket?.connected() != true) return
try {
socket?.emit("device:log", JSONObject().apply {
put("device_id", config.deviceId)
put("tag", tag)
put("level", level)
put("message", message)
})
} catch (e: Throwable) { Log.w("WebSocketService", "sendLog: ${e.message}") }
}
fun sendPlaybackState(contentId: String, positionSec: Float) {
if (socket?.connected() != true) return
try {

View file

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

View file

@ -179,6 +179,16 @@
.wall-mode #playerContainer > iframe,
.wall-mode #playerContainer > div > iframe { position: static !important; width: 100% !important; height: 100% !important; }
/* #109: PiP overlay layer — a fixed full-viewport layer ABOVE #playerContainer that
the playlist never touches. The same orientation transform is applied to it as to
#playerContainer, so corner positions track the visible content in every orientation.
Pointer-transparent and empty (invisible) until an overlay is shown. */
#pipContainer { position: fixed; inset: 0; pointer-events: none; z-index: 9000; }
#pipContainer > .pip-box { position: absolute; overflow: hidden; box-sizing: border-box; box-shadow: 0 6px 28px rgba(0,0,0,0.55); }
#pipContainer > .pip-box > .pip-title { font: 600 16px sans-serif; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#pipContainer > .pip-box > img,
#pipContainer > .pip-box > iframe { display: block; width: 100%; border: 0; }
/* Status overlay */
#statusOverlay {
position: fixed; inset: 0; background: #000; display: flex; flex-direction: column;
@ -210,6 +220,9 @@
<!-- Player Container -->
<div id="playerContainer" style="display:none"></div>
<!-- #109: PiP overlay layer (above the player; never touched by playlist render) -->
<div id="pipContainer"></div>
<!-- Status Overlay -->
<div id="statusOverlay" style="display:none">
<div class="spinner"></div>
@ -918,6 +931,88 @@
if (data.type === 'screen_off') toggleScreenOff();
if (data.type === 'screen_on') { document.getElementById('screenOffOverlay')?.remove(); }
});
// #109: PiP overlay — a pushed floating layer above the playlist. The player
// fetches uri itself (same trust model as remote_url content).
socket.on('device:pip-show', (data) => pipShow(data));
socket.on('device:pip-clear', (data) => pipClear(data && data.pip_id));
}
// ==================== PiP overlay (#109) ====================
// Single overlay slot, last-show-wins; duration timer (0 = until cleared); pip-clear
// (id-aware) or timer tears down. Renders into #pipContainer, never the player. Mirrors
// the Tizen PipOverlay (tizen/js/pip-overlay.js). Teardown is wrapped so a malformed
// payload can't wedge the layer.
let pipTimer = null, pipCurrent = null;
const PIP_POS = {
'top-left': { top: '4%', left: '4%' }, 'top-right': { top: '4%', right: '4%' },
'bottom-left': { bottom: '4%', left: '4%' }, 'bottom-right': { bottom: '4%', right: '4%' },
'center': { top: '50%', left: '50%', transform: 'translate(-50%,-50%)' },
};
const pipColor = (c) => (typeof c === 'string' && /^#[0-9A-Fa-f]{6}$/.test(c)) ? c : null;
const pipPx = (v, d) => { const n = Number(v); return (isFinite(n) && n > 0 ? n : d) + 'px'; };
function pipReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'pip', level, message: msg }); } catch (e) {}
}
function pipTeardown() {
try { if (pipTimer) clearTimeout(pipTimer); } catch (e) {}
pipTimer = null; pipCurrent = null;
const c = document.getElementById('pipContainer'); if (c) c.innerHTML = '';
}
function pipShow(p) {
const container = document.getElementById('pipContainer');
if (!p || !container) return;
try {
pipTeardown(); // single slot, last-show-wins
const box = document.createElement('div');
box.className = 'pip-box';
box.style.width = pipPx(p.width, 480);
box.style.height = pipPx(p.height, 360);
box.style.background = pipColor(p.background_color) || '#000000';
if (p.opacity != null && isFinite(Number(p.opacity))) box.style.opacity = String(Math.max(0, Math.min(1, Number(p.opacity))));
if (p.border_radius != null && isFinite(Number(p.border_radius))) box.style.borderRadius = pipPx(p.border_radius, 0);
const pos = PIP_POS[p.position] || PIP_POS['top-right'];
Object.keys(pos).forEach((k) => { box.style[k] = pos[k]; });
const hasTitle = p.title != null && p.title !== '';
if (hasTitle) {
const bar = document.createElement('div');
bar.className = 'pip-title';
bar.textContent = String(p.title);
bar.style.color = pipColor(p.title_color) || '#ffffff';
bar.style.background = 'rgba(0,0,0,0.45)';
box.appendChild(bar);
}
let media;
if (p.type === 'web') {
media = document.createElement('iframe');
media.setAttribute('frameborder', '0');
media.setAttribute('scrolling', 'no');
media.setAttribute('allow', ''); // mute web audio by default (deny autoplay)
media.src = p.uri;
} else {
media = document.createElement('img');
media.src = p.uri;
}
media.style.height = hasTitle ? 'calc(100% - 32px)' : '100%';
media.style.objectFit = 'cover';
box.appendChild(media);
container.appendChild(box);
pipCurrent = p.pip_id || '(anon)';
const dur = Number(p.duration);
if (isFinite(dur) && dur > 0) pipTimer = setTimeout(() => pipClear(pipCurrent), dur * 1000);
pipReport('info', `pip show ${p.type || '?'} ${p.pip_id || ''} pos=${p.position || 'top-right'} dur=${isFinite(dur) ? dur : 0}`);
} catch (e) {
pipTeardown();
pipReport('warn', 'pip show failed: ' + (e && e.message ? e.message : e));
}
}
function pipClear(pipId) {
// A clear carrying a pip_id only clears if it matches the showing overlay.
if (pipId && pipCurrent && pipId !== pipCurrent) return;
const had = !!pipCurrent;
pipTeardown();
if (had) pipReport('info', 'pip cleared' + (pipId ? ' ' + pipId : ''));
}
function register() {
@ -1134,15 +1229,23 @@
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
// Apply orientation
// Apply orientation. #109: the PiP layer gets the SAME transform as the player so a
// corner overlay tracks the visible content (not the physical panel) in every orientation.
if (data.orientation) {
const rotations = { 'landscape': '0deg', 'portrait': '90deg', 'landscape-flipped': '180deg', 'portrait-flipped': '270deg' };
document.getElementById('playerContainer').style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (data.orientation.includes('portrait')) {
document.getElementById('playerContainer').style.transformOrigin = 'center center';
document.getElementById('playerContainer').style.width = '100vh';
document.getElementById('playerContainer').style.height = '100vw';
const portrait = data.orientation.includes('portrait');
[document.getElementById('playerContainer'), document.getElementById('pipContainer')].forEach((el) => {
if (!el) return;
el.style.transform = `rotate(${rotations[data.orientation] || '0deg'})`;
if (portrait) {
el.style.transformOrigin = 'center center';
el.style.width = '100vh';
el.style.height = '100vw';
} else {
el.style.width = '';
el.style.height = '';
}
});
}
// Apply (or clear) wall mode. Force re-render when wall config changes