mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 22:45:41 +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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user