import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; import { search } from '../api/subsonicSearch'; import type { SearchResults, SubsonicArtist } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Search, Disc3, Users, Music, TextSearch } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage'; import { showToast } from '../utils/ui/toast'; import { useShareSearch } from '../hooks/useShareSearch'; import ShareSearchResults from './search/ShareSearchResults'; function debounce(fn: (q: string) => void, ms: number): (q: string) => void { let timer: ReturnType; return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); }; } function LiveSearchAlbumThumb({ coverArt }: { coverArt: string }) { const src = useMemo(() => buildCoverArtUrl(coverArt, 40), [coverArt]); const cacheKey = useMemo(() => coverArtCacheKey(coverArt, 40), [coverArt]); return ; } function LiveSearchArtistThumb({ artist }: { artist: Pick }) { const [failed, setFailed] = useState(false); const coverId = artist.coverArt || artist.id; const src = useMemo(() => buildCoverArtUrl(coverId, 40), [coverId]); const cacheKey = useMemo(() => coverArtCacheKey(coverId, 40), [coverId]); useEffect(() => { setFailed(false); }, [coverId]); if (failed) return
; return ( setFailed(true)} /> ); } export default function LiveSearch() { const { t } = useTranslation(); const [query, setQuery] = useState(''); const [results, setResults] = useState(null); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const [isFocused, setIsFocused] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); const navigate = useNavigate(); 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 inputRef = useRef(null); const collapsedRef = useRef(false); const compactHeaderControlsRef = useRef(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const closeSearch = useCallback(() => { setOpen(false); setQuery(''); }, []); const share = useShareSearch(query, closeSearch); const doSearch = useCallback( debounce(async (q: string) => { if (!q.trim()) { setResults(null); setOpen(false); return; } setLoading(true); try { const r = await search(q); setResults(r); setOpen(true); } finally { setLoading(false); } }, 300), [musicLibraryFilterVersion] ); useEffect(() => { if (share.shareMatch) { setResults(null); setLoading(false); setOpen(true); setActiveIndex(-1); return; } doSearch(query); setActiveIndex(-1); }, [query, doSearch, share.shareMatch]); const isSearchActive = isFocused || open || query.trim().length > 0; useEffect(() => { const root = ref.current; if (!root) return; const header = root.closest('.content-header') as HTMLElement | null; if (!header) return; const overlayActive = isCollapsed && isSearchActive; if (overlayActive) { header.dataset.liveSearchOverlay = 'true'; } else { delete header.dataset.liveSearchOverlay; } return () => { delete header.dataset.liveSearchOverlay; }; }, [isCollapsed, isSearchActive]); useEffect(() => { const root = ref.current; if (!root) return; const header = root.closest('.content-header') as HTMLElement | null; if (!header) return; const spacer = header.querySelector('.spacer') as HTMLElement | null; if (!spacer) return; const MIN_EXPANDED_WIDTH = 260; const SPACER_RESERVE = 24; const HYSTERESIS_PX = 20; // Live/Orbit compact-mode is intentionally stickier than search collapse, // otherwise both systems can feed each other and oscillate. const HEADER_CONTROLS_COMPACT_ON_SPACER = 36; const HEADER_CONTROLS_COMPACT_OFF_SPACER = 108; const SWITCH_COOLDOWN_MS = 180; const collapseThreshold = MIN_EXPANDED_WIDTH + SPACER_RESERVE; const expandThreshold = collapseThreshold + HYSTERESIS_PX; let lastSwitchAt = 0; let cooldownTimer: number | null = null; const updateCollapsed = () => { const searchWidth = root.getBoundingClientRect().width; const spacerWidth = spacer.getBoundingClientRect().width; const budget = searchWidth + spacerWidth; const headerOverflowing = header.scrollWidth - header.clientWidth > 1; let nextCollapsed = collapsedRef.current ? budget < expandThreshold : budget < collapseThreshold; // Priority rule: if we are already compacting Live/Orbit labels, search // must stay collapsed until compact mode can be released. if (compactHeaderControlsRef.current) { nextCollapsed = true; } if (nextCollapsed !== collapsedRef.current) { const now = performance.now(); const remaining = SWITCH_COOLDOWN_MS - (now - lastSwitchAt); if (remaining > 0) { if (cooldownTimer == null) { cooldownTimer = window.setTimeout(() => { cooldownTimer = null; updateCollapsed(); }, remaining); } return; } lastSwitchAt = now; collapsedRef.current = nextCollapsed; setIsCollapsed(nextCollapsed); } const nextCompactControls = nextCollapsed ? ( compactHeaderControlsRef.current // Stay compact until we clearly have room and no overflow. ? (headerOverflowing || spacerWidth < HEADER_CONTROLS_COMPACT_OFF_SPACER) // Enter compact only when both tight spacer and real overflow exist. : (headerOverflowing && spacerWidth < HEADER_CONTROLS_COMPACT_ON_SPACER) ) : false; if (nextCompactControls !== compactHeaderControlsRef.current) { compactHeaderControlsRef.current = nextCompactControls; if (nextCompactControls) { header.dataset.liveHeaderCompact = 'true'; } else { delete header.dataset.liveHeaderCompact; } } }; updateCollapsed(); const ro = new ResizeObserver(updateCollapsed); ro.observe(header); ro.observe(spacer); ro.observe(root); window.addEventListener('resize', updateCollapsed); return () => { ro.disconnect(); window.removeEventListener('resize', updateCollapsed); delete header.dataset.liveHeaderCompact; if (cooldownTimer != null) { window.clearTimeout(cooldownTimer); } }; }, []); // 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 = !!share.shareMatch || (results && (results.artists.length || results.albums.length || results.songs.length)); // Flat list of all navigable items for keyboard nav const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [ { id: 'share-link', action: () => { if (share.canQueueShareMatch) void share.enqueueShareMatch(); else if (share.canOpenShareAlbum) share.openShareAlbum(); else if (share.canOpenShareArtist) share.openShareArtist(); else if (share.canOpenShareComposer) share.openShareComposer(); }, }, ] : results ? [ ...(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: () => { const track = songToTrack(s); enqueue([track]); showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info'); setOpen(false); setQuery(''); }}))), ] : []; const handleKeyDown = (e: React.KeyboardEvent) => { if (share.shareMatch) { if (e.key === 'Enter') { e.preventDefault(); if (share.canQueueShareMatch) void share.enqueueShareMatch(); else if (share.canOpenShareAlbum) share.openShareAlbum(); else if (share.canOpenShareArtist) share.openShareArtist(); else if (share.canOpenShareComposer) share.openShareComposer(); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(share.hasShareKeyboardTarget ? 0 : -1); } else if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } return; } if (!open || !flatItems.length) { if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); } return; } if (e.key === 'ArrowDown') { e.preventDefault(); const next = Math.min(activeIndex + 1, flatItems.length - 1); setActiveIndex(next); dropdownRef.current?.querySelectorAll('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' }); } else if (e.key === 'ArrowUp') { e.preventDefault(); const next = Math.max(activeIndex - 1, -1); setActiveIndex(next); if (next >= 0) dropdownRef.current?.querySelectorAll('.search-result-item')[next]?.scrollIntoView({ block: 'nearest' }); } else if (e.key === 'Enter') { e.preventDefault(); if (activeIndex >= 0) { flatItems[activeIndex].action(); setActiveIndex(-1); } else if (query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); } } else if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); } }; return (
{ if (isSearchActive) return; if (!isCollapsed) return; e.preventDefault(); setIsFocused(true); requestAnimationFrame(() => inputRef.current?.focus()); }} > {loading ? (
) : ( )} setQuery(e.target.value)} onFocus={() => { setIsFocused(true); if (results) setOpen(true); }} onBlur={() => setIsFocused(false)} onKeyDown={handleKeyDown} aria-autocomplete="list" aria-controls="search-results" aria-expanded={open} autoComplete="off" /> {query && ( )}
{open && (
{!hasResults && !loading && (
{t('search.noResults', { query })}
)} {share.shareMatch && ( void share.enqueueShareMatch()} onOpenAlbum={share.openShareAlbum} onOpenArtist={share.openShareArtist} onOpenComposer={share.openShareComposer} onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)} shareTrackSong={share.shareTrackSong} shareTrackResolving={share.shareTrackResolving} shareTrackUnavailable={share.shareTrackUnavailable} shareAlbum={share.shareAlbum} shareAlbumResolving={share.shareAlbumResolving} shareAlbumUnavailable={share.shareAlbumUnavailable} shareArtist={share.shareArtist} shareArtistResolving={share.shareArtistResolving} shareArtistUnavailable={share.shareArtistUnavailable} shareComposer={share.shareComposer} shareComposerResolving={share.shareComposerResolving} shareComposerUnavailable={share.shareComposerUnavailable} /> )} {(() => { if (share.shareMatch) return null; let idx = 0; return <> {results?.artists.length ? (
{t('search.artists')}
{results.artists.map(a => { const i = idx++; const isCtxActive = ctxIsOpen && ctxType === 'artist' && ctxItemId === a.id; return ( ); })}
) : null} {results?.albums.length ? (
{t('search.albums')}
{results.albums.map(a => { const i = idx++; const isCtxActive = ctxIsOpen && ctxType === 'album' && ctxItemId === a.id; return ( ); })}
) : null} {results?.songs.length ? (
{t('search.songs')}
{results.songs.map(s => { const i = idx++; const isCtxActive = ctxIsOpen && ctxType === 'song' && ctxItemId === s.id; return ( ); })}
) : null} ; })()}
)}
); }