From 4cc750ba3a55597bc5dc5c1a8728ebca5d73700a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 19:44:12 -0500 Subject: [PATCH] Text widgets: stop losing text off the bottom, and show an edit without an app restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate faults in the same widget, both reported on #234. 1. Text taller than the screen vanished in silence. renderText set overflow:hidden on the document with nothing able to scroll it, so anything past the bottom edge was simply gone: "Text goes to bottom and disappears. It dont fit." The content now gets a wrapper and an overflow mode: fit (default) shrink until it fits — a NO-OP when the content already fits, so it rescues widgets that are currently losing text without changing ones that are fine scroll pan through it on a loop with a pause at each end, for content genuinely longer than a screen where shrinking would make it unreadable clip the old behaviour, kept because a designer-positioned layout may deliberately run past the edge and must not be rescaled underneath its author Measuring runs after layout, after web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts arriving late is the classic cause of a fit computed against the wrong height. 2. Editing a widget did not reach the screen until the app was restarted. The render endpoint serves live config, but the player deliberately keeps a widget's WebView while its URL is unchanged (re-navigating every duration is a visible flash and destroys widget state — a half-typed directory search, scroll position). Editing changes the content, not the id, so the URL never changed and the reuse check always hit. The widget's updated_at now travels to the player as widget_rev and goes into the render URL, so the URL differs exactly when the content differs — and only then, so the anti-flash reuse still holds for untouched widgets. The rev is refreshed at send time rather than read from the published snapshot, because a widget edit does not republish the playlist. Editing a widget also now pushes to the displays showing it, instead of notifying nothing at all. 859 server tests green. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- .../com/remotedisplay/player/MainActivity.kt | 5 +- .../player/player/PlaylistController.kt | 4 + server/routes/playlists.js | 18 ++-- server/routes/widgets.js | 91 ++++++++++++++++++- server/ws/deviceSocket.js | 18 ++++ 5 files changed, 125 insertions(+), 11 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 a0a659e..7859d45 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -876,8 +876,11 @@ class MainActivity : AppCompatActivity() { // layouts; multi-zone widgets go through ZoneManager). Previously unhandled, // so widgets were blank/broken in default-fullscreen and the fullscreen template. if (item.isWidget) { + // rev makes the URL change when — and only when — the widget's content changed, so an + // edit reloads while an untouched widget still hits the no-flash reuse path. val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" + - (if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "") + (if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "?d=") + + "&rev=${item.widgetRev}" Log.i("MainActivity", "Playing widget fullscreen: $url") mediaPlayer.showWidget(url) wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f) 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 d82c7c2..7869e13 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 @@ -19,6 +19,9 @@ data class PlaylistItem( val remoteUrl: String? = null, val muted: Boolean = false, val widgetId: String? = null, + // Changes whenever the widget is edited. Carried into the render URL so an edited widget gets + // a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse. + val widgetRev: Long = 0L, val widgetType: String? = null, val schedules: List = emptyList(), // feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut). @@ -169,6 +172,7 @@ class PlaylistController( remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null }, muted = obj.optInt("muted", 0) == 1, widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null }, + widgetRev = obj.optLong("widget_rev", 0L), widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null }, schedules = parseSchedules(obj.optJSONArray("schedules")), transition = Transitions.parse(obj.optJSONObject("transition")) diff --git a/server/routes/playlists.js b/server/routes/playlists.js index eb37791..c418987 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -70,7 +70,7 @@ function buildSnapshotItems(playlistId) { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size, c.duration_sec as content_duration, c.remote_url, c.unstable_connection, c.captions_enabled, c.captions_lang, c.subtitle_url, c.subtitle_lang, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -216,7 +216,7 @@ router.get('/:id', requirePlaylistRead, (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -277,7 +277,7 @@ router.post('/:id/publish', requirePlaylistWrite, (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -327,7 +327,7 @@ router.post('/:id/discard', requirePlaylistWrite, (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -352,7 +352,7 @@ router.get('/:id/items', requirePlaylistRead, (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -471,7 +471,7 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -555,7 +555,7 @@ router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -603,7 +603,7 @@ router.post('/:id/items/:itemId/duplicate', requirePlaylistWrite, (req, res) => COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id @@ -632,7 +632,7 @@ router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => { COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.thumbnail_path, c.duration_sec as content_duration, c.file_size, c.remote_url, - w.name as widget_name, w.widget_type, w.config as widget_config + w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev FROM playlist_items pi LEFT JOIN content c ON pi.content_id = c.id LEFT JOIN widgets w ON pi.widget_id = w.id diff --git a/server/routes/widgets.js b/server/routes/widgets.js index c9458cc..0337a69 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -157,6 +157,30 @@ router.put('/:id', (req, res) => { if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id); if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id); + // Push the change to any display currently showing this widget. Editing a widget used to + // notify nothing at all: the render endpoint serves live config, but a player that already has + // the widget on screen keeps its WebView (deliberately — re-navigating a widget every duration + // is a visible flash and destroys widget state). With no push and no change to the URL, an edit + // reached the screen only when the app was restarted. Reported on #234: "I changed the text and + // the new text did not appear on the screen. I had to close the app and then open again." + // + // The push is what makes it prompt; the rev in the payload is what makes the player reload. + try { + const io = req.app.get('io'); + if (io) { + const { buildPlaylistPayload } = require('../ws/deviceSocket'); + const commandQueue = require('../lib/command-queue'); + const affected = db.prepare(` + SELECT DISTINCT d.id FROM devices d + JOIN playlist_items pi ON pi.playlist_id = d.playlist_id + WHERE pi.widget_id = ? + `).all(req.params.id); + for (const d of affected) { + commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload); + } + } + } catch (e) { /* best-effort; the heartbeat refresh still picks it up */ } + res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id)); }); @@ -405,6 +429,68 @@ function renderText(c) { html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => { return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`; }); + + // What to do when the text is taller than the screen. It used to be clipped in silence: the + // document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display + // shorter than the content the bottom simply vanished — reported as "text goes to bottom and + // disappears. It dont fit." + // + // fit (default) shrink until it fits. A no-op when the content already fits, so this + // rescues widgets that are currently losing text without altering ones that are fine. + // scroll pan through it on a loop, with a pause at each end. For content that is genuinely + // longer than a screen, where shrinking it would make it unreadable. + // clip the old behaviour, kept because a designer-positioned layout may deliberately run + // past the edge and must not be rescaled underneath the author. + const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit'; + + // Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after + // web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts + // loading late is the classic cause of a fit that was computed against the wrong height. + const fitScript = overflowMode === 'clip' ? '' : ``; + // Security: c.html / c.css are intentionally raw user-authored content, but the // render is public and same-origin with the dashboard - injected