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).
This commit is contained in:
ScreenTinker 2026-06-08 21:49:03 -05:00
parent 50d7dbe222
commit 73912d5f58
8 changed files with 103 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -529,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;