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
+8 -68
View File
@@ -19,7 +19,6 @@ import React, {
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { Trash2 } from 'lucide-react';
// ── Types ─────────────────────────────────────────────────────────
export interface DragPayload {
@@ -34,8 +33,6 @@ export interface DragPayload {
interface DragState {
payload: DragPayload | null;
position: { x: number; y: number };
/** `queue_reorder` only: true when cursor is outside every registered queue rect */
queueReorderOutside?: boolean;
}
interface DragDropContextValue {
@@ -55,41 +52,10 @@ const Ctx = createContext<DragDropContextValue>({
export const useDragDrop = () => useContext(Ctx);
function isQueueReorderDrag(data: string): boolean {
try {
return JSON.parse(data).type === 'queue_reorder';
} catch {
return false;
}
}
/** Hit-tests for queue UI rects (main sidebar + mini-player list). Used so the drag ghost only shows “remove” outside those bounds. */
const queueDragHitTests: Array<(x: number, y: number) => boolean> = [];
/**
* Register a function that returns true when (clientX, clientY) lies inside
* this windows queue drop area. Unregister on cleanup.
*/
export function registerQueueDragHitTest(fn: (x: number, y: number) => boolean): () => void {
queueDragHitTests.push(fn);
return () => {
const i = queueDragHitTests.indexOf(fn);
if (i >= 0) queueDragHitTests.splice(i, 1);
};
}
function computeQueueReorderOutside(clientX: number, clientY: number): boolean {
if (queueDragHitTests.length === 0) return false;
const inside = queueDragHitTests.some((t) => t(clientX, clientY));
return !inside;
}
// ── Ghost overlay ─────────────────────────────────────────────────
function DragGhost({ state }: { state: DragState }) {
if (!state.payload) return null;
const { label, coverUrl, data } = state.payload;
const queueReorder = isQueueReorderDrag(data);
const showTrash = queueReorder && state.queueReorderOutside === true;
const { label, coverUrl } = state.payload;
return createPortal(
<div
style={{
@@ -117,14 +83,6 @@ function DragGhost({ state }: { state: DragState }) {
userSelect: 'none',
}}
>
{showTrash && (
<Trash2
size={16}
strokeWidth={2.25}
aria-hidden
style={{ flexShrink: 0, color: 'var(--danger, var(--ctp-red, #f38ba8))' }}
/>
)}
{coverUrl && (
<img
src={coverUrl}
@@ -161,13 +119,7 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
// Clear any text selection the browser may have started during the
// threshold detection phase (mousedown → mousemove before startDrag).
window.getSelection()?.removeAllRanges();
setState({
payload,
position: { x, y },
...(isQueueReorderDrag(payload.data)
? { queueReorderOutside: computeQueueReorderOutside(x, y) }
: {}),
});
setState({ payload, position: { x, y } });
},
[],
);
@@ -183,18 +135,7 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
// a text-selection drag, which causes element highlighting and
// horizontal auto-scroll in grid containers.
e.preventDefault();
setState((prev) => {
if (!prev.payload) return prev;
const pos = { x: e.clientX, y: e.clientY };
if (!isQueueReorderDrag(prev.payload.data)) {
return { ...prev, position: pos };
}
return {
...prev,
position: pos,
queueReorderOutside: computeQueueReorderOutside(pos.x, pos.y),
};
});
setState((prev) => ({ ...prev, position: { x: e.clientX, y: e.clientY } }));
};
/** End drag; optionally fire `psy-drop` at the last known cursor position. */
@@ -205,15 +146,14 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
window.getSelection()?.removeAllRanges();
if (dispatchDrop) {
const p = stateRef.current.position;
const pl = stateRef.current.payload;
const evt = new CustomEvent('psy-drop', {
bubbles: true,
detail: pl
? { ...pl, clientX: p.x, clientY: p.y }
: pl,
detail: stateRef.current.payload,
});
const el = document.elementFromPoint(p.x, p.y);
const el = document.elementFromPoint(
stateRef.current.position.x,
stateRef.current.position.y,
);
if (el) el.dispatchEvent(evt);
}