mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
Merge remote-tracking branch 'origin/main' into exp/orbit
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -58,6 +58,7 @@ export const deTranslation = {
|
||||
albums: 'Alben',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Suche leeren',
|
||||
addedToQueueToast: '„{{title}}" zur Warteschlange hinzugefügt',
|
||||
title: 'Suche',
|
||||
resultsFor: 'Ergebnisse für „{{query}}"',
|
||||
album: 'Album',
|
||||
|
||||
@@ -60,6 +60,7 @@ export const enTranslation = {
|
||||
albums: 'Albums',
|
||||
songs: 'Songs',
|
||||
clearLabel: 'Clear search',
|
||||
addedToQueueToast: 'Added "{{title}}" to queue',
|
||||
title: 'Search',
|
||||
resultsFor: 'Results for "{{query}}"',
|
||||
album: 'Album',
|
||||
|
||||
@@ -59,6 +59,7 @@ export const esTranslation = {
|
||||
albums: 'Álbumes',
|
||||
songs: 'Canciones',
|
||||
clearLabel: 'Limpiar búsqueda',
|
||||
addedToQueueToast: '"{{title}}" añadido a la cola',
|
||||
title: 'Buscar',
|
||||
resultsFor: 'Resultados para "{{query}}"',
|
||||
album: 'Álbum',
|
||||
|
||||
@@ -58,6 +58,7 @@ export const frTranslation = {
|
||||
albums: 'Albums',
|
||||
songs: 'Morceaux',
|
||||
clearLabel: 'Effacer la recherche',
|
||||
addedToQueueToast: '« {{title}} » ajouté à la file',
|
||||
title: 'Recherche',
|
||||
resultsFor: 'Résultats pour « {{query}} »',
|
||||
album: 'Album',
|
||||
|
||||
@@ -58,6 +58,7 @@ export const nbTranslation = {
|
||||
albums: 'Album',
|
||||
songs: 'Sanger',
|
||||
clearLabel: 'Fjern søk',
|
||||
addedToQueueToast: '«{{title}}» lagt til i køen',
|
||||
title: 'Søk',
|
||||
resultsFor: 'Resultater for "{{query}}"',
|
||||
album: 'Album',
|
||||
|
||||
@@ -58,6 +58,7 @@ export const nlTranslation = {
|
||||
albums: 'Albums',
|
||||
songs: 'Nummers',
|
||||
clearLabel: 'Zoekopdracht wissen',
|
||||
addedToQueueToast: '"{{title}}" toegevoegd aan de wachtrij',
|
||||
title: 'Zoeken',
|
||||
resultsFor: 'Resultaten voor "{{query}}"',
|
||||
album: 'Album',
|
||||
|
||||
@@ -60,6 +60,7 @@ export const ruTranslation = {
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
clearLabel: 'Очистить поиск',
|
||||
addedToQueueToast: '«{{title}}» добавлен в очередь',
|
||||
title: 'Поиск',
|
||||
resultsFor: 'Результаты по «{{query}}»',
|
||||
album: 'Альбом',
|
||||
|
||||
@@ -58,6 +58,7 @@ export const zhTranslation = {
|
||||
albums: '专辑',
|
||||
songs: '歌曲',
|
||||
clearLabel: '清除搜索',
|
||||
addedToQueueToast: '已将"{{title}}"加入队列',
|
||||
title: '搜索',
|
||||
resultsFor: '"{{query}}" 的搜索结果',
|
||||
album: '专辑',
|
||||
|
||||
@@ -742,7 +742,8 @@
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item.active {
|
||||
.search-result-item.active,
|
||||
.search-result-item.context-active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user