Files
jellytau/src/routes/settings/+page.svelte
T
dtourolle f3fa45f742
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 22m12s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 37s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m10s
feat(diagnostics): persistent redacted logging and an exportable bundle
The app forgot everything it did the moment it exited. The Rust half
logged through env_logger to stdout only -- invisible to anyone who
launched from a desktop icon, and on Android worse than that: stdout is
not logcat, so the backend produced no visible output at all on the
platform carrying this project's hardest bugs. The autoplay deadlock,
the truncated-stream restart and the background-audio stall were all
diagnosed by talking a user through `adb logcat`, because there was no
other way to see anything. A panic left nothing behind at all.

Logs now go to a size-capped rotating file, to logcat on Android, and to
the webview console in dev. A panic is recorded with its backtrace before
the process dies. The frontend's messages are forwarded into the same
file, so one timeline holds both halves of the app in order -- which is
what makes a race between them legible after the fact, and races between
them are the expensive bug class here.

Redaction runs in the log FORMATTER, not at export time. A credential
sitting in a file on the device is already a disclosure; stripping it on
the way out would be too late. The exporter redacts a second time to
cover files written by builds that predate this. api_key, X-Emby-Token,
Authorization, "AccessToken" and Token="..." all reduce to [REDACTED],
while host, item ids and filenames are deliberately kept -- a log scrubbed
of those is one nobody can debug anything from. Server URLs keep scheme
and host and drop any embedded user:pass@.

Two things the tests caught that review would not have:

  - redact_headers recursed on its own output. The replacement keeps the
    header NAME, so the next call matched the same header forever; the
    test died with a stack overflow. It is a forward scan now.
  - The frontend forwarder used `void plugin.error(...)`. `void` discards
    a promise's value but not its rejection, so in any webview without
    IPC -- a unit test, SSR, a browser preview -- every log line became an
    unhandled rejection. 20 of them showed up the first time coverage
    ran. Each call now attaches a catch.

Only info and above cross the IPC boundary: debug is per-tick player
state and forwarding it would be thousands of calls a minute for output
nobody reads. A failing forwarder never propagates and never prevents the
console write.

Nothing is transmitted anywhere. The export writes a zip and reports its
path; the user attaches it themselves, which is also what keeps this from
becoming telemetry. An Android share intent is explicitly out of scope --
it is Kotlin work that belongs with the other native code.

The panic hook chains to the previous hook rather than replacing it,
because utils/lock.rs installs a silencing hook around tests that provoke
poisoned locks on purpose.

Spec in docs/specs/diagnostics-and-logging.md; UR-078 / DR-218 / UT-209.

Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
2026-08-21 18:58:57 +02:00

1427 lines
56 KiB
Svelte

