Silence a backgrounded player, and rebuild zones when a layout is edited in place

Two more from #234, both Android-only.

1. "I closed the app and I can still hear the sound." Nothing in the Android lifecycle pauses a
   WebView, and MainActivity had no onStop at all, so a YouTube embed kept playing with the app in
   the background and the panel kept making noise with the app apparently closed. onStop rather
   than onPause: onPause also fires for a transient dialog or a permission prompt, and pausing
   playback for those would be a visible stutter on a wall. Pauses via the IFrame-API bridge that
   already exists for live mute, so returning to the foreground resumes in place instead of
   restarting the clip.

2. "I added 4 zones and they dont appear on the screen. I had 3 zones before and they appeared."
   The zone rebuild fired only when the layout ID changed. Editing a layout in place keeps its id,
   so setupZones never ran: the geometry stayed at three zones and only the assignments
   re-rendered into the old ones, which is why it took a force-stop to appear. The rebuild now also
   triggers on a signature of the zones themselves (id, position, size, z-index, type, fit).

Compiles clean; NOT yet verified on hardware — both need a device to prove, unlike the audio-on-
item-switch fix which was measured before and after on an emulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
Claude 2026-07-30 19:48:31 -05:00
parent 4cc750ba3a
commit abdb3b434d
3 changed files with 64 additions and 2 deletions

View file

@ -575,8 +575,21 @@ class MainActivity : AppCompatActivity() {
}.sorted().joinToString("|") }.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig val changed = assignmentSig != zoneManager?.lastAssignmentSig
// The ZONES themselves can change without the layout id changing — editing a layout
// in place (adding a 4th zone to a 3-zone layout) keeps the same id. Rebuilding only
// on an id change meant the new zone never appeared: the geometry stayed as it was
// and only the assignments re-rendered into the OLD zones, so the change looked like
// it had been ignored until the app was force-stopped. Reported on #234.
val zoneSig = (0 until layoutZones.length()).map { i ->
val z = layoutZones.getJSONObject(i)
"${z.optString("id")}:${z.optDouble("x_percent", -1.0)}:${z.optDouble("y_percent", -1.0)}:" +
"${z.optDouble("width_percent", -1.0)}:${z.optDouble("height_percent", -1.0)}:" +
"${z.optInt("z_index", 0)}:${z.optString("zone_type")}:${z.optString("fit_mode")}"
}.sorted().joinToString("|")
val zonesChanged = zoneSig != zoneManager?.lastZoneSig
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments") com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) { if (zoneManager?.hasZones() != true || layoutId != currentLayoutId || zonesChanged) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)") Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post { handler.post {
hideStatus() hideStatus()
@ -587,6 +600,7 @@ class MainActivity : AppCompatActivity() {
zoneManager?.setupZones(layoutZones, layoutId) zoneManager?.setupZones(layoutZones, layoutId)
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId) zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId)
zoneManager?.lastAssignmentSig = assignmentSig zoneManager?.lastAssignmentSig = assignmentSig
zoneManager?.lastZoneSig = zoneSig
} }
} else if (changed) { } else if (changed) {
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering") Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
@ -1287,6 +1301,22 @@ class MainActivity : AppCompatActivity() {
) )
} }
/**
* Nothing in the Android lifecycle pauses a WebView, so a YouTube embed kept playing with the
* app in the background and the panel kept making noise with the app "closed". Reported on
* #234. onStop (not onPause) is the right hook: onPause also fires for a transient dialog or a
* permission prompt, and pausing playback for those would be a visible stutter on a wall.
*/
override fun onStop() {
super.onStop()
if (::mediaPlayer.isInitialized) mediaPlayer.onAppBackgrounded()
}
override fun onStart() {
super.onStart()
if (::mediaPlayer.isInitialized) mediaPlayer.onAppForegrounded()
}
override fun onDestroy() { override fun onDestroy() {
remoteStreaming = false remoteStreaming = false
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper // Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper

View file

@ -209,13 +209,42 @@ class MediaPlayerManager(
// would restart the video and flicker. Main thread only (WebView access). // would restart the video and flicker. Main thread only (WebView access).
private fun setYoutubeMuted(muted: Boolean) { private fun setYoutubeMuted(muted: Boolean) {
youtubeMuted = muted youtubeMuted = muted
val func = if (muted) "mute" else "unMute" postYoutubeCommand(if (muted) "mute" else "unMute")
}
/** Send one IFrame-API command to the embed. Main thread only (WebView access). */
private fun postYoutubeCommand(func: String) {
val js = "(function(){try{var f=document.querySelector('iframe');" + val js = "(function(){try{var f=document.querySelector('iframe');" +
"if(f&&f.contentWindow){f.contentWindow.postMessage(" + "if(f&&f.contentWindow){f.contentWindow.postMessage(" +
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()" "JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } } youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
} }
/**
* The app is going to the background. Stop making noise.
*
* A WebView keeps running when its Activity stops nothing in the lifecycle pauses it so a
* YouTube embed carried on playing with the app closed and the audio kept coming out of the
* panel: "I closed the app and I can still hear the sound... I force stop the app and then open
* again." A signage player that is not on screen must be silent.
*
* Pause rather than blank, so returning to the foreground resumes in place instead of
* restarting the clip. pauseTimers() is process-wide, which is fine here (one WebView) and is
* what actually stops the embed's own scripted playback.
*/
fun onAppBackgrounded() {
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("pauseVideo")
youtubeWebView?.let { wv -> wv.post { try { wv.onPause(); wv.pauseTimers() } catch (_: Throwable) {} } }
exoPlayer?.pause()
}
/** Back in the foreground: undo onAppBackgrounded. */
fun onAppForegrounded() {
youtubeWebView?.let { wv -> wv.post { try { wv.resumeTimers(); wv.onResume() } catch (_: Throwable) {} } }
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("playVideo")
if (currentType == MediaType.VIDEO) exoPlayer?.play()
}
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the // Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts. // full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
fun showWidget(url: String) { fun showWidget(url: String) {

View file

@ -51,6 +51,9 @@ class ZoneManager(
var currentLayoutId: String? = null var currentLayoutId: String? = null
private set private set
var lastAssignmentSig: String? = null var lastAssignmentSig: String? = null
// Geometry of the zones currently built. Editing a layout in place keeps its id, so the id
// alone cannot tell "same layout, same zones" from "same layout, zones changed".
var lastZoneSig: String? = null
// #74/#75: device-effective IANA timezone for per-item schedule evaluation. // #74/#75: device-effective IANA timezone for per-item schedule evaluation.
@Volatile private var effectiveTimezone: String? = null @Volatile private var effectiveTimezone: String? = null