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
+5 -8
View File
@@ -13,11 +13,9 @@ interface Props {
moreText?: string;
onLoadMore?: () => Promise<void>;
showRating?: boolean;
/** Optional content rendered in the row header, left of the scroll-nav. */
headerExtra?: React.ReactNode;
}
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating, headerExtra }: Props) {
export default function AlbumRow({ title, titleLink, albums, moreLink, moreText, onLoadMore, showRating }: Props) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
@@ -73,16 +71,15 @@ export default function AlbumRow({ title, titleLink, albums, moreLink, moreText,
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
)}
<div className="album-row-nav">
{headerExtra}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
+1 -2
View File
@@ -119,7 +119,6 @@ const TrackRow = React.memo(function TrackRow({
const isActive = currentTrackId === song.id;
// Primitive selector: row only re-renders when *this song's* preview state flips.
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
const renderCell = (colDef: ColDef) => {
const key = colDef.key as ColKey;
@@ -157,7 +156,7 @@ const TrackRow = React.memo(function TrackRow({
</button>
<button
type="button"
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}${isPreviewAudioStarted ? ' audio-started' : ''}`}
className={`playlist-suggestion-preview-btn${isPreviewing ? ' is-previewing' : ''}`}
onClick={e => {
e.stopPropagation();
usePreviewStore.getState().startPreview({ id: song.id, title: song.title, artist: song.artist, coverArt: song.coverArt, duration: song.duration }, 'albums');
+1 -45
View File
@@ -9,7 +9,7 @@ import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useKeybindingsStore, matchInAppBinding } from '../store/keybindingsStore';
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
import { useDragDrop } from '../contexts/DragDropContext';
import { useWindowVisibility } from '../hooks/useWindowVisibility';
import { IS_LINUX } from '../utils/platform';
import MiniContextMenu from './MiniContextMenu';
@@ -115,18 +115,6 @@ export default function MiniPlayer() {
const [volumeOpen, setVolumeOpen] = useState(false);
const ticker = useRef<number | null>(null);
const queueScrollRef = useRef<HTMLDivElement>(null);
const miniQueueWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!queueOpen) return;
const hitTest = (cx: number, cy: number) => {
const el = miniQueueWrapRef.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);
}, [queueOpen]);
const volumeBtnRef = useRef<HTMLButtonElement>(null);
const volumePopRef = useRef<HTMLDivElement>(null);
const [volumePopStyle, setVolumePopStyle] = useState<React.CSSProperties>({});
@@ -427,37 +415,6 @@ export default function MiniPlayer() {
return () => el.removeEventListener('psy-drop', onPsyDrop);
}, [queueOpen, state.queue.length]);
// Drop outside the mini queue strip → remove (same UX as main QueuePanel).
useEffect(() => {
if (!queueOpen) return;
const onDocPsyDrop = (e: Event) => {
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 wrap = miniQueueWrapRef.current;
if (!wrap) return;
const r = wrap.getBoundingClientRect();
const inside =
cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom;
if (inside) return;
psyDragFromIdxRef.current = null;
dropTargetRef.current = null;
setDropTarget(null);
emit('mini:remove', { index: parsed.index }).catch(() => {});
};
document.addEventListener('psy-drop', onDocPsyDrop);
return () => document.removeEventListener('psy-drop', onDocPsyDrop);
}, [queueOpen]);
// Auto-scroll the current track into view when the queue expands.
useEffect(() => {
if (!queueOpen) return;
@@ -676,7 +633,6 @@ export default function MiniPlayer() {
{queueOpen && (
<OverlayScrollArea
wrapRef={miniQueueWrapRef}
viewportRef={queueScrollRef}
className="mini-queue-wrap"
viewportClassName="mini-queue"
+1 -9
View File
@@ -20,8 +20,6 @@ export type OverlayScrollAreaProps = {
viewportScrollBehaviorAuto?: boolean;
/** Ref to the scrollable element (querySelector, scrollIntoView, etc.). */
viewportRef?: React.Ref<HTMLDivElement>;
/** Ref to the outer wrapper (incl. overlay scrollbar rail). */
wrapRef?: React.Ref<HTMLDivElement>;
/** Optional id on the viewport (e.g. main app scroll for route pages). */
viewportId?: string;
/** Optional wheel handler on the scrollable viewport. */
@@ -51,7 +49,6 @@ export default function OverlayScrollArea({
railInset = 'none',
viewportScrollBehaviorAuto = false,
viewportRef: viewportRefProp,
wrapRef: wrapRefProp,
viewportId,
viewportOnWheel,
viewportOnTouchMove,
@@ -112,11 +109,6 @@ export default function OverlayScrollArea({
assignRef(viewportRefProp, el);
};
const setWrapNode = (el: HTMLDivElement | null) => {
wrapRef.current = el;
assignRef(wrapRefProp, el);
};
const rootClass = [
'overlay-scroll',
RAIL_INSET_CLASS[railInset],
@@ -129,7 +121,7 @@ export default function OverlayScrollArea({
const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' ');
return (
<div ref={setWrapNode} className={rootClass} onMouseMove={onMouseMove}>
<div ref={wrapRef} className={rootClass} onMouseMove={onMouseMove}>
<div
id={viewportId}
ref={setViewportNode}
+1 -2
View File
@@ -237,7 +237,6 @@ export default function PlayerBar() {
const playSlotRef = useRef<HTMLSpanElement>(null);
const scheduleRemaining = usePlaybackScheduleRemaining();
const isPreviewing = usePreviewStore(s => s.previewingId !== null);
const previewAudioStarted = usePreviewStore(s => s.audioStarted);
const previewingTrack = usePreviewStore(s => s.previewingTrack);
const isRadio = !!currentRadio;
@@ -313,7 +312,7 @@ export default function PlayerBar() {
<>
<footer
ref={playerBarRef}
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}${showPreviewMeta && previewAudioStarted ? ' audio-started' : ''}`}
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}${showPreviewMeta ? ' is-previewing' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
role="region"
aria-label={t('player.regionLabel')}
+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>
+3 -15
View File
@@ -825,7 +825,7 @@ export function SeekbarPreview({
}
};
const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
@@ -912,14 +912,11 @@ export default function WaveformSeek({ trackId }: Props) {
const waveformBins = usePlayerStore(s => s.waveformBins);
const duration = usePlayerStore(s => s.currentTrack?.duration ?? 0);
const seekbarStyle = useAuthStore(s => s.seekbarStyle);
const reducedAnimations = useAuthStore(s => s.reducedAnimations);
// Ref so the subscription callback (closed over at mount) can read the
// current style without stale-closure issues.
const styleRef = useRef(seekbarStyle);
styleRef.current = seekbarStyle;
const reducedRef = useRef(reducedAnimations);
reducedRef.current = reducedAnimations;
useEffect(() => {
if (!trackId) {
@@ -1054,7 +1051,6 @@ export default function WaveformSeek({ trackId }: Props) {
animStateRef.current = makeAnimState();
let rafId: number | null = null;
let pollId: number | null = null;
let skip = false;
const stop = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
@@ -1066,22 +1062,14 @@ export default function WaveformSeek({ trackId }: Props) {
}
};
const tick = () => {
if (document.hidden || window.__psyHidden || window.__psyBlurred) {
if (document.hidden || window.__psyHidden) {
pollId = window.setTimeout(() => {
pollId = null;
tick();
}, 400);
return;
}
// 30 fps cap when reducedAnimations is on: skip every other rAF, advance
// animation time by a doubled delta so wave speed stays the same.
if (reducedRef.current && skip) {
skip = false;
rafId = requestAnimationFrame(tick);
return;
}
skip = reducedRef.current;
animStateRef.current.time += reducedRef.current ? 0.032 : 0.016;
animStateRef.current.time += 0.016;
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
rafId = requestAnimationFrame(tick);
};