From a36880b1471815a1e4bc951b5be5ce2911411512 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 22 Jun 2026 23:16:29 -0500 Subject: [PATCH] fix: per-item mute round-trip + multi-zone orphan-zone fallback & warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent multi-zone bugs, plus operator-facing warnings, i18n, and regression tests guarding the data contracts. Bug 1 — per-item mute was a no-op end to end: - GET /api/devices/:id dropped the `muted` column from its assignments SELECT, so the dashboard toggle never reflected state (the muted=false case in particular). Column restored to the device payload. - Android player now honours the per-item mute flag for YouTube (initial state + live via the IFrame JS API). Bug 2 — items whose zone_id belongs to a different layout were silently dropped: - Player fallback (web + Android): an orphaned zone_id is recovered into the largest zone instead of vanishing, with telemetry. - server/lib/zone-validate.js is the single source of truth for the orphan rule (zone not in the device's active layout); used by the device payload (per-item `orphan` flag + `active_layout_zones`) and the device list (`orphan_count`). - Assign-time hardening: a stale zone_id (not in the device's active layout) is cleared to null on POST/PUT rather than persisted as a new orphan. - scripts/find-orphan-zone-items.js: read-only sweep for existing orphans. Dashboard warnings (operator-facing, never on the live player): - Per-item badge + reassign affordance, device-list glance, preview banner. - Graceful degradation: the zone selector falls back to /api/layouts/:id so it can't vanish on a stale payload. i18n: orphan-zone strings added to en/es/fr/de/pt/it (hi falls back by design; count strings interpolate through tn()). Tests: server/test/device-zone-contract.test.js adds 5 regression tests for the data contracts above (muted true/false round-trip, active_layout_zones, orphan flag + count, orphan-clears-on-reassign, assign-time clearing). 172/172 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/remotedisplay/player/MainActivity.kt | 2 +- .../player/player/MediaPlayerManager.kt | 39 +++- .../player/player/ZoneManager.kt | 20 ++- .../player/util/WebViewSupport.kt | 12 +- frontend/js/i18n/de.js | 5 + frontend/js/i18n/en.js | 5 + frontend/js/i18n/es.js | 5 + frontend/js/i18n/fr.js | 5 + frontend/js/i18n/it.js | 5 + frontend/js/i18n/pt.js | 5 + frontend/js/views/dashboard.js | 5 +- frontend/js/views/device-detail.js | 88 ++++++---- scripts/find-orphan-zone-items.js | 66 +++++++ server/lib/zone-validate.js | 51 ++++++ server/player/index.html | 66 ++++++- server/routes/assignments.js | 35 +++- server/routes/devices.js | 18 +- server/routes/layouts.js | 15 +- server/test/device-zone-contract.test.js | 166 ++++++++++++++++++ 19 files changed, 554 insertions(+), 59 deletions(-) create mode 100644 scripts/find-orphan-zone-items.js create mode 100644 server/lib/zone-validate.js create mode 100644 server/test/device-zone-contract.test.js 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 9c61f6b..43893f3 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -657,7 +657,7 @@ class MainActivity : AppCompatActivity() { // YouTube content - play in WebView if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) { Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}") - mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec) + mediaPlayer.playYoutube(item.remoteUrl!!, item.durationSec, item.muted) wsService?.sendPlaybackState(item.contentId, 0f) return } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt index 6bbede9..ee47535 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt @@ -50,9 +50,14 @@ class MediaPlayerManager( } } - fun playYoutube(embedUrl: String, durationSec: Int = 0) { - Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl") + // #129: remembered so the live device:mute-changed toggle knows YouTube's current + // state and the IFrame API bridge can flip it without reloading the embed. + private var youtubeMuted = false + + fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) { + Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)") currentType = MediaType.YOUTUBE + youtubeMuted = muted || wallMute playerView.visibility = android.view.View.GONE imageView.visibility = android.view.View.GONE @@ -64,12 +69,25 @@ class MediaPlayerManager( com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube") setBackgroundColor(android.graphics.Color.BLACK) // Load via an embed wrapper with a valid youtube.com origin (Error 153 fix). - val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl) + // #129: initial mute comes from the per-item flag (no longer hardcoded). + val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl, youtubeMuted) if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null) else loadUrl(embedUrl) } } + // #129: live mute for the YouTube embed via the IFrame API postMessage bridge + // (enablejsapi=1 is set on the embed). Avoids a full reload of the player, which + // would restart the video and flicker. Main thread only (WebView access). + private fun setYoutubeMuted(muted: Boolean) { + youtubeMuted = muted + val func = if (muted) "mute" else "unMute" + val js = "(function(){try{var f=document.querySelector('iframe');" + + "if(f&&f.contentWindow){f.contentWindow.postMessage(" + + "JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()" + youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } } + } + // Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the // full-screen WebView; ZoneManager handles widgets in multi-zone layouts. fun showWidget(url: String) { @@ -185,12 +203,17 @@ class MediaPlayerManager( fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true) // #129: live per-item mute. Applies a dashboard mute toggle to the CURRENTLY playing - // video in real time (decoupled from a playlist reload). Only video carries audio here - // — YouTube embeds autoplay muted and images/widgets are silent — so this targets the - // ExoPlayer volume. Persistence across the next play comes from the playlist payload's - // per-item `muted` (honored in playVideo). Main thread only. + // item in real time (decoupled from a playlist reload). Native video -> ExoPlayer + // volume; YouTube -> the IFrame API mute()/unMute() bridge (setYoutubeMuted), which + // previously this method ignored so YouTube could never be un/muted live. Images/ + // widgets are silent. Persistence across the next play comes from the playlist + // payload's per-item `muted` (honored in playVideo/playYoutube). Main thread only. fun setVideoMuted(muted: Boolean) { - if (currentType == MediaType.VIDEO) exoPlayer?.volume = if (muted) 0f else 1f + when (currentType) { + MediaType.VIDEO -> exoPlayer?.volume = if (muted) 0f else 1f + MediaType.YOUTUBE -> setYoutubeMuted(muted) // #129: was a no-op for YouTube + else -> {} + } } // ---- Video-wall (wall:sync) accessors. All must be called on the main thread. ---- diff --git a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt index 37f1801..260ba7a 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt @@ -97,10 +97,24 @@ class ZoneManager( } // Group assignments by zone_id, ordered by sort_order so rotation is stable. + // Zone-orphan fallback: an item whose zone_id isn't a zone in the ACTIVE layout + // (assigned under a different layout, or the layout was duplicated/switched — copies + // get fresh zone ids) would otherwise be SILENTLY DROPPED: its bucket matches no + // rendered zone. Re-bucket it into the LARGEST-area zone's rotation so it shares + // screen time there (one item at a time -> never overlays existing content) and warn + // via DebugLog (mirrored to the dashboard device-log when debug is on). + val validZoneIds = zones.map { it.id }.toHashSet() + val fallbackZone = zones.maxByOrNull { it.widthPercent * it.heightPercent } val assignmentsByZone = mutableMapOf>() for (i in 0 until assignments.length()) { val a = assignments.getJSONObject(i) - val zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null) + var zoneId = if (a.isNull("zone_id")) null else a.optString("zone_id", null) + if (zoneId != null && !validZoneIds.contains(zoneId) && fallbackZone != null) { + val item = a.optString("filename", "").ifEmpty { a.optString("content_id", a.optString("widget_id", "?")) } + com.remotedisplay.player.util.DebugLog.w("Zone", + "orphan zone_id=$zoneId item=$item -> fallback zone '${fallbackZone.name}'") + zoneId = fallbackZone.id + } assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a) } assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } } @@ -199,7 +213,9 @@ class ZoneManager( // YouTube - render via an embed wrapper with a valid origin (Error 153 fix) mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> { val webView = createWebView() - val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl) + // #129: initial mute from the per-item flag (was hardcoded mute=1). Live + // per-zone mute isn't wired (device:mute-changed targets the fullscreen item). + val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(remoteUrl, isMuted) if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.EMBED_BASE, html, "text/html", "UTF-8", null) else webView.loadUrl(remoteUrl) webView.layoutParams = params diff --git a/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt b/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt index cadc9bf..cb7baac 100644 --- a/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt +++ b/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt @@ -74,10 +74,18 @@ object WebViewSupport { * HTML wrapper for a YouTube embed. Loaded via loadDataWithBaseURL(YT_BASE, ...) * so the iframe has a valid youtube.com origin/referer (a bare loadUrl of the * embed gives Error 153 "player misconfigured"). Returns null if no video id. + * + * #129: the initial mute now comes from the per-item [muted] flag (was hardcoded + * mute=1, which made YouTube un-unmuteable). The WebView sets + * mediaPlaybackRequiresUserGesture=false, so mute=0 still autoplays WITH audio. + * enablejsapi=1 lets the live device:mute-changed toggle drive the player via the + * IFrame API postMessage bridge (MediaPlayerManager.setYoutubeMuted) without a + * flicker-y reload. */ - fun youtubeEmbedHtml(url: String): String? { + fun youtubeEmbedHtml(url: String, muted: Boolean = true): String? { val id = extractYoutubeId(url) ?: return null - val src = "$YT_BASE/embed/$id?autoplay=1&mute=1&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1" + val mute = if (muted) 1 else 0 + val src = "$YT_BASE/embed/$id?autoplay=1&mute=$mute&controls=0&rel=0&modestbranding=1&loop=1&playlist=$id&playsinline=1&enablejsapi=1" return "" + "" + "" diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index a4bc9cf..9e25d48 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -2,6 +2,11 @@ // standard for B2B software in DACH). Native review recommended before // publicizing as fully supported. export default { + // #zone-orphan dashboard warnings + 'device.pl_item.orphan_zone': 'Zone aus einem anderen Layout — neu zuweisen', + 'device.pl_item.orphan_zone_tip': 'Die Zone dieses Elements gehört nicht zum aktuellen Layout des Geräts. Es wird weiterhin abgespielt (in die größte Zone verschoben), sollte aber einer Zone dieses Layouts neu zugewiesen werden.', + 'dashboard.device_orphan_tip_one': '{n} Element ist einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen', + 'dashboard.device_orphan_tip_other': '{n} Elemente sind einer Zone zugewiesen, die nicht im Layout dieses Geräts enthalten ist — zum Neuzuweisen das Gerät öffnen', // Nav 'nav.displays': 'Bildschirme', 'nav.content': 'Inhalt', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 2fd3df9..ab0a6b2 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1,6 +1,11 @@ // English translations. This file is the source of truth for keys — // every other locale should mirror its keys (or fall back to en). export default { + // #zone-orphan dashboard warnings + 'device.pl_item.orphan_zone': 'Zone from a different layout — reassign', + 'device.pl_item.orphan_zone_tip': "This item's zone isn't part of the device's current layout. It still plays (recovered into the largest zone), but reassign it to a zone in this layout.", + 'dashboard.device_orphan_tip_one': "{n} item assigned to a zone that isn't in this device's layout — open the device to reassign", + 'dashboard.device_orphan_tip_other': "{n} items assigned to a zone that isn't in this device's layout — open the device to reassign", // Nav (sidebar) 'nav.displays': 'Displays', 'nav.content': 'Content', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 9f2f73d..edd7bc3 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -1,6 +1,11 @@ // Spanish translations. Reviewed for UI register (informal tú). // Native review still recommended before publicizing as fully supported. export default { + // #zone-orphan dashboard warnings + 'device.pl_item.orphan_zone': 'Zona de otro diseño — reasignar', + 'device.pl_item.orphan_zone_tip': 'La zona de este elemento no pertenece al diseño actual del dispositivo. Se sigue reproduciendo (recuperado en la zona más grande), pero reasígnalo a una zona de este diseño.', + 'dashboard.device_orphan_tip_one': '{n} elemento asignado a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar', + 'dashboard.device_orphan_tip_other': '{n} elementos asignados a una zona que no está en el diseño de este dispositivo — abre el dispositivo para reasignar', // Nav 'nav.displays': 'Pantallas', 'nav.content': 'Contenido', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index bd15d2d..6eda700 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -2,6 +2,11 @@ // standard for software UIs in France; tu would feel underdressed for a B2B tool). // Native review recommended before publicizing as fully supported. export default { + // #zone-orphan dashboard warnings + 'device.pl_item.orphan_zone': 'Zone d\'une autre mise en page — réattribuer', + 'device.pl_item.orphan_zone_tip': 'La zone de cet élément ne fait pas partie de la mise en page actuelle de l\'appareil. Il continue de s\'afficher (récupéré dans la plus grande zone), mais réattribuez-le à une zone de cette mise en page.', + 'dashboard.device_orphan_tip_one': '{n} élément attribué à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour le réattribuer', + 'dashboard.device_orphan_tip_other': '{n} éléments attribués à une zone absente de la mise en page de cet appareil — ouvrez l\'appareil pour les réattribuer', // Nav 'nav.displays': 'Écrans', 'nav.content': 'Contenu', diff --git a/frontend/js/i18n/it.js b/frontend/js/i18n/it.js index e2058a6..3f9d7c4 100644 --- a/frontend/js/i18n/it.js +++ b/frontend/js/i18n/it.js @@ -1,6 +1,11 @@ // Italian translations. This file is the source of truth for keys — // every other locale should mirror its keys (or fall back to en). export default { + // #zone-orphan dashboard warnings + 'device.pl_item.orphan_zone': 'Zona di un altro layout — riassegna', + 'device.pl_item.orphan_zone_tip': 'La zona di questo elemento non fa parte del layout attuale del dispositivo. Continua a essere riprodotto (recuperato nella zona più grande), ma riassegnalo a una zona di questo layout.', + 'dashboard.device_orphan_tip_one': '{n} elemento assegnato a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarlo', + 'dashboard.device_orphan_tip_other': '{n} elementi assegnati a una zona non presente nel layout di questo dispositivo — apri il dispositivo per riassegnarli', // Nav (sidebar) 'nav.displays': 'Schermi', 'nav.content': 'Contenuti', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index a960e08..5dfde5f 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -2,6 +2,11 @@ // Reviewed for UI register (informal você). Native review recommended before // publicizing as fully supported. export default { + // #zone-orphan dashboard warnings + 'device.pl_item.orphan_zone': 'Zona de outro layout — reatribuir', + 'device.pl_item.orphan_zone_tip': 'A zona deste item não faz parte do layout atual do dispositivo. Ele continua sendo reproduzido (recuperado na maior zona), mas reatribua-o a uma zona deste layout.', + 'dashboard.device_orphan_tip_one': '{n} item atribuído a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir', + 'dashboard.device_orphan_tip_other': '{n} itens atribuídos a uma zona que não está no layout deste dispositivo — abra o dispositivo para reatribuir', // Nav 'nav.displays': 'Telas', 'nav.content': 'Conteúdo', diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 52f2e2f..06a65b1 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -114,7 +114,10 @@ function renderDeviceCard(device) {
-
${esc(device.name)}
+
${esc(device.name)}${device.orphan_count > 0 ? ` + + ${device.orphan_count} + ` : ''}
${device.owner_name || device.owner_email ? `
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 70e2eb9..329c148 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -580,6 +580,10 @@ function renderPlaylist(assignments) { ${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''} ${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
+ ${a.orphan ? `
+ + ${t('device.pl_item.orphan_zone')} +
` : ''}