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
+17 -3
View File
@@ -31,6 +31,7 @@ vi.mock('@/utils/orbitBulkGuard', () => ({
}));
import QueuePanel from './QueuePanel';
import { fireEvent } from '@testing-library/react';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/store/playerStore';
import { useAuthStore } from '@/store/authStore';
@@ -164,15 +165,28 @@ describe('QueuePanel — display mode', () => {
});
describe('QueuePanel — toolbar', () => {
it('exposes Shuffle / Save Playlist / Load Playlist / Share Queue / Clear via aria-label', () => {
it('exposes Shuffle / Playlist / Share Queue / Clear / AutoDJ via aria-label', () => {
const tracks = makeTracks(3);
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
const { getByLabelText } = renderWithProviders(<QueuePanel />);
expect(getByLabelText('Shuffle queue')).toBeInTheDocument();
expect(getByLabelText('Save Playlist')).toBeInTheDocument();
expect(getByLabelText('Load Playlist')).toBeInTheDocument();
expect(getByLabelText('Playlist')).toBeInTheDocument();
expect(getByLabelText('Copy queue share link')).toBeInTheDocument();
expect(getByLabelText('Clear queue')).toBeInTheDocument();
expect(getByLabelText('AutoDJ')).toBeInTheDocument();
});
it('Save and Load live inside the Playlist submenu, not directly on the toolbar', () => {
const tracks = makeTracks(3);
seedQueue(tracks, { index: 0, currentTrack: tracks[0] });
const { getByLabelText, container } = renderWithProviders(<QueuePanel />);
// The submenu is closed initially.
expect(container.querySelector('.queue-menu')).toBeNull();
fireEvent.click(getByLabelText('Playlist'));
const menu = container.querySelector('.queue-menu');
expect(menu).not.toBeNull();
expect(menu?.textContent).toContain('Save Playlist');
expect(menu?.textContent).toContain('Load Playlist');
});
it('Shuffle button is disabled when the queue has fewer than 2 tracks', () => {
+1 -7
View File
@@ -109,10 +109,7 @@ function QueuePanelHostOrSolo() {
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);
@@ -337,13 +334,10 @@ function QueuePanelHostOrSolo() {
handleCopyQueueShare={handleCopyQueueShare}
handleClear={handleClear}
gaplessEnabled={gaplessEnabled}
setGaplessEnabled={setGaplessEnabled}
crossfadeEnabled={crossfadeEnabled}
setCrossfadeEnabled={setCrossfadeEnabled}
crossfadeTrimSilence={crossfadeTrimSilence}
crossfadeSecs={crossfadeSecs}
setCrossfadeSecs={setCrossfadeSecs}
crossfadeTrimSilence={crossfadeTrimSilence}
setCrossfadeTrimSilence={setCrossfadeTrimSilence}
infiniteQueueEnabled={infiniteQueueEnabled}
setInfiniteQueueEnabled={setInfiniteQueueEnabled}
t={t}
+36 -47
View File
@@ -1,9 +1,10 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { emit } from '@tauri-apps/api/event';
import { Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react';
import { Blend, Infinity as InfinityIcon, ListMusic, MoveRight, Shuffle, Volume2, VolumeX, Waves } from 'lucide-react';
import type { TFunction } from 'i18next';
import type { MiniSyncPayload } from '../../utils/miniPlayerBridge';
import { getTransitionMode } from '../../utils/playback/playbackTransition';
interface Props {
state: MiniSyncPayload;
@@ -31,6 +32,8 @@ export function MiniToolbar({
crossfadeOpen, setCrossfadeOpen, crossfadeBtnRef, crossfadePopRef, crossfadePopStyle,
queueOpen, toggleQueue, t,
}: Props) {
const mode = getTransitionMode(state);
return (
<div className="mini-player__toolbar" data-tauri-drag-region="false">
<div className="mini-player__volume-wrap">
@@ -108,8 +111,8 @@ export function MiniToolbar({
<button
type="button"
className={`mini-player__tool${state.gaplessEnabled ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-gapless', { value: !state.gaplessEnabled }).catch(() => {})}
className={`mini-player__tool${mode === 'gapless' ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-transition-mode', { value: mode === 'gapless' ? 'none' : 'gapless' }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
@@ -120,8 +123,8 @@ export function MiniToolbar({
<button
ref={crossfadeBtnRef}
type="button"
className={`mini-player__tool${state.crossfadeEnabled || crossfadeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-crossfade', { value: !state.crossfadeEnabled }).catch(() => {})}
className={`mini-player__tool${mode === 'crossfade' || crossfadeOpen ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-transition-mode', { value: mode === 'crossfade' ? 'none' : 'crossfade' }).catch(() => {})}
onContextMenu={(e) => { e.preventDefault(); setCrossfadeOpen(v => !v); }}
data-tauri-drag-region="false"
data-tooltip={t('queue.crossfade')}
@@ -136,53 +139,39 @@ export function MiniToolbar({
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 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>
{!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')}
/>
</>
)}
<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(() => {});
emit('mini:set-transition-mode', { value: 'crossfade' }).catch(() => {});
}}
className="mini-player__crossfade-slider"
aria-label={t('queue.crossfade')}
/>
</div>,
document.body,
)}
<button
type="button"
className={`mini-player__tool${mode === 'autodj' ? ' mini-player__tool--active' : ''}`}
onClick={() => emit('mini:set-transition-mode', { value: mode === 'autodj' ? 'none' : 'autodj' }).catch(() => {})}
data-tauri-drag-region="false"
data-tooltip={t('queue.autoDj')}
aria-label={t('queue.autoDj')}
>
<Blend size={13} />
</button>
<button
type="button"
className={`mini-player__tool${state.infiniteQueueEnabled ? ' mini-player__tool--active' : ''}`}
+95 -83
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Check, FolderOpen, Infinity, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
Blend, Check, FolderOpen, Infinity, ListMusic, MoveRight, Save, Share2, Shuffle, Trash2, Waves,
} from 'lucide-react';
import type { TFunction } from 'i18next';
import type { QueueItemRef } from '../../store/playerStoreTypes';
@@ -8,6 +8,7 @@ import type {
QueueToolbarButtonConfig,
QueueToolbarButtonId,
} from '../../store/queueToolbarStore';
import { getTransitionMode, setTransitionMode } from '../../utils/playback/playbackTransition';
interface Props {
queue: QueueItemRef[];
@@ -20,13 +21,10 @@ interface Props {
handleCopyQueueShare: () => void;
handleClear: () => void;
gaplessEnabled: boolean;
setGaplessEnabled: (v: boolean) => void;
crossfadeEnabled: boolean;
setCrossfadeEnabled: (v: boolean) => void;
crossfadeTrimSilence: boolean;
crossfadeSecs: number;
setCrossfadeSecs: (v: number) => void;
crossfadeTrimSilence: boolean;
setCrossfadeTrimSilence: (v: boolean) => void;
infiniteQueueEnabled: boolean;
setInfiniteQueueEnabled: (v: boolean) => void;
t: TFunction;
@@ -35,27 +33,38 @@ interface Props {
export function QueueToolbar({
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
handleSave, handleLoad, handleCopyQueueShare, handleClear,
gaplessEnabled, setGaplessEnabled, crossfadeEnabled, setCrossfadeEnabled,
crossfadeSecs, setCrossfadeSecs, crossfadeTrimSilence, setCrossfadeTrimSilence,
gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence,
crossfadeSecs, setCrossfadeSecs,
infiniteQueueEnabled, setInfiniteQueueEnabled,
t,
}: Props) {
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const [showPlaylistMenu, setShowPlaylistMenu] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
const playlistBtnRef = useRef<HTMLButtonElement>(null);
const playlistMenuRef = useRef<HTMLDivElement>(null);
const mode = getTransitionMode({ gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence });
useEffect(() => {
if (!showCrossfadePopover) return;
if (!showCrossfadePopover && !showPlaylistMenu) return;
const handle = (e: MouseEvent) => {
const target = e.target as Node;
if (
crossfadeBtnRef.current?.contains(e.target as Node) ||
crossfadePopoverRef.current?.contains(e.target as Node)
) return;
setShowCrossfadePopover(false);
showCrossfadePopover &&
!crossfadeBtnRef.current?.contains(target) &&
!crossfadePopoverRef.current?.contains(target)
) setShowCrossfadePopover(false);
if (
showPlaylistMenu &&
!playlistBtnRef.current?.contains(target) &&
!playlistMenuRef.current?.contains(target)
) setShowPlaylistMenu(false);
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]);
}, [showCrossfadePopover, showPlaylistMenu]);
return (
<div className="queue-toolbar">
@@ -69,24 +78,40 @@ export function QueueToolbar({
<Shuffle size={13} />
</button>
);
case 'save':
case 'playlist':
return (
<button
key={btn.id}
className={`queue-round-btn${saveState === 'saved' ? ' active' : ''}`}
onClick={handleSave}
disabled={saveState === 'saving'}
data-tooltip={activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
aria-label={t('queue.savePlaylist')}
>
{saveState === 'saved' ? <Check size={13} /> : <Save size={13} />}
</button>
);
case 'load':
return (
<button key={btn.id} className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
</button>
<div key={btn.id} style={{ position: 'relative' }}>
<button
ref={playlistBtnRef}
className={`queue-round-btn${showPlaylistMenu ? ' active' : ''}`}
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(v => !v); }}
data-tooltip={showPlaylistMenu ? undefined : t('queue.playlist')}
aria-label={t('queue.playlist')}
>
<ListMusic size={13} />
</button>
{showPlaylistMenu && (
<div className="crossfade-popover queue-menu" ref={playlistMenuRef}>
<button
type="button"
className="queue-menu-item"
onClick={() => { handleSave(); setShowPlaylistMenu(false); }}
disabled={saveState === 'saving'}
>
{saveState === 'saved' ? <Check size={14} /> : <Save size={14} />}
{activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
</button>
<button
type="button"
className="queue-menu-item"
onClick={() => { handleLoad(); setShowPlaylistMenu(false); }}
>
<FolderOpen size={14} />
{t('queue.loadPlaylist')}
</button>
</div>
)}
</div>
);
case 'share':
return (
@@ -112,8 +137,8 @@ export function QueueToolbar({
return (
<button
key={btn.id}
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
className={`queue-round-btn${mode === 'gapless' ? ' active' : ''}`}
onClick={() => { setShowCrossfadePopover(false); setTransitionMode(mode === 'gapless' ? 'none' : 'gapless'); }}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
@@ -125,14 +150,16 @@ export function QueueToolbar({
<div key={btn.id} style={{ position: 'relative' }}>
<button
ref={crossfadeBtnRef}
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
className={`queue-round-btn${mode === 'crossfade' || showCrossfadePopover ? ' active' : ''}`}
onClick={() => {
// Left click: toggle on/off only. Right click opens the popover.
if (!crossfadeEnabled) setGaplessEnabled(false);
setCrossfadeEnabled(!crossfadeEnabled);
// Left click toggles classic crossfade on/off. Right click
// opens the seconds popover.
setShowPlaylistMenu(false);
setTransitionMode(mode === 'crossfade' ? 'none' : 'crossfade');
}}
onContextMenu={(e) => {
e.preventDefault();
setShowPlaylistMenu(false);
setShowCrossfadePopover(v => !v);
}}
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
@@ -142,58 +169,43 @@ export function QueueToolbar({
</button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<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 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));
setTransitionMode('crossfade');
}}
className="crossfade-popover-slider"
aria-label={t('queue.crossfade')}
/>
<div className="crossfade-popover-range">
<span>0.1s</span><span>10s</span>
</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>
);
case 'autodj':
return (
<button
key={btn.id}
className={`queue-round-btn${mode === 'autodj' ? ' active' : ''}`}
onClick={() => { setShowCrossfadePopover(false); setShowPlaylistMenu(false); setTransitionMode(mode === 'autodj' ? 'none' : 'autodj'); }}
data-tooltip={t('queue.autoDj')}
aria-label={t('queue.autoDj')}
>
<Blend size={13} />
</button>
);
case 'infinite':
return (
<button
-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>
</>
);