mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
7a7a9f5e6b
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.
290 lines
11 KiB
TypeScript
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
|
|
);
|
|
}
|