<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { commands } from "$lib/api/bindings";
import type {
AudioSettings,
CacheConfig,
EqPreset,
ExclusionCandidate,
LibrarySettings,
StreamingQuality,
VideoSettings,
VolumeLevel,
DiagnosticsInfo,
DiagnosticsBundle,
} 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 PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import {
checkForUpdate,
installUpdate,
updateCapability,
RELEASES_URL,
type UpdateAction,
} from "$lib/utils/updateCheck";
const log = createLogger("SettingsPage");
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,
streamingQuality: "original",
});
// Bandwidth ceilings offered by the streaming-quality picker, as
// [variant, label, detail] — the numbers behind each step are Jellyfin
// encoding vocabulary, so Rust serves the list. TRACES: UR-074 | DR-162
let streamingQualities = $state<[StreamingQuality, string, string][]>([]);
// 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,
});
// Folders the user has hidden from browsing, and the folders they may choose
// from. Both come from Rust: which containers are offerable, and what hiding
// one actually excludes, are domain decisions — this page only renders the
// list and sends back the ids that are ticked.
// TRACES: UR-076 | DR-209
let librarySettings = $state<LibrarySettings>({ excludedItemIds: [] });
let exclusionCandidates = $state<ExclusionCandidate[]>([]);
let exclusionsLoading = $state(false);
const excludedIds = $derived(new Set(librarySettings.excludedItemIds ?? []));
// 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 },
];
// Native-video opt-in (Android). `supportsNativeVideo` comes from Rust, which
// owns the "does this platform have a native video surface" decision; the
// toggle is hidden entirely where it cannot apply.
let supportsNativeVideo = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
nativeVideoEnabled = v;
});
function handleNativeVideoToggle() {
experimentalNativeVideo.set(!nativeVideoEnabled);
}
// Not returned from onMount: that callback is async, so its return value is a
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
onMount(async () => {
await loadSettings();
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
// Which update story this platform gets. Android cannot install its own
// APK, so it is offered the releases page instead of an install button.
const { platform } = await import("@tauri-apps/plugin-os");
canInstallUpdates = updateCapability(platform()) === "install";
await loadDiagnostics();
});
async function loadSettings() {
try {
loading = true;
networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult, presets, qualities, libraryResult] =
await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
commands.playerGetEqPresets(),
commands.playerGetStreamingQualities(),
commands.libraryGetSettings(),
]);
librarySettings = libraryResult;
// 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;
streamingQualities = qualities;
// Load cache stats and the folder picker in parallel but don't block on
// either — both need a round trip the rest of the page doesn't.
loadCacheStats();
loadExclusionCandidates();
} catch (e) {
log.error("Failed to load settings:", e);
} finally {
loading = false;
}
}
/**
* Ask the backend which folders may be hidden. Needs a live repository, so it
* quietly renders nothing when signed out rather than erroring on a page that
* is otherwise perfectly usable offline.
*
* TRACES: UR-076 | DR-209
*/
async function loadExclusionCandidates() {
try {
exclusionsLoading = true;
const handle = auth.getRepository().getHandle();
exclusionCandidates = await commands.libraryGetExclusionCandidates(handle);
} catch (e) {
log.warn("Failed to load library folders:", e);
exclusionCandidates = [];
} finally {
exclusionsLoading = false;
}
}
/**
* Tick or untick one folder. The backend returns the list it actually stored,
* so the picker shows what is in force rather than what was requested.
*
* TRACES: UR-076 | DR-209
*/
async function toggleExcludedItem(itemId: string) {
const current = librarySettings.excludedItemIds ?? [];
const next = current.includes(itemId)
? current.filter((id) => id !== itemId)
: [...current, itemId];
// Optimistic, so the checkbox doesn't lag a round trip behind the tap.
librarySettings = { ...librarySettings, excludedItemIds: next };
try {
librarySettings = await commands.librarySetSettings({ excludedItemIds: next });
} catch (e) {
log.error("Failed to save hidden folders:", e);
librarySettings = { ...librarySettings, excludedItemIds: current };
}
}
async function loadCacheStats() {
try {
cacheLoading = true;
cacheStats = await getCacheStats();
} catch (e) {
log.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) {
log.error("Failed to set cache limit:", e);
}
}
async function handleClearCache() {
try {
clearingCache = true;
await clearCache();
await loadCacheStats();
} catch (e) {
log.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) {
log.error("Failed to save audio settings:", e);
}
}
async function persistVideo() {
try {
await commands.playerSetVideoSettings(videoSettings);
} catch (e) {
log.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) {
log.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();
}
/** TRACES: UR-074 | DR-162 */
function handleStreamingQualityChange(quality: StreamingQuality) {
videoSettings.streamingQuality = quality;
persistVideo();
}
function handleSmartCachingToggle() {
cacheConfig.albumAffinityEnabled = !cacheConfig.albumAffinityEnabled;
persistCache();
}
function handleQueuePrecacheToggle() {
cacheConfig.queuePrecacheEnabled = !cacheConfig.queuePrecacheEnabled;
persistCache();
}
function handleWifiOnlyToggle() {
cacheConfig.wifiOnly = !cacheConfig.wifiOnly;
persistCache();
}
// ---------------------------------------------------------------------
// Updates
//
// The decision of *what to offer* lives in $lib/utils/updateCheck.ts and is
// unit-tested there; this component only renders the answer. On Android the
// answer is always "open the releases page" -- the updater plugin is not
// compiled for that target at all.
//
// TRACES: UR-077 | DR-217
let updateState = $state<"idle" | "checking" | "current" | "available" | "installing" | "failed">(
"idle",
);
let updateAction = $state<UpdateAction>({ kind: "none" });
let updateProgress = $state(0);
let canInstallUpdates = $state(true);
async function handleCheckForUpdates() {
updateState = "checking";
try {
if (!canInstallUpdates) {
// Nothing to interrogate on mobile; go straight to the download page.
await openUrl(RELEASES_URL);
updateState = "idle";
return;
}
const action = await checkForUpdate();
updateAction = action;
updateState = action.kind === "none" ? "current" : "available";
} catch (e) {
log.warn("update check failed", e);
updateState = "failed";
}
}
async function handleInstallUpdate() {
updateState = "installing";
updateProgress = 0;
try {
// installUpdate relaunches the app on success, so there is deliberately
// no "done" state here -- the process is gone before we could set one.
await installUpdate((fraction) => {
updateProgress = fraction;
});
} catch (e) {
log.error("update install failed", e);
updateState = "failed";
}
}
// ---------------------------------------------------------------------
// Diagnostics
//
// The app used to forget everything it did on exit, which is why several
// playback bugs here needed multiple rounds of "can you reproduce it under
// adb logcat". Logs are now on disk, redacted, and exportable as one file to
// attach to a bug report. Nothing is transmitted anywhere.
//
// TRACES: UR-078 | DR-218
let diagnosticsInfo = $state<DiagnosticsInfo | null>(null);
let exportedBundle = $state<DiagnosticsBundle | null>(null);
let exporting = $state(false);
let exportError = $state<string | null>(null);
const LOG_LEVELS = ["error", "warn", "info", "debug", "trace"] as const;
async function loadDiagnostics() {
try {
diagnosticsInfo = await commands.diagnosticsGetInfo();
} catch (e) {
// A settings page that cannot read the log level is still a usable
// settings page.
log.warn("Failed to read diagnostics info:", e);
}
}
async function handleLogLevelChange(level: string) {
try {
await commands.diagnosticsSetLevel(level);
await loadDiagnostics();
} catch (e) {
log.error("Failed to set log level:", e);
}
}
async function handleExportDiagnostics() {
exporting = true;
exportError = null;
exportedBundle = null;
try {
// The server URL is passed so the backend can record its scheme and host
// in the bundle. It reduces it to those two parts — the token that may be
// on the URL never reaches the file.
const serverUrl = auth.getServerUrl();
exportedBundle = await commands.diagnosticsExport(serverUrl);
await loadDiagnostics();
} catch (e) {
log.error("Diagnostics export failed:", e);
exportError = String(e);
} finally {
exporting = false;
}
}
</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>
<!-- Hidden folders — music libraries often hold a folder of something the
user doesn't think of as music (podcasts, audiobooks, sound effects),
which otherwise turns up in every album, artist and track listing.
The candidate list and the meaning of "hidden" both come from Rust.
TRACES: UR-076 | DR-209 -->
<div id="hidden-folders" class="scroll-mt-4 bg-[var(--color-surface)] rounded-lg p-6">
<div class="mb-4">
<h2 class="text-xl font-semibold text-white">Hidden Folders</h2>
<p class="text-sm text-gray-400 mt-1">
Folders to leave out of music browsing and search. Useful when a music library also
holds podcasts or audiobooks. Hidden folders can still be opened from a direct link, and
anything already playing or downloaded is unaffected.
</p>
</div>
{#if exclusionsLoading}
<p class="text-sm text-gray-400">Loading folders...</p>
{:else if exclusionCandidates.length === 0}
<p class="text-sm text-gray-400">
No music folders to choose from. Connect to your server to pick folders to hide.
</p>
{:else}
<div class="space-y-2">
{#each exclusionCandidates as candidate (candidate.id)}
<button
onclick={() => toggleExcludedItem(candidate.id)}
class="w-full flex items-center justify-between gap-3 py-3 px-4 rounded-lg text-left transition-all {excludedIds.has(
candidate.id,
)
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={excludedIds.has(candidate.id)}
>
<span class="min-w-0">
<span class="block font-semibold truncate">{candidate.name}</span>
<span class="block text-xs opacity-75 truncate">
{candidate.isLibrary ? "Whole library" : `In ${candidate.libraryName}`}
</span>
</span>
<span class="text-xs font-semibold uppercase tracking-wide shrink-0">
{excludedIds.has(candidate.id) ? "Hidden" : "Visible"}
</span>
</button>
{/each}
</div>
<p class="text-xs text-gray-500 mt-3">
Changes apply to listings loaded from now on; reopen a page to see them take effect.
</p>
{/if}
</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>
<!-- Streaming quality: the bandwidth ceiling every video stream is
opened against. The steps and their labels come from Rust.
TRACES: UR-074 | DR-162 -->
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<h3 class="text-xl font-semibold text-white">Streaming Quality</h3>
<p class="text-sm text-gray-400 mt-1 mb-4">
Limit how much bandwidth video streams may use. Lower settings ask the server to
transcode before sending, which saves data on metered or slow connections at the cost of
picture quality. You can also change this for a single video from the player's quality
menu.
</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
{#each streamingQualities as [quality, label, detail]}
<button
onclick={() => handleStreamingQualityChange(quality)}
class="py-3 px-3 rounded-lg transition-all text-left
{videoSettings.streamingQuality === quality
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'}"
aria-pressed={videoSettings.streamingQuality === quality}
>
<div class="font-semibold text-sm">{label}</div>
<div class="text-xs opacity-75 mt-0.5">{detail}</div>
</button>
{/each}
</div>
<p class="text-xs text-gray-500 mt-3">
Applies to videos started from now on; a video already playing keeps the quality it
started at.
</p>
</div>
<!-- Native video (experimental). Only rendered where the platform's Rust
backend actually has a native video surface (Android). -->
{#if supportsNativeVideo}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">
<h3 class="text-xl font-semibold text-white">
Native Video
<span
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
>
Experimental
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the built-in web
player, for better performance and battery life, and so picture-in-picture shows
the video rather than the app. On by default. Turn it off to fall back to the
built-in web player if a video misbehaves.
</p>
</div>
<button
onclick={handleNativeVideoToggle}
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle native video"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">Takes effect the next time you start a video.</p>
</div>
{/if}
</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>
<!-- Pending server updates. Lives here as well as behind the offline
banner's badge, because a row that keeps failing is still queued once
the server is reachable again — when no banner is on screen.
TRACES: UR-025 | DR-132 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Waiting to sync</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<PendingSyncList />
</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>
<!-- Diagnostics.
Logs live on disk, redacted, and export as one file for a bug
report. Nothing is transmitted; the user attaches it themselves.
TRACES: UR-078 | DR-218 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Diagnostics</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-6">
<div>
<h3 class="text-lg font-semibold text-white mb-1">Detail level</h3>
<p class="text-sm text-gray-400 mb-3">
Higher detail helps diagnose a problem but writes more to disk. The setting survives a
restart, so you can turn it up and then reproduce the bug.
</p>
<div class="flex flex-wrap gap-2">
{#each LOG_LEVELS as level (level)}
<button
class="px-3 py-1.5 rounded-lg text-sm font-medium capitalize {diagnosticsInfo?.level ===
level
? 'bg-[var(--color-jellyfin)] text-white'
: 'bg-gray-700 text-gray-300'}"
onclick={() => handleLogLevelChange(level)}
>
{level}
</button>
{/each}
</div>
</div>
<div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white">Export diagnostics</h3>
<p class="text-sm text-gray-400 mt-1">
Bundles the logs and your app/OS versions into one file to attach to a bug report.
Access tokens and passwords are removed; nothing is uploaded anywhere.
</p>
</div>
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
onclick={handleExportDiagnostics}
disabled={exporting}
>
{exporting ? "Exporting…" : "Export"}
</button>
</div>
{#if exportedBundle}
<div class="mt-3 border border-gray-700 rounded-lg p-3 space-y-2">
<p class="text-sm text-green-400">
Saved {formatBytes(exportedBundle.sizeBytes)} from {exportedBundle.fileCount} log file{exportedBundle.fileCount ===
1
? ""
: "s"}.
</p>
<p class="text-xs text-gray-400 break-all font-mono">{exportedBundle.path}</p>
{#if canInstallUpdates}
<button
class="text-sm text-[var(--color-jellyfin)] underline"
onclick={() => revealItemInDir(exportedBundle!.path)}
>
Show in folder
</button>
{/if}
</div>
{:else if exportError}
<p class="mt-3 text-sm text-yellow-400">Couldn't export: {exportError}</p>
{/if}
</div>
{#if diagnosticsInfo}
<div class="text-xs text-gray-500 space-y-1">
<p class="font-mono break-all">{diagnosticsInfo.logDir}</p>
<p>{formatBytes(diagnosticsInfo.totalSizeBytes)} of logs held</p>
</div>
{/if}
</div>
</div>
<!-- Updates.
Desktop installs in place; Android can only be pointed at the
releases page, because an app may not replace its own APK. The
decision lives in $lib/utils/updateCheck.ts, not in this markup.
TRACES: UR-077 | DR-217 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Updates</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-white">
{canInstallUpdates ? "Check for updates" : "Get the latest version"}
</h3>
<p class="text-sm text-gray-400 mt-1">
{#if canInstallUpdates}
Downloads are verified against JellyTau's signing key before anything is
installed.
{:else}
Android installs are handled by the system installer — this opens the releases
page.
{/if}
</p>
</div>
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium disabled:opacity-50 whitespace-nowrap"
onclick={handleCheckForUpdates}
disabled={updateState === "checking" || updateState === "installing"}
>
{#if updateState === "checking"}
Checking…
{:else if canInstallUpdates}
Check now
{:else}
Open releases
{/if}
</button>
</div>
{#if updateState === "current"}
<p class="text-sm text-green-400">You're on the latest version.</p>
{:else if updateState === "failed"}
<p class="text-sm text-yellow-400">
Couldn't reach the update server. This is safe to ignore — JellyTau keeps working.
</p>
{:else if updateState === "installing"}
<div>
<p class="text-sm text-gray-300 mb-2">
Downloading… {Math.round(updateProgress * 100)}%
</p>
<div class="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-[var(--color-jellyfin)] transition-all"
style="width: {updateProgress * 100}%"
></div>
</div>
<p class="text-xs text-gray-500 mt-2">JellyTau will restart when this finishes.</p>
</div>
{:else if updateState === "available" && updateAction.kind === "install"}
<div class="border border-gray-700 rounded-lg p-4 space-y-3">
<p class="text-white font-medium">Version {updateAction.version} is available</p>
{#if updateAction.notes}
<pre
class="text-sm text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">{updateAction.notes}</pre>
{/if}
<button
class="px-4 py-2 rounded-lg bg-[var(--color-jellyfin)] text-white font-medium"
onclick={handleInstallUpdate}
>
Install and restart
</button>
</div>
{:else if updateState === "available" && updateAction.kind === "open-releases"}
<button
class="text-sm text-[var(--color-jellyfin)] underline"
onclick={() =>
openUrl(updateAction.kind === "open-releases" ? updateAction.url : RELEASES_URL)}
>
Version {updateAction.version} is available — open the releases page
</button>
{/if}
</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>