mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
+3
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' : ''}`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -372,6 +372,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Performance Probe: opt-in thread-group CPU poll toggle and includeThreadGroups IPC fix (PR #891)',
|
||||
'Queue: switchable display mode — Queue (upcoming only) vs Playlist (full list), with header toggle and settings entry (PR #922)',
|
||||
'Community Theme Store: semantic-token refactor, dedicated Themes tab with day/night scheduler, install/update/uninstall, local .zip import, free-form community themes (safety floor + state-reactive styling), and per-theme download counts with popularity/newest/name sorting; 80+ palettes moved to an on-demand CDN repo (PR #1009, #1011, #1012, #1013, #1014, #1015, #1016, #1018, #1020, #1036)',
|
||||
'AutoDJ as a standalone playback mode — own toolbar button and audio-settings entry, mutually exclusive with Crossfade and Gapless via a shared transition helper, combined playlist save/load submenu, and boxed playback settings sections (PR #1124)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Warteschlange mischen',
|
||||
gapless: 'Nahtlos',
|
||||
crossfade: 'Crossfade',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Endlose Warteschlange',
|
||||
playlist: 'Playlist',
|
||||
autoAdded: '— Automatisch hinzugefügt —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
|
||||
@@ -564,16 +564,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'Beim ersten Abspielen eines neuen Titels kann die Lautstärke kurz schwanken, während gemessen wird. Beim nächsten Mal greift die gespeicherte Messung und alles bleibt stabil. Kommende Queue-Titel werden meist schon während des vorherigen Songs analysiert — in der Praxis tritt das daher selten auf.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Überblendung zwischen Tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
crossfadeTrimSilence: 'Stille zwischen Tracks kürzen',
|
||||
crossfadeTrimSilenceDesc: 'Stille am Ende des aktuellen und am Anfang des nächsten Tracks überspringen, damit die Überblendung Musik statt Leere überlappt. Für zuverlässige Ergebnisse den Hot-Playback-Cache aktivieren, damit der nächste Track rechtzeitig bereit ist',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Keine feste Dauer — AutoDJ richtet sich nach dem tatsächlichen Klang und überlappt echte Aus- und Einblendungen statt einer festen Sekundenzahl. Für zuverlässige Ergebnisse den Hot-Playback-Cache aktivieren.',
|
||||
notWithGapless: 'Nicht verfügbar wenn Nahtlose Wiedergabe aktiv ist',
|
||||
notWithCrossfade: 'Nicht verfügbar wenn Crossfade aktiv ist',
|
||||
gapless: 'Nahtlose Wiedergabe',
|
||||
gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden',
|
||||
transitionsTitle: 'Übergänge zwischen Tracks',
|
||||
transitionsDesc: 'Wie aufeinanderfolgende Tracks ineinander übergehen. Es kann immer nur ein Modus aktiv sein.',
|
||||
transitionOff: 'Aus',
|
||||
queueBehaviourTitle: 'Warteschlangen-Verhalten',
|
||||
preservePlayNextOrder: '„Als Nächstes"-Reihenfolge bewahren',
|
||||
preservePlayNextOrderDesc: 'Neu hinzugefügte „Als Nächstes"-Titel reihen sich hinten an statt sich vorn einzuschieben.',
|
||||
trackPreviewsTitle: 'Track-Vorschau',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Shuffle queue',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Infinite Queue',
|
||||
playlist: 'Playlist',
|
||||
autoAdded: '— Added automatically —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Hide',
|
||||
|
||||
@@ -631,16 +631,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'First play of a brand-new track may briefly drift in volume while it is being measured. The next play uses the cached measurement and is rock-solid. Upcoming queue tracks are usually pre-analysed during the previous song, so this rarely happens in practice.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Fade between tracks',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
crossfadeTrimSilence: 'Trim silence between tracks',
|
||||
crossfadeTrimSilenceDesc: 'Skip trailing silence of the current track and leading silence of the next so the fade overlaps music, not dead air. For reliable results, enable the Hot playback cache so the next track is ready in time',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'No fixed duration — AutoDJ follows the actual audio, blending real fade-outs and intros instead of a set number of seconds. For reliable results, enable the Hot playback cache.',
|
||||
notWithGapless: 'Not available while Gapless is active',
|
||||
notWithCrossfade: 'Not available while Crossfade is active',
|
||||
gapless: 'Gapless Playback',
|
||||
gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs',
|
||||
transitionsTitle: 'Track transitions',
|
||||
transitionsDesc: 'How consecutive tracks blend together. Only one mode can be active at a time.',
|
||||
transitionOff: 'Off',
|
||||
queueBehaviourTitle: 'Queue behaviour',
|
||||
preservePlayNextOrder: 'Preserve "Play Next" order',
|
||||
preservePlayNextOrderDesc: 'Newly added Play Next items queue up behind earlier ones instead of jumping in front.',
|
||||
trackPreviewsTitle: 'Track Previews',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Mezclar cola',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Cola Infinita',
|
||||
playlist: 'Lista',
|
||||
autoAdded: '— Agregado automáticamente —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Ocultar',
|
||||
|
||||
@@ -563,16 +563,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'En la primera reproducción de un tema nuevo el volumen puede oscilar brevemente mientras se mide. La siguiente vez se usa la medición guardada y todo permanece estable. Las pistas próximas en la cola suelen analizarse durante la canción anterior, por lo que en la práctica esto rara vez ocurre.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Transición entre pistas',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
crossfadeTrimSilence: 'Recortar el silencio entre pistas',
|
||||
crossfadeTrimSilenceDesc: 'Omite el silencio al final de la pista actual y al inicio de la siguiente para que la transición se solape con la música, no con el vacío. Para resultados fiables, activa la Caché de reproducción activa para que la siguiente pista esté lista a tiempo',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Sin duración fija: AutoDJ se adapta al audio real y se solapa con los fundidos e introducciones reales en lugar de un número fijo de segundos. Para resultados fiables, activa la Caché de reproducción activa.',
|
||||
notWithGapless: 'No disponible mientras Gapless está activo',
|
||||
notWithCrossfade: 'No disponible mientras Crossfade está activo',
|
||||
gapless: 'Reproducción Gapless',
|
||||
gaplessDesc: 'Precarga siguiente pista para eliminar silencios entre canciones',
|
||||
transitionsTitle: 'Transiciones entre pistas',
|
||||
transitionsDesc: 'Cómo se enlazan las pistas consecutivas. Solo un modo puede estar activo a la vez.',
|
||||
transitionOff: 'Desactivado',
|
||||
queueBehaviourTitle: 'Comportamiento de la cola',
|
||||
preservePlayNextOrder: 'Mantener el orden de "Reproducir Siguiente"',
|
||||
preservePlayNextOrderDesc: 'Los elementos añadidos a "Reproducir Siguiente" se encolan detrás de los anteriores en vez de saltar al principio.',
|
||||
trackPreviewsTitle: 'Previsualizaciones de pistas',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Mélanger la file',
|
||||
gapless: 'Sans blanc',
|
||||
crossfade: 'Fondu',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'File infinie',
|
||||
playlist: 'Playlist',
|
||||
autoAdded: '— Ajouté automatiquement —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Masquer',
|
||||
|
||||
@@ -551,16 +551,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'À la première lecture d’un nouveau morceau, le volume peut brièvement varier pendant la mesure. La fois suivante, la mesure mise en cache prend le relais et tout reste stable. Les pistes à venir dans la file sont en général pré-analysées pendant la lecture précédente, ce cas est donc rare en pratique.',
|
||||
crossfade: 'Fondu enchaîné',
|
||||
crossfadeDesc: 'Fondu entre les pistes',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
crossfadeTrimSilence: 'Couper le silence entre les pistes',
|
||||
crossfadeTrimSilenceDesc: 'Ignorer le silence en fin de piste actuelle et en début de la suivante pour que le fondu se superpose à la musique, pas au vide. Pour un résultat fiable, activez le Cache de lecture à chaud afin que la piste suivante soit prête à temps',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Sans durée fixe : AutoDJ suit l’audio réel et se superpose aux vrais fondus et intros plutôt qu’à un nombre fixe de secondes. Pour un résultat fiable, activez le Cache de lecture à chaud.',
|
||||
notWithGapless: 'Non disponible quand la lecture sans blanc est active',
|
||||
notWithCrossfade: 'Non disponible quand le fondu enchaîné est actif',
|
||||
gapless: 'Lecture sans blanc',
|
||||
gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux',
|
||||
transitionsTitle: 'Transitions entre pistes',
|
||||
transitionsDesc: 'Comment les pistes consécutives s’enchaînent. Un seul mode peut être actif à la fois.',
|
||||
transitionOff: 'Désactivé',
|
||||
queueBehaviourTitle: 'Comportement de la file',
|
||||
preservePlayNextOrder: "Préserver l'ordre « Lire ensuite »",
|
||||
preservePlayNextOrderDesc: 'Les nouveaux éléments « Lire ensuite » s\'ajoutent à la fin de la file existante au lieu de la doubler.',
|
||||
trackPreviewsTitle: 'Aperçus de pistes',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Bland kø',
|
||||
gapless: 'Uten mellomrom',
|
||||
crossfade: 'Crossfade',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Uendelig kø',
|
||||
playlist: 'Spilleliste',
|
||||
autoAdded: '- Lagt til automatisk -',
|
||||
radioAdded: '- Radio -',
|
||||
hide: 'Skjul',
|
||||
|
||||
@@ -550,16 +550,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'Første avspilling av et helt nytt spor kan drive litt i volum mens målingen skjer. Neste gang brukes den lagrede målingen, og alt blir stabilt. Kommende spor i køen blir vanligvis pre-analysert mens det forrige spilles, så dette skjer sjelden i praksis.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Tone mellom spor',
|
||||
crossfadeSecs: '{{n}}s',
|
||||
crossfadeTrimSilence: 'Trim stillhet mellom spor',
|
||||
crossfadeTrimSilenceDesc: 'Hopp over stillhet på slutten av gjeldende spor og starten på neste, slik at toningen overlapper musikk, ikke stillhet. For pålitelige resultater bør du aktivere Varm avspillingsbuffer slik at neste spor er klart i tide',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Ingen fast varighet — AutoDJ følger selve lyden og overlapper ekte inn-/uttoninger i stedet for et fast antall sekunder. For pålitelige resultater bør du aktivere Varm avspillingsbuffer.',
|
||||
notWithGapless: 'Ikke tilgjengelig mens Gapless er aktiv',
|
||||
notWithCrossfade: 'Ikke tilgjengelig mens Crossfade er aktiv',
|
||||
gapless: 'Gapless avspilling',
|
||||
gaplessDesc: 'Forhåndsbuffer neste spor for å eliminere mellomrom mellom sanger',
|
||||
transitionsTitle: 'Overganger mellom spor',
|
||||
transitionsDesc: 'Hvordan etterfølgende spor går over i hverandre. Bare én modus kan være aktiv om gangen.',
|
||||
transitionOff: 'Av',
|
||||
queueBehaviourTitle: 'Køoppførsel',
|
||||
preservePlayNextOrder: 'Behold "Spill neste"-rekkefølge',
|
||||
preservePlayNextOrderDesc: 'Nye "Spill neste"-elementer havner bak de eksisterende i stedet for å snike seg foran.',
|
||||
trackPreviewsTitle: 'Sporforhåndsvisning',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Wachtrij shufflen',
|
||||
gapless: 'Naadloos',
|
||||
crossfade: 'Overgang',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Oneindige wachtrij',
|
||||
playlist: 'Afspeellijst',
|
||||
autoAdded: '— Automatisch toegevoegd —',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Verbergen',
|
||||
|
||||
@@ -551,16 +551,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'Bij de eerste keer afspelen van een nieuw nummer kan het volume even fluctueren terwijl er gemeten wordt. De volgende keer gebruikt de speler de opgeslagen meting en blijft alles rotsvast. Komende nummers in de wachtrij worden meestal al tijdens het vorige nummer geanalyseerd, dus dit komt in de praktijk zelden voor.',
|
||||
crossfade: 'Overgang',
|
||||
crossfadeDesc: 'Fade tussen nummers',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
crossfadeTrimSilence: 'Stilte tussen nummers bijsnijden',
|
||||
crossfadeTrimSilenceDesc: 'Sla de stilte aan het einde van het huidige nummer en aan het begin van het volgende over, zodat de overgang muziek overlapt en geen stilte. Schakel voor betrouwbare resultaten de Warme afspeelcache in zodat het volgende nummer op tijd klaarstaat',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Geen vaste duur — AutoDJ volgt de werkelijke audio en overlapt echte fades en intro’s in plaats van een vast aantal seconden. Schakel voor betrouwbare resultaten de Warme afspeelcache in.',
|
||||
notWithGapless: 'Niet beschikbaar als naadloos afspelen actief is',
|
||||
notWithCrossfade: 'Niet beschikbaar als overgang actief is',
|
||||
gapless: 'Naadloos afspelen',
|
||||
gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren',
|
||||
transitionsTitle: 'Overgangen tussen nummers',
|
||||
transitionsDesc: 'Hoe opeenvolgende nummers in elkaar overvloeien. Er kan maar één modus tegelijk actief zijn.',
|
||||
transitionOff: 'Uit',
|
||||
queueBehaviourTitle: 'Wachtrijgedrag',
|
||||
preservePlayNextOrder: '"Volgende afspelen"-volgorde behouden',
|
||||
preservePlayNextOrderDesc: 'Nieuwe "Volgende afspelen"-items komen achter bestaande te staan in plaats van ervoor.',
|
||||
trackPreviewsTitle: 'Track-voorvertoning',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Amestecă coada',
|
||||
gapless: 'Gapless',
|
||||
crossfade: 'Crossfade',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Coadă infinită',
|
||||
playlist: 'Listă',
|
||||
autoAdded: '— Adăugat automat—',
|
||||
radioAdded: '— Radio —',
|
||||
hide: 'Ascunde',
|
||||
|
||||
@@ -566,16 +566,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'Prima redate a unei piese noi își poate schimba brusc volumul cât este măsurată. Următoarea redare folosește măsurătoarea stocată și este stabilă. Piesele următoare din coadă sunt de obicei pre-analizate în timpul piesei anterioare, deci acest fenomen se întâmplă rar în practică.',
|
||||
crossfade: 'Crossfade',
|
||||
crossfadeDesc: 'Estompează între piese',
|
||||
crossfadeSecs: '{{n}} s',
|
||||
crossfadeTrimSilence: 'Elimină liniștea dintre piese',
|
||||
crossfadeTrimSilenceDesc: 'Sare peste liniștea de la finalul piesei curente și de la începutul celei următoare, astfel încât estomparea să se suprapună peste muzică, nu peste gol. Pentru rezultate fiabile, activează Cache-ul hot playback ca piesa următoare să fie gata la timp',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Fără durată fixă — AutoDJ urmează sunetul real și se suprapune peste estompările și introducerile reale, în loc de un număr fix de secunde. Pentru rezultate fiabile, activează Cache-ul hot playback.',
|
||||
notWithGapless: 'Nu este valabil cât Gapless este activ',
|
||||
notWithCrossfade: 'Nu este valabil cât Crossfade este activ',
|
||||
gapless: 'Playback Gapless',
|
||||
gaplessDesc: 'Pre-încarcă următoarea piesă pentru a elimina spațiile dintre piese',
|
||||
transitionsTitle: 'Tranziții între piese',
|
||||
transitionsDesc: 'Cum se îmbină piesele consecutive. Doar un mod poate fi activ la un moment dat.',
|
||||
transitionOff: 'Dezactivat',
|
||||
queueBehaviourTitle: 'Comportamentul cozii',
|
||||
preservePlayNextOrder: 'Prezervă ordinea "Redă următoarea"',
|
||||
preservePlayNextOrderDesc: 'Elementele Redă Următoarea noi adăugate sunt puse după cele adăugate mai devreme în loc să sară în față.',
|
||||
trackPreviewsTitle: 'Previzualizări Piese',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: 'Перемешать',
|
||||
gapless: 'Без пауз',
|
||||
crossfade: 'Кроссфейд',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: 'Бесконечная очередь',
|
||||
playlist: 'Плейлист',
|
||||
autoAdded: '— Добавлено автоматически —',
|
||||
radioAdded: '— Радио —',
|
||||
hide: 'Скрыть',
|
||||
|
||||
@@ -651,16 +651,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'При первом прослушивании нового трека громкость может ненадолго колебаться, пока идёт измерение. Со второго раза применяется уже сохранённое значение и всё стабильно. Следующие треки в очереди обычно анализируются во время предыдущей композиции, поэтому на практике это случается редко.',
|
||||
crossfade: 'Кроссфейд',
|
||||
crossfadeDesc: 'Плавный переход между треками',
|
||||
crossfadeSecs: '{{n}} с',
|
||||
crossfadeTrimSilence: 'Обрезать тишину между треками',
|
||||
crossfadeTrimSilenceDesc: 'Пропускать тишину в конце текущего трека и в начале следующего, чтобы переход накладывался на музыку, а не на пустоту. Для надёжной работы крайне желательно включить «Горячий кэш воспроизведения», чтобы следующий трек был готов вовремя',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: 'Без фиксированной длительности — AutoDJ подстраивается под само звучание, накладываясь на реальные затухания и вступления, а не на заданное число секунд. Для надёжной работы включите «Горячий кэш воспроизведения».',
|
||||
notWithGapless: 'Недоступно при включённом режиме без пауз',
|
||||
notWithCrossfade: 'Недоступно при включённом кроссфейде',
|
||||
gapless: 'Без пауз между треками',
|
||||
gaplessDesc: 'Заранее подгружать следующий трек, чтобы не было тишины',
|
||||
transitionsTitle: 'Переходы между треками',
|
||||
transitionsDesc: 'Как соединяются идущие подряд треки. Одновременно может быть активен только один режим.',
|
||||
transitionOff: 'Выкл.',
|
||||
queueBehaviourTitle: 'Поведение очереди',
|
||||
preservePlayNextOrder: 'Сохранять порядок «Играть следующим»',
|
||||
preservePlayNextOrderDesc: 'Новые элементы «Играть следующим» становятся в конец очереди, а не лезут вперёд.',
|
||||
trackPreviewsTitle: 'Превью треков',
|
||||
|
||||
@@ -22,7 +22,9 @@ export const queue = {
|
||||
shuffle: '随机打乱队列',
|
||||
gapless: '无缝播放',
|
||||
crossfade: '交叉淡入淡出',
|
||||
autoDj: 'AutoDJ',
|
||||
infiniteQueue: '无限队列',
|
||||
playlist: '播放列表',
|
||||
autoAdded: '— 自动添加 —',
|
||||
radioAdded: '— 收音机 —',
|
||||
hide: '隐藏',
|
||||
|
||||
@@ -550,16 +550,14 @@ export const settings = {
|
||||
loudnessFirstPlayNote:
|
||||
'首次播放新曲目时,音量可能在测量过程中略有波动。下一次播放将使用缓存的测量结果,保持稳定。队列中即将播放的曲目通常在上一首播放期间已完成预分析,所以这种情况在实际使用中很少出现。',
|
||||
crossfade: '交叉淡入淡出',
|
||||
crossfadeDesc: '曲目间淡入淡出',
|
||||
crossfadeSecs: '{{n}} 秒',
|
||||
crossfadeTrimSilence: '修剪曲目间的静音',
|
||||
crossfadeTrimSilenceDesc: '跳过当前曲目结尾和下一曲目开头的静音,让淡变叠加在音乐而非空白上。为获得稳定效果,强烈建议启用「热播放缓存」,以便下一曲目及时就绪',
|
||||
autoDj: 'AutoDJ',
|
||||
autoDjDesc: '没有固定时长——AutoDJ 跟随实际音频,叠加在真实的淡入淡出上,而不是固定的秒数。为获得稳定效果,请启用「热播放缓存」。',
|
||||
notWithGapless: '无缝播放开启时不可用',
|
||||
notWithCrossfade: '交叉淡入淡出开启时不可用',
|
||||
gapless: '无缝播放',
|
||||
gaplessDesc: '预缓冲下一首曲目以消除歌曲间的间隙',
|
||||
transitionsTitle: '曲目间过渡',
|
||||
transitionsDesc: '连续曲目之间如何衔接。同一时间只能启用一种模式。',
|
||||
transitionOff: '关闭',
|
||||
queueBehaviourTitle: '队列行为',
|
||||
preservePlayNextOrder: '保留"下一首播放"顺序',
|
||||
preservePlayNextOrderDesc: '新添加的"下一首播放"项目排在现有项目之后,而不是插到前面。',
|
||||
trackPreviewsTitle: '曲目预览',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DEFAULT_QUEUE_TOOLBAR_BUTTONS,
|
||||
migrateQueueToolbarButtons,
|
||||
type QueueToolbarButtonConfig,
|
||||
} from './queueToolbarStore';
|
||||
|
||||
const ids = (b: QueueToolbarButtonConfig[]) => b.map(x => x.id);
|
||||
const vis = (b: QueueToolbarButtonConfig[], id: string) => b.find(x => x.id === id)?.visible;
|
||||
|
||||
describe('migrateQueueToolbarButtons', () => {
|
||||
it('passes the current default through unchanged', () => {
|
||||
expect(migrateQueueToolbarButtons(DEFAULT_QUEUE_TOOLBAR_BUTTONS)).toEqual(DEFAULT_QUEUE_TOOLBAR_BUTTONS);
|
||||
});
|
||||
|
||||
it('collapses legacy save + load into a single playlist button at the earlier position', () => {
|
||||
const legacy = [
|
||||
{ id: 'shuffle', visible: true },
|
||||
{ id: 'save', visible: true },
|
||||
{ id: 'load', visible: true },
|
||||
{ id: 'share', visible: true },
|
||||
{ id: 'clear', visible: true },
|
||||
{ id: 'separator', visible: true },
|
||||
{ id: 'gapless', visible: true },
|
||||
{ id: 'crossfade', visible: true },
|
||||
{ id: 'infinite', visible: true },
|
||||
];
|
||||
const out = migrateQueueToolbarButtons(legacy);
|
||||
expect(ids(out)).toEqual([
|
||||
'shuffle', 'playlist', 'share', 'clear', 'separator', 'gapless', 'crossfade', 'autodj', 'infinite',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps playlist visible if either legacy save or load was visible', () => {
|
||||
const out = migrateQueueToolbarButtons([
|
||||
{ id: 'save', visible: false },
|
||||
{ id: 'load', visible: true },
|
||||
]);
|
||||
expect(vis(out, 'playlist')).toBe(true);
|
||||
|
||||
const hidden = migrateQueueToolbarButtons([
|
||||
{ id: 'save', visible: false },
|
||||
{ id: 'load', visible: false },
|
||||
]);
|
||||
expect(vis(hidden, 'playlist')).toBe(false);
|
||||
});
|
||||
|
||||
it('inserts autodj right after crossfade and inherits its visibility', () => {
|
||||
const out = migrateQueueToolbarButtons([
|
||||
{ id: 'crossfade', visible: false },
|
||||
{ id: 'infinite', visible: true },
|
||||
]);
|
||||
const i = ids(out);
|
||||
expect(i.indexOf('autodj')).toBe(i.indexOf('crossfade') + 1);
|
||||
expect(vis(out, 'autodj')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves a customised order', () => {
|
||||
const out = migrateQueueToolbarButtons([
|
||||
{ id: 'crossfade', visible: true },
|
||||
{ id: 'shuffle', visible: true },
|
||||
{ id: 'playlist', visible: true },
|
||||
]);
|
||||
// autodj follows crossfade, leading items keep their order, missing defaults appended.
|
||||
expect(ids(out).slice(0, 4)).toEqual(['crossfade', 'autodj', 'shuffle', 'playlist']);
|
||||
// every default id is present exactly once
|
||||
for (const d of DEFAULT_QUEUE_TOOLBAR_BUTTONS) {
|
||||
expect(ids(out).filter(x => x === d.id)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops corrupt entries and tolerates non-array input', () => {
|
||||
const out = migrateQueueToolbarButtons([
|
||||
null,
|
||||
{ id: 'shuffle', visible: true },
|
||||
{ id: 123, visible: true },
|
||||
{ id: 'bogus', visible: true },
|
||||
{ visible: true },
|
||||
]);
|
||||
expect(ids(out)).toContain('shuffle');
|
||||
expect(ids(out)).not.toContain('bogus');
|
||||
// unknown/corrupt gone, defaults filled in
|
||||
expect(ids(out).sort()).toEqual(DEFAULT_QUEUE_TOOLBAR_BUTTONS.map(b => b.id).sort());
|
||||
|
||||
expect(migrateQueueToolbarButtons(undefined)).toEqual(DEFAULT_QUEUE_TOOLBAR_BUTTONS);
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,13 @@ import { persist } from 'zustand/middleware';
|
||||
|
||||
export type QueueToolbarButtonId =
|
||||
| 'shuffle'
|
||||
| 'save'
|
||||
| 'load'
|
||||
| 'playlist'
|
||||
| 'share'
|
||||
| 'clear'
|
||||
| 'separator'
|
||||
| 'gapless'
|
||||
| 'crossfade'
|
||||
| 'autodj'
|
||||
| 'infinite';
|
||||
|
||||
export interface QueueToolbarButtonConfig {
|
||||
@@ -18,21 +18,81 @@ export interface QueueToolbarButtonConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default order and visibility for queue toolbar buttons.
|
||||
* Matches the historical layout in QueuePanel.tsx.
|
||||
* Default order and visibility for queue toolbar buttons. `playlist` is a
|
||||
* submenu hosting save + load; `crossfade` and `autodj` sit next to each other
|
||||
* as the two crossfade-style transition modes.
|
||||
*/
|
||||
export const DEFAULT_QUEUE_TOOLBAR_BUTTONS: QueueToolbarButtonConfig[] = [
|
||||
{ id: 'shuffle', visible: true },
|
||||
{ id: 'save', visible: true },
|
||||
{ id: 'load', visible: true },
|
||||
{ id: 'playlist', visible: true },
|
||||
{ id: 'share', visible: true },
|
||||
{ id: 'clear', visible: true },
|
||||
{ id: 'separator', visible: true },
|
||||
{ id: 'gapless', visible: true },
|
||||
{ id: 'crossfade', visible: true },
|
||||
{ id: 'autodj', visible: true },
|
||||
{ id: 'infinite', visible: true },
|
||||
];
|
||||
|
||||
/** Pre-split ids that still live in persisted configs from older versions. */
|
||||
type LegacyEntry = { id: QueueToolbarButtonId | 'save' | 'load'; visible: boolean };
|
||||
|
||||
/**
|
||||
* Bring a persisted button array up to the current id set, preserving the
|
||||
* user's order and visibility:
|
||||
* 1. drop corrupt entries,
|
||||
* 2. collapse legacy `save` + `load` into a single `playlist` button at the
|
||||
* earlier of the two positions (visible if either was visible),
|
||||
* 3. drop anything not in the current id set,
|
||||
* 4. insert the new `autodj` button right after `crossfade`, and
|
||||
* 5. append any still-missing defaults.
|
||||
*/
|
||||
export function migrateQueueToolbarButtons(raw: unknown): QueueToolbarButtonConfig[] {
|
||||
const arr = Array.isArray(raw) ? raw : [];
|
||||
const cleaned = arr.filter(
|
||||
(b): b is LegacyEntry =>
|
||||
b != null && typeof b.id === 'string' && typeof (b as LegacyEntry).visible === 'boolean',
|
||||
);
|
||||
|
||||
// Legacy save + load -> single playlist button.
|
||||
const legacySaveLoad = cleaned.filter(b => b.id === 'save' || b.id === 'load');
|
||||
const alreadyHasPlaylist = cleaned.some(b => b.id === 'playlist');
|
||||
let collapsed: LegacyEntry[] = cleaned;
|
||||
if (legacySaveLoad.length > 0 && !alreadyHasPlaylist) {
|
||||
const playlistVisible = legacySaveLoad.some(b => b.visible);
|
||||
let inserted = false;
|
||||
collapsed = [];
|
||||
for (const b of cleaned) {
|
||||
if (b.id === 'save' || b.id === 'load') {
|
||||
if (!inserted) { collapsed.push({ id: 'playlist', visible: playlistVisible }); inserted = true; }
|
||||
continue;
|
||||
}
|
||||
collapsed.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only current ids (also drops any leftover save/load).
|
||||
const knownIds = new Set<QueueToolbarButtonId>(DEFAULT_QUEUE_TOOLBAR_BUTTONS.map(b => b.id));
|
||||
let safe = collapsed.filter(
|
||||
(b): b is QueueToolbarButtonConfig => knownIds.has(b.id as QueueToolbarButtonId),
|
||||
);
|
||||
|
||||
// Insert the new autodj button next to an existing crossfade, inheriting its
|
||||
// visibility (the upgrade case). When crossfade isn't present yet (fresh or
|
||||
// corrupt input) we leave autodj to the default-fill step below so it lands
|
||||
// in its canonical position instead of at the front.
|
||||
const cfIdx = safe.findIndex(b => b.id === 'crossfade');
|
||||
if (cfIdx >= 0 && !safe.some(b => b.id === 'autodj')) {
|
||||
const autodj: QueueToolbarButtonConfig = { id: 'autodj', visible: safe[cfIdx].visible };
|
||||
safe = [...safe.slice(0, cfIdx + 1), autodj, ...safe.slice(cfIdx + 1)];
|
||||
}
|
||||
|
||||
// Append any default still missing (fresh install, or pruned ids).
|
||||
const seen = new Set(safe.map(b => b.id));
|
||||
const missing = DEFAULT_QUEUE_TOOLBAR_BUTTONS.filter(b => !seen.has(b.id));
|
||||
return missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
}
|
||||
|
||||
interface QueueToolbarStore {
|
||||
buttons: QueueToolbarButtonConfig[];
|
||||
setButtons: (buttons: QueueToolbarButtonConfig[]) => void;
|
||||
@@ -57,13 +117,7 @@ export const useQueueToolbarStore = create<QueueToolbarStore>()(
|
||||
name: 'psysonic_queue_toolbar',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (!state) return;
|
||||
// Sanitize: remove null/corrupt entries
|
||||
const knownIds = new Set(DEFAULT_QUEUE_TOOLBAR_BUTTONS.map(b => b.id));
|
||||
const safe = (state.buttons ?? [])
|
||||
.filter((b): b is QueueToolbarButtonConfig => b != null && typeof b.id === 'string' && knownIds.has(b.id as QueueToolbarButtonId));
|
||||
const seen = new Set(safe.map(b => b.id));
|
||||
const missing = DEFAULT_QUEUE_TOOLBAR_BUTTONS.filter(b => !seen.has(b.id));
|
||||
state.buttons = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
state.buttons = migrateQueueToolbarButtons(state.buttons);
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -240,39 +240,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-player__crossfade-modes {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mini-player__crossfade-mode {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 5px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.mini-player__crossfade-mode:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-subtle));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mini-player__crossfade-mode.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--bg-app);
|
||||
}
|
||||
|
||||
.mini-player__volume-pct {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -544,6 +544,35 @@ span.settings-server-use-active-slot {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
/* Boxed sub-section panel inside a settings card — sets related controls
|
||||
apart as a distinct block (e.g. Normalization / Track transitions /
|
||||
Queue behaviour in the Playback card). */
|
||||
.settings-group {
|
||||
background: var(--bg-app);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-group + .settings-group {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.settings-group-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.settings-group-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.settings-card.user-row {
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
@@ -162,6 +162,46 @@
|
||||
color: var(--bg-app);
|
||||
}
|
||||
|
||||
/* Compact dropdown menu reusing the .crossfade-popover box (the playlist
|
||||
save/load submenu). Items stack vertically as left-aligned rows. */
|
||||
.queue-menu {
|
||||
width: 150px;
|
||||
padding: 6px;
|
||||
gap: 2px;
|
||||
/* Open rightward so the menu stays inside the queue panel — the playlist
|
||||
button sits on the left, where the inherited right:0 would push it out
|
||||
under the main container. */
|
||||
left: 0;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.queue-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.queue-menu-item:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.queue-menu-item:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.queue-toolbar-sep {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { listen, emitTo } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { setTransitionMode, type TransitionMode } from './playback/playbackTransition';
|
||||
import { resolveQueueTrack } from './library/queueTrackView';
|
||||
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
|
||||
|
||||
@@ -247,18 +248,10 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
|
||||
// Gapless ↔ Crossfade are mutually exclusive. Bridge handles the exclusion
|
||||
// so the mini doesn't need to know about both states to act.
|
||||
const gaplessUnlisten = listen<{ value: boolean }>('mini:set-gapless', (e) => {
|
||||
const v = !!e.payload?.value;
|
||||
const a = useAuthStore.getState();
|
||||
if (v) a.setCrossfadeEnabled(false);
|
||||
a.setGaplessEnabled(v);
|
||||
});
|
||||
|
||||
const crossfadeUnlisten = listen<{ value: boolean }>('mini:set-crossfade', (e) => {
|
||||
const v = !!e.payload?.value;
|
||||
const a = useAuthStore.getState();
|
||||
if (v) a.setGaplessEnabled(false);
|
||||
a.setCrossfadeEnabled(v);
|
||||
const transitionModeUnlisten = listen<{ value: string }>('mini:set-transition-mode', (e) => {
|
||||
const v = e.payload?.value;
|
||||
const modes: TransitionMode[] = ['none', 'gapless', 'crossfade', 'autodj'];
|
||||
if (modes.includes(v as TransitionMode)) setTransitionMode(v as TransitionMode);
|
||||
});
|
||||
|
||||
const crossfadeSecsUnlisten = listen<{ value: number }>('mini:set-crossfade-secs', (e) => {
|
||||
@@ -267,10 +260,6 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
useAuthStore.getState().setCrossfadeSecs(Math.max(0.1, Math.min(10, v)));
|
||||
});
|
||||
|
||||
const crossfadeTrimSilenceUnlisten = listen<{ value: boolean }>('mini:set-crossfade-trim-silence', (e) => {
|
||||
useAuthStore.getState().setCrossfadeTrimSilence(!!e.payload?.value);
|
||||
});
|
||||
|
||||
const infiniteQueueUnlisten = listen<{ value: boolean }>('mini:set-infinite-queue', (e) => {
|
||||
const v = !!e.payload?.value;
|
||||
useAuthStore.getState().setInfiniteQueueEnabled(v);
|
||||
@@ -300,10 +289,8 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|
||||
shuffleUnlisten.then(fn => fn()).catch(() => {});
|
||||
undoQueueUnlisten.then(fn => fn()).catch(() => {});
|
||||
redoQueueUnlisten.then(fn => fn()).catch(() => {});
|
||||
gaplessUnlisten.then(fn => fn()).catch(() => {});
|
||||
crossfadeUnlisten.then(fn => fn()).catch(() => {});
|
||||
transitionModeUnlisten.then(fn => fn()).catch(() => {});
|
||||
crossfadeSecsUnlisten.then(fn => fn()).catch(() => {});
|
||||
crossfadeTrimSilenceUnlisten.then(fn => fn()).catch(() => {});
|
||||
infiniteQueueUnlisten.then(fn => fn()).catch(() => {});
|
||||
songInfoUnlisten.then(fn => fn()).catch(() => {});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import {
|
||||
getTransitionMode,
|
||||
transitionFlagsFor,
|
||||
setTransitionMode,
|
||||
type TransitionMode,
|
||||
} from './playbackTransition';
|
||||
|
||||
const MODES: TransitionMode[] = ['none', 'gapless', 'crossfade', 'autodj'];
|
||||
|
||||
describe('getTransitionMode', () => {
|
||||
it('maps each flag combination to its mode', () => {
|
||||
expect(getTransitionMode({ gaplessEnabled: false, crossfadeEnabled: false, crossfadeTrimSilence: false })).toBe('none');
|
||||
expect(getTransitionMode({ gaplessEnabled: true, crossfadeEnabled: false, crossfadeTrimSilence: false })).toBe('gapless');
|
||||
expect(getTransitionMode({ gaplessEnabled: false, crossfadeEnabled: true, crossfadeTrimSilence: false })).toBe('crossfade');
|
||||
expect(getTransitionMode({ gaplessEnabled: false, crossfadeEnabled: true, crossfadeTrimSilence: true })).toBe('autodj');
|
||||
});
|
||||
|
||||
it('treats trim-silence as AutoDJ only while crossfade is on', () => {
|
||||
// Stale trim flag with crossfade off is still "none", not AutoDJ.
|
||||
expect(getTransitionMode({ gaplessEnabled: false, crossfadeEnabled: false, crossfadeTrimSilence: true })).toBe('none');
|
||||
});
|
||||
|
||||
it('is total — gapless wins if both gapless and crossfade are set', () => {
|
||||
expect(getTransitionMode({ gaplessEnabled: true, crossfadeEnabled: true, crossfadeTrimSilence: true })).toBe('gapless');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transitionFlagsFor', () => {
|
||||
it('round-trips through getTransitionMode for every mode', () => {
|
||||
for (const mode of MODES) {
|
||||
expect(getTransitionMode(transitionFlagsFor(mode))).toBe(mode);
|
||||
}
|
||||
});
|
||||
|
||||
it('never sets more than one independent behaviour at once', () => {
|
||||
for (const mode of MODES) {
|
||||
const f = transitionFlagsFor(mode);
|
||||
// crossfade and gapless are the two independent toggles; trim only rides on crossfade
|
||||
expect(f.gaplessEnabled && f.crossfadeEnabled).toBe(false);
|
||||
if (f.crossfadeTrimSilence) expect(f.crossfadeEnabled).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTransitionMode', () => {
|
||||
beforeEach(() => {
|
||||
setTransitionMode('none');
|
||||
});
|
||||
|
||||
it('applies the flags atomically and enforces exclusivity', () => {
|
||||
setTransitionMode('crossfade');
|
||||
let s = useAuthStore.getState();
|
||||
expect(s.crossfadeEnabled).toBe(true);
|
||||
expect(s.crossfadeTrimSilence).toBe(false);
|
||||
expect(s.gaplessEnabled).toBe(false);
|
||||
|
||||
setTransitionMode('autodj');
|
||||
s = useAuthStore.getState();
|
||||
expect(s.crossfadeEnabled).toBe(true);
|
||||
expect(s.crossfadeTrimSilence).toBe(true);
|
||||
expect(s.gaplessEnabled).toBe(false);
|
||||
|
||||
setTransitionMode('gapless');
|
||||
s = useAuthStore.getState();
|
||||
expect(s.gaplessEnabled).toBe(true);
|
||||
expect(s.crossfadeEnabled).toBe(false);
|
||||
expect(s.crossfadeTrimSilence).toBe(false);
|
||||
|
||||
setTransitionMode('none');
|
||||
s = useAuthStore.getState();
|
||||
expect(s.gaplessEnabled).toBe(false);
|
||||
expect(s.crossfadeEnabled).toBe(false);
|
||||
expect(s.crossfadeTrimSilence).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves crossfadeSecs when switching between crossfade and autodj', () => {
|
||||
useAuthStore.getState().setCrossfadeSecs(7);
|
||||
setTransitionMode('crossfade');
|
||||
setTransitionMode('autodj');
|
||||
setTransitionMode('crossfade');
|
||||
expect(useAuthStore.getState().crossfadeSecs).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
/**
|
||||
* The one active track-transition behaviour. These are mutually exclusive —
|
||||
* only one can be on at a time.
|
||||
*
|
||||
* AutoDJ has no dedicated persisted flag: it is crossfade with content-driven
|
||||
* silence trimming (`crossfadeTrimSilence`). Keeping it encoded on the existing
|
||||
* flags avoids a state migration and leaves the audio engine — which reads the
|
||||
* three flags below directly — untouched.
|
||||
*/
|
||||
export type TransitionMode = 'none' | 'gapless' | 'crossfade' | 'autodj';
|
||||
|
||||
/** The persisted flags that together encode the active transition mode. */
|
||||
export interface TransitionFlags {
|
||||
gaplessEnabled: boolean;
|
||||
crossfadeEnabled: boolean;
|
||||
crossfadeTrimSilence: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the single active mode from the persisted flags. Total by design —
|
||||
* gapless wins if both gapless and crossfade are somehow set (the UI never
|
||||
* produces that, but the getter must not return `undefined`).
|
||||
*/
|
||||
export function getTransitionMode(flags: TransitionFlags): TransitionMode {
|
||||
if (flags.gaplessEnabled) return 'gapless';
|
||||
if (flags.crossfadeEnabled) return flags.crossfadeTrimSilence ? 'autodj' : 'crossfade';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag combination for a mode — the single source of truth for "only one
|
||||
* active". `crossfadeSecs` is intentionally not touched here so switching
|
||||
* between classic crossfade and AutoDJ (and back) preserves the user's chosen
|
||||
* duration.
|
||||
*/
|
||||
export function transitionFlagsFor(mode: TransitionMode): TransitionFlags {
|
||||
switch (mode) {
|
||||
case 'gapless':
|
||||
return { gaplessEnabled: true, crossfadeEnabled: false, crossfadeTrimSilence: false };
|
||||
case 'crossfade':
|
||||
return { gaplessEnabled: false, crossfadeEnabled: true, crossfadeTrimSilence: false };
|
||||
case 'autodj':
|
||||
return { gaplessEnabled: false, crossfadeEnabled: true, crossfadeTrimSilence: true };
|
||||
case 'none':
|
||||
default:
|
||||
return { gaplessEnabled: false, crossfadeEnabled: false, crossfadeTrimSilence: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a transition mode atomically, enforcing exclusivity in one place.
|
||||
* Replaces the scattered `setGaplessEnabled(false)` / `setCrossfadeEnabled(...)`
|
||||
* / `setCrossfadeTrimSilence(...)` combinations across the queue toolbar, mini
|
||||
* player and settings.
|
||||
*/
|
||||
export function setTransitionMode(mode: TransitionMode): void {
|
||||
useAuthStore.setState(transitionFlagsFor(mode));
|
||||
}
|
||||
Reference in New Issue
Block a user