From 274ac5b3b4394b9faf61fc36dca6e8fea718f6e3 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sat, 2 May 2026 02:36:35 +0300 Subject: [PATCH] feat(queue): drag rows outside queue to remove; trash ghost outside bounds (#420) * feat(queue): drag rows outside queue to remove; trash ghost outside bounds - Document-level psy-drop when cursor leaves queue rect (main + mini) - DragDropContext: drop coordinates on psy-detail; optional wrapRef on OverlayScrollArea - Trash icon on drag ghost only outside registered queue hit-test regions * docs(changelog): add queue drag-out removal for PR #420 --- CHANGELOG.md | 9 ++++ src/components/MiniPlayer.tsx | 46 ++++++++++++++++- src/components/OverlayScrollArea.tsx | 10 +++- src/components/QueuePanel.tsx | 44 +++++++++++++++- src/contexts/DragDropContext.tsx | 76 +++++++++++++++++++++++++--- 5 files changed, 174 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d18ac59..4425e7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,15 @@ The top header behavior was reworked for narrow widths: search, Live and Orbit c Waveform mouse-wheel seeking now uses fixed step-based jumps with debounce smoothing for more predictable navigation and less jitter during rapid scrolling. +### Queue — Drag Outside to Remove + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#420](https://github.com/Psychotoxical/psysonic/pull/420)** + +You can remove a track from the play queue by dragging its row **outside** the queue sidebar (main window) or outside the mini player’s queue list. Drop targets still support reordering when you release inside the queue area. + +The drag ghost shows a **trash** affordance only while the cursor is outside the queue bounds; inside the queue it behaves as a normal reorder drag. The mouse-event `psy-drop` path now carries cursor coordinates so removal can be detected when the drop target is not the queue panel itself. + + ## Fixed - **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible. diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index b7eb7d8d..9d5092a2 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -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 } from '../contexts/DragDropContext'; +import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext'; import { useWindowVisibility } from '../hooks/useWindowVisibility'; import { IS_LINUX } from '../utils/platform'; import MiniContextMenu from './MiniContextMenu'; @@ -115,6 +115,18 @@ export default function MiniPlayer() { const [volumeOpen, setVolumeOpen] = useState(false); const ticker = useRef(null); const queueScrollRef = useRef(null); + const miniQueueWrapRef = useRef(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(null); const volumePopRef = useRef(null); const [volumePopStyle, setVolumePopStyle] = useState({}); @@ -415,6 +427,37 @@ 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; @@ -633,6 +676,7 @@ export default function MiniPlayer() { {queueOpen && ( ; + /** Ref to the outer wrapper (incl. overlay scrollbar rail). */ + wrapRef?: React.Ref; /** Optional id on the viewport (e.g. main app scroll for route pages). */ viewportId?: string; /** Optional wheel handler on the scrollable viewport. */ @@ -49,6 +51,7 @@ export default function OverlayScrollArea({ railInset = 'none', viewportScrollBehaviorAuto = false, viewportRef: viewportRefProp, + wrapRef: wrapRefProp, viewportId, viewportOnWheel, viewportOnTouchMove, @@ -109,6 +112,11 @@ 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], @@ -121,7 +129,7 @@ export default function OverlayScrollArea({ const viewportClass = ['overlay-scroll__viewport', viewportClassName].filter(Boolean).join(' '); return ( -
+
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); @@ -440,6 +441,16 @@ function QueuePanelHostOrSolo() { const asideRef = useRef(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']); @@ -507,6 +518,37 @@ 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; diff --git a/src/contexts/DragDropContext.tsx b/src/contexts/DragDropContext.tsx index 31d304ac..e6dd4f89 100644 --- a/src/contexts/DragDropContext.tsx +++ b/src/contexts/DragDropContext.tsx @@ -19,6 +19,7 @@ import React, { useState, } from 'react'; import { createPortal } from 'react-dom'; +import { Trash2 } from 'lucide-react'; // ── Types ───────────────────────────────────────────────────────── export interface DragPayload { @@ -33,6 +34,8 @@ 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 { @@ -52,10 +55,41 @@ const Ctx = createContext({ 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 window’s 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 } = state.payload; + const { label, coverUrl, data } = state.payload; + const queueReorder = isQueueReorderDrag(data); + const showTrash = queueReorder && state.queueReorderOutside === true; return createPortal(
+ {showTrash && ( + + )} {coverUrl && ( ({ ...prev, position: { x: e.clientX, y: e.clientY } })); + 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), + }; + }); }; /** End drag; optionally fire `psy-drop` at the last known cursor position. */ @@ -146,14 +205,15 @@ 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: stateRef.current.payload, + detail: pl + ? { ...pl, clientX: p.x, clientY: p.y } + : pl, }); - const el = document.elementFromPoint( - stateRef.current.position.x, - stateRef.current.position.y, - ); + const el = document.elementFromPoint(p.x, p.y); if (el) el.dispatchEvent(evt); }