Video streams were opened at a fixed allowance nobody could change: MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device profile that let the server direct-play a source of any size. On a metered or slow connection there was no way to spend less. StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/ 4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the audio share of it and the resolution that budget can carry. Those numbers are Jellyfin encoding vocabulary, so they live in Rust and the frontend only names a variant; labels and details come back over IPC from player_get_streaming_qualities, the same arrangement as the EQ presets. The cap has to reach the *negotiation*, not just the transcode URL: max_static_bitrate in the device profile is what makes the server refuse to direct-play a file fatter than the cap, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot. So it is applied at all four places that decide bandwidth — the HLS URL builder, PlaybackInfo, the Live TV stream, and the background-audio handoff (which takes the lower of the cap and its own 384 kbps). Video bitrate is the total minus the audio share so the two together honour the ceiling rather than overshooting it. The ceiling is process-wide rather than a repository field: it is a preference about this device's connection, must survive a repository rebuilt on re-login, and every URL builder plus the negotiation have to agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE. Two ways in. Settings holds the durable default, persisted to app_settings and restored at startup — unlike the rest of VideoSettings, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to see. The in-player menu is the "this film, this connection" override: a cap is a property of the stream the server is producing, so it cannot apply to one already in flight — player_set_stream_quality re-opens the stream at the new quality and resumes at the current position, reloading the native backend itself and handing HTML5 a URL for the same reloadSource primitive the audio-track switch uses. Tests pin the URL parameters at a capped and an uncapped step, the handoff taking the lower of the two, the ladder's internal consistency (video + audio == cap, resolution descending with bitrate) and the persisted token's round trip. The ceiling is process-wide, so the tests that depend on it serialise on a guard that restores the default. TRACES: UR-074 | DR-160 | UT-156, UT-157
1051 lines
40 KiB
Svelte
1051 lines
40 KiB
Svelte
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086, DR-132 -->
|
|
<script lang="ts">
|
|
import { onDestroy, onMount } from "svelte";
|
|
import { commands } from "$lib/api/bindings";
|
|
import type {
|
|
AudioSettings,
|
|
CacheConfig,
|
|
EqPreset,
|
|
StreamingQuality,
|
|
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 PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
|
|
import { library, viewMode } from "$lib/stores/library";
|
|
import {
|
|
isNetworkDetectionSupported,
|
|
reportNetworkState,
|
|
} from "$lib/services/networkType";
|
|
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
|
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
|
|
|
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-160
|
|
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,
|
|
});
|
|
|
|
// 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;
|
|
});
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
loading = true;
|
|
networkDetectionSupported = isNetworkDetectionSupported();
|
|
const [audioResult, videoResult, cacheResult, presets, qualities] = await Promise.all([
|
|
commands.playerGetAudioSettings(),
|
|
commands.playerGetVideoSettings(),
|
|
getCacheConfig(),
|
|
commands.playerGetEqPresets(),
|
|
commands.playerGetStreamingQualities(),
|
|
]);
|
|
// 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 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();
|
|
}
|
|
|
|
/** TRACES: UR-074 | DR-160 */
|
|
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();
|
|
}
|
|
</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>
|
|
|
|
<!-- Streaming quality: the bandwidth ceiling every video stream is
|
|
opened against. The steps and their labels come from Rust.
|
|
TRACES: UR-074 | DR-160 -->
|
|
<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. Better performance and battery life, and
|
|
required for picture-in-picture to show the video rather than
|
|
the app. Still less tested — turn this off if video fails to
|
|
appear or seeking 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>
|
|
|
|
<!-- 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>
|