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.
174 lines
6.4 KiB
Svelte
174 lines
6.4 KiB
Svelte
<script lang="ts">
|
|
import { playerController } from "$lib/player";
|
|
import { volume, isMuted, mergedVolume } from "$lib/stores/player";
|
|
import { isRemoteMode } from "$lib/stores/playbackMode";
|
|
import { selectedSession, sessions } from "$lib/stores/sessions";
|
|
import { platform } from "@tauri-apps/plugin-os";
|
|
|
|
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", open, onOpenChange }: Props = $props();
|
|
|
|
// On Android, volume is controlled by system volume buttons (not a slider)
|
|
const isAndroid = platform() === "android";
|
|
|
|
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;
|
|
});
|
|
|
|
async function handleVolumeChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const newVolume = parseFloat(target.value);
|
|
sliderValue = newVolume;
|
|
|
|
if ($isRemoteMode && $selectedSession) {
|
|
// Remote mode: send volume as 0-100 integer to remote session
|
|
await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100));
|
|
} else {
|
|
await playerController.setVolume(newVolume);
|
|
}
|
|
}
|
|
|
|
async function toggleMute() {
|
|
if ($isRemoteMode && $selectedSession) {
|
|
await sessions.sendToggleMute($selectedSession.id);
|
|
} else {
|
|
await playerController.toggleMute();
|
|
}
|
|
}
|
|
|
|
function toggleSlider() {
|
|
setOpen(!showSlider);
|
|
}
|
|
|
|
// Icon sizes based on prop (use $derived for reactivity)
|
|
const iconSize = $derived(size === "sm" ? "w-4 h-4" : size === "md" ? "w-5 h-5" : "w-6 h-6");
|
|
const buttonPadding = $derived(size === "sm" ? "p-1" : size === "md" ? "p-2" : "p-3");
|
|
</script>
|
|
|
|
{#if !isAndroid}
|
|
<div class="relative flex items-center gap-1">
|
|
<!-- Volume Icon Button (click to toggle slider) -->
|
|
<button
|
|
onclick={toggleSlider}
|
|
class="{buttonPadding} rounded-full hover:bg-white/10 transition-colors"
|
|
title="Volume"
|
|
>
|
|
{#if $isMuted || sliderValue === 0}
|
|
<!-- Muted Icon -->
|
|
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
|
/>
|
|
</svg>
|
|
{:else if sliderValue < 0.33}
|
|
<!-- Low Volume Icon -->
|
|
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15.536 8.464a5 5 0 010 7.072m-9.95-9.193L4 8.929V5.071a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586z"
|
|
/>
|
|
</svg>
|
|
{:else if sliderValue < 0.66}
|
|
<!-- Medium Volume Icon -->
|
|
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15.536 8.464a5 5 0 010 7.072M6.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h2.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L6.586 15z"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<!-- High Volume Icon -->
|
|
<svg class={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Volume Slider (toggle on click) -->
|
|
{#if showSlider}
|
|
<div
|
|
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"
|
|
>
|
|
<!-- Mute button inside slider popup -->
|
|
<button
|
|
onclick={toggleMute}
|
|
class="p-1 rounded hover:bg-white/10 transition-colors"
|
|
title={$isMuted ? "Unmute" : "Mute"}
|
|
>
|
|
{#if $isMuted || sliderValue === 0}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
|
|
/>
|
|
</svg>
|
|
{:else}
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={sliderValue}
|
|
oninput={handleVolumeChange}
|
|
class="w-24 h-1 accent-[var(--color-jellyfin)] cursor-pointer"
|
|
/>
|
|
<span class="text-xs text-gray-400 w-8 text-right">{Math.round(sliderValue * 100)}%</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Click outside to close volume slider -->
|
|
{#if showSlider}
|
|
<button class="fixed inset-0 z-[65]" onclick={() => setOpen(false)} aria-label="Close volume"
|
|
></button>
|
|
{/if}
|