feat(audio): graphic equalizer with presets and custom bands

Adds a 10-band graphic equalizer to AudioSettings (enabled flag +
per-band dB gains, normalised to 10 entries and clamped to range).
Presets return gain curves; the settings page gains EQ UI. libmpv
applies the filter on Linux (Android parity pending). Old persisted
settings without EQ fields load as disabled + flat.

Also includes the requirements/traceability/ux-flows doc updates for
this feature and the home long-press routing (UR-058/DR-087).

TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
This commit is contained in:
2026-07-24 23:49:13 +02:00
parent 589f08b873
commit c543f90ad3
9 changed files with 1454 additions and 369 deletions
+170 -5
View File
@@ -1,10 +1,11 @@
<!-- TRACES: UR-023, UR-029, UR-057 | DR-048, DR-077, DR-086 -->
<!-- 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";
@@ -39,8 +40,21 @@
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,
@@ -86,14 +100,21 @@
try {
loading = true;
networkDetectionSupported = isNetworkDetectionSupported();
const [audioResult, videoResult, cacheResult] = await Promise.all([
const [audioResult, videoResult, cacheResult, presets] = await Promise.all([
commands.playerGetAudioSettings(),
commands.playerGetVideoSettings(),
getCacheConfig(),
commands.playerGetEqPresets(),
]);
settings = audioResult;
// 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) {
@@ -210,6 +231,59 @@
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();
@@ -419,6 +493,84 @@
{/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>
@@ -746,8 +898,8 @@
album playback
</li>
<li>
<strong>Normalization</strong> uses ReplayGain tags and real-time
loudnorm filtering
<strong>Normalization</strong> evens out loudness between tracks
in real time, toward your selected level
</li>
</ul>
</div>
@@ -756,3 +908,16 @@
</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>