jellytau/src/lib/components/player/Controls.svelte
Duncan Tourolle 09780103a7
Some checks failed
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 12s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Failing after 1s
Split software arch desc for easier manintenance. Many fixes related to next video playing and remote playback
2026-03-01 19:47:46 +01:00

204 lines
6.0 KiB
Svelte

<!-- TRACES: UR-005 | DR-009 -->
<script lang="ts">
import { untrack } from "svelte";
import { sleepTimerActive } from "$lib/stores/sleepTimer";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
interface Props {
isPlaying?: boolean;
hasPrevious?: boolean;
hasNext?: boolean;
shuffle?: boolean;
repeat?: "off" | "all" | "one";
onPlayPause?: () => void;
onPrevious?: () => void;
onNext?: () => void;
onToggleShuffle?: () => void;
onCycleRepeat?: () => void;
onSleepTimerClick?: () => void;
}
let {
isPlaying = false,
hasPrevious = false,
hasNext = false,
shuffle = false,
repeat = "off",
onPlayPause,
onPrevious,
onNext,
onToggleShuffle,
onCycleRepeat,
onSleepTimerClick,
}: Props = $props();
// Local optimistic state for instant button feedback
let optimisticIsPlaying = $state(false);
let optimisticTimeout: ReturnType<typeof setTimeout> | null = null;
// Sync with prop changes (initializes and updates on prop change)
$effect(() => {
optimisticIsPlaying = isPlaying;
// Clear timeout when prop updates (state confirmed)
if (optimisticTimeout) {
clearTimeout(optimisticTimeout);
optimisticTimeout = null;
}
});
// Cleanup on unmount
$effect(() => {
return () => {
if (optimisticTimeout) {
clearTimeout(optimisticTimeout);
}
};
});
function handlePlayPause() {
// Immediately toggle optimistic state for instant visual feedback
optimisticIsPlaying = !optimisticIsPlaying;
// Clear any pending timeout
if (optimisticTimeout) {
clearTimeout(optimisticTimeout);
}
// Reset optimistic state after a delay if prop doesn't update
optimisticTimeout = setTimeout(() => {
optimisticIsPlaying = isPlaying;
}, 1000);
// Call the actual handler
untrack(() => onPlayPause?.());
}
</script>
<div class="flex items-center gap-4">
<!-- Sleep Timer (above shuffle) -->
{#if !$sleepTimerActive}
<button
onclick={(e) => {
e.stopPropagation();
onSleepTimerClick?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full text-gray-400 hover:text-white transition-colors"
title="Sleep timer"
aria-label="Sleep timer"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</button>
{:else}
<SleepTimerIndicator onClick={onSleepTimerClick} />
{/if}
<!-- Shuffle -->
<button
onclick={(e) => {
e.stopPropagation();
onToggleShuffle?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full transition-colors {shuffle
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
title="Shuffle"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z" />
</svg>
</button>
<!-- Previous -->
<button
onclick={(e) => {
e.stopPropagation();
onPrevious?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
disabled={!hasPrevious}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Previous"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
</svg>
</button>
<!-- Play/Pause -->
<button
onclick={(e) => {
e.stopPropagation();
handlePlayPause();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-3 rounded-full bg-white text-black hover:scale-105 transition-transform"
title={optimisticIsPlaying ? "Pause" : "Play"}
>
{#if optimisticIsPlaying}
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
{:else}
<svg class="w-6 h-6 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</button>
<!-- Next -->
<button
onclick={(e) => {
e.stopPropagation();
onNext?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
disabled={!hasNext}
class="p-2 rounded-full text-white hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Next"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
</button>
<!-- Repeat -->
<button
onclick={(e) => {
e.stopPropagation();
onCycleRepeat?.();
}}
ontouchstart={(e) => e.stopPropagation()}
ontouchmove={(e) => e.stopPropagation()}
ontouchend={(e) => e.stopPropagation()}
class="p-2 rounded-full transition-colors {repeat !== 'off'
? 'text-[var(--color-jellyfin)]'
: 'text-gray-400 hover:text-white'}"
title="Repeat: {repeat}"
>
{#if repeat === "one"}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4zm-4-2V9h-1l-2 1v1h1.5v4H13z" />
</svg>
{:else}
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
{/if}
</button>
</div>