Merge remote-tracking branch 'origin/main' into exp/orbit

This commit is contained in:
Psychotoxical
2026-04-25 02:32:29 +02:00
12 changed files with 63 additions and 19 deletions
+16 -5
View File
@@ -1089,6 +1089,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;
@@ -1417,11 +1433,6 @@ export default function ContextMenu() {
return (
<>
{/* Transparent backdrop — catches all outside clicks cleanly, preventing freeze */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 998 }}
onMouseDown={() => closeContextMenu()}
/>
<div
ref={menuRef}
className="context-menu animate-fade-in"
+30 -9
View File
@@ -6,6 +6,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
import { showToast } from '../utils/toast';
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
@@ -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<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(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() {
<div className="search-section-label"><Music size={12} /> {t('search.songs')}</div>
{results.songs.map(s => {
const i = idx++;
const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id;
return (
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}`}
onClick={() => {
playTrack(songToTrack(s));
setOpen(false); setQuery('');
}}
<button key={s.id} className={`search-result-item${activeIndex === i ? ' active' : ''}${isCtxActive ? ' context-active' : ''}`}
onClick={() => {
const track = songToTrack(s);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
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}>
<div className="search-result-icon"><Music size={14} /></div>
<div>
+7 -4
View File
@@ -7,6 +7,7 @@ import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useTranslation } from 'react-i18next';
import CachedImage from './CachedImage';
import { showToast } from '../utils/toast';
const STORAGE_KEY = 'psysonic_recent_searches';
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 }) {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const [query, setQuery] = useState('');
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 goCategory = (path: string) => { navigate(path); onClose(); };
const playSong = (song: SearchResults['songs'][number]) => {
const enqueueSong = (song: SearchResults['songs'][number]) => {
commit(query);
playTrack(songToTrack(song));
const track = songToTrack(song);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
onClose();
};
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-label">{t('search.songs')}</div>
{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 ? (
<CachedImage
className="mobile-search-thumb"