241 lines
7.7 KiB
Svelte
241 lines
7.7 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}
|