feat: split AutoDJ into a standalone playback feature (#1124)

* feat(playback): add transition-mode helper

Centralise the crossfade/AutoDJ/gapless mutual exclusivity in one place
instead of the scattered setter combinations across the toolbar, mini
player and settings. AutoDJ stays encoded as crossfade + trim-silence, so
the persisted flags and the audio engine are unchanged.

* feat(queue): split AutoDJ into its own toolbar button + playlist submenu

- AutoDJ becomes a standalone toolbar button (Blend icon) next to
  crossfade, driven by the shared transition-mode helper. The crossfade
  right-click popover drops the mode switch and keeps only the seconds
  slider.
- Save + load playlist collapse into one Playlist button opening a small
  submenu, freeing up toolbar space.
- queueToolbarStore gains a position-preserving rehydrate migration
  (legacy save/load -> playlist, autodj inserted after crossfade) with
  unit tests.
- Toolbar customizer and all 9 queue locales updated to match.

* feat(mini-player): standalone AutoDJ button, shared transition helper

Mirror the queue toolbar: AutoDJ gets its own Blend button, the crossfade
popover keeps only the seconds slider. The mini player now drives all
three transitions through a single additive `mini:set-transition-mode`
event handled by the shared helper, replacing the per-flag mini events.
Drop the now-dead crossfade-mode CSS.

* feat(settings): segmented track-transition picker, regroup playback

Replace the crossfade toggle + inner crossfade/AutoDJ switch with a single
Off | Gapless | Crossfade | AutoDJ segmented control (mirroring the
Normalization picker above), driven by the shared transition helper — the
mutual exclusivity now reads at a glance. Crossfade keeps its seconds
slider; AutoDJ shows its content-driven explainer. The block is regrouped
under "Track transitions" and "Queue behaviour" headings. Drops the now
unused crossfade/gapless description and not-available i18n keys across all
9 locales and adds the new transition strings.

* fix(queue): open the playlist submenu inward

The playlist submenu inherited the crossfade popover's right:0 anchor and,
sitting on the left of the toolbar, opened out under the main container.
Anchor it left:0 so it stays inside the queue panel. Update the toolbar
test for the new playlist submenu (save/load moved off the toolbar).

* feat(settings): box playback sub-sections into panels

Wrap Normalization, Track transitions and Queue behaviour each in their own
bordered panel with an accent uppercase header (new reusable .settings-group
classes), so the sections read as distinct blocks instead of one wall of
text. Drops the thin divider that separated them.

* docs: changelog, credits and what's new for AutoDJ standalone

Fold the standalone-AutoDJ changes into the existing AutoDJ entry in the
changelog and the in-app What's New, and add the credit (#1124).
This commit is contained in:
Psychotoxical
2026-06-18 13:31:38 +02:00
committed by GitHub
parent f28e82c022
commit fde7ab432f
37 changed files with 665 additions and 372 deletions
-3
View File
@@ -96,9 +96,6 @@ export function AudioTab() {
>
<div className="settings-card">
<NormalizationBlock preAnalysisEffectiveDb={preAnalysisEffectiveDb} t={t} />
<div className="divider" />
<PlaybackBehaviorBlock t={t} />
</div>
</SettingsSubSection>
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FolderOpen, GripVertical, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { Blend, GripVertical, Infinity, ListMusic, MoveRight, Share2, Shuffle, Trash2, Waves } from 'lucide-react';
import { useDragDrop, useDragSource } from '../../contexts/DragDropContext';
import { useQueueToolbarStore, QueueToolbarButtonId } from '../../store/queueToolbarStore';
@@ -8,25 +8,25 @@ type QueueToolbarDropTarget = { idx: number; before: boolean } | null;
const QUEUE_TOOLBAR_BUTTON_ICONS: Record<QueueToolbarButtonId, typeof Shuffle | null> = {
shuffle: Shuffle,
save: Save,
load: FolderOpen,
playlist: ListMusic,
share: Share2,
clear: Trash2,
separator: null, // No icon for separator
gapless: MoveRight,
crossfade: Waves,
autodj: Blend,
infinite: Infinity,
};
const QUEUE_TOOLBAR_LABEL_KEYS: Record<QueueToolbarButtonId, string> = {
shuffle: 'queue.shuffle',
save: 'queue.savePlaylist',
load: 'queue.loadPlaylist',
playlist: 'queue.playlist',
share: 'queue.shareQueue',
clear: 'queue.clear',
separator: 'settings.queueToolbarSeparator',
gapless: 'queue.gapless',
crossfade: 'queue.crossfade',
autodj: 'queue.autoDj',
infinite: 'queue.infiniteQueue',
};
@@ -28,13 +28,9 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
const auth = useAuthStore();
return (
<>
<div style={{ marginBottom: '0.6rem' }}>
<div style={{ fontWeight: 500 }}>{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
{t('settings.normalizationDesc')}
</div>
</div>
<div className="settings-group">
<div className="settings-group-title">{t('settings.normalization', { defaultValue: 'Normalization' })}</div>
<div className="settings-group-desc">{t('settings.normalizationDesc')}</div>
<div className="settings-segmented" style={{ marginBottom: auth.normalizationEngine === 'off' ? 0 : '0.85rem' }}>
<button
type="button"
@@ -180,6 +176,6 @@ export function NormalizationBlock({ preAnalysisEffectiveDb, t }: Props) {
<div className="settings-norm-note">{t('settings.loudnessFirstPlayNote')}</div>
</div>
)}
</>
</div>
);
}
@@ -1,119 +1,99 @@
import React from 'react';
import type { TFunction } from 'i18next';
import { useAuthStore } from '../../../store/authStore';
import {
getTransitionMode,
setTransitionMode,
type TransitionMode,
} from '../../../utils/playback/playbackTransition';
interface Props {
t: TFunction;
}
/**
* 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.
* Track-transition picker. Crossfade, AutoDJ and Gapless are mutually
* exclusive — only one can be active — so they are presented as a single
* `Off | Gapless | Crossfade | AutoDJ` segmented control (mirroring the
* Normalization picker above it) backed by the shared transition-mode helper.
*
* 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.
* Classic crossfade exposes the seconds slider; AutoDJ is content-driven and
* has no duration to configure (just a short explainer). The
* `preservePlayNextOrder` toggle is independent and grouped under its own
* "Queue behaviour" heading at the bottom.
*/
export function PlaybackBehaviorBlock({ t }: Props) {
const auth = useAuthStore();
const mode = getTransitionMode(auth);
const transitions: { id: TransitionMode; label: string }[] = [
{ id: 'none', label: t('settings.transitionOff') },
{ id: 'gapless', label: t('settings.gapless') },
{ id: 'crossfade', label: t('settings.crossfade') },
{ id: 'autodj', label: t('settings.autoDj') },
];
return (
<>
<div className="settings-toggle-row" style={auth.gaplessEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.crossfade')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.gaplessEnabled ? t('settings.notWithGapless') : t('settings.crossfadeDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
<input type="checkbox" checked={auth.crossfadeEnabled} disabled={auth.gaplessEnabled}
onChange={e => { auth.setGaplessEnabled(false); auth.setCrossfadeEnabled(e.target.checked); }} id="crossfade-toggle" />
<span className="toggle-track" />
</label>
</div>
{auth.crossfadeEnabled && !auth.gaplessEnabled && (
<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>
)}
<div className="settings-group">
<div className="settings-group-title">{t('settings.transitionsTitle')}</div>
<div className="settings-group-desc">{t('settings.transitionsDesc')}</div>
<div className="divider" />
<div className="settings-toggle-row" style={auth.crossfadeEnabled ? { opacity: 0.45, pointerEvents: 'none' } : undefined}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.gapless')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{auth.crossfadeEnabled ? t('settings.notWithCrossfade') : t('settings.gaplessDesc')}
</div>
<div className="settings-segmented">
{transitions.map(item => (
<button
key={item.id}
type="button"
className={`btn ${mode === item.id ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => setTransitionMode(item.id)}
>
{item.label}
</button>
))}
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
<input type="checkbox" checked={auth.gaplessEnabled} disabled={auth.crossfadeEnabled}
onChange={e => { auth.setCrossfadeEnabled(false); auth.setGaplessEnabled(e.target.checked); }} id="gapless-toggle" />
<span className="toggle-track" />
</label>
{mode === 'crossfade' && (
<div style={{ paddingLeft: '1rem', marginTop: '0.7rem', 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>
)}
{mode === 'autodj' && (
<div style={{ paddingLeft: '1rem', fontSize: 12, color: 'var(--text-muted)', marginTop: '0.7rem' }}>
{t('settings.autoDjDesc')}
</div>
)}
</div>
<div className="settings-toggle-row" style={{ marginTop: '0.75rem' }}>
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.preservePlayNextOrder')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.preservePlayNextOrderDesc')}
<div className="settings-group">
<div className="settings-group-title">{t('settings.queueBehaviourTitle')}</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>
{t('settings.preservePlayNextOrder')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.preservePlayNextOrderDesc')}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.preservePlayNextOrder')}>
<input type="checkbox" checked={auth.preservePlayNextOrder}
onChange={e => auth.setPreservePlayNextOrder(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<label className="toggle-switch" aria-label={t('settings.preservePlayNextOrder')}>
<input type="checkbox" checked={auth.preservePlayNextOrder}
onChange={e => auth.setPreservePlayNextOrder(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</>
);