feat: queue-ux-improvements (#419)

* feat(queue): add ETA display, equalizer indicator and collapsible now playing

* deleted endsAt and showDuration strings, changed eta update to 30s

* feat(queue): ETA tooltip, persistent Now Playing collapse, EQ bar pause, remove redundant Play icon

* feat(queue): fold ETA into existing total/remaining toggle as third mode

The standalone ETA span next to the track counter is removed; instead the
clickable duration label in the queue header now rotates through three
modes per click: total → remaining → eta → total. Counter (N/M) stays
where it was.

ETA mode keeps the live-feel treatment from the original PR (accent
colour while playing, muted at 50% opacity when paused). The other two
modes use plain accent.

i18n: queue.etaTooltip removed (no longer a separate descriptive label),
queue.showEta added as the action tooltip ('Show estimated end time')
in all 8 locales — matches the showRemaining / showTotal pattern.

* docs(changelog): add #419 queue UX improvements entry

Adds the [1.45.0] / Added entry for this PR's queue panel refinements
(position counter, tri-state duration toggle including ETA, collapsible
Now Playing section, animated EQ indicator).

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
This commit is contained in:
Kveld.
2026-05-02 16:45:55 -03:00
committed by GitHub
parent 5662dafe97
commit 18b4a982ef
33 changed files with 10115 additions and 1498 deletions
+176 -156
View File
@@ -23,7 +23,7 @@ import { copyTextToClipboard } from '../utils/serverMagicString';
import { showToast } from '../utils/toast';
import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
@@ -189,20 +189,32 @@ function LoadPlaylistModal({ onClose, onLoad }: { onClose: () => void, onLoad: (
);
}
type DurationMode = 'total' | 'remaining' | 'eta';
interface QueueHeaderProps {
queue: Track[];
queueIndex: number;
showRemainingTime: boolean;
setShowRemainingTime: React.Dispatch<React.SetStateAction<boolean>>;
activePlaylist: { id: string; name: string } | null;
isNowPlayingCollapsed: boolean;
setIsNowPlayingCollapsed: (v: boolean) => void;
durationMode: DurationMode;
setDurationMode: (m: DurationMode) => void;
t: TFunction;
}
function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTime, activePlaylist, t }: QueueHeaderProps) {
const currentTime = usePlayerStore((s) => s.currentTime);
function QueueHeader({ queue, queueIndex, activePlaylist, isNowPlayingCollapsed, setIsNowPlayingCollapsed, durationMode, setDurationMode, t }: QueueHeaderProps) {
const currentTime = usePlayerStore((s) => Math.floor(s.currentTime / 30) * 30);
const isPlaying = usePlayerStore((s) => s.isPlaying);
if (queue.length === 0) return null;
const totalSecs = queue.reduce((acc: number, t: any) => acc + (t.duration || 0), 0);
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + queue.slice(queueIndex + 1).reduce((acc: number, t: any) => acc + (t.duration || 0), 0));
const totalSecs = useMemo(() =>
queue.reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
[queue]
);
const futureTracksDuration = useMemo(() =>
queue.slice(queueIndex + 1).reduce((acc: number, track: Track) => acc + (track.duration || 0), 0),
[queue, queueIndex]
);
const remainingSecs = Math.max(0, (queue[queueIndex]?.duration ?? 0) - currentTime + futureTracksDuration);
const fmt = (secs: number) => {
const h = Math.floor(secs / 3600);
@@ -210,27 +222,53 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
const s = secs % 60;
return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}`;
};
const fmtEta = (secs: number) => {
const finishTime = new Date(Date.now() + secs * 1000);
return new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }).format(finishTime);
};
const dur = showRemainingTime ? `-${fmt(Math.floor(remainingSecs))}` : fmt(Math.floor(totalSecs));
let dur: string | null = null;
if (queue.length > 0) {
if (durationMode === 'total') dur = fmt(Math.floor(totalSecs));
else if (durationMode === 'remaining') dur = `-${fmt(Math.floor(remainingSecs))}`;
else dur = fmtEta(remainingSecs);
}
const nextMode: DurationMode =
durationMode === 'total' ? 'remaining' :
durationMode === 'remaining' ? 'eta' : 'total';
const nextTooltipKey =
nextMode === 'total' ? 'queue.showTotal' :
nextMode === 'remaining' ? 'queue.showRemaining' : 'queue.showEta';
const isEta = durationMode === 'eta';
return (
<div className="queue-header">
<div style={{ display: "flex", flexDirection: "column", minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: "8px", minWidth: 0 }}>
<h2 style={{ fontSize: "16px", fontWeight: 700, margin: 0, flexShrink: 0 }}>{t("queue.title")}</h2>
<span
onClick={() => setShowRemainingTime((v: boolean) => !v)}
data-tooltip={showRemainingTime ? t("queue.showTotal") : t("queue.showRemaining")}
style={{
fontSize: "13px",
color: "var(--accent)",
whiteSpace: "nowrap",
cursor: "pointer",
userSelect: "none",
}}
>
{queue.length} {queue.length === 1 ? t("queue.trackSingular") : t("queue.trackPlural")} · {dur}
</span>
{queue.length > 0 && (
<span style={{ fontSize: "13px", color: "var(--text-muted)", whiteSpace: "nowrap", userSelect: "none" }}>
({queueIndex + 1}/{queue.length})
</span>
)}
{dur !== null && (
<span
onClick={() => setDurationMode(nextMode)}
data-tooltip={t(nextTooltipKey)}
style={{
fontSize: "13px",
color: isEta ? (isPlaying ? "var(--accent)" : "var(--text-muted)") : "var(--accent)",
opacity: isEta && !isPlaying ? 0.5 : 1,
whiteSpace: "nowrap",
cursor: "pointer",
userSelect: "none",
}}
>
· {dur}
</span>
)}
</div>
{activePlaylist && (
<div className="truncate" style={{ fontSize: "11px", color: "var(--text-muted)", marginTop: "2px", display: "flex", alignItems: "center", gap: "4px" }}>
@@ -239,6 +277,17 @@ function QueueHeader({ queue, queueIndex, showRemainingTime, setShowRemainingTim
</div>
)}
</div>
<button
className="queue-action-btn"
onClick={() => queue.length > 0 && setIsNowPlayingCollapsed(!isNowPlayingCollapsed)}
disabled={queue.length === 0}
data-tooltip={queue.length === 0 ? t('queue.emptyQueue') : (isNowPlayingCollapsed ? t('queue.showNowPlaying') : t('queue.hideNowPlaying'))}
aria-label={queue.length === 0 ? t('queue.emptyQueue') : (isNowPlayingCollapsed ? t('queue.showNowPlaying') : t('queue.hideNowPlaying'))}
aria-expanded={!isNowPlayingCollapsed}
style={{ marginLeft: '8px', opacity: queue.length === 0 ? 0.3 : 1, cursor: queue.length === 0 ? 'not-allowed' : 'pointer' }}
>
<ChevronDown size={18} style={{ transform: isNowPlayingCollapsed ? 'rotate(-90deg)' : 'none', transition: 'transform 0.2s ease' }} />
</button>
</div>
);
}
@@ -301,7 +350,6 @@ function QueuePanelHostOrSolo() {
const clearQueue = usePlayerStore(s => s.clearQueue);
const reorderQueue = usePlayerStore(s => s.reorderQueue);
const removeTrack = usePlayerStore(s => s.removeTrack);
const shuffleQueue = usePlayerStore(s => s.shuffleQueue);
const enqueue = usePlayerStore(s => s.enqueue);
const enqueueAt = usePlayerStore(s => s.enqueueAt);
@@ -333,7 +381,9 @@ function QueuePanelHostOrSolo() {
const setTab = useLyricsStore(s => s.setTab);
const luckyRolling = useLuckyMixStore(s => s.isRolling);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const isNowPlayingCollapsed = useAuthStore(s => s.queueNowPlayingCollapsed);
const setIsNowPlayingCollapsed = useAuthStore(s => s.setQueueNowPlayingCollapsed);
const [durationMode, setDurationMode] = useState<DurationMode>('total');
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const [lufsTgtOpen, setLufsTgtOpen] = useState(false);
const [lufsTgtPopStyle, setLufsTgtPopStyle] = useState<React.CSSProperties>({});
@@ -344,6 +394,7 @@ function QueuePanelHostOrSolo() {
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
const reanalyzeLoudnessForTrack = usePlayerStore(s => s.reanalyzeLoudnessForTrack);
const isStorePlaying = usePlayerStore(s => s.isPlaying);
const authLoudnessTargetLufs = useAuthStore(s => s.loudnessTargetLufs);
const setLoudnessTargetLufs = useAuthStore(s => s.setLoudnessTargetLufs);
const loudnessPreAnalysisAttenuationDb = useAuthStore(s => s.loudnessPreAnalysisAttenuationDb);
@@ -441,16 +492,6 @@ function QueuePanelHostOrSolo() {
const asideRef = useRef<HTMLElement>(null);
useEffect(() => {
const hitTest = (cx: number, cy: number) => {
const el = asideRef.current;
if (!el) return false;
const r = el.getBoundingClientRect();
return cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
};
return registerQueueDragHitTest(hitTest);
}, []);
const { isDragging: isPsyDragging, startDrag, payload: psyPayload } = useDragDrop();
/** Only these drag types may be dropped into the queue. */
const QUEUE_DROP_TYPES = new Set(['song', 'album', 'queue_reorder']);
@@ -518,37 +559,6 @@ function QueuePanelHostOrSolo() {
return () => aside.removeEventListener('psy-drop', onPsyDrop);
}, [enqueueAt]);
// Drag a queue row outside the panel → remove (drop never reaches `aside`).
useEffect(() => {
const onDocPsyDrop = (e: Event) => {
if (!isQueueVisible) return;
const d = (e as CustomEvent<{ data?: string; clientX?: number; clientY?: number }>).detail;
if (!d?.data) return;
const cx = d.clientX;
const cy = d.clientY;
if (typeof cx !== 'number' || typeof cy !== 'number') return;
let parsed: { type?: string; index?: number } | null = null;
try {
parsed = JSON.parse(d.data);
} catch {
return;
}
if (parsed?.type !== 'queue_reorder' || typeof parsed.index !== 'number') return;
const aside = asideRef.current;
if (!aside) return;
const r = aside.getBoundingClientRect();
const inside =
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
if (inside) return;
psyDragFromIdxRef.current = null;
externalDropTargetRef.current = null;
setExternalDropTarget(null);
removeTrack(parsed.index);
};
document.addEventListener('psy-drop', onDocPsyDrop);
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
}, [isQueueVisible, removeTrack]);
useEffect(function queueAutoScroll() {
if (suppressNextAutoScrollRef.current) {
suppressNextAutoScrollRef.current = false;
@@ -647,13 +657,15 @@ function QueuePanelHostOrSolo() {
<QueueHeader
queue={queue}
queueIndex={queueIndex}
showRemainingTime={showRemainingTime}
setShowRemainingTime={setShowRemainingTime}
activePlaylist={activePlaylist}
isNowPlayingCollapsed={isNowPlayingCollapsed}
setIsNowPlayingCollapsed={setIsNowPlayingCollapsed}
durationMode={durationMode}
setDurationMode={setDurationMode}
t={t}
/>
{currentTrack && (
{currentTrack && !isNowPlayingCollapsed && (
<div className="queue-current-track">
{(() => {
const baseParts = [
@@ -869,95 +881,97 @@ function QueuePanelHostOrSolo() {
)}
{activeTab === 'queue' ? (<>
<div className="queue-toolbar">
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
<button
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>
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
</button>
<button
className="queue-round-btn"
onClick={() => void handleCopyQueueShare()}
data-tooltip={t('queue.shareQueue')}
aria-label={t('queue.shareQueue')}
>
<Share2 size={13} />
</button>
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
<Trash2 size={13} />
</button>
<div className="queue-toolbar-sep" />
<button
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<MoveRight size={13} />
</button>
<div style={{ position: 'relative' }}>
<button
ref={crossfadeBtnRef}
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
onClick={() => {
if (crossfadeEnabled) {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
}}
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<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);
{!isNowPlayingCollapsed && (
<div className="queue-toolbar">
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
<button
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>
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
</button>
<button
className="queue-round-btn"
onClick={() => void handleCopyQueueShare()}
data-tooltip={t('queue.shareQueue')}
aria-label={t('queue.shareQueue')}
>
<Share2 size={13} />
</button>
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
<Trash2 size={13} />
</button>
<div className="queue-toolbar-sep" />
<button
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => { setCrossfadeEnabled(false); setShowCrossfadePopover(false); setGaplessEnabled(!gaplessEnabled); }}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<MoveRight size={13} />
</button>
<div style={{ position: 'relative' }}>
<button
ref={crossfadeBtnRef}
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
onClick={() => {
if (crossfadeEnabled) {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setGaplessEnabled(false);
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>0.1s</span><span>10s</span>
</div>
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<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>
)}
</div>
<button
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<Infinity size={13} />
</button>
</div>
<button
className={`queue-round-btn${infiniteQueueEnabled ? ' active' : ''}`}
onClick={() => setInfiniteQueueEnabled(!infiniteQueueEnabled)}
data-tooltip={t('queue.infiniteQueue')}
aria-label={t('queue.infiniteQueue')}
>
<Infinity size={13} />
</button>
</div>
)}
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
@@ -1036,9 +1050,15 @@ function QueuePanelHostOrSolo() {
}}
style={dragStyle}
>
{isPlaying && (
<div className={`eq-bars${isStorePlaying ? '' : ' paused'}`} style={{ marginRight: '8px', flexShrink: 0 }}>
<div className="eq-bar" />
<div className="eq-bar" />
<div className="eq-bar" />
</div>
)}
<div className="queue-item-info">
<div className="queue-item-title truncate" style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{isPlaying && <Play size={10} fill="currentColor" style={{ flexShrink: 0 }} />}
<span className="truncate">{track.title}</span>
</div>
<div className="queue-item-artist truncate">{track.artist}</div>