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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-08 20:07:23 -05:00
parent 50ad1f670b
commit 911cd07951
3 changed files with 58 additions and 10 deletions

View file

@ -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}")

View file

@ -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

View file

@ -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)
}
}