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]); }, [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;
@@ -1417,11 +1433,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);
} }