From 59c536c9231caa4d180e769a11c43af533f9935c Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 27 Jul 2026 20:39:59 -0500 Subject: [PATCH] Keep a solo widget mounted, and size its keyboard to the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems on a panel showing one fullscreen widget, both visible as flashing. The player re-navigated the WebView every duration_sec. PlaylistController.next() requests a playlist refresh between plays and playCurrentItem() re-issues the item unconditionally, so a one-item playlist reloaded the same URL forever. The existing dedupe guard only covers the playlist-update path, so it logged "not restarting" AFTER the reload had already happened. On an interactive widget that also discarded whatever the viewer had typed. showWidget() is now idempotent: same URL with the widget already on screen returns without re-navigating, and the cached URL is cleared at every media-type transition so switching away and back still reloads. The refresh itself is untouched — schedule re-evaluation and dayparting still run on the timer, and widgets keep refreshing their own data client-side (directory-search polls its board every 30s and preserves the current query). The web player already behaved this way via reevaluateHeldWidget; this brings the Android player to parity. Separately, the directory-search keyboard was laid out in fixed pixels for a 1920-wide viewport. A panel's CSS viewport is its resolution over its density, so a 1080p screen at 240dpi presents 1280x720 — where four rows of 56px keys took ~37% of the height instead of ~24%, and the lone max-width:700px breakpoint never fired to correct it. Key metrics are now clamped against vh. The clamp maxima are the previous fixed values and both vh terms exceed them at 1080 tall, so a 1080 viewport renders pixel-identically; shorter viewports scale down. The breakpoint no longer re-pins .key, which would have undone the clamp. Co-Authored-By: Claude Opus 5 (1M context) --- .../player/player/MediaPlayerManager.kt | 20 ++++++++++ server/routes/widgets.js | 15 ++++++-- server/test/directory-search.test.js | 37 +++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) 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 289564c..8716357 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 @@ -39,6 +39,9 @@ class MediaPlayerManager( private val mainHandler = Handler(Looper.getMainLooper()) private var exoPlayer: ExoPlayer? = null private var currentType: MediaType = MediaType.NONE + // The URL the widget WebView currently has loaded, so re-showing the same widget can be a + // no-op. Cleared whenever anything else takes the surface (see clearWidgetUrl callers). + private var currentWidgetUrl: String? = null // Wall mode: followers must stay muted even as the leader's sync switches them // to a new (possibly unmuted) item, so the mute has to survive each playVideo. private var wallMute = false @@ -150,6 +153,7 @@ class MediaPlayerManager( // no-transition hard cut. private fun mountImageBitmap(bitmap: Bitmap) { currentType = MediaType.IMAGE + currentWidgetUrl = null // surface reused - a later widget show must reload playerView.visibility = android.view.View.GONE imageView.visibility = android.view.View.VISIBLE youtubeWebView?.visibility = android.view.View.GONE @@ -161,6 +165,7 @@ class MediaPlayerManager( fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) { Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)") currentType = MediaType.YOUTUBE + currentWidgetUrl = null // surface reused - a later widget show must reload youtubeMuted = muted || wallMute playerView.visibility = android.view.View.GONE @@ -195,8 +200,20 @@ class MediaPlayerManager( // Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the // full-screen WebView; ZoneManager handles widgets in multi-zone layouts. fun showWidget(url: String) { + // A solo-widget playlist re-shows the SAME item every duration_sec, and a playlist refresh + // re-issues the current item too. Re-navigating the WebView for a URL it already has is a + // visible flash, and it destroys widget state - a half-typed directory search, scroll + // position, anything the viewer was doing. Widgets refresh their own data client-side + // (directory-search polls the board's data.json every 30s), so the reload buys nothing. + // Make the show idempotent: same URL + widget already on screen => leave it running. + if (currentType == MediaType.WIDGET && url == currentWidgetUrl && youtubeWebView != null) { + Log.i("MediaPlayerManager", "Widget already showing, not reloading: $url") + youtubeWebView?.visibility = android.view.View.VISIBLE + return + } Log.i("MediaPlayerManager", "Showing widget: $url") currentType = MediaType.WIDGET + currentWidgetUrl = url playerView.visibility = android.view.View.GONE imageView.visibility = android.view.View.GONE @@ -213,6 +230,7 @@ class MediaPlayerManager( fun playVideoFromUrl(url: String, muted: Boolean = false) { Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)") currentType = MediaType.VIDEO + currentWidgetUrl = null // surface reused - a later widget show must reload playerView.visibility = android.view.View.VISIBLE imageView.visibility = android.view.View.GONE @@ -286,6 +304,7 @@ class MediaPlayerManager( private fun mountVideo(file: File, muted: Boolean = false) { currentType = MediaType.VIDEO + currentWidgetUrl = null // surface reused - a later widget show must reload // Show player, hide image playerView.visibility = android.view.View.VISIBLE @@ -341,6 +360,7 @@ class MediaPlayerManager( youtubeWebView?.loadUrl("about:blank") youtubeWebView?.visibility = android.view.View.GONE currentType = MediaType.NONE + currentWidgetUrl = null // surface reused - a later widget show must reload } fun release() { diff --git a/server/routes/widgets.js b/server/routes/widgets.js index d3cb8a5..c9458cc 100644 --- a/server/routes/widgets.js +++ b/server/routes/widgets.js @@ -878,11 +878,18 @@ function renderDirectorySearch(c) { .entry.available, .entry.available .id { color:#00ff00; } body.light .entry.available, body.light .entry.available .id { color:#059669; } - .keyboard { flex:0 0 auto; padding:8px 12px 14px; background:rgba(0,0,0,0.25); user-select:none; } + /* The keyboard is sized against the VIEWPORT, not in fixed px. A panel's CSS viewport is its + physical resolution divided by its density, so a 1080p screen at 240dpi presents only 1280x720 + CSS px - and a keyboard laid out for 1920x1080 then eats ~37% of the height instead of ~24%. + The vh terms scale it down on short viewports; the clamp() maxima are the original values, so + a 1080-tall viewport renders pixel-identically to before (5.3vh and 2.3vh both exceed their + max at 1080 and clamp). The px minima keep the keys tappable on very short screens. */ + .keyboard { flex:0 0 auto; padding:clamp(5px,0.8vh,8px) 12px clamp(8px,1.3vh,14px); background:rgba(0,0,0,0.25); user-select:none; } body.light .keyboard { background:rgba(0,0,0,0.05); } - .krow { display:flex; gap:6px; justify-content:center; margin-bottom:6px; } + .krow { display:flex; gap:clamp(4px,0.6vh,6px); justify-content:center; margin-bottom:clamp(4px,0.6vh,6px); } .key { - flex:1 1 0; max-width:96px; min-width:0; height:56px; font-size:24px; text-transform:uppercase; + flex:1 1 0; max-width:96px; min-width:0; + height:clamp(34px,5.3vh,56px); font-size:clamp(15px,2.3vh,24px); text-transform:uppercase; border:0; border-radius:8px; background:rgba(255,255,255,0.12); color:inherit; cursor:pointer; } .key:active { background:#4a9eff; color:#fff; } @@ -894,7 +901,7 @@ function renderDirectorySearch(c) { .header h1 { font-size:30px; } #q { font-size:26px; padding:14px 16px; } .entry { font-size:24px; } - .key { height:46px; font-size:20px; } + /* .key is viewport-scaled above - no fixed override here, it would undo the clamp. */ } diff --git a/server/test/directory-search.test.js b/server/test/directory-search.test.js index e4d007b..d8bf97c 100644 --- a/server/test/directory-search.test.js +++ b/server/test/directory-search.test.js @@ -114,3 +114,40 @@ test('search page wires the live-sync poll to its source board', async () => { assert.ok(html.includes('"source_widget_id":"board1"'), 'source board id inlined into the page'); assert.ok(html.includes('/data.json'), 'page polls the data.json feed'); }); + +// The on-screen keyboard must be sized against the VIEWPORT, not in fixed px. A panel's CSS +// viewport is its resolution over its density, so a 1080p screen at 240dpi presents 1280x720 CSS +// px — where a keyboard laid out for 1920x1080 ate ~37% of the height instead of ~24%. The clamp() +// maxima are the ORIGINAL fixed values, so a 1080-tall viewport must stay pixel-identical. +test('the on-screen keyboard scales with the viewport instead of using fixed pixels', async () => { + const bid = 'kb-board', sid = 'kb-search'; + seed(bid, 'directory-board', BOARD); + seed(sid, 'directory-search', { source_widget_id: bid }); + const { status, html } = await fetchRender(sid); + assert.equal(status, 200); + + const keyRule = html.match(/\.key\s*\{[^}]*\}/s); + assert.ok(keyRule, '.key rule is present'); + assert.match(keyRule[0], /height:clamp\(/, 'key height is clamped to the viewport'); + assert.match(keyRule[0], /font-size:clamp\(/, 'key font-size is clamped to the viewport'); + assert.ok(!/height:\s*56px/.test(keyRule[0]), 'no bare fixed height survives'); + + // vh terms must exceed their max at 1080 tall, so existing 1080 panels render unchanged. + const h = keyRule[0].match(/height:clamp\(([\d.]+)px,\s*([\d.]+)vh,\s*([\d.]+)px\)/); + assert.ok(h, 'height clamp is well-formed'); + const [, hMin, hVh, hMax] = h.map(Number); + assert.ok(hVh * 1080 / 100 >= hMax, 'at a 1080-tall viewport the height clamps to its max (no visual change)'); + assert.ok(hVh * 720 / 100 < hMax, 'at a 720-tall viewport the height actually scales down'); + assert.ok(hMin >= 30, 'keys stay tappable on very short viewports'); +}); + +test('the narrow breakpoint no longer pins the key size back to fixed pixels', async () => { + const bid = 'kb2-board', sid = 'kb2-search'; + seed(bid, 'directory-board', BOARD); + seed(sid, 'directory-search', { source_widget_id: bid }); + const { html } = await fetchRender(sid); + const mq = html.match(/@media \(max-width:700px\)\s*\{[^}]*\}[^}]*\}/s); + assert.ok(mq, 'the narrow breakpoint still exists'); + assert.ok(!/\.key\s*\{[^}]*height:\s*\d+px/.test(mq[0]), + 'the breakpoint must not re-pin .key to a fixed height and undo the clamp'); +});