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..f09447c 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) } @@ -260,7 +262,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) @@ -287,9 +294,12 @@ class MainActivity : AppCompatActivity() { } } - // Start or resume playback after downloads complete + // Start or resume playback after downloads complete — but ONLY in + // single-zone/fullscreen mode. In multi-zone, ZoneManager drives each + // zone; restarting the fullscreen controller here made it keep playing + // items behind the zones (wasted work + phantom audio for videos). handler.post { - playlistController.startIfNeeded() + if (zoneManager?.hasZones() != true) playlistController.startIfNeeded() } } } // end else (not suspended) @@ -415,6 +425,18 @@ 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, + // 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()) { 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..eec1910 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() @@ -55,13 +55,30 @@ class MediaPlayerManager( exoPlayer?.stop() youtubeWebView?.apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.mediaPlaybackRequiresUserGesture = false - webViewClient = WebViewClient() - webChromeClient = WebChromeClient() + com.remotedisplay.player.util.WebViewSupport.configure(this, "YouTube") setBackgroundColor(android.graphics.Color.BLACK) - loadUrl(embedUrl) + // Load via an embed wrapper with a valid youtube.com origin (Error 153 fix). + val html = com.remotedisplay.player.util.WebViewSupport.youtubeEmbedHtml(embedUrl) + if (html != null) loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.YT_BASE, html, "text/html", "UTF-8", null) + else loadUrl(embedUrl) + } + } + + // 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 { + com.remotedisplay.player.util.WebViewSupport.configure(this, "Widget") + loadUrl(url) } } 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) } } 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..68c435b 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 @@ -2,6 +2,8 @@ package com.remotedisplay.player.player import android.content.Context import android.net.Uri +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.View import android.view.ViewGroup @@ -35,11 +37,15 @@ class ZoneManager( private val onAllVideosComplete: () -> Unit ) { private val TAG = "ZoneManager" + private val handler = Handler(Looper.getMainLooper()) private val zoneViews = mutableMapOf() private val zoneExoPlayers = mutableMapOf() + // Per-zone rotation timers: each zone cycles its own list of assignments. + private val zoneRotators = mutableMapOf() private var zones = listOf() - private var activeVideoCount = 0 - private var completedVideoCount = 0 + // Render context kept for rotation re-renders. + private var renderServerUrl = "" + private var renderCache: com.remotedisplay.player.data.ContentCache? = null var currentLayoutId: String? = null private set @@ -68,163 +74,181 @@ class ZoneManager( } fun renderAssignments(assignments: JSONArray, serverUrl: String, contentCache: com.remotedisplay.player.data.ContentCache) { - // Clear existing zone views - container.removeAllViews() + // Clear ONLY our own zone views/timers. `container` is the activity root and + // also holds the static playerView/imageView/youtubeWebView/statusOverlay - + // removeAllViews() here would detach those and black the screen on switch-back. + cancelAllRotations() + zoneViews.values.forEach { container.removeView(it) } zoneViews.clear() releaseExoPlayers() - activeVideoCount = 0 - completedVideoCount = 0 + renderServerUrl = serverUrl + renderCache = contentCache val containerWidth = container.width val containerHeight = container.height - if (containerWidth == 0 || containerHeight == 0) { - // Container not laid out yet, post delayed + // Container not laid out yet, retry after layout. container.post { renderAssignments(assignments, serverUrl, contentCache) } return } - // Map assignments by zone_id + // Group assignments by zone_id, ordered by sort_order so rotation is stable. 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) assignmentsByZone.getOrPut(zoneId) { mutableListOf() }.add(a) } + assignmentsByZone.values.forEach { list -> list.sortBy { it.optInt("sort_order", 0) } } - // Render each zone - only show content specifically assigned to this zone - // Unassigned content (zone_id=null) goes to the FIRST zone only + // Unassigned content (zone_id=null) goes to the FIRST zone only. var unassignedUsed = false for (zone in zones.sortedBy { it.zIndex }) { val zoneAssignments: List = assignmentsByZone[zone.id] ?: if (!unassignedUsed) { unassignedUsed = true; assignmentsByZone[null] ?: emptyList() } else emptyList() - val firstAssignment = zoneAssignments.firstOrNull() ?: continue + if (zoneAssignments.isEmpty()) continue - // Calculate pixel position val x = (zone.xPercent / 100f * containerWidth).toInt() val y = (zone.yPercent / 100f * containerHeight).toInt() val w = (zone.widthPercent / 100f * containerWidth).toInt() val h = (zone.heightPercent / 100f * containerHeight).toInt() + val params = FrameLayout.LayoutParams(w, h).apply { leftMargin = x; topMargin = y } - val params = FrameLayout.LayoutParams(w, h).apply { - leftMargin = x - topMargin = y - } - - val mimeType = firstAssignment.optString("mime_type", "") - val remoteUrl = if (firstAssignment.isNull("remote_url")) null else firstAssignment.optString("remote_url", null) - val widgetType = if (firstAssignment.isNull("widget_type")) null else firstAssignment.optString("widget_type", null) - 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", "") - val isMuted = firstAssignment.optInt("muted", 0) == 1 - - when { - // Widget - render in WebView - widgetType != null -> { - val widgetId = firstAssignment.optString("widget_id", "") - val webView = createWebView() - webView.loadUrl("$serverUrl/api/widgets/$widgetId/render") - webView.layoutParams = params - container.addView(webView) - zoneViews[zone.id] = webView - Log.i(TAG, "Zone ${zone.name}: widget $widgetType") - } - - // YouTube - render in WebView - mimeType == "video/youtube" && !remoteUrl.isNullOrEmpty() -> { - val webView = createWebView() - webView.loadUrl(remoteUrl) - webView.layoutParams = params - container.addView(webView) - zoneViews[zone.id] = webView - Log.i(TAG, "Zone ${zone.name}: youtube $remoteUrl") - } - - // Video - mimeType.startsWith("video/") -> { - val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl - else if (contentId != null) contentCache.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() } - ?: "$serverUrl/uploads/content/$filepath" - else continue - - val playerView = (android.view.LayoutInflater.from(context) - .inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply { - useController = false - layoutParams = params - } - val exoPlayer = ExoPlayer.Builder(context).build().apply { - setMediaItem(MediaItem.fromUri(src)) - repeatMode = Player.REPEAT_MODE_ALL - // Use muted flag from assignment, default unmuted for first video - volume = if (isMuted) 0f else 1f - prepare() - playWhenReady = true - } - playerView.player = exoPlayer - container.addView(playerView) - zoneViews[zone.id] = playerView - zoneExoPlayers[zone.id] = exoPlayer - activeVideoCount++ - Log.i(TAG, "Zone ${zone.name}: video $src") - } - - // Image - mimeType.startsWith("image/") -> { - val imageView = ImageView(context).apply { - scaleType = when (zone.fitMode) { - "contain" -> ImageView.ScaleType.FIT_CENTER - "fill" -> ImageView.ScaleType.FIT_XY - else -> ImageView.ScaleType.CENTER_CROP - } - layoutParams = params - } - - // Target the zone size (already known) so we don't decode larger than the - // visible region. Falls back to screen size if zone hasn't been measured. - val targetW = if (w > 0) w else com.remotedisplay.player.util.ImageLoader.screenWidth(context) - val targetH = if (h > 0) h else com.remotedisplay.player.util.ImageLoader.screenHeight(context) - - val file = contentId?.let { contentCache.getCachedFile(it) } - if (file != null) { - val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH) - if (bitmap != null) { - try { imageView.setImageBitmap(bitmap) } - catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") } - } else { - Log.w(TAG, "Zone ${zone.name}: skipping unloadable image $contentId") - } - } else if (!remoteUrl.isNullOrEmpty()) { - Thread { - val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(remoteUrl, targetW, targetH) - if (bitmap != null) { - imageView.post { - try { imageView.setImageBitmap(bitmap) } - catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") } - } - } else { - Log.w(TAG, "Zone ${zone.name}: skipping unloadable remote image") - } - }.start() - } - - container.addView(imageView) - zoneViews[zone.id] = imageView - Log.i(TAG, "Zone ${zone.name}: image") - } - } + com.remotedisplay.player.util.DebugLog.i("Zone", "Zone '${zone.name}' (${zone.widthPercent.toInt()}x${zone.heightPercent.toInt()}%) -> ${zoneAssignments.size} item(s)") + showZoneItem(zone, zoneAssignments, 0, params) } - Log.i(TAG, "Rendered ${zoneViews.size} zone views") } + // Render assignment[index] in a zone, replacing its current view. If the zone + // has more than one assignment it rotates: images/widgets advance on a duration + // timer; videos advance when they end (single-item zones loop the video). + private fun showZoneItem(zone: Zone, assignments: List, index: Int, params: FrameLayout.LayoutParams) { + cancelZoneRotation(zone.id) + zoneViews.remove(zone.id)?.let { container.removeView(it) } + zoneExoPlayers.remove(zone.id)?.release() + + val a = assignments[index % assignments.size] + val multi = assignments.size > 1 + val advance: () -> Unit = { showZoneItem(zone, assignments, index + 1, params) } + + val mimeType = a.optString("mime_type", "") + val remoteUrl = if (a.isNull("remote_url")) null else a.optString("remote_url", null) + val widgetType = if (a.isNull("widget_type")) null else a.optString("widget_type", null) + val contentId = if (a.isNull("content_id")) null else a.optString("content_id", null) + val filepath = a.optString("filepath", "") + val isMuted = a.optInt("muted", 0) == 1 + val durationMs = a.optInt("duration_sec", 10).coerceAtLeast(3) * 1000L + + // Per-zone content switch log (fires on initial render AND each rotation), so + // the live debug panel shows each zone advancing on its own interval. + val label = a.optString("filename", "").ifEmpty { widgetType?.let { "widget:$it" } ?: mimeType.ifEmpty { "item" } } + com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${(index % assignments.size) + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)") + + when { + // Widget - render in WebView + widgetType != null -> { + val widgetId = a.optString("widget_id", "") + val webView = createWebView() + webView.loadUrl("$renderServerUrl/api/widgets/$widgetId/render") + webView.layoutParams = params + container.addView(webView); zoneViews[zone.id] = webView + if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) + } + // 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) + if (html != null) webView.loadDataWithBaseURL(com.remotedisplay.player.util.WebViewSupport.YT_BASE, html, "text/html", "UTF-8", null) + else webView.loadUrl(remoteUrl) + webView.layoutParams = params + container.addView(webView); zoneViews[zone.id] = webView + if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) + } + // Video + mimeType.startsWith("video/") -> { + val src = if (!remoteUrl.isNullOrEmpty()) remoteUrl + else if (contentId != null) renderCache?.getCachedFile(contentId)?.let { Uri.fromFile(it).toString() } + ?: "$renderServerUrl/uploads/content/$filepath" + else { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance); return } + val playerView = (android.view.LayoutInflater.from(context) + .inflate(com.remotedisplay.player.R.layout.zone_player, null) as PlayerView).apply { + useController = false + layoutParams = params + } + val exoPlayer = ExoPlayer.Builder(context).build().apply { + setMediaItem(MediaItem.fromUri(src)) + repeatMode = if (multi) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ALL + volume = if (isMuted) 0f else 1f + if (multi) addListener(object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + if (state == Player.STATE_ENDED) handler.post { advance() } + } + }) + prepare() + playWhenReady = true + } + playerView.player = exoPlayer + container.addView(playerView); zoneViews[zone.id] = playerView; zoneExoPlayers[zone.id] = exoPlayer + } + // Image + mimeType.startsWith("image/") -> { + val imageView = ImageView(context).apply { + scaleType = when (zone.fitMode) { + "contain" -> ImageView.ScaleType.FIT_CENTER + "fill" -> ImageView.ScaleType.FIT_XY + else -> ImageView.ScaleType.CENTER_CROP + } + layoutParams = params + } + val targetW = if (params.width > 0) params.width else com.remotedisplay.player.util.ImageLoader.screenWidth(context) + val targetH = if (params.height > 0) params.height else com.remotedisplay.player.util.ImageLoader.screenHeight(context) + val file = contentId?.let { renderCache?.getCachedFile(it) } + if (file != null) { + val bitmap = com.remotedisplay.player.util.ImageLoader.decodeFile(file, targetW, targetH) + if (bitmap != null) { + try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") } + } else { + Log.w(TAG, "Zone ${zone.name}: skipping unloadable image $contentId") + } + } else if (!remoteUrl.isNullOrEmpty()) { + Thread { + val bitmap = com.remotedisplay.player.util.ImageLoader.decodeUrl(remoteUrl, targetW, targetH) + if (bitmap != null) { + imageView.post { + try { imageView.setImageBitmap(bitmap) } catch (e: Throwable) { Log.e(TAG, "setImageBitmap failed: ${e.message}") } + } + } else { + Log.w(TAG, "Zone ${zone.name}: skipping unloadable remote image") + } + }.start() + } + container.addView(imageView); zoneViews[zone.id] = imageView + if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) + } + // Unknown / empty assignment - keep rotating so it doesn't get stuck. + else -> { if (multi) scheduleZoneAdvance(zone.id, durationMs, advance) } + } + } + + private fun scheduleZoneAdvance(zoneId: String, delayMs: Long, advance: () -> Unit) { + val r = Runnable { advance() } + zoneRotators[zoneId] = r + handler.postDelayed(r, delayMs) + } + + private fun cancelZoneRotation(zoneId: String) { + zoneRotators.remove(zoneId)?.let { handler.removeCallbacks(it) } + } + + private fun cancelAllRotations() { + zoneRotators.values.forEach { handler.removeCallbacks(it) } + zoneRotators.clear() + } + private fun createWebView(): WebView { return WebView(context).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.mediaPlaybackRequiresUserGesture = false - setBackgroundColor(android.graphics.Color.TRANSPARENT) - webViewClient = WebViewClient() + com.remotedisplay.player.util.WebViewSupport.configure(this, "Zone") } } @@ -234,8 +258,12 @@ class ZoneManager( } fun cleanup() { + cancelAllRotations() releaseExoPlayers() - container.removeAllViews() + // Remove ONLY the views we added for zones; the activity's static views live + // in this same container and must NOT be removed (else single-zone/fullscreen + // playback, which reuses them, renders black). + zoneViews.values.forEach { container.removeView(it) } zoneViews.clear() zones = listOf() } 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/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt b/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt new file mode 100644 index 0000000..d9cbcd8 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/util/WebViewSupport.kt @@ -0,0 +1,79 @@ +package com.remotedisplay.player.util + +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient + +/** + * Shared setup + helpers for the player's WebViews (zone widgets, fullscreen + * widgets, YouTube). Centralizes: + * - JS / DOM storage / autoplay-without-gesture, + * - mixed-content ALLOW (self-hosted servers are often http on the LAN; without + * this an https page embedding http - or vice versa - is silently blocked into + * a black broken-frame), + * - error/console logging piped to DebugLog so a failing web frame shows the + * real reason in the live debug panel instead of just a black broken-page view, + * - a YouTube embed that loads with a valid youtube.com origin (fixes Error 153). + */ +object WebViewSupport { + + const val YT_BASE = "https://www.youtube.com" + + fun configure(webView: WebView, tag: String) { + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + mediaPlaybackRequiresUserGesture = false + mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + } + webView.setBackgroundColor(android.graphics.Color.TRANSPARENT) + webView.webViewClient = object : WebViewClient() { + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + if (request?.isForMainFrame == true) { + DebugLog.e(tag, "WebView load error ${error?.errorCode} ${error?.description} url=${request.url}") + } + } + override fun onReceivedHttpError(view: WebView?, request: WebResourceRequest?, errorResponse: WebResourceResponse?) { + if (request?.isForMainFrame == true) { + DebugLog.e(tag, "WebView HTTP ${errorResponse?.statusCode} url=${request.url}") + } + } + } + webView.webChromeClient = object : WebChromeClient() { + override fun onConsoleMessage(msg: ConsoleMessage?): Boolean { + if (msg?.messageLevel() == ConsoleMessage.MessageLevel.ERROR) { + DebugLog.w(tag, "JS error: ${msg.message()} @${msg.sourceId()}:${msg.lineNumber()}") + } + return super.onConsoleMessage(msg) + } + } + } + + fun extractYoutubeId(url: String): String? { + val patterns = listOf( + Regex("""embed/([A-Za-z0-9_-]{6,})"""), + Regex("""[?&]v=([A-Za-z0-9_-]{6,})"""), + Regex("""youtu\.be/([A-Za-z0-9_-]{6,})""") + ) + for (p in patterns) p.find(url)?.let { return it.groupValues[1] } + return null + } + + /** + * 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. + */ + fun youtubeEmbedHtml(url: String): 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" + return "" + + "" + + "" + } +} 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')}
+ +
+