Files
Psychotoxical-psysonic/src/components/MobileSearchOverlay.tsx
T
Frank Stellmacher 7a7a9f5e6b refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
2026-05-14 14:27:44 +02:00

290 lines
11 KiB
TypeScript

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 { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } 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';
const STORAGE_KEY = 'psysonic_recent_searches';
const MAX_RECENT = 6;
function loadRecent(): string[] {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; }
}
function saveRecent(q: string, prev: string[]): string[] {
const updated = [q.trim(), ...prev.filter(s => s !== q.trim())].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
}
function debounce(fn: (q: string) => void, ms: number): (q: string) => void {
let timer: ReturnType<typeof setTimeout>;
return (q: string) => { clearTimeout(timer); timer = setTimeout(() => fn(q), ms); };
}
function MobileSearchArtistThumb({ artist }: { artist: Pick<SubsonicArtist, 'id' | 'coverArt'> }) {
const [failed, setFailed] = useState(false);
const coverId = artist.coverArt || artist.id;
const src = useMemo(() => buildCoverArtUrl(coverId, 80), [coverId]);
const ck = useMemo(() => coverArtCacheKey(coverId, 80), [coverId]);
useEffect(() => { setFailed(false); }, [coverId]);
if (failed) {
return (
<div className="mobile-search-avatar mobile-search-avatar--circle">
<Users size={20} />
</div>
);
}
return (
<CachedImage
className="mobile-search-thumb mobile-search-thumb--artist-round"
src={src}
cacheKey={ck}
alt=""
loading="eager"
fetchQueueBias={FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM}
onError={() => setFailed(true)}
/>
);
}
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const navigate = useNavigate();
const enqueue = usePlayerStore(s => s.enqueue);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [recentSearches, setRecentSearches] = useState<string[]>(loadRecent);
const inputRef = useRef<HTMLInputElement>(null);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, []);
const doSearch = useCallback(
debounce(async (q: string) => {
if (!q.trim()) { setResults(null); setLoading(false); return; }
setLoading(true);
try { setResults(await search(q)); }
finally { setLoading(false); }
}, 300),
[musicLibraryFilterVersion]
);
useEffect(() => { doSearch(query); }, [query, doSearch]);
const commit = (q: string) => {
if (q.trim()) setRecentSearches(prev => saveRecent(q, prev));
};
const goTo = (path: string) => { commit(query); navigate(path); onClose(); };
const goCategory = (path: string) => { navigate(path); onClose(); };
const enqueueSong = (song: SearchResults['songs'][number]) => {
commit(query);
const track = songToTrack(song);
enqueue([track]);
showToast(t('search.addedToQueueToast', { title: track.title }), 2200, 'info');
onClose();
};
const useRecent = (term: string) => {
setQuery(term);
inputRef.current?.focus();
};
const removeRecent = (term: string, e: React.MouseEvent) => {
e.stopPropagation();
setRecentSearches(prev => {
const updated = prev.filter(s => s !== term);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
};
const hasResults = results && (results.artists.length || results.albums.length || results.songs.length);
const showEmpty = !query;
return createPortal(
<div className="mobile-search-overlay">
{/* ── Search bar ── */}
<div className="mobile-search-bar">
<div className="mobile-search-field">
{loading ? (
<div className="mobile-search-spinner" />
) : (
<Search size={16} className="mobile-search-icon" />
)}
<input
ref={inputRef}
className="mobile-search-input"
type="search"
placeholder={t('search.placeholder')}
value={query}
onChange={e => setQuery(e.target.value)}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
{query && (
<button
className="mobile-search-clear"
onClick={() => { setQuery(''); setResults(null); inputRef.current?.focus(); }}
aria-label={t('search.clearLabel')}
>
<X size={15} />
</button>
)}
</div>
<button className="mobile-search-cancel" onClick={onClose}>
{t('common.cancel')}
</button>
</div>
<div className="mobile-search-results">
{/* ── Empty state ── */}
{showEmpty && (
<div className="mobile-search-empty-state">
{recentSearches.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.recentSearches')}</div>
{recentSearches.map(term => (
<button key={term} className="mobile-search-item" onClick={() => useRecent(term)}>
<div className="mobile-search-avatar">
<Clock size={18} />
</div>
<div className="mobile-search-item-info" style={{ flex: 1 }}>
<span className="mobile-search-item-title">{term}</span>
</div>
<button
className="mobile-search-recent-remove"
onClick={e => removeRecent(term, e)}
aria-label={t('search.clearLabel')}
>
<X size={14} />
</button>
</button>
))}
</div>
)}
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.browse')}</div>
<div className="mobile-search-chips">
<button className="mobile-search-chip" onClick={() => goCategory('/albums')}>
<Music2 size={15} /> {t('search.albums')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/artists')}>
<Users size={15} /> {t('search.artists')}
</button>
<button className="mobile-search-chip" onClick={() => goCategory('/genres')}>
<Music size={15} /> {t('search.genres')}
</button>
</div>
</div>
<div className="mobile-search-hint">
<Search size={52} className="mobile-search-hint-icon" />
<span className="mobile-search-hint-text">{t('search.emptyHint')}</span>
</div>
</div>
)}
{/* ── No results ── */}
{!loading && query && !hasResults && (
<div className="mobile-search-noresults">
{t('search.noResults', { query })}
</div>
)}
{/* ── Results ── */}
{hasResults && (
<>
{results!.artists.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.artists')}</div>
{results!.artists.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/artist/${a.id}`)}>
<MobileSearchArtistThumb artist={a} />
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{t('search.artists')}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.albums.length > 0 && (
<div className="mobile-search-section">
<div className="mobile-search-section-label">{t('search.albums')}</div>
{results!.albums.map(a => (
<button key={a.id} className="mobile-search-item" onClick={() => goTo(`/album/${a.id}`)}>
{a.coverArt ? (
<CachedImage
className="mobile-search-thumb"
src={buildCoverArtUrl(a.coverArt, 80)}
cacheKey={coverArtCacheKey(a.coverArt, 80)}
alt=""
/>
) : (
<div className="mobile-search-avatar">
<Disc3 size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{a.name}</span>
<span className="mobile-search-item-sub">{a.artist}</span>
</div>
<ChevronRight size={16} className="mobile-search-item-chevron" />
</button>
))}
</div>
)}
{results!.songs.length > 0 && (
<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={() => enqueueSong(s)}>
{s.coverArt ? (
<CachedImage
className="mobile-search-thumb"
src={buildCoverArtUrl(s.coverArt, 80)}
cacheKey={coverArtCacheKey(s.coverArt, 80)}
alt=""
/>
) : (
<div className="mobile-search-avatar">
<Music size={20} />
</div>
)}
<div className="mobile-search-item-info">
<span className="mobile-search-item-title">{s.title}</span>
<span className="mobile-search-item-sub">{s.artist}{s.album ? ` · ${s.album}` : ''}</span>
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>,
document.body
);
}