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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user