Keep a solo widget mounted, and size its keyboard to the viewport

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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-07-27 20:39:59 -05:00
parent 6ee1c96b04
commit 59c536c923
3 changed files with 68 additions and 4 deletions

View file

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

View file

@ -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. */
}
</style>
</head>

View file

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