mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat: v1.34.0 — Mobile UI Early Preview, Russian 2, macOS network fix
Co-authored-by: kilyabin <kilyabin@users.noreply.github.com> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } 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 { search, SearchResults, buildCoverArtUrl } from '../api/subsonic';
|
||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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); };
|
||||
}
|
||||
|
||||
export default function MobileSearchOverlay({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
|
||||
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);
|
||||
|
||||
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),
|
||||
[]
|
||||
);
|
||||
|
||||
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 playSong = (song: SearchResults['songs'][number]) => {
|
||||
commit(query);
|
||||
playTrack(songToTrack(song));
|
||||
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}`)}>
|
||||
<div className="mobile-search-avatar mobile-search-avatar--circle">
|
||||
<Users size={20} />
|
||||
</div>
|
||||
<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 ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(a.coverArt, 80)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<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={() => playSong(s)}>
|
||||
{s.coverArt ? (
|
||||
<img className="mobile-search-thumb" src={buildCoverArtUrl(s.coverArt, 80)} alt="" loading="lazy" />
|
||||
) : (
|
||||
<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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user