Search's instant leg read only downloaded items, so with no downloads it returned nothing and every keystroke fell through to a full Recursive=true server query. It now reads the whole synced catalog through the same availability CTE get_items uses, gated on the same include_catalog_browse flag so search and browse cannot diverge. (UR-065, DR-108) Also fixes three defects found while confirming that: - items_fts grew by a full duplicate index every catalog pass. INSERT OR REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement took a fresh rowid and inserted a second entry. Now a real upsert, with migration 021 rebuilding existing indexes. (DR-110) - DELETE FROM items existed nowhere, so server-side deletions never propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types, skipping downloaded items, and refusing to run after a partial crawl because items.parent_id cascades. (DR-110) - The index omitted MusicArtist, Playlist and People, which search groups results by. Adds them plus people_fts (migration 022). (DR-111) Re-indexing moves from a frontend startup call to a Rust background task with a 6h TTL, so a long session no longer searches a stale catalog and a restart no longer forces a crawl regardless of freshness. (DR-109, IR-030) Downloads gain a lifetime tier. Eviction selected every completed row by age with no download_source filter, so hitting the storage limit deleted the oldest download -- typically one saved deliberately for offline -- to make room for a precached track. It now reclaims only 'auto' rows, and expired ones are reclaimed first, before live cache is evicted. (DR-126, DR-127) Downloaded video and audio-only handoffs now play from disk instead of streaming; the video path had never consulted downloads at all. No transcode is involved: MPV runs video=no and ExoPlayer has no surface for an Audio item. (DR-123 in part, DR-128) FTS queries are built as quoted phrases so apostrophes, hyphens and slashes are data rather than operator syntax, and the item-type filter is bound rather than interpolated. Specs: docs/specs/catalog-index-search.md, docs/specs/read-through-media-cache.md Includes concurrently-developed favourites browsing and background-audio stream-end handling; the two workstreams share offline.rs, lib.rs and online.rs, so no subset of files builds independently.
928 lines
34 KiB
Svelte
928 lines
34 KiB
Svelte
<!-- TRACES: UR-023, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086 -->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { commands } from "$lib/api/bindings";
|
|
import type {
|
|
AudioSettings,
|
|
CacheConfig,
|
|
EqPreset,
|
|
VideoSettings,
|
|
VolumeLevel,
|
|
} from "$lib/api/bindings";
|
|
import {
|
|
getCacheStats,
|
|
setCacheLimit,
|
|
clearCache,
|
|
formatBytes,
|
|
gbToBytes,
|
|
bytesToGb,
|
|
type ImageCacheStats,
|
|
} from "$lib/services/imageCache";
|
|
import { getCacheConfig, updateCacheConfig } from "$lib/services/preload";
|
|
import SearchGroupOrderList from "$lib/components/settings/SearchGroupOrderList.svelte";
|
|
import { library, viewMode } from "$lib/stores/library";
|
|
import {
|
|
isNetworkDetectionSupported,
|
|
reportNetworkState,
|
|
} from "$lib/services/networkType";
|
|
|
|
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,
|
|
normalizeVolume: false,
|
|
volumeLevel: "normal",
|
|
equalizerEnabled: false,
|
|
equalizerBands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
});
|
|
|
|
// Equalizer band centre-frequency labels (must match Rust EQ_BANDS order).
|
|
// Presentation only — the gain curves themselves come from the backend.
|
|
const EQ_BAND_LABELS = ["31", "62", "125", "250", "500", "1k", "2k", "4k", "8k", "16k"];
|
|
const EQ_GAIN_MIN = -12;
|
|
const EQ_GAIN_MAX = 12;
|
|
// Preset name → gain curve, fetched from the backend (domain data lives in Rust).
|
|
let eqPresets = $state<[EqPreset, number[]][]>([]);
|
|
// Non-optional view of the bands for template bindings (the wire type marks
|
|
// equalizerBands optional via serde default; loadSettings guarantees it dense).
|
|
const eqBands = $derived(settings.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
|
|
|
let videoSettings = $state<VideoSettings>({
|
|
autoPlayNextEpisode: true,
|
|
autoPlayCountdownSeconds: 10,
|
|
autoPlayMaxEpisodes: 0,
|
|
});
|
|
|
|
// Download/caching behaviour, incl. the WiFi-only gate (UR-053).
|
|
let cacheConfig = $state<CacheConfig>({
|
|
queuePrecacheEnabled: true,
|
|
queuePrecacheCount: 3,
|
|
albumAffinityEnabled: true,
|
|
albumAffinityThreshold: 3,
|
|
storageLimit: 10 * 1024 * 1024 * 1024,
|
|
wifiOnly: false,
|
|
// Placeholder only — replaced by the backend's value on load. How long a
|
|
// temporary (auto-cached) download lives before it is reclaimed; the policy
|
|
// itself is Rust's (DR-127).
|
|
temporaryTtlHours: 24 * 7,
|
|
});
|
|
|
|
// Whether the platform can actually detect the network type. On desktop it
|
|
// can't, so the WiFi-only toggle would be inert — we disable and explain it
|
|
// rather than offering a switch that does nothing.
|
|
let networkDetectionSupported = $state(false);
|
|
|
|
let loading = $state(true);
|
|
|
|
// Image cache state
|
|
let cacheStats = $state<ImageCacheStats | null>(null);
|
|
let cacheLoading = $state(false);
|
|
let clearingCache = $state(false);
|
|
|
|
// Cache limit options in bytes
|
|
const cacheLimitOptions = [
|
|
{ label: "500 MB", bytes: gbToBytes(0.5) },
|
|
{ label: "1 GB", bytes: gbToBytes(1), default: true },
|
|
{ label: "2 GB", bytes: gbToBytes(2) },
|
|
{ label: "5 GB", bytes: gbToBytes(5) },
|
|
{ label: "Unlimited", bytes: 0 },
|
|
];
|
|
|
|
onMount(async () => {
|
|
await loadSettings();
|
|
});
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
loading = true;
|
|
networkDetectionSupported = isNetworkDetectionSupported();
|
|
const [audioResult, videoResult, cacheResult, presets] = await Promise.all([
|
|
commands.playerGetAudioSettings(),
|
|
commands.playerGetVideoSettings(),
|
|
getCacheConfig(),
|
|
commands.playerGetEqPresets(),
|
|
]);
|
|
// equalizerBands is optional on the wire (serde default); guarantee a
|
|
// dense 10-band array so the slider bindings are never undefined.
|
|
settings = {
|
|
...audioResult,
|
|
equalizerBands: audioResult.equalizerBands ?? [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
|
};
|
|
videoSettings = videoResult;
|
|
cacheConfig = cacheResult;
|
|
eqPresets = presets;
|
|
// Load cache stats in parallel but don't block on it
|
|
loadCacheStats();
|
|
} catch (e) {
|
|
console.error("Failed to load settings:", e);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function loadCacheStats() {
|
|
try {
|
|
cacheLoading = true;
|
|
cacheStats = await getCacheStats();
|
|
} catch (e) {
|
|
console.error("Failed to load cache stats:", e);
|
|
} finally {
|
|
cacheLoading = false;
|
|
}
|
|
}
|
|
|
|
async function handleCacheLimitChange(limitBytes: number) {
|
|
try {
|
|
await setCacheLimit(limitBytes);
|
|
// Reload stats to reflect new limit
|
|
await loadCacheStats();
|
|
} catch (e) {
|
|
console.error("Failed to set cache limit:", e);
|
|
}
|
|
}
|
|
|
|
async function handleClearCache() {
|
|
try {
|
|
clearingCache = true;
|
|
await clearCache();
|
|
await loadCacheStats();
|
|
} catch (e) {
|
|
console.error("Failed to clear cache:", e);
|
|
} finally {
|
|
clearingCache = false;
|
|
}
|
|
}
|
|
|
|
// Get the current selected limit option
|
|
function isCurrentLimit(optionBytes: number): boolean {
|
|
if (!cacheStats) return false;
|
|
// Unlimited is 0
|
|
if (optionBytes === 0 && cacheStats.limitBytes === 0) return true;
|
|
// Allow small tolerance for floating point
|
|
return Math.abs(cacheStats.limitBytes - optionBytes) < 1000;
|
|
}
|
|
|
|
// Calculate cache usage percentage
|
|
function getCacheUsagePercent(): number {
|
|
if (!cacheStats || cacheStats.limitBytes === 0) return 0;
|
|
return Math.min(100, (cacheStats.totalSizeBytes / cacheStats.limitBytes) * 100);
|
|
}
|
|
|
|
// Settings apply the moment the user changes a control — there is no Save
|
|
// button. Each helper writes just the settings group it owns so a single
|
|
// toggle doesn't re-push unrelated state.
|
|
async function persistAudio() {
|
|
try {
|
|
await commands.playerSetAudioSettings(settings);
|
|
} catch (e) {
|
|
console.error("Failed to save audio settings:", e);
|
|
}
|
|
}
|
|
|
|
async function persistVideo() {
|
|
try {
|
|
await commands.playerSetVideoSettings(videoSettings);
|
|
} catch (e) {
|
|
console.error("Failed to save video settings:", e);
|
|
}
|
|
}
|
|
|
|
async function persistCache() {
|
|
try {
|
|
await updateCacheConfig(cacheConfig);
|
|
// Re-report the network so the backend re-evaluates the gate against the
|
|
// just-changed wifi-only preference, releasing or holding the queue now
|
|
// rather than at the next network change.
|
|
await reportNetworkState();
|
|
} catch (e) {
|
|
console.error("Failed to save download settings:", e);
|
|
}
|
|
}
|
|
|
|
// Slider drags fire `input` on every tick; update the live display there but
|
|
// only persist on `change` (pointer release) so we don't spam the backend.
|
|
function handleCrossfadeInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
settings.crossfadeDuration = parseFloat(target.value);
|
|
}
|
|
|
|
function handleCrossfadeChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
settings.crossfadeDuration = parseFloat(target.value);
|
|
persistAudio();
|
|
}
|
|
|
|
function handleGaplessToggle() {
|
|
settings.gaplessPlayback = !settings.gaplessPlayback;
|
|
persistAudio();
|
|
}
|
|
|
|
function handleNormalizeToggle() {
|
|
settings.normalizeVolume = !settings.normalizeVolume;
|
|
persistAudio();
|
|
}
|
|
|
|
function handleVolumeLevelChange(level: VolumeLevel) {
|
|
settings.volumeLevel = level;
|
|
persistAudio();
|
|
}
|
|
|
|
// --- Equalizer (UR-027) ---
|
|
|
|
function handleEqToggle() {
|
|
settings.equalizerEnabled = !settings.equalizerEnabled;
|
|
persistAudio();
|
|
}
|
|
|
|
// Apply a preset's gain curve (from the backend) to the bands.
|
|
function handleEqPreset(gains: number[]) {
|
|
settings.equalizerBands = [...gains];
|
|
persistAudio();
|
|
}
|
|
|
|
// Live-update a single band while dragging; persist on release (change).
|
|
function handleEqBandInput(index: number, e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const bands = [...eqBands];
|
|
bands[index] = parseFloat(target.value);
|
|
settings.equalizerBands = bands;
|
|
}
|
|
|
|
function handleEqBandChange(index: number, e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
const bands = [...eqBands];
|
|
bands[index] = parseFloat(target.value);
|
|
settings.equalizerBands = bands;
|
|
persistAudio();
|
|
}
|
|
|
|
// The name of the preset whose curve matches the current bands, or null
|
|
// ("Custom"). Presentation-only label — the backend defines the curves.
|
|
const activeEqPreset = $derived.by<EqPreset | null>(() => {
|
|
const eq = eqBands;
|
|
for (const [name, gains] of eqPresets) {
|
|
if (gains.length === eq.length && gains.every((g, i) => g === eq[i])) {
|
|
return name;
|
|
}
|
|
}
|
|
return null;
|
|
});
|
|
|
|
// Human labels for preset chips.
|
|
const EQ_PRESET_LABELS: Record<EqPreset, string> = {
|
|
flat: "Flat",
|
|
rock: "Rock",
|
|
pop: "Pop",
|
|
jazz: "Jazz",
|
|
classical: "Classical",
|
|
bassBoost: "Bass Boost",
|
|
trebleBoost: "Treble Boost",
|
|
vocal: "Vocal",
|
|
};
|
|
|
|
function handleAutoPlayToggle() {
|
|
videoSettings.autoPlayNextEpisode = !videoSettings.autoPlayNextEpisode;
|
|
persistVideo();
|
|
}
|
|
|
|
function handleCountdownInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
|
}
|
|
|
|
function handleCountdownChange(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
videoSettings.autoPlayCountdownSeconds = parseInt(target.value, 10);
|
|
persistVideo();
|
|
}
|
|
|
|
function handleEpisodeLimitChange(value: number) {
|
|
videoSettings.autoPlayMaxEpisodes = value;
|
|
persistVideo();
|
|
}
|
|
|
|
function handleSmartCachingToggle() {
|
|
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
|
|
persistCache();
|
|
}
|
|
|
|
function handleQueuePrecacheToggle() {
|
|
cacheConfig.queuePrecacheEnabled = !cacheConfig.queuePrecacheEnabled;
|
|
persistCache();
|
|
}
|
|
|
|
function handleWifiOnlyToggle() {
|
|
cacheConfig.wifiOnly = !cacheConfig.wifiOnly;
|
|
persistCache();
|
|
}
|
|
</script>
|
|
|
|
<div class="max-w-2xl mx-auto space-y-8 p-6">
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-white mb-2">Settings</h1>
|
|
<p class="text-gray-400">Configure display, playback, and downloads</p>
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="text-center py-12 text-gray-400">
|
|
<p>Loading settings...</p>
|
|
</div>
|
|
{:else}
|
|
<div class="space-y-6">
|
|
<!-- Display — grid/list preference, a second view onto the library
|
|
viewMode store (same source of truth as the library page-header
|
|
toggle, so the two stay in sync for free). -->
|
|
<div id="display" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="mb-4">
|
|
<h2 class="text-xl font-semibold text-white">Display</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
How your library and collections are laid out
|
|
</p>
|
|
</div>
|
|
<p class="text-sm font-medium text-gray-300 mb-3">Layout</p>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<button
|
|
onclick={() => library.setViewMode("grid")}
|
|
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg transition-all {$viewMode ===
|
|
'grid'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
aria-pressed={$viewMode === "grid"}
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7z" />
|
|
</svg>
|
|
<span class="font-semibold">Grid</span>
|
|
</button>
|
|
<button
|
|
onclick={() => library.setViewMode("list")}
|
|
class="flex items-center justify-center gap-2 py-3 px-4 rounded-lg transition-all {$viewMode ===
|
|
'list'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
aria-pressed={$viewMode === "list"}
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z" />
|
|
</svg>
|
|
<span class="font-semibold">List</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Crossfade -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Crossfade</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Fade between tracks for seamless transitions
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
|
|
{settings.crossfadeDuration.toFixed(1)}s
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="12"
|
|
step="0.5"
|
|
value={settings.crossfadeDuration}
|
|
oninput={handleCrossfadeInput}
|
|
onchange={handleCrossfadeChange}
|
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
|
/>
|
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
|
<span>0s (Off)</span>
|
|
<span>12s (Max)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Gapless Playback -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Gapless Playback</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Eliminate silence between tracks in albums
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleGaplessToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.gaplessPlayback
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle gapless playback"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.gaplessPlayback
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volume Normalization -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Volume Normalization</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Automatically adjust volume levels for consistent playback
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleNormalizeToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.normalizeVolume
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle volume normalization"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.normalizeVolume
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if settings.normalizeVolume}
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<p class="text-sm font-medium text-gray-300 mb-3">Target Volume Level</p>
|
|
<div class="grid grid-cols-3 gap-3">
|
|
<button
|
|
onclick={() => handleVolumeLevelChange("loud")}
|
|
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
|
|
'loud'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">Loud</div>
|
|
<div class="text-xs opacity-75">-11 LUFS</div>
|
|
</button>
|
|
<button
|
|
onclick={() => handleVolumeLevelChange("normal")}
|
|
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
|
|
'normal'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">Normal</div>
|
|
<div class="text-xs opacity-75">-14 LUFS</div>
|
|
</button>
|
|
<button
|
|
onclick={() => handleVolumeLevelChange("quiet")}
|
|
class="py-3 px-4 rounded-lg transition-all {settings.volumeLevel ===
|
|
'quiet'
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">Quiet</div>
|
|
<div class="text-xs opacity-75">-23 LUFS</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Equalizer (UR-027) -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-white">Equalizer</h2>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Shape the sound with presets or custom bands (Linux)
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleEqToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {settings.equalizerEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle equalizer"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {settings.equalizerEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if settings.equalizerEnabled}
|
|
<!-- Preset chips -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<p class="text-sm font-medium text-gray-300 mb-3">Presets</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each eqPresets as [name, gains] (name)}
|
|
<button
|
|
onclick={() => handleEqPreset(gains)}
|
|
class="px-3 py-1.5 rounded-full text-sm transition-all {activeEqPreset ===
|
|
name
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
{EQ_PRESET_LABELS[name]}
|
|
</button>
|
|
{/each}
|
|
{#if activeEqPreset === null}
|
|
<span
|
|
class="px-3 py-1.5 rounded-full text-sm bg-[var(--color-jellyfin)] text-white"
|
|
>
|
|
Custom
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Band sliders -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<p class="text-sm font-medium text-gray-300 mb-4">Bands (dB)</p>
|
|
<div class="flex justify-between gap-1 sm:gap-2">
|
|
{#each EQ_BAND_LABELS as label, i (label)}
|
|
<div class="flex flex-col items-center gap-2 flex-1 min-w-0">
|
|
<span class="text-xs text-gray-400 tabular-nums">
|
|
{eqBands[i] > 0 ? "+" : ""}{eqBands[i]}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
min={EQ_GAIN_MIN}
|
|
max={EQ_GAIN_MAX}
|
|
step="1"
|
|
value={eqBands[i]}
|
|
oninput={(e) => handleEqBandInput(i, e)}
|
|
onchange={(e) => handleEqBandChange(i, e)}
|
|
class="eq-slider"
|
|
aria-label="{label} Hz gain"
|
|
/>
|
|
<span class="text-xs text-gray-500">{label}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Video Playback Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Video Playback</h2>
|
|
|
|
<!-- Auto-play Next Episode -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">Auto-play Next Episode</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Automatically start the next episode when one finishes
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleAutoPlayToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {videoSettings.autoPlayNextEpisode
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle auto-play next episode"
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {videoSettings.autoPlayNextEpisode
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if videoSettings.autoPlayNextEpisode}
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<p class="text-sm font-medium text-gray-300">Countdown Duration</p>
|
|
<p class="text-xs text-gray-500 mt-1">
|
|
Time before next episode starts automatically
|
|
</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-2xl font-bold text-[var(--color-jellyfin)]">
|
|
{videoSettings.autoPlayCountdownSeconds}s
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="5"
|
|
max="30"
|
|
step="5"
|
|
value={videoSettings.autoPlayCountdownSeconds}
|
|
oninput={handleCountdownInput}
|
|
onchange={handleCountdownChange}
|
|
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-[var(--color-jellyfin)]"
|
|
/>
|
|
<div class="flex justify-between text-xs text-gray-500 mt-2">
|
|
<span>5s</span>
|
|
<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={() => handleEpisodeLimitChange(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>
|
|
|
|
<!-- Search Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Search</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<h3 class="text-lg font-semibold text-white mb-1">Result Group Order</h3>
|
|
<p class="text-sm text-gray-400 mb-4">
|
|
Drag or use the arrows to choose the order search result groups appear in.
|
|
Empty groups are hidden automatically.
|
|
</p>
|
|
<SearchGroupOrderList />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image Cache Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2>
|
|
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
|
|
<!-- Cache Usage -->
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="text-lg font-semibold text-white">Cache Usage</h3>
|
|
{#if cacheLoading}
|
|
<span class="text-sm text-gray-400">Loading...</span>
|
|
{:else if cacheStats}
|
|
<span class="text-sm text-gray-300">
|
|
{formatBytes(cacheStats.totalSizeBytes)} / {cacheStats.limitBytes === 0 ? "Unlimited" : formatBytes(cacheStats.limitBytes)}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if cacheStats && cacheStats.limitBytes > 0}
|
|
<!-- Progress bar -->
|
|
<div class="w-full bg-gray-700 rounded-full h-3 mb-2">
|
|
<div
|
|
class="h-3 rounded-full transition-all duration-300 {getCacheUsagePercent() > 90 ? 'bg-red-500' : getCacheUsagePercent() > 70 ? 'bg-yellow-500' : 'bg-[var(--color-jellyfin)]'}"
|
|
style="width: {getCacheUsagePercent()}%"
|
|
></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<p class="text-sm text-gray-400">
|
|
{#if cacheStats}
|
|
{cacheStats.itemCount} images cached
|
|
{:else}
|
|
Thumbnails and artwork are cached locally for faster loading
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Cache Limit -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<h3 class="text-lg font-semibold text-white mb-3">Storage Limit</h3>
|
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-2">
|
|
{#each cacheLimitOptions as option}
|
|
<button
|
|
onclick={() => handleCacheLimitChange(option.bytes)}
|
|
class="py-2 px-3 rounded-lg transition-all text-sm {isCurrentLimit(option.bytes)
|
|
? 'bg-[var(--color-jellyfin)] text-white'
|
|
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
|
|
>
|
|
<div class="font-semibold">{option.label}</div>
|
|
{#if option.default}
|
|
<div class="text-xs opacity-75">Default</div>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clear Cache -->
|
|
<div class="pt-4 border-t border-gray-700">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-white">Clear Image Cache</h3>
|
|
<p class="text-sm text-gray-400">Remove all cached thumbnails and artwork</p>
|
|
</div>
|
|
<button
|
|
onclick={handleClearCache}
|
|
disabled={clearingCache || (cacheStats?.itemCount ?? 0) === 0}
|
|
class="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{clearingCache ? "Clearing..." : "Clear Cache"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Download Settings -->
|
|
<div class="border-t border-gray-700 pt-6">
|
|
<h2 class="text-2xl font-bold text-white mb-4">Downloads</h2>
|
|
|
|
<!-- Storage Limit -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
|
<div class="mb-4">
|
|
<h3 class="text-xl font-semibold text-white">Storage Limit</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Maximum storage for offline downloads
|
|
</p>
|
|
</div>
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
>
|
|
<div class="font-semibold">5 GB</div>
|
|
</button>
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-[var(--color-jellyfin)] text-white"
|
|
>
|
|
<div class="font-semibold">10 GB</div>
|
|
<div class="text-xs opacity-75">Default</div>
|
|
</button>
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
>
|
|
<div class="font-semibold">20 GB</div>
|
|
</button>
|
|
<button
|
|
class="py-3 px-4 rounded-lg transition-all bg-gray-700 text-gray-300 hover:bg-gray-600"
|
|
>
|
|
<div class="font-semibold">Unlimited</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Smart Caching -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">Smart Caching</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Automatically download albums you're listening to
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleSmartCachingToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.albumAffinityEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle smart caching"
|
|
aria-pressed={cacheConfig.albumAffinityEnabled}
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.albumAffinityEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Queue Pre-caching -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6 mb-4">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">Queue Pre-caching</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
Download the next {cacheConfig.queuePrecacheCount} tracks in the queue
|
|
automatically
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleQueuePrecacheToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.queuePrecacheEnabled
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'}"
|
|
aria-label="Toggle queue pre-caching"
|
|
aria-pressed={cacheConfig.queuePrecacheEnabled}
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.queuePrecacheEnabled
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WiFi Only -->
|
|
<div class="bg-[var(--color-surface)] rounded-lg p-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-white">WiFi Only</h3>
|
|
<p class="text-sm text-gray-400 mt-1">
|
|
{#if networkDetectionSupported}
|
|
Hold downloads unless on an unmetered network. Cellular and
|
|
metered hotspots are excluded; WiFi and Ethernet are allowed.
|
|
{:else}
|
|
Only available on Android — this device has no metered
|
|
connection to detect.
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onclick={handleWifiOnlyToggle}
|
|
class="relative inline-flex h-8 w-14 items-center rounded-full transition-colors {cacheConfig.wifiOnly
|
|
? 'bg-[var(--color-jellyfin)]'
|
|
: 'bg-gray-600'} {networkDetectionSupported
|
|
? ''
|
|
: 'opacity-50 cursor-not-allowed'}"
|
|
aria-label="Toggle WiFi only downloads"
|
|
aria-pressed={cacheConfig.wifiOnly}
|
|
disabled={!networkDetectionSupported}
|
|
>
|
|
<span
|
|
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {cacheConfig.wifiOnly
|
|
? 'translate-x-7'
|
|
: 'translate-x-1'}"
|
|
></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Box -->
|
|
<div class="bg-blue-900/20 border border-blue-800 rounded-lg p-4">
|
|
<div class="flex gap-3">
|
|
<svg
|
|
class="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5"
|
|
fill="currentColor"
|
|
viewBox="0 0 20 20"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
clip-rule="evenodd"
|
|
/>
|
|
</svg>
|
|
<div class="text-sm text-blue-300">
|
|
<p class="font-semibold mb-1">About these settings:</p>
|
|
<ul class="list-disc list-inside space-y-1 text-blue-200">
|
|
<li>
|
|
<strong>Crossfade</strong> smoothly blends the end of one track with the
|
|
beginning of the next
|
|
</li>
|
|
<li>
|
|
<strong>Gapless</strong> removes silence between tracks for continuous
|
|
album playback
|
|
</li>
|
|
<li>
|
|
<strong>Normalization</strong> evens out loudness between tracks
|
|
in real time, toward your selected level
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
/* Vertical EQ band sliders. `appearance: slider-vertical` is deprecated;
|
|
use writing-mode which is the supported path in modern WebKit/Chromium. */
|
|
.eq-slider {
|
|
writing-mode: vertical-lr;
|
|
direction: rtl;
|
|
width: 8px;
|
|
height: 96px;
|
|
accent-color: var(--color-jellyfin);
|
|
cursor: pointer;
|
|
}
|
|
</style>
|