mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(crossfade): AutoDJ — content-aware silence-trimming crossfade (#1122)
* feat(crossfade): add "trim silence between tracks" toggle
New persisted setting `crossfadeTrimSilence` (default off; existing
installs rehydrate off via the persist default-merge). Surfaced in
Settings -> Audio and in the crossfade popovers of the queue toolbar
and the mini-player.
Crossfade buttons now separate the two actions: left-click toggles
crossfade on/off, right-click opens the settings popover (seconds +
trim). Shared the mini popover positioning into useMiniAnchoredPopover
(now backing both volume and crossfade). Mini bridge carries
crossfadeSecs/crossfadeTrimSilence and gains mini:set-crossfade-secs /
mini:set-crossfade-trim-silence.
The actual silence-trimming playback behaviour is wired in a follow-up;
this commit only persists the user intent. i18n added across 9 locales.
* feat(crossfade): trim silence between tracks (waveform-driven)
Wire the actual silence-aware crossfade behind the (default-off)
crossfadeTrimSilence toggle. Detection is derived on the fly from the
cached 500-bin waveform + track duration — no new analysis pass or
cache fields.
- waveformSilence.ts: computeWaveformSilence(bins, duration) → lead/trail
silence + content bounds, using the peak curve, a low absolute cut and
a per-side cap. Unit-tested.
- A-tail (JS): handleAudioProgress advances the crossfade early, at
contentEnd - crossfadeSecs, when the current track ends in real
trailing silence, so the fade overlaps music. Guarded once per play
generation.
- B-head: audio_play gains an additive optional start_secs; the freshly
built source is try_seek'd past the next track's leading silence before
append, then seek_offset/samples_played are re-anchored so position is
content-relative. Non-seekable / cold sources degrade to today.
- Pre-buffer: crossfade next-track download + B-head probe moved to
crossfadePreload.ts with a fixed ~30 s budget before the track needs to
play (widened by trailing silence so the early advance keeps the
budget). Also fired right after a seek into the window so jumping near
the end still buffers in time.
Checks: tsc, vitest (store suite + new units), cargo test/clippy for
psysonic-audio.
* feat(crossfade): recommend hot cache for trim; probe B-head regardless
Add a "for reliable results, enable the Hot playback cache" note to the
trim-silence toggle description across all 9 locales — hot cache keeps
the next track on disk so it starts instantly past its lead silence.
Fix: the leading-silence probe (B-head) now runs even when hot cache is
on; only the redundant byte pre-download is gated on !hotCache. Without
this, enabling hot cache (the recommended setting) would have skipped the
probe and disabled leading-silence trimming.
* feat(crossfade): content-driven smart crossfade overlap
Smart crossfade derives the per-transition overlap purely from the
waveform envelopes — max(A outro fade, B intro rise) clamped 0.5–12s —
instead of the fixed crossfadeSecs ("work by fact"). The JS early
advance arms the computed overlap and audio_play applies it through a
new crossfade_secs_override (capping only this swap's fade); plain
loud→loud endings fall back to the engine crossfade at crossfadeSecs.
* feat(crossfade): "Crossfade | Smart crossfade" mode switch in UI
Replace the standalone "trim silence" toggle with a Crossfade / Smart
crossfade segmented control in settings and both crossfade popovers
(queue toolbar + mini-player). Classic Crossfade shows the seconds
slider; Smart crossfade is content-driven with no duration to set.
Adds smartCrossfade / smartCrossfadeDesc strings to all nine locales
and the "smart crossfade" search keyword.
* feat(crossfade): don't double-fade a track that already fades out
Decouple the outgoing track's fade-out from the incoming fade-in. When
A carries its own recorded fade-out (outroFadeA ≥ 1s and ≥ B's intro
rise), planCrossfadeTransition now sets outgoingFadeSec = 0; the engine
then skips A's TriggeredFadeOut so A keeps full gain and its recording
carries it down while B rises underneath — no more double attenuation
that made A vanish early and B blare in. Hard-cut endings still get an
engine fade over the overlap.
audio_play gains outgoing_fade_secs_override (Some(0) = ride A's own
fade), threaded via SinkSwapInputs.outgoing_fade_secs; the JS advance
arms it alongside the overlap.
* feat(crossfade): rename the smart crossfade mode to "AutoDJ"
User-facing rename of the content-driven crossfade mode from "Smart
crossfade" to "AutoDJ" across the settings segmented switch, the queue
and mini-player popovers, all nine locales, and the settings search
keywords. The underlying store flag (crossfadeTrimSilence) is unchanged.
* feat(crossfade): standard ~2s blend for hard loud→loud meetings
When AutoDJ trims a track's protective trailing silence and the loud
ending butts straight into a loud intro, neither edge fades, so the old
0.5s anti-click floor sounded like an abrupt cut. Use a standard ~2s
equal-power crossfade for that case (both edges analysed, nothing
fades); real fade-outs/buildups keep their longer content-driven span,
and the bare floor only survives when an envelope is missing.
* fix(crossfade): keep B's fade-in across the B-head start-offset seek
EqualPowerFadeIn::try_seek jumped straight to unity gain for any seek
≥100ms, which also hit the initial start-offset seek that skips the
incoming track's leading silence — so a crossfaded track with trimmed
lead silence popped in at full gain instead of fading in. Only skip the
fade-in for mid-playback seeks (sample_count > 0); a seek before any
audio has played keeps the fade-in.
* feat(crossfade): gate AutoDJ early fade on next-track readiness
The early, content-driven advance now fires only when the next track's
audio is actually available — in the engine RAM preload slot
(enginePreloadedTrackId) or local on disk (offline library, favourite-auto
or hot-cache ephemeral) — via isCrossfadeNextReady(). Analysis alone is
not enough: a cold, still-buffering stream would fade in over silence.
When B isn't ready the gen guard stays unset so it re-checks on later
ticks; if B never readies, the plain engine crossfade handles the
transition (graceful degrade) instead of a broken fade. A RAM preload
copy suffices — the full track need not be cached to disk.
* feat(crossfade): suppress engine auto-crossfade for AutoDJ + eager preload
With the hot cache off the readiness gate alone wasn't enough: the engine's
progress task autonomously fires its crossfade audio:ended ~crossfadeSecs
before the end, independently of JS, and would start a still-buffering next
track and fade over it — an audible jump.
- Engine: add autodj_suppress_autocrossfade flag (audio_set_autodj_suppress);
the progress task treats it like crossfade-off, so the early timer never
fires and audio:ended only comes from real source exhaustion / watchdog.
- JS drives the transition: set the flag when a content fade is pending
(wantEarly) and clear it for plain loud→loud / non-AutoDJ, so the normal
engine crossfade is preserved there. When the next track never readies, A
plays out and we degrade to a clean sequential start instead of a jump.
- audio_preload gains an `eager` flag; the crossfade/AutoDJ pre-buffer passes
it to skip the 8s start throttle so the RAM slot fills before the fade.
* docs(changelog): AutoDJ content-aware crossfade (PR #1122)
Add the 1.49.0 Added entry and the cucadmuh credits line for AutoDJ.
This commit is contained in:
@@ -19,6 +19,7 @@ import { MiniControls } from './miniPlayer/MiniControls';
|
||||
import { MiniToolbar } from './miniPlayer/MiniToolbar';
|
||||
import { MiniQueue } from './miniPlayer/MiniQueue';
|
||||
import { useMiniVolumePopover } from '../hooks/useMiniVolumePopover';
|
||||
import { useMiniCrossfadePopover } from '../hooks/useMiniCrossfadePopover';
|
||||
import { useMiniQueueDrag } from '../hooks/useMiniQueueDrag';
|
||||
import { useMiniSync } from '../hooks/useMiniSync';
|
||||
import { useMiniWindowSetup } from '../hooks/useMiniWindowSetup';
|
||||
@@ -49,6 +50,7 @@ export default function MiniPlayer() {
|
||||
return registerQueueDragHitTest(hitTest);
|
||||
}, [queueOpen]);
|
||||
const { volumeOpen, setVolumeOpen, volumePopStyle, volumeBtnRef, volumePopRef } = useMiniVolumePopover();
|
||||
const { crossfadeOpen, setCrossfadeOpen, crossfadePopStyle, crossfadeBtnRef, crossfadePopRef } = useMiniCrossfadePopover();
|
||||
|
||||
const {
|
||||
isReorderDrag, psyDragFromIdxRef, dropTarget, setDropTarget, dropTargetRef, startDrag,
|
||||
@@ -169,6 +171,11 @@ export default function MiniPlayer() {
|
||||
volumePopStyle={volumePopStyle}
|
||||
handleVolumeChange={handleVolumeChange}
|
||||
toggleMute={toggleMute}
|
||||
crossfadeOpen={crossfadeOpen}
|
||||
setCrossfadeOpen={setCrossfadeOpen}
|
||||
crossfadeBtnRef={crossfadeBtnRef}
|
||||
crossfadePopRef={crossfadePopRef}
|
||||
crossfadePopStyle={crossfadePopStyle}
|
||||
queueOpen={queueOpen}
|
||||
toggleQueue={toggleQueue}
|
||||
t={t}
|
||||
|
||||
@@ -106,10 +106,12 @@ function QueuePanelHostOrSolo() {
|
||||
|
||||
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
|
||||
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
|
||||
const crossfadeTrimSilence = useAuthStore(s => s.crossfadeTrimSilence);
|
||||
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
|
||||
const infiniteQueueEnabled = useAuthStore(s => s.infiniteQueueEnabled);
|
||||
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
|
||||
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
|
||||
const setCrossfadeTrimSilence = useAuthStore(s => s.setCrossfadeTrimSilence);
|
||||
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
|
||||
const setInfiniteQueueEnabled = useAuthStore(s => s.setInfiniteQueueEnabled);
|
||||
const normalizationEngine = useAuthStore(s => s.normalizationEngine);
|
||||
@@ -340,6 +342,8 @@ function QueuePanelHostOrSolo() {
|
||||
setCrossfadeEnabled={setCrossfadeEnabled}
|
||||
crossfadeSecs={crossfadeSecs}
|
||||
setCrossfadeSecs={setCrossfadeSecs}
|
||||
crossfadeTrimSilence={crossfadeTrimSilence}
|
||||
setCrossfadeTrimSilence={setCrossfadeTrimSilence}
|
||||
infiniteQueueEnabled={infiniteQueueEnabled}
|
||||
setInfiniteQueueEnabled={setInfiniteQueueEnabled}
|
||||
t={t}
|
||||
|
||||
@@ -15,6 +15,11 @@ interface Props {
|
||||
volumePopStyle: React.CSSProperties;
|
||||
handleVolumeChange: (v: number) => void;
|
||||
toggleMute: () => void;
|
||||
crossfadeOpen: boolean;
|
||||
setCrossfadeOpen: (updater: boolean | ((v: boolean) => boolean)) => void;
|
||||
crossfadeBtnRef: React.RefObject<HTMLButtonElement | null>;
|
||||
crossfadePopRef: React.RefObject<HTMLDivElement | null>;
|
||||
crossfadePopStyle: React.CSSProperties;
|
||||
queueOpen: boolean;
|
||||
toggleQueue: () => void;
|
||||
t: TFunction;
|
||||
@@ -22,7 +27,9 @@ interface Props {
|
||||
|
||||
export function MiniToolbar({
|
||||
state, volume, volumeOpen, setVolumeOpen, volumeBtnRef, volumePopRef, volumePopStyle,
|
||||
handleVolumeChange, toggleMute, queueOpen, toggleQueue, t,
|
||||
handleVolumeChange, toggleMute,
|
||||
crossfadeOpen, setCrossfadeOpen, crossfadeBtnRef, crossfadePopRef, crossfadePopStyle,
|
||||
queueOpen, toggleQueue, t,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="mini-player__toolbar" data-tauri-drag-region="false">
|
||||
@@ -111,15 +118,70 @@ export function MiniToolbar({
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={crossfadeBtnRef}
|
||||
type="button"
|
||||
className={`mini-player__tool${state.crossfadeEnabled ? ' mini-player__tool--active' : ''}`}
|
||||
className={`mini-player__tool${state.crossfadeEnabled || crossfadeOpen ? ' mini-player__tool--active' : ''}`}
|
||||
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
|
||||
onContextMenu={(e) => { e.preventDefault(); setCrossfadeOpen(v => !v); }}
|
||||
data-tauri-drag-region="false"
|
||||
data-tooltip={t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
>
|
||||
<Waves size={13} />
|
||||
</button>
|
||||
{crossfadeOpen && createPortal(
|
||||
<div
|
||||
ref={crossfadePopRef}
|
||||
className="mini-player__crossfade-popover"
|
||||
style={crossfadePopStyle}
|
||||
data-tauri-drag-region="false"
|
||||
>
|
||||
<div className="mini-player__crossfade-modes">
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__crossfade-mode${!state.crossfadeTrimSilence ? ' active' : ''}`}
|
||||
data-tauri-drag-region="false"
|
||||
onClick={() => {
|
||||
emit('mini:set-crossfade', { value: true }).catch(() => {});
|
||||
emit('mini:set-crossfade-trim-silence', { value: false }).catch(() => {});
|
||||
}}
|
||||
>
|
||||
{t('queue.crossfade')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mini-player__crossfade-mode${state.crossfadeTrimSilence ? ' active' : ''}`}
|
||||
data-tauri-drag-region="false"
|
||||
onClick={() => {
|
||||
emit('mini:set-crossfade', { value: true }).catch(() => {});
|
||||
emit('mini:set-crossfade-trim-silence', { value: true }).catch(() => {});
|
||||
}}
|
||||
>
|
||||
{t('settings.autoDj')}
|
||||
</button>
|
||||
</div>
|
||||
{!state.crossfadeTrimSilence && (
|
||||
<>
|
||||
<div className="mini-player__crossfade-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="mini-player__crossfade-value">{(state.crossfadeSecs ?? 3).toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={state.crossfadeSecs ?? 3}
|
||||
onChange={e => emit('mini:set-crossfade-secs', { value: parseFloat(e.target.value) }).catch(() => {})}
|
||||
className="mini-player__crossfade-slider"
|
||||
aria-label={t('queue.crossfade')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,6 +25,8 @@ interface Props {
|
||||
setCrossfadeEnabled: (v: boolean) => void;
|
||||
crossfadeSecs: number;
|
||||
setCrossfadeSecs: (v: number) => void;
|
||||
crossfadeTrimSilence: boolean;
|
||||
setCrossfadeTrimSilence: (v: boolean) => void;
|
||||
infiniteQueueEnabled: boolean;
|
||||
setInfiniteQueueEnabled: (v: boolean) => void;
|
||||
t: TFunction;
|
||||
@@ -34,7 +36,8 @@ export function QueueToolbar({
|
||||
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
|
||||
handleSave, handleLoad, handleCopyQueueShare, handleClear,
|
||||
gaplessEnabled, setGaplessEnabled, crossfadeEnabled, setCrossfadeEnabled,
|
||||
crossfadeSecs, setCrossfadeSecs, infiniteQueueEnabled, setInfiniteQueueEnabled,
|
||||
crossfadeSecs, setCrossfadeSecs, crossfadeTrimSilence, setCrossfadeTrimSilence,
|
||||
infiniteQueueEnabled, setInfiniteQueueEnabled,
|
||||
t,
|
||||
}: Props) {
|
||||
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
|
||||
@@ -124,14 +127,13 @@ export function QueueToolbar({
|
||||
ref={crossfadeBtnRef}
|
||||
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (crossfadeEnabled) {
|
||||
setCrossfadeEnabled(false);
|
||||
setShowCrossfadePopover(false);
|
||||
} else {
|
||||
setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(true);
|
||||
setShowCrossfadePopover(true);
|
||||
}
|
||||
// Left click: toggle on/off only. Right click opens the popover.
|
||||
if (!crossfadeEnabled) setGaplessEnabled(false);
|
||||
setCrossfadeEnabled(!crossfadeEnabled);
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setShowCrossfadePopover(v => !v);
|
||||
}}
|
||||
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
|
||||
aria-label={t('queue.crossfade')}
|
||||
@@ -140,26 +142,54 @@ export function QueueToolbar({
|
||||
</button>
|
||||
{showCrossfadePopover && (
|
||||
<div className="crossfade-popover" ref={crossfadePopoverRef}>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>0.1s</span><span>10s</span>
|
||||
<div className="crossfade-popover-modes">
|
||||
<button
|
||||
type="button"
|
||||
className={`crossfade-popover-mode${!crossfadeTrimSilence ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (gaplessEnabled) setGaplessEnabled(false);
|
||||
setCrossfadeTrimSilence(false);
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
>
|
||||
{t('queue.crossfade')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`crossfade-popover-mode${crossfadeTrimSilence ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
if (gaplessEnabled) setGaplessEnabled(false);
|
||||
setCrossfadeTrimSilence(true);
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
>
|
||||
{t('settings.autoDj')}
|
||||
</button>
|
||||
</div>
|
||||
{!crossfadeTrimSilence && (
|
||||
<>
|
||||
<div className="crossfade-popover-label">
|
||||
<Waves size={11} />
|
||||
{t('queue.crossfade')}
|
||||
<span className="crossfade-popover-value">{crossfadeSecs.toFixed(1)} s</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={crossfadeSecs}
|
||||
onChange={e => {
|
||||
setCrossfadeSecs(parseFloat(e.target.value));
|
||||
setCrossfadeEnabled(true);
|
||||
}}
|
||||
className="crossfade-popover-slider"
|
||||
/>
|
||||
<div className="crossfade-popover-range">
|
||||
<span>0.1s</span><span>10s</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,8 +10,12 @@ interface Props {
|
||||
* Crossfade ↔ Gapless are mutually exclusive — enabling one forces the
|
||||
* other off (`setGaplessEnabled(false)` / `setCrossfadeEnabled(false)`
|
||||
* on the toggle handlers) and the inactive row dims via opacity +
|
||||
* pointerEvents:none. The crossfade-seconds slider only renders while
|
||||
* crossfade is the active mode.
|
||||
* pointerEvents:none.
|
||||
*
|
||||
* When crossfade is on, a "Crossfade | Smart crossfade" segmented switch
|
||||
* (`crossfadeTrimSilence` false/true) picks the mode: classic crossfade
|
||||
* exposes the seconds slider, smart crossfade is content-driven and has
|
||||
* no duration to configure (just a short explainer).
|
||||
*
|
||||
* The `preservePlayNextOrder` toggle is independent of both and pinned
|
||||
* to the bottom of the block.
|
||||
@@ -37,20 +41,44 @@ export function PlaybackBehaviorBlock({ t }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
<div style={{ paddingLeft: '1rem', marginTop: '0.6rem' }}>
|
||||
<div className="settings-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${!auth.crossfadeTrimSilence ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => auth.setCrossfadeTrimSilence(false)}
|
||||
>
|
||||
{t('settings.crossfade')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${auth.crossfadeTrimSilence ? 'btn-primary' : 'btn-ghost'}`}
|
||||
onClick={() => auth.setCrossfadeTrimSilence(true)}
|
||||
>
|
||||
{t('settings.autoDj')}
|
||||
</button>
|
||||
</div>
|
||||
{auth.crossfadeTrimSilence ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: '0.6rem' }}>
|
||||
{t('settings.autoDjDesc')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: '0.6rem', display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={auth.crossfadeSecs}
|
||||
onChange={e => auth.setCrossfadeSecs(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, minWidth: 80, maxWidth: 200 }}
|
||||
id="crossfade-secs-slider"
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 36 }}>
|
||||
{t('settings.crossfadeSecs', { n: auth.crossfadeSecs.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
||||
{ tab: 'audio', titleKey: 'settings.hiResTitle', keywords: 'hi-res hires resampling bit depth sample rate dsd 24bit' },
|
||||
{ tab: 'audio', titleKey: 'settings.eqTitle', keywords: 'equalizer eq bass treble autoeq filter pre-gain' },
|
||||
{ tab: 'audio', titleKey: 'settings.playbackRateTitle', keywords: 'speed playback rate tempo pitch varispeed preserve corrected time stretch' },
|
||||
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade gapless replaygain replay gain volume' },
|
||||
{ tab: 'audio', titleKey: 'settings.playbackTitle', keywords: 'playback crossfade autodj auto dj smart crossfade gapless replaygain replay gain volume trim silence' },
|
||||
{ tab: 'lyrics', titleKey: 'settings.lyricsSourcesTitle', keywords: 'lyrics sources providers lrclib netease server youlyplus karaoke standard static' },
|
||||
{ tab: 'lyrics', titleKey: 'settings.sidebarLyricsStyle', keywords: 'lyrics scroll style classic apple music' },
|
||||
{ tab: 'integrations', titleKey: 'musicNetwork.title', keywords: 'last.fm lastfm libre.fm rocksky listenbrainz maloja scrobble scrobbling music network' },
|
||||
|
||||
Reference in New Issue
Block a user