fix(search,ctx): enqueue on live-search click + reposition CM with right-click (#298)

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) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-25 02:32:18 +02:00
committed by GitHub
parent 67d51a0975
commit 93b724fc65
12 changed files with 63 additions and 19 deletions
+16 -5
View File
@@ -1081,6 +1081,22 @@ export default function ContextMenu() {
}); });
}, [contextMenu.isOpen]); }, [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(() => { useEffect(() => {
if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return; if (!pendingSubmenuKeyboardFocus || !playlistSubmenuOpen) return;
let cancelled = false; let cancelled = false;
@@ -1409,11 +1425,6 @@ export default function ContextMenu() {
return ( return (
<> <>
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
onMouseDown={() => closeContextMenu()}
/>
<div <div
ref={menuRef} ref={menuRef}
className="context-menu animate-fade-in" className="context-menu animate-fade-in"
+27 -6
View File
@@ -6,6 +6,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
import { showToast } from '../utils/toast';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void { function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
@@ -23,7 +24,11 @@ export default function LiveSearch() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1); const [activeIndex, setActiveIndex] = useState(-1);
const navigate = useNavigate(); 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<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
@@ -45,14 +50,19 @@ export default function LiveSearch() {
useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]); 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(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (ctxIsOpen) return;
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}; };
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, []); }, [ctxIsOpen]);
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); 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.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.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))),
...(results.songs.map(s => ({ id: s.id, action: () => { ...(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(''); setOpen(false); setQuery('');
}}))), }}))),
] : []; ] : [];
@@ -191,12 +203,21 @@ export default function LiveSearch() {
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div> <div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => { {results.songs.map(s => {
const i = idx++; const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
return ( return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`} <button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => { onClick={() => {
playTrack(songToTrack(s)); const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
setOpen(false); setQuery(''); setOpen(false); setQuery('');
}} }}
onContextMenu={(e) => {
e.preventDefault();
// Keep the dropdown open — context menu portal renders above it,
// and closing here would yank the list out from under the user.
openContextMenu(e.clientX, e.clientY, songToTrack(s), 'song');
}}
role="option" aria-selected={activeIndex === i}> role="option" aria-selected={activeIndex === i}>
<div className="search-result-icon"><Music size={14} /></div> <div className="search-result-icon"><Music size={14} /></div>
<div> <div>
+7 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
import { showToast } from '../utils/toast';
const STORAGE_KEY = 'psysonic_recent_searches'; const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6; const MAX_RECENT = 6;
@@ -29,7 +30,7 @@ function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) { export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null); const [results, setResults] = useState<SearchResults | null>(null);
@@ -64,9 +65,11 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
const goTo = (path: string) => { commit(query); navigate(path); onClose(); }; const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
const goCategory = (path: string) => { navigate(path); onClose(); }; const goCategory = (path: string) => { navigate(path); onClose(); };
const playSong = (song: SearchResults['songs'][number]) => { const enqueueSong = (song: SearchResults['songs'][number]) => {
commit(query); commit(query);
playTrack(songToTrack(song)); const track = songToTrack(song);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
onClose(); onClose();
}; };
const useRecent = (term: string) => { const useRecent = (term: string) => {
@@ -229,7 +232,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
<div className="mobile-search-section"> <div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.songs')}</div> <div className="mobile-search-section-label">{t('search.songs')}</div>
{results!.songs.map(s => ( {results!.songs.map(s => (
<button key={s.id} className="mobile-search-item" onClick={() => playSong(s)}> <button key={s.id} className="mobile-search-item" onClick={() => enqueueSong(s)}>
{s.coverArt ? ( {s.coverArt ? (
<CachedImage <CachedImage
className="mobile-search-thumb" className="mobile-search-thumb"
+1
View File
@@ -58,6 +58,7 @@ export const deTranslation = {
albums: 'Alben', albums: 'Alben',
songs: 'Songs', songs: 'Songs',
clearLabel: 'Suche leeren', clearLabel: 'Suche leeren',
addedToQueueToast: '„{{title}}" zur Warteschlange hinzugefügt',
title: 'Suche', title: 'Suche',
resultsFor: 'Ergebnisse für „{{query}}"', resultsFor: 'Ergebnisse für „{{query}}"',
album: 'Album', album: 'Album',
+1
View File
@@ -60,6 +60,7 @@ export const enTranslation = {
albums: 'Albums', albums: 'Albums',
songs: 'Songs', songs: 'Songs',
clearLabel: 'Clear search', clearLabel: 'Clear search',
addedToQueueToast: 'Added "{{title}}" to queue',
title: 'Search', title: 'Search',
resultsFor: 'Results for "{{query}}"', resultsFor: 'Results for "{{query}}"',
album: 'Album', album: 'Album',
+1
View File
@@ -59,6 +59,7 @@ export const esTranslation = {
albums: 'Álbumes', albums: 'Álbumes',
songs: 'Canciones', songs: 'Canciones',
clearLabel: 'Limpiar búsqueda', clearLabel: 'Limpiar búsqueda',
addedToQueueToast: '"{{title}}" añadido a la cola',
title: 'Buscar', title: 'Buscar',
resultsFor: 'Resultados para "{{query}}"', resultsFor: 'Resultados para "{{query}}"',
album: 'Álbum', album: 'Álbum',
+1
View File
@@ -58,6 +58,7 @@ export const frTranslation = {
albums: 'Albums', albums: 'Albums',
songs: 'Morceaux', songs: 'Morceaux',
clearLabel: 'Effacer la recherche', clearLabel: 'Effacer la recherche',
addedToQueueToast: '« {{title}} » ajouté à la file',
title: 'Recherche', title: 'Recherche',
resultsFor: 'Résultats pour « {{query}} »', resultsFor: 'Résultats pour « {{query}} »',
album: 'Album', album: 'Album',
+1
View File
@@ -58,6 +58,7 @@ export const nbTranslation = {
albums: 'Album', albums: 'Album',
songs: 'Sanger', songs: 'Sanger',
clearLabel: 'Fjern søk', clearLabel: 'Fjern søk',
addedToQueueToast: '«{{title}}» lagt til i køen',
title: 'Søk', title: 'Søk',
resultsFor: 'Resultater for "{{query}}"', resultsFor: 'Resultater for "{{query}}"',
album: 'Album', album: 'Album',
+1
View File
@@ -58,6 +58,7 @@ export const nlTranslation = {
albums: 'Albums', albums: 'Albums',
songs: 'Nummers', songs: 'Nummers',
clearLabel: 'Zoekopdracht wissen', clearLabel: 'Zoekopdracht wissen',
addedToQueueToast: '"{{title}}" toegevoegd aan de wachtrij',
title: 'Zoeken', title: 'Zoeken',
resultsFor: 'Resultaten voor "{{query}}"', resultsFor: 'Resultaten voor "{{query}}"',
album: 'Album', album: 'Album',
+1
View File
@@ -60,6 +60,7 @@ export const ruTranslation = {
albums: 'Альбомы', albums: 'Альбомы',
songs: 'Треки', songs: 'Треки',
clearLabel: 'Очистить поиск', clearLabel: 'Очистить поиск',
addedToQueueToast: '«{{title}}» добавлен в очередь',
title: 'Поиск', title: 'Поиск',
resultsFor: 'Результаты по «{{query}}»', resultsFor: 'Результаты по «{{query}}»',
album: 'Альбом', album: 'Альбом',
+1
View File
@@ -58,6 +58,7 @@ export const zhTranslation = {
albums: '专辑', albums: '专辑',
songs: '歌曲', songs: '歌曲',
clearLabel: '清除搜索', clearLabel: '清除搜索',
addedToQueueToast: '已将"{{title}}"加入队列',
title: '搜索', title: '搜索',
resultsFor: '"{{query}}" 的搜索结果', resultsFor: '"{{query}}" 的搜索结果',
album: '专辑', album: '专辑',
+2 -1
View File
@@ -742,7 +742,8 @@
} }
.search-result-item:hover, .search-result-item:hover,
.search-result-item.active { .search-result-item.active,
.search-result-item.context-active {
background: var(--bg-hover); background: var(--bg-hover);
} }