improvements to the sleep timer
This commit is contained in:
@@ -9,6 +9,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
export interface AutoplaySettings {
|
||||
enabled: boolean;
|
||||
countdownSeconds: number;
|
||||
maxEpisodes: number;
|
||||
}
|
||||
|
||||
export async function getAutoplaySettings(): Promise<AutoplaySettings> {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<script lang="ts">
|
||||
interface PickerItem {
|
||||
value: number | string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: PickerItem[];
|
||||
selectedValue?: number | string;
|
||||
visibleCount?: number;
|
||||
itemHeight?: number;
|
||||
onSelect?: (value: number | string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedValue = items[0]?.value,
|
||||
visibleCount = 3,
|
||||
itemHeight = 56,
|
||||
onSelect,
|
||||
}: Props = $props();
|
||||
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
const paddingCount = $derived(Math.floor(visibleCount / 2));
|
||||
const containerHeight = $derived(visibleCount * itemHeight);
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollContainer) return;
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
const newIndex = Math.round(scrollTop / itemHeight);
|
||||
if (newIndex >= 0 && newIndex < items.length && newIndex !== selectedIndex) {
|
||||
selectedIndex = newIndex;
|
||||
onSelect?.(items[newIndex].value);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToIndex(index: number) {
|
||||
scrollContainer?.scrollTo({
|
||||
top: index * itemHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize scroll position
|
||||
$effect(() => {
|
||||
if (scrollContainer) {
|
||||
const idx = Math.max(0, items.findIndex((i) => i.value === selectedValue));
|
||||
scrollContainer.scrollTop = idx * itemHeight;
|
||||
selectedIndex = idx;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative overflow-hidden rounded-lg"
|
||||
style="height: {containerHeight}px"
|
||||
>
|
||||
<!-- Highlight band for center item -->
|
||||
<div
|
||||
class="absolute left-0 right-0 pointer-events-none z-10 border-y border-[var(--color-jellyfin)]/40 bg-[var(--color-jellyfin)]/5 rounded"
|
||||
style="top: {paddingCount * itemHeight}px; height: {itemHeight}px"
|
||||
></div>
|
||||
|
||||
<!-- Fade gradients -->
|
||||
<div class="absolute top-0 left-0 right-0 h-10 bg-gradient-to-b from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
|
||||
<div class="absolute bottom-0 left-0 right-0 h-10 bg-gradient-to-t from-[var(--color-surface)] to-transparent z-20 pointer-events-none"></div>
|
||||
|
||||
<!-- Scrollable container -->
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
onscroll={handleScroll}
|
||||
class="h-full overflow-y-auto scroll-snap-y scrollbar-none"
|
||||
style="-webkit-overflow-scrolling: touch"
|
||||
>
|
||||
<!-- Top padding -->
|
||||
{#each Array(paddingCount) as _}
|
||||
<div style="height: {itemHeight}px"></div>
|
||||
{/each}
|
||||
|
||||
<!-- Items -->
|
||||
{#each items as item, i}
|
||||
<button
|
||||
onclick={() => scrollToIndex(i)}
|
||||
class="w-full snap-center flex items-center justify-center transition-all duration-150
|
||||
{i === selectedIndex
|
||||
? 'text-white text-2xl font-bold'
|
||||
: 'text-gray-500 text-lg font-medium opacity-60'}"
|
||||
style="height: {itemHeight}px"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Bottom padding -->
|
||||
{#each Array(paddingCount) as _}
|
||||
<div style="height: {itemHeight}px"></div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scroll-snap-y {
|
||||
scroll-snap-type: y mandatory;
|
||||
}
|
||||
.snap-center {
|
||||
scroll-snap-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -45,6 +45,7 @@
|
||||
let sortBy = $state<string>("");
|
||||
let sortOrder = $state<"Ascending" | "Descending">("Ascending");
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let initialLoadDone = false;
|
||||
|
||||
$effect(() => {
|
||||
sortBy = config.defaultSort;
|
||||
@@ -57,6 +58,7 @@
|
||||
onMount(async () => {
|
||||
await loadItems();
|
||||
markLoaded();
|
||||
initialLoadDone = true;
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
@@ -97,10 +99,12 @@
|
||||
searchQuery = query;
|
||||
}
|
||||
|
||||
// Debounce search input (300ms delay)
|
||||
// Debounce search input (300ms delay) - skip initial mount to avoid duplicate load
|
||||
$effect(() => {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
const _query = searchQuery; // track for reactivity
|
||||
if (!initialLoadDone) return;
|
||||
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
debouncedSearchQuery = searchQuery;
|
||||
loadItems();
|
||||
|
||||
@@ -202,7 +202,8 @@
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
<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>
|
||||
{#if $sleepTimerActive}
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-[var(--color-jellyfin)] rounded-full"></span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -92,8 +93,28 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Sleep Timer Indicator -->
|
||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||
<!-- Sleep Timer -->
|
||||
{#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}
|
||||
|
||||
<!-- Previous -->
|
||||
<button
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { formatTime, calculateProgress } from "$lib/utils/playbackUnits";
|
||||
import { haptics } from "$lib/utils/haptics";
|
||||
import { toast } from "$lib/stores/toast";
|
||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import Controls from "./Controls.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import CastButton from "$lib/components/sessions/CastButton.svelte";
|
||||
@@ -368,7 +369,27 @@
|
||||
<!-- Cast Button (visible on all screen sizes) -->
|
||||
<CastButton size="sm" />
|
||||
|
||||
<!-- Sleep Timer Indicator -->
|
||||
<!-- Sleep Timer Button (always visible on larger screens) -->
|
||||
{#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 hover:bg-white/10 transition-colors hidden sm:block"
|
||||
title="Sleep timer"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-400" 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>
|
||||
{/if}
|
||||
<!-- Sleep Timer Indicator (shows when active) -->
|
||||
<SleepTimerIndicator onClick={onSleepTimerClick} />
|
||||
|
||||
<!-- Volume Control (Linux only) -->
|
||||
|
||||
@@ -5,24 +5,32 @@
|
||||
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 }: Props = $props();
|
||||
let { isOpen = false, onClose, mediaType }: Props = $props();
|
||||
|
||||
const timePickerItems = [
|
||||
{ value: 15, label: "15 min" },
|
||||
{ value: 30, label: "30 min" },
|
||||
{ value: 45, label: "45 min" },
|
||||
{ value: 60, label: "60 min" },
|
||||
];
|
||||
|
||||
let selectedMinutes = $state(30);
|
||||
|
||||
const timePresets = [15, 30, 45, 60];
|
||||
const episodePresets = [1, 2, 3];
|
||||
|
||||
const isEpisode = $derived($currentQueueItem?.type === "Episode");
|
||||
const isVideo = $derived(
|
||||
$currentQueueItem?.type === "Episode" || $currentQueueItem?.type === "Movie"
|
||||
);
|
||||
const effectiveType = $derived(mediaType ?? $currentQueueItem?.type);
|
||||
const isEpisode = $derived(effectiveType === "Episode");
|
||||
|
||||
function handleTimePreset(minutes: number) {
|
||||
sleepTimer.setTimeTimer(minutes);
|
||||
function handleSetTimer() {
|
||||
sleepTimer.setTimeTimer(selectedMinutes);
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
@@ -62,7 +70,7 @@
|
||||
}
|
||||
|
||||
function getEndOfTrackLabel(): string {
|
||||
const type = $currentQueueItem?.type;
|
||||
const type = effectiveType;
|
||||
if (type === "Episode") return "End of current episode";
|
||||
if (type === "Movie") return "End of current film";
|
||||
return "End of current track";
|
||||
@@ -73,7 +81,7 @@
|
||||
<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') handleBackdropClick(); }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onClose?.(); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sleep-timer-title"
|
||||
@@ -144,19 +152,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Time presets -->
|
||||
<!-- Time roller -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-sm font-medium text-gray-400 mb-3">Stop after time</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each timePresets as minutes}
|
||||
<button
|
||||
onclick={() => handleTimePreset(minutes)}
|
||||
class="p-4 rounded-lg border border-gray-800 hover:border-[var(--color-jellyfin)]/50 hover:bg-[var(--color-jellyfin)]/5 transition-all text-center"
|
||||
>
|
||||
<span class="text-lg font-medium text-white">{minutes}</span>
|
||||
<span class="text-sm text-gray-400 ml-1">min</span>
|
||||
</button>
|
||||
{/each}
|
||||
<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>
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import VolumeControl from "./VolumeControl.svelte";
|
||||
import SleepTimerModal from "./SleepTimerModal.svelte";
|
||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -20,15 +23,18 @@
|
||||
onReportStart?: (positionSeconds: number) => void;
|
||||
onReportStop?: (positionSeconds: number) => void;
|
||||
onEnded?: () => void; // Called when video playback ends naturally
|
||||
onNext?: () => void; // Called when user clicks next episode button
|
||||
hasNext?: boolean; // Whether there is a next episode available
|
||||
}
|
||||
|
||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded }: Props = $props();
|
||||
let { media, streamUrl, mediaSourceId, initialPosition, needsTranscoding = false, onClose, onSeek, onReportProgress, onReportStart, onReportStop, onEnded, onNext, hasNext = false }: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let isPlaying = $state(false);
|
||||
let currentTime = $state(0);
|
||||
let isFullscreen = $state(false);
|
||||
let showControls = $state(true);
|
||||
let showSleepTimerModal = $state(false);
|
||||
let isBuffering = $state(false);
|
||||
let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let seekOffset = $state(0); // Track offset when seeking in transcoded streams
|
||||
@@ -1408,6 +1414,15 @@
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Next Episode -->
|
||||
{#if hasNext}
|
||||
<button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -1520,6 +1535,21 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep Timer -->
|
||||
{#if $sleepTimerActive}
|
||||
<SleepTimerIndicator onClick={() => { showSleepTimerModal = true; }} />
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { showSleepTimerModal = true; }}
|
||||
class="text-white hover:text-gray-300"
|
||||
aria-label="Sleep timer"
|
||||
>
|
||||
<svg class="w-6 h-6" 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>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Volume Control -->
|
||||
<VolumeControl size="md" />
|
||||
|
||||
@@ -1545,6 +1575,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SleepTimerModal isOpen={showSleepTimerModal} onClose={() => { showSleepTimerModal = false; }} mediaType={media?.type} />
|
||||
|
||||
<style>
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</div>
|
||||
|
||||
<!-- User menu -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<span class="text-sm text-gray-400 hidden md:inline">{$currentUser?.name}</span>
|
||||
|
||||
<!-- Desktop: Downloads icon -->
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
let error = $state<string | null>(null);
|
||||
let showResumeDialog = $state(false);
|
||||
let savedProgress = $state<{ positionSeconds: number; progressPercent: number } | null>(null);
|
||||
let nextEpisode = $state<MediaItem | null>(null); // Next episode for video skip button
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let loadedItemId: string | null = null;
|
||||
@@ -408,6 +409,11 @@
|
||||
|
||||
isPlaying = true;
|
||||
loading = false;
|
||||
|
||||
// Fetch next episode for video episodes (for skip button)
|
||||
if (isVideo && currentMedia) {
|
||||
fetchNextEpisode(currentMedia);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("loadAndPlay error:", e);
|
||||
// Show detailed error including the full error object
|
||||
@@ -520,6 +526,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNextEpisode(media: MediaItem) {
|
||||
nextEpisode = null;
|
||||
if (media.type !== "Episode" || !media.seriesId) return;
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const episodes = await repo.getNextUpEpisodes(media.seriesId, 1);
|
||||
if (episodes.length > 0 && episodes[0].id !== media.id) {
|
||||
nextEpisode = episodes[0];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch next episode:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSkipToNextEpisode() {
|
||||
if (nextEpisode) {
|
||||
goto(`/player/${nextEpisode.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
@@ -588,6 +614,8 @@
|
||||
onReportProgress={handleReportProgress}
|
||||
onReportStop={handleReportStop}
|
||||
onEnded={handleVideoEnded}
|
||||
hasNext={nextEpisode !== null}
|
||||
onNext={handleSkipToNextEpisode}
|
||||
/>
|
||||
<NextEpisodePopup />
|
||||
{:else}
|
||||
|
||||
@@ -23,8 +23,18 @@
|
||||
interface VideoSettings {
|
||||
autoPlayNextEpisode: boolean;
|
||||
autoPlayCountdownSeconds: number;
|
||||
autoPlayMaxEpisodes: number;
|
||||
}
|
||||
|
||||
const episodeLimitOptions = [
|
||||
{ value: 0, label: "Unlimited" },
|
||||
{ value: 1, label: "1" },
|
||||
{ value: 2, label: "2" },
|
||||
{ value: 3, label: "3" },
|
||||
{ value: 5, label: "5" },
|
||||
{ value: 10, label: "10" },
|
||||
];
|
||||
|
||||
let settings = $state<AudioSettings>({
|
||||
crossfadeDuration: 0,
|
||||
gaplessPlayback: true,
|
||||
@@ -35,6 +45,7 @@
|
||||
let videoSettings = $state<VideoSettings>({
|
||||
autoPlayNextEpisode: true,
|
||||
autoPlayCountdownSeconds: 10,
|
||||
autoPlayMaxEpisodes: 0,
|
||||
});
|
||||
|
||||
let loading = $state(true);
|
||||
@@ -172,7 +183,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-8 p-6">
|
||||
<div class="max-w-2xl mx-auto space-y-8 p-6 pb-24 h-full overflow-y-auto">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Audio Settings</h1>
|
||||
<p class="text-gray-400">Configure playback and audio processing</p>
|
||||
@@ -359,6 +370,29 @@
|
||||
<span>30s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episode Limit -->
|
||||
<div class="pt-4 border-t border-gray-700">
|
||||
<div class="mb-4">
|
||||
<p class="text-sm font-medium text-gray-300">Episode Limit</p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Stop auto-playing after this many consecutive episodes
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-2">
|
||||
{#each episodeLimitOptions as option}
|
||||
<button
|
||||
onclick={() => { videoSettings.autoPlayMaxEpisodes = option.value; }}
|
||||
class="py-3 px-3 rounded-lg transition-all text-sm
|
||||
{videoSettings.autoPlayMaxEpisodes === option.value
|
||||
? 'bg-[var(--color-jellyfin)] text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
||||
>
|
||||
<div class="font-semibold">{option.label}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user