From 93b724fc65b98b2c7f114aec2dd218f7f8eb19e7 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Sat, 25 Apr 2026 02:32:18 +0200 Subject: [PATCH] fix(search,ctx): enqueue on live-search click + reposition CM with right-click (#298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled UX fixes around the header live search and the global context menu, born from the same testing pass. LiveSearch / MobileSearchOverlay - Single click on a song no longer calls playTrack(track) without a queue argument. The store fell back to the existing queue, didn't find the new track, and ended up playing something the user couldn't navigate or see in the queue list — the player header showed metadata but the queue itself didn't contain it. - New behaviour: enqueue([track]) + "Added to queue" toast. Whatever was playing keeps playing; the new track lands at the bottom and is visible/navigable. Mobile gets the same behaviour (tap-only). - Desktop also gets a right-click context menu on song rows for users who want immediate playback or different routing (Play Now, Play Next, Add to Playlist, etc.). The dropdown stays open while the CM is up, and the row picks up the .context-active highlight so the user can see which song the menu refers to. ContextMenu - Removed the transparent fullscreen backdrop (z-index 998) that previously caught outside clicks. It also blocked right-clicks from reaching elements *underneath* it — so right-clicking a different song row to reposition the menu hit the backdrop instead, closed the menu, and never opened a new one for the row the user actually pointed at. - Replaced with a document-level mousedown listener (gated on `contextMenu.isOpen`) that closes the menu when the click lands outside `menuRef`. Standard outside-click pattern, doesn't occlude the underlying UI, and right-clicking another row now naturally pivots the CM to that row. i18n: new search.addedToQueueToast key in 8 locales. Co-authored-by: Claude Opus 4.7 (1M context) --- src/components/ContextMenu.tsx | 21 ++++++++++---- src/components/LiveSearch.tsx | 39 ++++++++++++++++++++------ src/components/MobileSearchOverlay.tsx | 11 +++++--- src/locales/de.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/fr.ts | 1 + src/locales/nb.ts | 1 + src/locales/nl.ts | 1 + src/locales/ru.ts | 1 + src/locales/zh.ts | 1 + src/styles/components.css | 3 +- 12 files changed, 63 insertions(+), 19 deletions(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 7a784423..4601872d 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1081,6 +1081,22 @@ export default function ContextMenu() { }); }, [contextMenu.isOpen]); + // Outside-click closes the menu without occluding the underlying UI. The + // previous implementation rendered a transparent fullscreen backdrop, which + // also blocked right-clicks from reaching elements *under* it — so users + // couldn't reposition the menu by right-clicking another row. + useEffect(() => { + if (!contextMenu.isOpen) return; + const handler = (e: MouseEvent) => { + const target = e.target as Node | null; + if (!target) return; + if (menuRef.current?.contains(target)) return; + closeContextMenu(); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [contextMenu.isOpen, closeContextMenu]); + useEffect(() => { if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return; let cancelled = false; @@ -1409,11 +1425,6 @@ export default function ContextMenu() { return ( <> - {/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */} -
closeContextMenu()} - />
void, ms: number): (q: string) => void { let timer: ReturnType; @@ -23,7 +24,11 @@ export default function LiveSearch() { const [loading, setLoading] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const navigate = useNavigate(); - const playTrack = usePlayerStore(state => state.playTrack); + const enqueue = usePlayerStore(state => state.enqueue); + const openContextMenu = usePlayerStore(state => state.openContextMenu); + const ctxIsOpen = usePlayerStore(state => state.contextMenu.isOpen); + const ctxItemId = usePlayerStore(state => (state.contextMenu.item as { id?: string } | null)?.id); + const ctxType = usePlayerStore(state => state.contextMenu.type); const ref = useRef(null); const dropdownRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); @@ -45,14 +50,19 @@ export default function LiveSearch() { useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]); - // Close on click outside + // Close on click outside — but stay open while a song context menu is up. + // The CM renders a fullscreen transparent backdrop (z-index 998) above the + // dropdown, so any mousedown — including a second right-click on another + // row — would otherwise hit the backdrop and trip this handler, yanking the + // dropdown closed mid-interaction. useEffect(() => { const handler = (e: MouseEvent) => { + if (ctxIsOpen) return; if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); - }, []); + }, [ctxIsOpen]); const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); @@ -61,7 +71,9 @@ export default function LiveSearch() { ...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.songs.map(s => ({ id: s.id, action: () => { - playTrack(songToTrack(s)); + const track = songToTrack(s); + enqueue([track]); + showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info'); setOpen(false); setQuery(''); }}))), ] : []; @@ -191,12 +203,21 @@ export default function LiveSearch() {
{t('search.songs')}
{results.songs.map(s => { const i = idx++; + const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id; return ( -