From 911cd07951a567051085fa15fdc5dc55f8cc4248 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 20:07:23 -0500 Subject: [PATCH 1/7] fix(android): render widgets in fullscreen / single-zone layouts Widgets worked in multi-zone layouts (ZoneManager renders them in a WebView) but were broken in "default fullscreen" (no layout) and the fullscreen template (a single-zone layout) - both take the single-zone PlaylistController path, which: 1) called getString("content_id"), throwing on a widget assignment (no content_id) - in both the playlist builder AND the pre-download loop, which could break the whole fullscreen playlist; and 2) had no widget render case in playItem (so a widget never displayed). Fix: - PlaylistItem gains widgetId/widgetType + isWidget; the builder reads them and tolerates a missing content_id. - playItem renders a widget fullscreen via MediaPlayerManager.showWidget() (loads /api/widgets/:id/render in the full-screen WebView, mirroring ZoneManager). - Widgets auto-advance on their duration like images. - Pre-download loop skips widget assignments (no file to fetch). Compile-checked; signed APK builds. Needs on-device check: a widget plays in default-fullscreen and the fullscreen template, and mixed widget+media playlists advance correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/remotedisplay/player/MainActivity.kt | 18 ++++++++++++- .../player/player/MediaPlayerManager.kt | 25 ++++++++++++++++++- .../player/player/PlaylistController.kt | 25 +++++++++++++------ 3 files changed, 58 insertions(+), 10 deletions(-) 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 357c399..884369a 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -260,7 +260,12 @@ class MainActivity : AppCompatActivity() { thread { for (i in 0 until assignments.length()) { val item = assignments.getJSONObject(i) - val contentId = item.getString("content_id") + // Widget assignments have no downloadable content file - skip + // (also avoids getString throwing on a null content_id). + val widgetId = if (item.isNull("widget_id")) "" else item.optString("widget_id", "") + if (widgetId.isNotEmpty()) continue + val contentId = if (item.isNull("content_id")) "" else item.optString("content_id", "") + if (contentId.isEmpty()) continue val filename = item.optString("filename", "content") val remoteUrl = item.optString("remote_url", null) @@ -416,6 +421,17 @@ class MainActivity : AppCompatActivity() { private fun playItem(item: PlaylistItem) { hideStatus() + // Widget content - render fullscreen in a WebView (single-zone / fullscreen + // layouts; multi-zone widgets go through ZoneManager). Previously unhandled, + // so widgets were blank/broken in default-fullscreen and the fullscreen template. + if (item.isWidget) { + val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" + Log.i("MainActivity", "Playing widget fullscreen: $url") + mediaPlayer.showWidget(url) + wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f) + return + } + // YouTube content - play in WebView if (item.mimeType == "video/youtube" && !item.remoteUrl.isNullOrEmpty()) { Log.i("MainActivity", "Playing YouTube: ${item.remoteUrl}") 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 47acc81..ecd7ff0 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 @@ -25,7 +25,7 @@ class MediaPlayerManager( private var exoPlayer: ExoPlayer? = null private var currentType: MediaType = MediaType.NONE - enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE } + enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE, WIDGET } init { setupExoPlayer() @@ -65,6 +65,29 @@ class MediaPlayerManager( } } + // Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the + // full-screen WebView; ZoneManager handles widgets in multi-zone layouts. + fun showWidget(url: String) { + Log.i("MediaPlayerManager", "Showing widget: $url") + currentType = MediaType.WIDGET + + playerView.visibility = android.view.View.GONE + imageView.visibility = android.view.View.GONE + youtubeWebView?.visibility = android.view.View.VISIBLE + + exoPlayer?.stop() + + youtubeWebView?.apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.mediaPlaybackRequiresUserGesture = false + webViewClient = WebViewClient() + webChromeClient = WebChromeClient() + setBackgroundColor(android.graphics.Color.TRANSPARENT) + loadUrl(url) + } + } + fun playVideoFromUrl(url: String, muted: Boolean = false) { Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)") currentType = MediaType.VIDEO diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index f24f902..4bea5a9 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -17,9 +17,13 @@ data class PlaylistItem( val sortOrder: Int, val enabled: Boolean = true, val remoteUrl: String? = null, - val muted: Boolean = false + val muted: Boolean = false, + val widgetId: String? = null, + val widgetType: String? = null ) { val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty() + // Widget assignments have a widget_id and no downloadable content file. + val isWidget: Boolean get() = !widgetId.isNullOrEmpty() } class PlaylistController( @@ -51,7 +55,8 @@ class PlaylistController( newItems.add( PlaylistItem( assignmentId = obj.optInt("id", 0), - contentId = obj.getString("content_id"), + // Tolerant: widget assignments have no content_id (getString threw). + contentId = if (obj.isNull("content_id")) "" else obj.optString("content_id", ""), filename = obj.optString("filename", "unknown"), mimeType = obj.optString("mime_type", "video/mp4"), filepath = obj.optString("filepath", ""), @@ -60,14 +65,17 @@ class PlaylistController( sortOrder = obj.optInt("sort_order", 0), enabled = obj.optInt("enabled", 1) == 1, remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null }, - muted = obj.optInt("muted", 0) == 1 + muted = obj.optInt("muted", 0) == 1, + widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null }, + widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null } ) ) } - // Check if playlist actually changed - val oldContentIds = items.map { it.contentId } - val newContentIds = newItems.map { it.contentId } + // Check if playlist actually changed (key on content OR widget id, since + // widget items share an empty contentId). + val oldContentIds = items.map { it.contentId + "|" + (it.widgetId ?: "") } + val newContentIds = newItems.map { it.contentId + "|" + (it.widgetId ?: "") } val playlistChanged = oldContentIds != newContentIds if (!playlistChanged && items.isNotEmpty()) { @@ -169,8 +177,9 @@ class PlaylistController( Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)") onItemChanged(item) - // For images, auto-advance after duration. For videos, wait for completion callback. - if (item.mimeType.startsWith("image/")) { + // For images and widgets, auto-advance after duration. For videos, wait + // for the completion callback. + if (item.mimeType.startsWith("image/") || item.isWidget) { scheduleAdvance(item.durationSec * 1000L) } } From c7bbc4f8152a3400a441049de4c7dc07e59784de Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 20:34:30 -0500 Subject: [PATCH 2/7] fix(android): ZoneManager.cleanup must not remove the activity's static views The black-screen on fullscreen widgets (and any single-zone playback after using a multi-zone layout) was here: cleanup() called container.removeAllViews(), but `container` is the activity root that also holds the static playerView/imageView/ youtubeWebView/statusOverlay. Removing them detached the WebView that the fullscreen widget path reuses -> black. Remove only the zone views we added. --- .../java/com/remotedisplay/player/player/ZoneManager.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 0615fa0..73ddb35 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 @@ -235,7 +235,11 @@ class ZoneManager( fun cleanup() { releaseExoPlayers() - container.removeAllViews() + // Remove ONLY the views we added for zones. `container` is the activity's + // root, which also holds the static playerView/imageView/youtubeWebView/ + // statusOverlay - removeAllViews() would detach those, so single-zone / + // fullscreen playback (which reuses them) rendered black after using zones. + zoneViews.values.forEach { container.removeView(it) } zoneViews.clear() zones = listOf() } From 50d7dbe22202c2afe676eeb3c7892673c14cbd66 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 21:31:27 -0500 Subject: [PATCH 3/7] fix(player): zone reset on multi->single layout switch + don't blank multi-zone - Server (deviceSocket buildPlaylistPayload): when a device's layout has <2 zones (single or none), strip leftover zone_id from assignments. After switching a device from multi-zone back to fullscreen, content was stuck bound to a gone left/right zone_id and never played; nulling it lets both players fall back to the default fullscreen renderer. - Web player: render multi-zone zones BEFORE the single-item 'renderable?' bail, so an empty/placeholder current rotation item can't blank the whole screen. --- server/player/index.html | 8 ++++++++ server/ws/deviceSocket.js | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/server/player/index.html b/server/player/index.html index 11c5815..91d1646 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -1320,6 +1320,14 @@ const container = document.getElementById('playerContainer'); container.style.display = 'block'; + // Multi-zone: each zone pulls its own content by zone_id, independent of the + // rotating "current item". Render zones here (before the single-item bail) so + // an empty/placeholder current item can't blank the whole multi-zone screen. + if (layout && layout.zones && layout.zones.length > 1 && !wallConfig) { + renderZones(container, item); + return; + } + // Defense in depth: bail to waiting state on missing/malformed item rather // than fall through every branch and leave a blank container. const hasRenderableType = item && ( diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index ec1900f..7ef1cff 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -154,6 +154,16 @@ function buildPlaylistPayload(deviceId) { } } + // Zone reset: if the device isn't in a real multi-zone layout (single zone or + // no layout), strip any leftover zone_id from assignments. Otherwise, after + // switching a device from a multi-zone layout back to single/fullscreen, the + // content stays bound to a now-gone left/right zone_id and never plays. With + // zone_id nulled, both players fall back to the default fullscreen renderer. + const zoneCount = layout?.zones?.length || 0; + if (zoneCount < 2 && Array.isArray(assignments)) { + assignments = assignments.map(a => (a && a.zone_id != null ? { ...a, zone_id: null } : a)); + } + return { assignments, layout, orientation: device?.orientation || 'landscape', wall_config }; } From 73912d5f58587e18d41d4416335a7ef7c1415794 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 21:49:03 -0500 Subject: [PATCH 4/7] feat(debug): live per-device debug logging toggle on the device screen Checkbox on the device-detail page streams the Android player's player/zone logs live (no adb). Transient (off on reconnect), not persisted. - Android: DebugLog util (logcat + optional socket emit); 'set_debug' command wires the sink + flag; key player/zone decisions (layout mode, playItem, per-zone render) emit through it. - Server: relay device:log -> dashboard workspace room as dashboard:device-log. - Dashboard: 'Debug logging' checkbox sends set_debug; live log panel streams lines (rendered via textContent; capped at 500). --- .../com/remotedisplay/player/MainActivity.kt | 3 ++ .../player/player/ZoneManager.kt | 1 + .../player/service/WebSocketService.kt | 15 ++++++++ .../com/remotedisplay/player/util/DebugLog.kt | 25 +++++++++++++ frontend/js/i18n/en.js | 2 ++ frontend/js/socket.js | 6 ++++ frontend/js/views/device-detail.js | 35 +++++++++++++++++++ server/ws/deviceSocket.js | 16 +++++++++ 8 files changed, 103 insertions(+) create mode 100644 android/app/src/main/java/com/remotedisplay/player/util/DebugLog.kt 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 884369a..93d00ac 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -229,6 +229,7 @@ class MainActivity : AppCompatActivity() { }.sorted().joinToString("|") val changed = assignmentSig != zoneManager?.lastAssignmentSig + com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments") if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) { Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)") handler.post { @@ -252,6 +253,7 @@ class MainActivity : AppCompatActivity() { } } else { // Single-zone mode - use PlaylistController (existing behavior) + com.remotedisplay.player.util.DebugLog.i("Player", "Layout: SINGLE/FULLSCREEN (${layoutZones?.length() ?: 0} zones), ${assignments.length()} assignments") if (zoneManager?.hasZones() == true) handler.post { zoneManager?.cleanup() } playlistController.updatePlaylist(assignments) } @@ -420,6 +422,7 @@ class MainActivity : AppCompatActivity() { private fun playItem(item: PlaylistItem) { hideStatus() + com.remotedisplay.player.util.DebugLog.i("Player", "playItem: ${item.filename} mime=${item.mimeType} widget=${item.widgetId ?: "-"} zone=fullscreen") // Widget content - render fullscreen in a WebView (single-zone / fullscreen // layouts; multi-zone widgets go through ZoneManager). Previously unhandled, 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 73ddb35..c98892c 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 @@ -117,6 +117,7 @@ class ZoneManager( val widgetConfig = if (firstAssignment.isNull("widget_config")) null else firstAssignment.optString("widget_config", null) val contentId = if (firstAssignment.isNull("content_id")) null else firstAssignment.optString("content_id", null) val filepath = firstAssignment.optString("filepath", "") + com.remotedisplay.player.util.DebugLog.i("Zone", "Zone '${zone.name}' (${zone.widthPercent.toInt()}x${zone.heightPercent.toInt()}%) -> ${widgetType?.let { "widget:$it" } ?: mimeType.ifEmpty { "empty" }}") val isMuted = firstAssignment.optInt("muted", 0) == 1 when { 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 1574556..225116f 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 @@ -269,6 +269,21 @@ class WebSocketService : Service() { "screen_on" -> { Thread { try { Runtime.getRuntime().exec(arrayOf("input", "keyevent", "224")).waitFor() } catch (_: Exception) {} }.start() } + "set_debug" -> { + val on = payload?.optBoolean("enabled", false) ?: false + // Point the sink at this socket, then flip the flag. When on, + // DebugLog.* mirrors player/zone lines to the dashboard. + com.remotedisplay.player.util.DebugLog.sink = { tag, level, msg -> + try { + socket?.emit("device:log", JSONObject().apply { + put("tag", tag); put("level", level); put("message", msg) + }) + } catch (_: Throwable) {} + } + com.remotedisplay.player.util.DebugLog.enabled = on + Log.i("WebSocketService", "Remote debug logging ${if (on) "ENABLED" else "disabled"}") + com.remotedisplay.player.util.DebugLog.i("Debug", "Remote debug logging ${if (on) "ON" else "OFF"}") + } else -> handler.post { try { onCommand?.invoke(type, payload) } catch (e: Throwable) { Log.e("WebSocketService", "onCommand cb: ${e.message}") } } } } diff --git a/android/app/src/main/java/com/remotedisplay/player/util/DebugLog.kt b/android/app/src/main/java/com/remotedisplay/player/util/DebugLog.kt new file mode 100644 index 0000000..7b105af --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/util/DebugLog.kt @@ -0,0 +1,25 @@ +package com.remotedisplay.player.util + +import android.util.Log + +/** + * Lightweight player debug logger. Always writes to logcat; when remote debug is + * enabled (toggled from the dashboard device-detail screen via a `set_debug` + * command), it ALSO streams the line to the server over the device socket so it + * can be watched live without adb. Off by default; no network when disabled. + */ +object DebugLog { + @Volatile var enabled = false + // Set by WebSocketService: (tag, level, message) -> emit over the device socket. + @Volatile var sink: ((String, String, String) -> Unit)? = null + + fun d(tag: String, msg: String) { Log.d(tag, msg); send(tag, "d", msg) } + fun i(tag: String, msg: String) { Log.i(tag, msg); send(tag, "i", msg) } + fun w(tag: String, msg: String) { Log.w(tag, msg); send(tag, "w", msg) } + fun e(tag: String, msg: String) { Log.e(tag, msg); send(tag, "e", msg) } + + private fun send(tag: String, level: String, msg: String) { + if (!enabled) return + try { sink?.invoke(tag, level, msg) } catch (_: Throwable) {} + } +} diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 9c0cb00..9e665b2 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -286,6 +286,8 @@ export default { 'device.form.default_content_none': 'None (show "Waiting...")', 'device.form.notes_label': 'Notes', 'device.form.notes_placeholder': 'Location, setup details, etc.', + 'device.debug.toggle': 'Debug logging (live)', + 'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.', 'device.form.save_settings': 'Save Settings', // Control buttons 'device.ctl.reboot_device': 'Reboot Device', diff --git a/frontend/js/socket.js b/frontend/js/socket.js index f961bd4..c1a2163 100644 --- a/frontend/js/socket.js +++ b/frontend/js/socket.js @@ -52,6 +52,12 @@ export function connectSocket() { emit('playback-state', data); }); + // Live device debug log line (device-detail screen streams these when the + // per-device "Debug logging" checkbox is on). + dashboardSocket.on('dashboard:device-log', (data) => { + emit('device-log', data); + }); + // Playback progress (play_start with duration — drives device-card progress bars) dashboardSocket.on('dashboard:playback-progress', (data) => { emit('playback-progress', data); diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 07d4436..b79a93c 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -8,6 +8,7 @@ let currentDevice = null; let statusHandler = null; let screenshotHandler = null; let playbackHandler = null; +let logHandler = null; let screenshotInterval = null; let remoteActive = false; @@ -99,9 +100,24 @@ export function render(container, deviceId) { } }; + // Live debug log lines streamed from the device (when the Debug logging + // checkbox is on). Appended via textContent — no HTML injection. + logHandler = (data) => { + if (data.device_id !== deviceId) return; + const panel = document.getElementById('debugLogPanel'); + if (!panel) return; + const line = document.createElement('div'); + const time = new Date(data.ts || Date.now()).toLocaleTimeString(); + line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`; + panel.appendChild(line); + while (panel.childElementCount > 500) panel.removeChild(panel.firstChild); + panel.scrollTop = panel.scrollHeight; + }; + on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('playback-state', playbackHandler); + on('device-log', logHandler); } async function loadDevice(deviceId, activeTab = null) { @@ -324,6 +340,15 @@ async function loadDevice(deviceId, activeTab = null) { + +
+ +
${t('device.debug.hint')}
+ +
+