From 2ff07bfa49addbe5d99753e1f12fa0c64dd6b1f2 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 23 Aug 2026 17:53:16 +0200 Subject: [PATCH] fix(player): one menu at a time, and inside the screen it opens on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the video control bar, reported together because they present together: the menus cover each other, and in portrait they cover the edge of the screen instead of the video. DR-256 (a) — audio track, quality and subtitles each owned a `show…` boolean and no toggle cleared the others. Opening a second menu stacked it over the first in the same corner: the newer panel hid rows of the older, both stayed live, and both kept taking clicks. A single `openMenu` value replaces the three booleans, which makes "at most one menu is open" a property of the state rather than something every handler has to remember to enforce. The desktop volume popup was a fourth uncoordinated menu in the same row, so `VolumeControl` grew optional controlled-open props and joined the group; without them it still manages itself, which is how MiniPlayer and AudioPlayer keep it. DR-256 (b) — every panel was `absolute right-0` against *its own icon button*. Those icons sit mid-row, so a 200-220 px panel extended left from a point well inside the bar and hung off the left edge of a phone in portrait: half the tracks could not be read, let alone tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw - 2rem)` wide and `min(300px, 45vh)` tall. A full-screen dismiss layer inside the controls subtree closes it on a tap elsewhere — inside, so the tap never reaches the container's gesture layer and cannot toggle playback (DR-098). The volume popup had the same placement bug from the other side: `left-full` opened it rightward from an icon near the right end of every bar it appears in. It opens upward, right-aligned, now. And the icon row wraps rather than overflowing — in portrait the transport controls plus nine icons are wider than the screen, which pushed fullscreen and close past the edge. The test renders the real component and drives the toggles, because neither fault is visible from a helper: both are properties of the composition. It was written first and failed on both counts — `["Audio Track", "Subtitles"]` open at once, and no shared panel to anchor. --- CHANGELOG.md | 10 + docs/requirements.md | 3 + .../player/VideoPlayer.menus.test.ts | 239 ++++++++++ src/lib/components/player/VideoPlayer.svelte | 434 +++++++++--------- .../components/player/VolumeControl.svelte | 28 +- 5 files changed, 494 insertions(+), 220 deletions(-) create mode 100644 src/lib/components/player/VideoPlayer.menus.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b6a2ed..90db6285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,16 @@ fact about the platform rather than asking the thing that would know. for was not discarded if you skipped onward first — so the next item began wherever you had dragged to in the previous one. (DR-253) +- **The player's menus no longer cover each other, or the edge of the screen.** + Audio track, quality and subtitles could all be open at once, stacked in the + same corner with the newest panel hiding rows of the one underneath, and each + one was anchored to its own icon — which sits mid-row, so on a phone in + portrait the panel hung off the left edge and half the tracks could not be + read or tapped. One menu is open at a time now (the desktop volume slider + included), it opens against the edge of the control bar clamped to the screen + it is on, and tapping anywhere else dismisses it. The row of icons wraps + instead of pushing fullscreen and close past the edge. (DR-256) + ### 🧹 Under the hood - The conformance suite can be run on its own: `bun run test:player` for the diff --git a/docs/requirements.md b/docs/requirements.md index 8183ef07..cb48b8b7 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -449,6 +449,7 @@ Internal architecture, components, and application logic. | DR-253 | A deferred seek is discarded when the file it was issued against stops being the one loading. `seek` holds a position while MPV has nothing loaded and the `FileLoaded` handler applies it (DR-241), but neither `load` nor `stop` cleared it — so scrubbing near the end of a transcoded item, which re-opens the stream, and then skipping to the next item before the reload completed applied the old position to the new item. It started wherever the previous one had been scrubbed to, silently | Player | UR-040, UR-005 | Done | | DR-254 | Advancing to the next episode drops a per-playback quality override. The override is process-wide and describes one playback: a viewer who drops to 720p for a struggling episode has said nothing about the next. Every advance the frontend drives clears it via `player_play_item`; the background audio-only advance loads the next episode in Rust and skipped all three clearing sites, so every later episode stayed capped with nothing in the UI saying why | Repository | UR-074 | Done | | DR-255 | One helper answers "what URL should an engine open". `playback_url` was gated to Android because only ExoPlayer needed it, and that gate is why a byte-identical copy was later added for the cross-platform open path — the original is invisible in a Linux build, so nothing warned. Two matches over `MediaSource` meant a new variant could be handled in one and forgotten in the other | Player | UR-081 | Done | +| DR-256 | The video control bar opens **at most one menu at a time**, and opens it where it can be read. Audio track, quality and subtitles each owned a `show…` boolean that no other toggle cleared, so a second menu opened stacked over the first — two panels in the same corner, the newer one covering rows of the older, both still taking clicks. A single `openMenu` value replaces them, which makes "one menu" a property of the state rather than something every handler must remember; the desktop volume popup joins the same group through `VolumeControl`'s optional controlled-open props. Placement was the second half of the same defect: every panel was `absolute right-0` against **its own icon button**, and those icons sit mid-row, so a 220 px panel hung off the left edge of a portrait phone and half the tracks could not be read or tapped. One shared panel now anchors to the control ROW's right edge, clamped to `min(20rem, 100vw − 2rem)` wide and `min(300px, 45vh)` tall, with a full-screen dismiss layer inside the controls subtree so a tap elsewhere closes it without reaching the container's tap gestures (DR-098). The icon row itself wraps instead of overflowing — in portrait the transport controls plus nine icons are wider than the screen, which put fullscreen and close past the edge | UI | UR-020, UR-021, UR-066, UR-074 | Done | | DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `