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:
@@ -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 200–220 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,8 +2849,155 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Control buttons -->
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- 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
|
||||
class="fixed inset-0 z-10 cursor-default"
|
||||
onclick={closeMenu}
|
||||
aria-label="Close menu"
|
||||
tabindex="-1"
|
||||
></button>
|
||||
<div
|
||||
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>
|
||||
{#each audioTracks() as track, i}
|
||||
<button
|
||||
onclick={() => selectAudioTrack(track.index, i)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${i + 1}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if selectedAudioTrackIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{: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>
|
||||
<!--
|
||||
What the server is actually doing. Only knowable now that
|
||||
the backend reports it. TRACES: UR-079 | DR-228
|
||||
-->
|
||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
||||
</div>
|
||||
{#each qualityOptions as option (option.quality)}
|
||||
<button
|
||||
onclick={() => selectQuality(option.quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
option.quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{option.label}</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedQuality === option.quality}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if openMenu === "subtitle"}
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Subtitles
|
||||
</div>
|
||||
<!-- Off option -->
|
||||
<button
|
||||
onclick={() => selectSubtitle(null)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
null
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">Off</span>
|
||||
{#if selectedSubtitleIndex === null}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Subtitle tracks -->
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${track.index}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
{#if track.isForced}
|
||||
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if track.codec}
|
||||
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedSubtitleIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)] flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Play/Pause -->
|
||||
<button
|
||||
@@ -2871,60 +3026,23 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 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}
|
||||
<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}
|
||||
<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">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Audio Track
|
||||
</div>
|
||||
{#each audioTracks() as track, i}
|
||||
<button
|
||||
onclick={() => selectAudioTrack(track.index, i)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${i + 1}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if selectedAudioTrackIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
<!--
|
||||
@@ -2932,147 +3050,34 @@
|
||||
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">
|
||||
<div class="px-3 py-2 border-b border-white/20">
|
||||
<div class="text-white text-sm font-semibold">Quality</div>
|
||||
<!--
|
||||
What the server is actually doing. Only knowable now that
|
||||
the backend reports it. TRACES: UR-079 | DR-228
|
||||
-->
|
||||
<div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
|
||||
</div>
|
||||
{#each qualityOptions as option (option.quality)}
|
||||
<button
|
||||
onclick={() => selectQuality(option.quality)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
|
||||
option.quality
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">{option.label}</span>
|
||||
<span class="text-xs text-gray-400">
|
||||
{option.detail}{#if option.quality === "original" && option.sourceBitrate}
|
||||
· {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if selectedQuality === option.quality}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
<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">
|
||||
<div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
|
||||
Subtitles
|
||||
</div>
|
||||
<!-- Off option -->
|
||||
<button
|
||||
onclick={() => selectSubtitle(null)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
null
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<span class="text-sm">Off</span>
|
||||
{#if selectedSubtitleIndex === null}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Subtitle tracks -->
|
||||
{#each subtitleTracks() as track}
|
||||
<button
|
||||
onclick={() => selectSubtitle(track.index)}
|
||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
|
||||
track.index
|
||||
? 'bg-white/20'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm">
|
||||
{track.displayTitle || track.language || `Track ${track.index}`}
|
||||
{#if track.isDefault}
|
||||
<span class="text-xs text-gray-400 ml-1">(Default)</span>
|
||||
{/if}
|
||||
{#if track.isForced}
|
||||
<span class="text-xs text-gray-400 ml-1">(Forced)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if track.codec}
|
||||
<span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if selectedSubtitleIndex === track.index}
|
||||
<svg
|
||||
class="w-4 h-4 text-[var(--color-jellyfin)]"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user