mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
Merge pull request #33 from screentinker/fix/fullscreen-widgets
fix(android): widgets not rendering in fullscreen / single-zone layouts
This commit is contained in:
commit
6ef2cb548c
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, View>()
|
||||
private val zoneExoPlayers = mutableMapOf<String, ExoPlayer>()
|
||||
// Per-zone rotation timers: each zone cycles its own list of assignments.
|
||||
private val zoneRotators = mutableMapOf<String, Runnable>()
|
||||
private var zones = listOf<Zone>()
|
||||
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<String?, MutableList<JSONObject>>()
|
||||
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<JSONObject> = 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<JSONObject>, 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}") } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 "<!DOCTYPE html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" +
|
||||
"<style>html,body{margin:0;padding:0;height:100%;background:#000;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style>" +
|
||||
"</head><body><iframe src=\"$src\" allow=\"autoplay; encrypted-media\" allowfullscreen></iframe></body></html>"
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
</div>
|
||||
<button class="btn btn-secondary btn-sm" id="saveNotesBtn">${t('device.form.save_settings')}</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px">
|
||||
<input type="checkbox" id="debugLogToggle"> ${t('device.debug.toggle')}
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text-muted);margin:4px 0 0 24px">${t('device.debug.hint')}</div>
|
||||
<div id="debugLogPanel" style="display:none;margin-top:8px;background:#0b0f1a;border:1px solid var(--border);border-radius:6px;padding:8px;height:220px;overflow-y:auto;font-family:monospace;font-size:11px;line-height:1.45;color:#cbd5e1"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:20px;display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-secondary btn-sm" id="rebootBtn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
@ -571,6 +596,15 @@ async function setupActions(device) {
|
|||
} catch {}
|
||||
|
||||
// Save settings (notes + orientation + default content)
|
||||
// Debug logging toggle: sends a transient set_debug command to the device and
|
||||
// reveals the live log panel. State is per-session (resets on device reconnect).
|
||||
document.getElementById('debugLogToggle')?.addEventListener('change', (e) => {
|
||||
const enabled = e.target.checked;
|
||||
const panel = document.getElementById('debugLogPanel');
|
||||
if (panel) panel.style.display = enabled ? 'block' : 'none';
|
||||
sendCommand(device.id, 'set_debug', { enabled });
|
||||
});
|
||||
|
||||
document.getElementById('saveNotesBtn')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await api.updateDevice(device.id, {
|
||||
|
|
@ -1268,6 +1302,7 @@ export function cleanup() {
|
|||
if (statusHandler) off('device-status', statusHandler);
|
||||
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
||||
if (playbackHandler) off('playback-state', playbackHandler);
|
||||
if (logHandler) off('device-log', logHandler);
|
||||
if (screenshotInterval) clearInterval(screenshotInterval);
|
||||
if (remoteActive && currentDevice) stopRemote(currentDevice.id);
|
||||
remoteActive = false;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
@ -519,6 +529,22 @@ module.exports = function setupDeviceSocket(io) {
|
|||
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state', data);
|
||||
});
|
||||
|
||||
// Live debug log line from the player (only sent when debug logging is toggled
|
||||
// on for this device). Relayed to the device's workspace dashboard room so the
|
||||
// open device-detail screen can stream it. Not persisted.
|
||||
socket.on('device:log', (data) => {
|
||||
if (!requireDeviceAuth() || !currentDeviceId) return;
|
||||
const message = typeof data?.message === 'string' ? data.message.slice(0, 2000) : '';
|
||||
if (!message) return;
|
||||
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:device-log', {
|
||||
device_id: currentDeviceId,
|
||||
tag: typeof data?.tag === 'string' ? data.tag.slice(0, 64) : '',
|
||||
level: typeof data?.level === 'string' ? data.level.slice(0, 8) : 'd',
|
||||
message,
|
||||
ts: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
// Play event logging (proof-of-play)
|
||||
socket.on('device:play-event', (data) => {
|
||||
if (!requireDeviceAuth()) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue