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'); +});