From fde7ab432f03d489f2ad406d51987ae6d3ae2e65 Mon Sep 17 00:00:00 2001
From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
Date: Thu, 18 Jun 2026 13:31:38 +0200
Subject: [PATCH] feat: split AutoDJ into a standalone playback feature (#1124)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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).
---
CHANGELOG.md | 5 +-
WHATS_NEW.md | 2 +-
src/components/QueuePanel.test.tsx | 20 +-
src/components/QueuePanel.tsx | 8 +-
src/components/miniPlayer/MiniToolbar.tsx | 83 ++++----
src/components/queuePanel/QueueToolbar.tsx | 178 ++++++++++--------
src/components/settings/AudioTab.tsx | 3 -
.../settings/QueueToolbarCustomizer.tsx | 10 +-
.../settings/audio/NormalizationBlock.tsx | 12 +-
.../settings/audio/PlaybackBehaviorBlock.tsx | 168 ++++++++---------
src/config/settingsCredits.ts | 1 +
src/locales/de/queue.ts | 2 +
src/locales/de/settings.ts | 10 +-
src/locales/en/queue.ts | 2 +
src/locales/en/settings.ts | 10 +-
src/locales/es/queue.ts | 2 +
src/locales/es/settings.ts | 10 +-
src/locales/fr/queue.ts | 2 +
src/locales/fr/settings.ts | 10 +-
src/locales/nb/queue.ts | 2 +
src/locales/nb/settings.ts | 10 +-
src/locales/nl/queue.ts | 2 +
src/locales/nl/settings.ts | 10 +-
src/locales/ro/queue.ts | 2 +
src/locales/ro/settings.ts | 10 +-
src/locales/ru/queue.ts | 2 +
src/locales/ru/settings.ts | 10 +-
src/locales/zh/queue.ts | 2 +
src/locales/zh/settings.ts | 10 +-
src/store/queueToolbarStore.test.ts | 87 +++++++++
src/store/queueToolbarStore.ts | 80 ++++++--
src/styles/components/mini-player-window.css | 33 ----
src/styles/components/settings.css | 29 +++
src/styles/layout/queue-panel.css | 40 ++++
src/utils/miniPlayerBridge.ts | 25 +--
src/utils/playback/playbackTransition.test.ts | 85 +++++++++
src/utils/playback/playbackTransition.ts | 60 ++++++
37 files changed, 665 insertions(+), 372 deletions(-)
create mode 100644 src/store/queueToolbarStore.test.ts
create mode 100644 src/utils/playback/playbackTransition.test.ts
create mode 100644 src/utils/playback/playbackTransition.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7fa2810d..26157a3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,9 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### AutoDJ — content-aware crossfade
-**By [@cucadmuh](https://github.com/cucadmuh), PR [#1122](https://github.com/Psychotoxical/psysonic/pull/1122)**
+**By [@cucadmuh](https://github.com/cucadmuh), PR [#1122](https://github.com/Psychotoxical/psysonic/pull/1122) and [@Psychotoxical](https://github.com/Psychotoxical), PR [#1124](https://github.com/Psychotoxical/psysonic/pull/1124)**
-* New **AutoDJ** crossfade mode (next to classic Crossfade in the audio settings and the crossfade popover). Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Off by default; classic Crossfade is unchanged. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
+* New **AutoDJ** crossfade mode. Instead of a fixed crossfade time, it blends what you actually hear: it trims the dead silence at the end of one track and the start of the next, and picks the overlap from the music itself — a track that fades out rides its own fade while the next one rises underneath, and two tracks that both start/end loud get a short musical blend instead of an abrupt cut. Works most reliably with the Hot playback cache enabled, since the next track's audio needs to be ready for the blend.
+* AutoDJ is now its own mode rather than a sub-option of Crossfade — its own button in the queue toolbar and its own entry in the audio settings. Crossfade, AutoDJ and Gapless are mutually exclusive (only one active at a time) under a single Off / Gapless / Crossfade / AutoDJ picker, the playback settings are regrouped into clearer Normalization / Track transitions / Queue behaviour panels, and the queue toolbar's separate Save and Load playlist buttons are combined into one Playlist menu (existing toolbar layouts are preserved). Off by default; classic Crossfade is unchanged.
## Fixed
diff --git a/WHATS_NEW.md b/WHATS_NEW.md
index 0b676834..36aa2d2c 100644
--- a/WHATS_NEW.md
+++ b/WHATS_NEW.md
@@ -14,7 +14,7 @@ Within each section, order by **user impact** (most noticeable first) — not PR
### AutoDJ — minimum pauses, maximum music
-- New **AutoDJ** mode in **Settings → Audio** and the crossfade popover — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. Off by default; classic **Crossfade** is unchanged.
+- New **AutoDJ** mode — a smart crossfade that blends tracks intelligently. Each transition is shaped to fit the music: awkward gaps fade away, handovers feel natural, and you spend less time in silence between songs. It's now its own choice in **Settings → Audio** and its own button in the queue toolbar, sitting alongside Crossfade and Gapless — pick one at a time. Off by default; classic **Crossfade** is unchanged.
### Playlist folders — your playlists, organised
diff --git a/src/components/QueuePanel.test.tsx b/src/components/QueuePanel.test.tsx
index 8a6189f6..ca28093c 100644
--- a/src/components/QueuePanel.test.tsx
+++ b/src/components/QueuePanel.test.tsx
@@ -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();
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();
+ // 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', () => {
diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx
index d40431c0..305822c7 100644
--- a/src/components/QueuePanel.tsx
+++ b/src/components/QueuePanel.tsx
@@ -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}
diff --git a/src/components/miniPlayer/MiniToolbar.tsx b/src/components/miniPlayer/MiniToolbar.tsx
index fd179617..c451047c 100644
--- a/src/components/miniPlayer/MiniToolbar.tsx
+++ b/src/components/miniPlayer/MiniToolbar.tsx
@@ -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 (