From 911cd07951a567051085fa15fdc5dc55f8cc4248 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 8 Jun 2026 20:07:23 -0500 Subject: [PATCH] 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) } }