fix(player): one menu at a time, and inside the screen it opens on

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.
This commit is contained in:
2026-08-23 17:53:16 +02:00
parent e579d4bff2
commit 2ff07bfa49
5 changed files with 494 additions and 220 deletions
+10
View File
@@ -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
+3
View File
@@ -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 `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -767,6 +768,8 @@ Internal architecture, components, and application logic.
| UT-224 | Stopping clears an active background-audio handoff, both the flag and the base offset, so a later position read cannot be interpreted against a handoff that no longer exists. Previously verified only by listening to a device | DR-250 | Done |
| UT-225 | Both `load` and `stop` discard a deferred seek, so a position held for a file that is no longer loading cannot be applied to whatever loads next | DR-253 | Done |
| UT-226 | The background episode advance clears the per-playback quality override, so a ceiling chosen for one episode does not cap every episode after it | DR-254 | Done |
| UT-227 | Opening any one of the control bar's menus closes whichever was open — track, quality, subtitle and the desktop volume popup are one group, never two panels at once — and a second click on the open menu's own toggle closes it | DR-256 | Done |
| UT-228 | The open menu panel is anchored to the control row rather than to the icon that opened it, and carries a viewport-clamped width, so it cannot hang off the edge of a portrait screen | DR-256 | Done |
### Integration Tests
@@ -0,0 +1,239 @@
/**
* Regression tests for the video player's track / quality / subtitle menus,
* rendered against the REAL component.
*
* TRACES: UR-020, UR-021, UR-066, UR-074 | DR-256 | UT-227, UT-228
*
* Two defects shipped together, and neither is visible from a pure helper:
*
* 1. Each menu owned its own `show…` boolean and no toggle cleared the others,
* so opening the subtitle menu on top of the audio menu left two panels
* overlapping in the same corner the newer one covering rows of the
* older one, both still live.
*
* 2. Each panel was `absolute right-0` against *its own icon button*, which
* sits mid-row. A 200220 px panel hung off the left edge of a portrait
* phone, so half the tracks could not be read or tapped.
*
* Both are properties of the composition, so these tests drive the real
* markup: click the toggles, then assert what a viewer would see.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, fireEvent } from "@testing-library/svelte";
import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
function testSelection() {
return {
url: "http://x/master.m3u8",
transport: { type: "hls" },
playbackKind: { type: "transcode" },
rendition: null,
available: [
{
quality: "original",
label: "Original",
detail: "Source",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
{
quality: "high",
label: "8 Mbps",
detail: "1080p",
exceedsSource: false,
sourceBitrate: 8_000_000,
},
],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: true,
} as unknown as import("$lib/api/bindings").StreamSelection;
}
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/player", () => ({
playerController: {
toggle: vi.fn(() => Promise.resolve()),
seekVideo: vi.fn(() => Promise.resolve()),
seek: vi.fn(() => Promise.resolve()),
setActiveAdapter: vi.fn(),
clearActiveAdapter: vi.fn(),
getActiveAdapter: vi.fn(() => null),
switchAudioTrack: vi.fn(() => Promise.resolve()),
setStreamQuality: vi.fn(() => Promise.resolve(null)),
},
}));
vi.mock("$lib/player/adapters/rustReportHost", () => ({
createRustReportHost: () => ({
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getUserId: () => "user-1",
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
},
}));
/** Two audio tracks and one subtitle track — enough for all three menus. */
const MEDIA = {
id: "item-1",
name: "Test Episode",
type: "Episode",
runTimeTicks: 6_000_000_000,
durationMs: 600_000,
mediaStreams: [
{ index: 1, kind: "audio", displayTitle: "English AAC", language: "eng", isDefault: true },
{ index: 2, kind: "audio", displayTitle: "Commentary", language: "eng" },
{
index: 3,
kind: "subtitle",
displayTitle: "English SRT",
language: "eng",
codec: "srt",
deliverableAsSidecar: true,
},
],
} as any;
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, selection: testSelection(), onClose: vi.fn() },
});
}
/** The menu panels currently on screen, found by their headings. */
function openPanels(container: HTMLElement): string[] {
return ["Audio Track", "Quality", "Subtitles"].filter((heading) =>
[...container.querySelectorAll("div")].some(
(el) => el.children.length === 0 && el.textContent?.trim() === heading,
),
);
}
function clickToggle(container: HTMLElement, label: string) {
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
expect(button, `expected a "${label}" button in the controls`).toBeTruthy();
return fireEvent.click(button!);
}
describe("VideoPlayer track menus", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
switch (cmd) {
case "player_get_streaming_qualities":
return [];
case "player_get_video_settings":
return { streamingQuality: "original" };
default:
return undefined;
}
});
});
// UT-227
it("opening one menu closes any other — never two panels stacked in the corner", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select audio track");
await tick();
expect(openPanels(container)).toEqual(["Audio Track"]);
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual(["Quality"]);
// A second click on the open menu's own toggle closes it.
await clickToggle(container, "Select streaming quality");
await tick();
expect(openPanels(container)).toEqual([]);
});
// UT-227
it("the volume slider is part of the same group — it closes an open track menu", async () => {
const { container } = renderPlayer();
await tick();
await clickToggle(container, "Select subtitles");
await tick();
expect(openPanels(container)).toEqual(["Subtitles"]);
// The volume popup (desktop only) is a menu of this bar too.
const volume = container.querySelector<HTMLButtonElement>('button[title="Volume"]');
expect(volume).toBeTruthy();
await fireEvent.click(volume!);
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeTruthy();
expect(openPanels(container)).toEqual([]);
// …and a track menu closes the volume popup again.
await clickToggle(container, "Select subtitles");
await tick();
expect(container.querySelector("[aria-label='Volume controls']")).toBeNull();
expect(openPanels(container)).toEqual(["Subtitles"]);
});
// UT-228
it("the open panel is anchored to the control bar and clamped to the viewport", async () => {
const { container } = renderPlayer();
await tick();
for (const label of ["Select audio track", "Select subtitles", "Select streaming quality"]) {
await clickToggle(container, label);
await tick();
const panel = container.querySelector<HTMLElement>("[data-testid='player-menu']");
expect(panel, `${label} should open the shared menu panel`).toBeTruthy();
// Anchored to the control row, not to the icon button: a panel anchored
// to a mid-row button runs off the left edge in portrait.
const toggle = container.querySelector<HTMLElement>(`button[aria-label="${label}"]`);
expect(panel!.contains(toggle!)).toBe(false);
expect(toggle!.parentElement!.contains(panel!)).toBe(false);
// …and never wider than the screen it opens on.
expect(panel!.className).toMatch(/max-w-\[|w-\[min\(/);
await clickToggle(container, label);
await tick();
}
});
});
+141 -131
View File
@@ -305,18 +305,38 @@
getMediaSourceId: () => mediaSourceId ?? null,
};
/**
* Which of the control bar's menus is open, if any.
*
* ONE piece of state for all three, deliberately. They each used to own a
* `show…` boolean and no toggle cleared the others, so opening the subtitle
* menu while the audio menu was up left two panels overlapping in the same
* corner — the second covering rows of the first, both still live and both
* still taking clicks. A single value makes "at most one menu is open" a
* property of the type rather than something every handler has to remember.
*
* TRACES: UR-020, UR-021, UR-074 | DR-256 | UT-227
*/
type PlayerMenu = "audio" | "quality" | "subtitle" | "volume";
let openMenu = $state<PlayerMenu | null>(null);
function toggleMenu(menu: PlayerMenu) {
openMenu = openMenu === menu ? null : menu;
}
function closeMenu() {
openMenu = null;
}
// Audio track selection
let showAudioTrackMenu = $state(false);
let selectedAudioTrackIndex = $state<number | null>(null);
// Subtitle track selection
let showSubtitleMenu = $state(false);
let selectedSubtitleIndex = $state<number | null>(null);
// Streaming bandwidth ceiling. The ladder and the current value both come from
// Rust — the frontend never encodes what a step means.
// TRACES: UR-074 | DR-162
let showQualityMenu = $state(false);
let changingQuality = $state(false);
/**
* The device's durable default, shown when the stream is a direct play and so
@@ -580,7 +600,7 @@
!shouldHideControls({
isPlaying,
isSeeking,
menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
menuOpen: openMenu !== null,
})
) {
return;
@@ -2353,15 +2373,11 @@
}, 800);
}
function toggleAudioTrackMenu() {
showAudioTrackMenu = !showAudioTrackMenu;
}
async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
const previousTrackIndex = selectedAudioTrackIndex;
selectedAudioTrackIndex = streamIndex;
showAudioTrackMenu = false;
closeMenu();
try {
// The BACKEND decides whether the audio-track switch needs a transcode
@@ -2414,10 +2430,6 @@
}
}
function toggleQualityMenu() {
showQualityMenu = !showQualityMenu;
}
/**
* Re-open the current stream at a different bandwidth ceiling.
*
@@ -2434,7 +2446,7 @@
* TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
*/
async function selectQuality(quality: StreamingQuality) {
showQualityMenu = false;
closeMenu();
if (quality === selectedQuality || changingQuality) return;
changingQuality = true;
@@ -2470,10 +2482,6 @@
}
}
function toggleSubtitleMenu() {
showSubtitleMenu = !showSubtitleMenu;
}
/**
* Show exactly one (or no) text track on the HTML5 element. `null` disables
* every track, which is what the menu's "Off" entry means.
@@ -2516,7 +2524,7 @@
async function selectSubtitle(streamIndex: number | null) {
log.debug("Selecting subtitle - streamIndex:", streamIndex);
selectedSubtitleIndex = streamIndex;
showSubtitleMenu = false;
closeMenu();
// For HTML5 video element, update the text tracks
if (useHtml5Element) {
@@ -2841,58 +2849,31 @@
</div>
{/if}
<!-- Control buttons -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<!-- Control buttons.
`relative` because the track / quality / subtitle panel below is
anchored to this ROW, not to the icon that opens it. Anchoring each
panel to its own button put a 220 px panel under a mid-row icon, which
hangs off the left edge of a portrait phone. TRACES: UR-066 | DR-256 -->
<div class="relative flex items-center justify-between">
<!-- One panel, one open menu. Each menu used to own a `show…` boolean
that no other toggle cleared, so a second menu opened stacked on top
of the first. TRACES: DR-256 -->
{#if openMenu && openMenu !== "volume"}
<!-- Tapping anywhere else dismisses the menu. Inside the controls
subtree, so a tap here never reaches the container tap gestures
(DR-098) and it disappears with the bar. -->
<button
onclick={togglePlayPause}
class="text-white hover:text-gray-300"
aria-label={isPlaying ? "Pause" : "Play"}
>
{#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next Episode -->
{#if hasNext}
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
{/if}
</div>
<div class="flex items-center gap-4">
<!-- Audio Track Selection -->
{#if audioTracks().length > 1}
<div class="relative">
<button
onclick={toggleAudioTrackMenu}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
<!-- Audio Track Menu -->
{#if showAudioTrackMenu}
class="fixed inset-0 z-10 cursor-default"
onclick={closeMenu}
aria-label="Close menu"
tabindex="-1"
></button>
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
data-testid="player-menu"
class="absolute bottom-full right-0 mb-2 z-20 w-[min(20rem,calc(100vw-2rem))] max-h-[min(300px,45vh)] overflow-y-auto bg-black/90 backdrop-blur-sm rounded-lg shadow-xl"
>
<div class="p-2">
{#if openMenu === "audio"}
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Audio Track
</div>
@@ -2912,7 +2893,7 @@
</span>
{#if selectedAudioTrackIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -2921,37 +2902,7 @@
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!--
Streaming quality (bandwidth ceiling), populated from what this media
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
-->
{#if qualityOptions.length > 1}
<div class="relative">
<button
onclick={toggleQualityMenu}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
{#if showQualityMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
{:else if openMenu === "quality"}
<div class="px-3 py-2 border-b border-white/20">
<div class="text-white text-sm font-semibold">Quality</div>
<!--
@@ -2977,7 +2928,7 @@
</div>
{#if selectedQuality === option.quality}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -2986,33 +2937,7 @@
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Subtitle Selection -->
{#if subtitleTracks().length > 0}
<div class="relative">
<button
onclick={toggleSubtitleMenu}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
<!-- Subtitle Menu -->
{#if showSubtitleMenu}
<div
class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
>
<div class="p-2">
{:else if openMenu === "subtitle"}
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
Subtitles
</div>
@@ -3027,7 +2952,7 @@
<span class="text-sm">Off</span>
{#if selectedSubtitleIndex === null}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -3060,7 +2985,7 @@
</div>
{#if selectedSubtitleIndex === track.index}
<svg
class="w-4 h-4 text-[var(--color-jellyfin)]"
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
fill="currentColor"
viewBox="0 0 24 24"
>
@@ -3069,10 +2994,90 @@
{/if}
</button>
{/each}
{/if}
</div>
</div>
{/if}
<div class="flex items-center gap-4">
<!-- Play/Pause -->
<button
onclick={togglePlayPause}
class="text-white hover:text-gray-300"
aria-label={isPlaying ? "Pause" : "Play"}
>
{#if isPlaying}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next Episode -->
{#if hasNext}
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
{/if}
</div>
<!-- Wraps rather than overflowing: in portrait these icons plus the
transport controls are wider than the screen, and the last of them
(fullscreen, close) went off the edge. TRACES: UR-066 | DR-256 -->
<div class="flex flex-wrap items-center justify-end gap-x-4 gap-y-2">
<!-- Audio Track Selection -->
{#if audioTracks().length > 1}
<button
onclick={() => toggleMenu("audio")}
class="text-white hover:text-gray-300"
aria-label="Select audio track"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
</button>
{/if}
<!--
Streaming quality (bandwidth ceiling), populated from what this media
source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
-->
{#if qualityOptions.length > 1}
<button
onclick={() => toggleMenu("quality")}
class="text-white hover:text-gray-300 disabled:opacity-50"
disabled={changingQuality}
aria-label="Select streaming quality"
>
<!-- Speedometer: bitrate ceiling -->
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
/>
</svg>
</button>
{/if}
<!-- Subtitle Selection -->
{#if subtitleTracks().length > 0}
<button
onclick={() => toggleMenu("subtitle")}
class="text-white hover:text-gray-300"
aria-label="Select subtitles"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
/>
</svg>
</button>
{/if}
<!-- Sleep Timer -->
@@ -3098,8 +3103,13 @@
</button>
{/if}
<!-- Volume Control -->
<VolumeControl size="md" />
<!-- Volume Control. Its popup is a menu of this bar like any other,
so the bar owns whether it is open. TRACES: DR-256 -->
<VolumeControl
size="md"
open={openMenu === "volume"}
onOpenChange={(next) => (openMenu = next ? "volume" : null)}
/>
<!-- Picture-in-picture (Android only) -->
{#if pipSupported}
+20 -8
View File
@@ -7,16 +7,31 @@
interface Props {
size?: "sm" | "md" | "lg";
/**
* Controlled open state. Omit it and the slider manages its own; pass it
* (with `onOpenChange`) when the host has other menus that must not be
* open at the same time — the video player's control bar does.
*
* TRACES: DR-256
*/
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { size = "md" }: Props = $props();
let { size = "md", open, onOpenChange }: Props = $props();
// On Android, volume is controlled by system volume buttons (not a slider)
const isAndroid = platform() === "android";
let showSlider = $state(false);
let selfOpen = $state(false);
const showSlider = $derived(open ?? selfOpen);
let sliderValue = $state($mergedVolume);
function setOpen(next: boolean) {
if (onOpenChange) onOpenChange(next);
else selfOpen = next;
}
// Sync slider with merged volume (handles both local and remote)
$effect(() => {
sliderValue = $mergedVolume;
@@ -44,7 +59,7 @@
}
function toggleSlider() {
showSlider = !showSlider;
setOpen(!showSlider);
}
// Icon sizes based on prop (use $derived for reactivity)
@@ -106,7 +121,7 @@
<!-- Volume Slider (toggle on click) -->
{#if showSlider}
<div
class="absolute left-full ml-2 bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
class="absolute bottom-full right-0 mb-2 max-w-[calc(100vw-2rem)] bg-[var(--color-surface)] rounded-lg shadow-lg p-3 z-[70] flex items-center gap-2"
role="group"
aria-label="Volume controls"
>
@@ -153,9 +168,6 @@
<!-- Click outside to close volume slider -->
{#if showSlider}
<button
class="fixed inset-0 z-[65]"
onclick={() => (showSlider = false)}
aria-label="Close volume"
<button class="fixed inset-0 z-[65]" onclick={() => setOpen(false)} aria-label="Close volume"
></button>
{/if}