jellytau/src/lib/components/player/VolumeControl.svelte
Duncan Tourolle 1f6977cd01
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
Playback fix
2026-07-02 18:13:55 +02:00

162 lines
5.8 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";
}
let { size = "md" }: Props = $props();
// On Android, volume is controlled by system volume buttons (not a slider)
const isAndroid = platform() === "android";
let showSlider = $state(false);
let sliderValue = $state($mergedVolume);
// 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() {
showSlider = !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 left-full ml-2 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={() => showSlider = false}
aria-label="Close volume"
></button>
{/if}