Files
jellytau/src/lib/components/player/SleepTimerModal.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

220 lines
7.4 KiB
Svelte

<!-- TRACES: UR-026 | DR-029, DR-050 -->
<script lang="ts">
import { sleepTimer, sleepTimerMode, sleepTimerActive } from "$lib/stores/sleepTimer";
import { currentQueueItem } from "$lib/stores/queue";
import ScrollPicker from "$lib/components/common/ScrollPicker.svelte";
interface Props {
isOpen?: boolean;
onClose?: () => void;
mediaType?: string; // Override queue-based detection (e.g. for video player)
}
let { isOpen = false, onClose, mediaType }: Props = $props();
const timePickerItems = [
// 5 min increments to 30
{ value: 5, label: "5 min" },
{ value: 10, label: "10 min" },
{ value: 15, label: "15 min" },
{ value: 20, label: "20 min" },
{ value: 25, label: "25 min" },
{ value: 30, label: "30 min" },
// 15 min increments to 2 hrs
{ value: 45, label: "45 min" },
{ value: 60, label: "1 hr" },
{ value: 75, label: "1 hr 15 min" },
{ value: 90, label: "1 hr 30 min" },
{ value: 105, label: "1 hr 45 min" },
{ value: 120, label: "2 hr" },
// 1 hr increments after
{ value: 180, label: "3 hr" },
{ value: 240, label: "4 hr" },
{ value: 300, label: "5 hr" },
];
let selectedMinutes = $state(15);
const episodePresets = [1, 2, 3];
const effectiveType = $derived(mediaType ?? $currentQueueItem?.type);
const isEpisode = $derived(effectiveType === "Episode");
function handleSetTimer() {
sleepTimer.setTimeTimer(selectedMinutes);
onClose?.();
}
function handleEndOfTrack() {
sleepTimer.setEndOfTrackTimer();
onClose?.();
}
function handleEpisodePreset(count: number) {
sleepTimer.setEpisodesTimer(count);
onClose?.();
}
function handleCancel() {
sleepTimer.cancel();
onClose?.();
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget && onClose) {
onClose();
}
}
function getActiveLabel(): string {
const mode = $sleepTimerMode;
switch (mode.kind) {
case "time":
return "Timer active";
case "endOfTrack":
return "Stops after current";
case "episodes":
return `${mode.remaining} episode${mode.remaining !== 1 ? "s" : ""} remaining`;
default:
return "";
}
}
function getEndOfTrackLabel(): string {
const type = effectiveType;
if (type === "Episode") return "End of current episode";
if (type === "Movie") return "End of current film";
return "End of current track";
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/60 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4"
onclick={handleBackdropClick}
onkeydown={(e) => {
if (e.key === "Escape") onClose?.();
}}
role="dialog"
aria-modal="true"
aria-labelledby="sleep-timer-title"
tabindex="-1"
>
<div
class="bg-[var(--color-surface)] rounded-t-2xl sm:rounded-2xl w-full sm:max-w-md max-h-[80vh] sm:max-h-[70vh] flex flex-col shadow-2xl"
onclick={(e) => e.stopPropagation()}
role="none"
>
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
<h2 id="sleep-timer-title" class="text-lg font-semibold text-white">Sleep Timer</h2>
<button
onclick={onClose}
class="p-2 -m-2 text-gray-400 hover:text-white transition-colors"
aria-label="Close"
>
<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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4">
<!-- Active timer indicator -->
{#if $sleepTimerActive}
<div
class="mb-4 p-4 rounded-lg bg-[var(--color-jellyfin)]/10 border border-[var(--color-jellyfin)]/30"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<svg
class="w-5 h-5 text-[var(--color-jellyfin)]"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
/>
</svg>
<span class="text-sm font-medium text-[var(--color-jellyfin)]">
{getActiveLabel()}
</span>
</div>
<button
onclick={handleCancel}
class="text-xs text-gray-400 hover:text-white transition-colors"
>
Cancel
</button>
</div>
</div>
{/if}
<!-- Time roller -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after time</h3>
<div class="flex flex-col items-center gap-3">
<ScrollPicker
items={timePickerItems}
selectedValue={selectedMinutes}
visibleCount={3}
itemHeight={56}
onSelect={(val) => {
selectedMinutes = val as number;
}}
/>
<button
onclick={handleSetTimer}
class="w-full py-3 rounded-lg bg-[var(--color-jellyfin)] text-white font-semibold hover:opacity-90 transition-opacity"
>
Set {selectedMinutes} min timer
</button>
</div>
</div>
<!-- End of current track/episode/film -->
<div class="mb-6">
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after current</h3>
<button
onclick={handleEndOfTrack}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
>
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
</svg>
<span class="text-white">{getEndOfTrackLabel()}</span>
</button>
</div>
<!-- Episode countdown (only for TV episodes) -->
{#if isEpisode}
<div>
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after episodes</h3>
<div class="space-y-2">
{#each episodePresets as count}
<button
onclick={() => handleEpisodePreset(count)}
class="w-full p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-left flex items-center gap-3"
>
<svg class="w-6 h-6 text-gray-400" fill="currentColor" viewBox="0 0 24 24">
<path
d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"
/>
</svg>
<span class="text-white">{count} more episode{count !== 1 ? "s" : ""}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}