feat: v1.23.0 — Advanced Search, Genre Mix overhaul, Playlist append, Contributors table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-30 22:56:59 +02:00
parent 74b519f9f5
commit 43c656dfc3
16 changed files with 714 additions and 144 deletions
+354
View File
@@ -0,0 +1,354 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Play, SlidersHorizontal } from 'lucide-react';
import {
search, getGenres, getAlbumsByGenre, getAlbumList, getRandomSongs,
SubsonicGenre, SubsonicArtist, SubsonicAlbum, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import CustomSelect from '../components/CustomSelect';
import { useDragDrop } from '../contexts/DragDropContext';
type ResultType = 'all' | 'artists' | 'albums' | 'songs';
interface SearchOpts {
query: string;
genre: string;
yearFrom: string;
yearTo: string;
resultType: ResultType;
}
interface Results {
artists: SubsonicArtist[];
albums: SubsonicAlbum[];
songs: SubsonicSong[];
}
export default function AdvancedSearch() {
const { t } = useTranslation();
const [params] = useSearchParams();
const navigate = useNavigate();
const psyDrag = useDragDrop();
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const [query, setQuery] = useState(params.get('q') ?? '');
const [genre, setGenre] = useState('');
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
const [resultType, setResultType] = useState<ResultType>('all');
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [results, setResults] = useState<Results | null>(null);
const [loading, setLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [genreNote, setGenreNote] = useState(false);
const runSearch = async (opts: SearchOpts) => {
setLoading(true);
setHasSearched(true);
setGenreNote(false);
const { query: q, genre: g, yearFrom: yf, yearTo: yt, resultType: rt } = opts;
const from = yf ? parseInt(yf) : null;
const to = yt ? parseInt(yt) : null;
let artists: SubsonicArtist[] = [];
let albums: SubsonicAlbum[] = [];
let songs: SubsonicSong[] = [];
try {
if (q.trim()) {
const r = await search(q.trim(), { artistCount: 30, albumCount: 50, songCount: 100 });
artists = r.artists;
albums = r.albums;
songs = r.songs;
if (g) {
albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
songs = songs.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
}
if (from !== null) {
albums = albums.filter(a => !a.year || a.year >= from);
songs = songs.filter(s => !s.year || s.year >= from);
}
if (to !== null) {
albums = albums.filter(a => !a.year || a.year <= to);
songs = songs.filter(s => !s.year || s.year <= to);
}
} else if (g) {
const [albumRes, songRes] = await Promise.all([
rt === 'songs' || rt === 'artists' ? Promise.resolve([]) : getAlbumsByGenre(g, 50),
rt === 'albums' || rt === 'artists' ? Promise.resolve([]) : getRandomSongs(100, g),
]);
albums = albumRes as SubsonicAlbum[];
songs = songRes as SubsonicSong[];
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
if (songs.length > 0) setGenreNote(true);
} else if (from !== null || to !== null) {
const fromYear = from ?? 1900;
const toYear = to ?? new Date().getFullYear();
albums = await getAlbumList('byYear', 100, 0, { fromYear, toYear });
}
setResults({
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
});
} catch {
setResults({ artists: [], albums: [], songs: [] });
}
setLoading(false);
};
useEffect(() => {
getGenres().then(data =>
setGenres(data.sort((a, b) => a.value.localeCompare(b.value)))
).catch(() => {});
const q = params.get('q') ?? '';
if (q) runSearch({ query: q, genre: '', yearFrom: '', yearTo: '', resultType: 'all' });
}, []);
const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault();
runSearch({ query, genre, yearFrom, yearTo, resultType });
};
const total = results
? results.artists.length + results.albums.length + results.songs.length
: 0;
const typeOptions: { id: ResultType; label: string }[] = [
{ id: 'all', label: t('search.advancedAll') },
{ id: 'artists', label: t('search.artists') },
{ id: 'albums', label: t('search.albums') },
{ id: 'songs', label: t('search.songs') },
];
const genreSelectOptions = [
{ value: '', label: t('search.advancedAllGenres') },
...genres.map(g => ({ value: g.value, label: g.value })),
];
return (
<div className="content-body animate-fade-in">
<div style={{ marginBottom: '1.5rem' }}>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<SlidersHorizontal size={22} style={{ color: 'var(--accent)', flexShrink: 0 }} />
{t('search.advanced')}
</h1>
</div>
{/* ── Filter panel ──────────────────────────────────────── */}
<form onSubmit={handleSubmit}>
<div className="settings-card" style={{ padding: '1.25rem', marginBottom: '2rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
{/* Row 1: Search term */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedSearchTerm')}
</span>
<input
className="input"
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('search.advancedSearchPlaceholder')}
style={{ flex: 1 }}
autoFocus
/>
</div>
{/* Row 2: Genre + Year */}
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, color: 'var(--text-muted)', minWidth: 90, flexShrink: 0 }}>
{t('search.advancedGenre')}
</span>
<div style={{ minWidth: 180 }}>
<CustomSelect
value={genre}
options={genreSelectOptions}
onChange={setGenre}
/>
</div>
<span style={{ fontSize: 13, color: 'var(--text-muted)', marginLeft: '0.75rem', flexShrink: 0 }}>
{t('search.advancedYear')}
</span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearFrom}
onChange={e => setYearFrom(e.target.value)}
placeholder={t('search.advancedYearFrom')}
style={{ width: 76 }}
/>
<span style={{ color: 'var(--text-muted)', fontSize: 14 }}></span>
<input
className="input"
type="number"
min={1900}
max={new Date().getFullYear()}
value={yearTo}
onChange={e => setYearTo(e.target.value)}
placeholder={t('search.advancedYearTo')}
style={{ width: 76 }}
/>
</div>
{/* Row 3: Result type + Search button */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '0.3rem', flexWrap: 'wrap' }}>
{typeOptions.map(opt => (
<button
key={opt.id}
type="button"
className={`btn ${resultType === opt.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 14px' }}
onClick={() => setResultType(opt.id)}
>
{opt.label}
</button>
))}
</div>
<button
className="btn btn-primary"
type="submit"
disabled={loading}
style={{ minWidth: 100 }}
>
{loading
? <div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
: t('search.advancedSearch')
}
</button>
</div>
</div>
</div>
</form>
{/* ── Results ───────────────────────────────────────────── */}
{!hasSearched ? (
<div className="empty-state" style={{ opacity: 0.6 }}>
{t('search.advancedEmpty')}
</div>
) : loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
) : total === 0 ? (
<div className="empty-state">{t('search.advancedNoResults')}</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
{results && results.artists.length > 0 && (
<ArtistRow
title={`${t('search.artists')} (${results.artists.length})`}
artists={results.artists}
/>
)}
{results && results.albums.length > 0 && (
<AlbumRow
title={`${t('search.albums')} (${results.albums.length})`}
albums={results.albums}
/>
)}
{results && results.songs.length > 0 && (
<section>
<h2 className="section-title" style={{ marginBottom: '0.75rem' }}>
{t('search.songs')} ({results.songs.length})
{genreNote && (
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text-muted)', marginLeft: '0.75rem' }}>
{t('search.advancedGenreNote')}
</span>
)}
</h2>
<div className="tracklist">
<div className="tracklist-header" style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}>
<span />
<span>{t('randomMix.trackTitle')}</span>
<span>{t('randomMix.trackArtist')}</span>
<span>{t('randomMix.trackAlbum')}</span>
<span>{t('randomMix.trackGenre')}</span>
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{results.songs.map(song => {
const track = songToTrack(song);
return (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '32px 1fr 1fr 1fr 90px 70px' }}
onDoubleClick={() => playTrack(track, results.songs.map(songToTrack))}
role="row"
onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
onMouseDown={e => {
if (e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY);
}
};
const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={e => { e.stopPropagation(); playTrack(track, results.songs.map(songToTrack)); }}
>
<Play size={13} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className="track-artist"
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>
{song.artist}
</span>
</div>
<div className="track-info">
<span
className="track-title"
style={{ fontSize: '0.85rem', color: 'var(--subtext0)', cursor: 'pointer' }}
onClick={() => navigate(`/album/${song.albumId}`)}
>
{song.album}
</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{song.genre ?? '—'}
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</span>
</div>
);
})}
</div>
</section>
)}
</div>
)}
</div>
);
}
+15 -5
View File
@@ -3,7 +3,7 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { ListPlus, X } from 'lucide-react';
import { ListPlus, Play, X } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { unstar } from '../api/subsonic';
@@ -69,12 +69,22 @@ export default function Favorites() {
<section className="album-row-section">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button
className="btn btn-primary"
onClick={() => {
const tracks = songs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
<Play size={15} />
{t('favorites.playAll')}
</button>
<button
className="btn btn-surface"
onClick={() => {
const tracks = songs.map(songToTrack);
enqueue(tracks);
}}
onClick={() => {
const tracks = songs.map(songToTrack);
enqueue(tracks);
}}
>
<ListPlus size={15} />
{t('favorites.enqueueAll')}
+68 -84
View File
@@ -13,23 +13,6 @@ const AUDIOBOOK_GENRES = [
'fantasy', 'comedy', 'literature',
];
interface SuperGenre {
id: string;
label: string;
keywords: string[];
}
const SUPER_GENRES: SuperGenre[] = [
{ id: 'metal', label: 'Metal', keywords: ['metal', 'thrash', 'doom', 'sludge', 'hardcore', 'grindcore', 'deathcore', 'metalcore', 'stoner', 'crust', 'black', 'death'] },
{ id: 'rock', label: 'Rock', keywords: ['rock', 'punk', 'grunge', 'alternative', 'indie', 'post-rock', 'prog', 'garage', 'psychedelic', 'shoegaze'] },
{ id: 'pop', label: 'Pop', keywords: ['pop', 'synth-pop', 'dream pop', 'electropop', 'indie pop', 'dance pop'] },
{ id: 'electronic', label: 'Electronic', keywords: ['electronic', 'techno', 'trance', 'ambient', 'edm', 'house', 'dubstep', 'drum and bass', 'dnb', 'electro', 'idm', 'synthwave', 'darkwave', 'industrial'] },
{ id: 'jazz', label: 'Jazz', keywords: ['jazz', 'blues', 'soul', 'funk', 'swing', 'bebop', 'fusion'] },
{ id: 'classical', label: 'Classical', keywords: ['classical', 'orchestra', 'symphony', 'baroque', 'opera', 'chamber', 'romantic'] },
{ id: 'hiphop', label: 'Hip-Hop', keywords: ['hip-hop', 'hip hop', 'rap', 'r&b', 'rnb', 'trap', 'grime'] },
{ id: 'country', label: 'Country', keywords: ['country', 'folk', 'bluegrass', 'americana', 'western'] },
{ id: 'world', label: 'World', keywords: ['world', 'latin', 'reggae', 'ska', 'afro', 'celtic', 'flamenco', 'bossa nova'] },
];
function formatDuration(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -58,7 +41,9 @@ export default function RandomMix() {
// Genre Mix state
const [serverGenres, setServerGenres] = useState<SubsonicGenre[]>([]);
const [selectedSuperGenre, setSelectedSuperGenre] = useState<string | null>(null);
const [allAvailableGenres, setAllAvailableGenres] = useState<string[]>([]);
const [displayedGenres, setDisplayedGenres] = useState<string[]>([]);
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [genreMixSongs, setGenreMixSongs] = useState<SubsonicSong[]>([]);
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixComplete, setGenreMixComplete] = useState(false);
@@ -83,7 +68,16 @@ export default function RandomMix() {
useEffect(() => {
fetchSongs();
getGenres().then(setServerGenres).catch(() => {});
getGenres().then(data => {
setServerGenres(data);
const audiobookLower = AUDIOBOOK_GENRES.map(g => g.toLowerCase());
const available = data
.filter(g => g.songCount > 0 && !audiobookLower.some(ab => g.value.toLowerCase().includes(ab)))
.sort((a, b) => b.songCount - a.songCount)
.map(g => g.value);
setAllAvailableGenres(available);
setDisplayedGenres(available.slice(0, 20));
}).catch(() => {});
}, []);
const filteredSongs = songs.filter(song => {
@@ -101,13 +95,13 @@ export default function RandomMix() {
return true;
});
const handlePlayAll = () => {
if (selectedSuperGenre && genreMixSongs.length > 0) {
playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack));
} else if (filteredSongs.length > 0) {
playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
}
};
const handlePlayAll = () => {
if (selectedGenre && genreMixSongs.length > 0) {
playTrack(songToTrack(genreMixSongs[0]), genreMixSongs.map(songToTrack));
} else if (filteredSongs.length > 0) {
playTrack(songToTrack(filteredSongs[0]), filteredSongs.map(songToTrack));
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
@@ -126,52 +120,26 @@ const handlePlayAll = () => {
}
};
// Compute which super-genres have matching server genres
const availableSuperGenres = SUPER_GENRES.filter(sg =>
serverGenres.some(sg2 =>
sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw))
)
);
const loadGenreMix = async (superGenreId: string) => {
const sg = SUPER_GENRES.find(s => s.id === superGenreId);
if (!sg) return;
const allMatched = serverGenres
.filter(sg2 => sg.keywords.some(kw => sg2.value.toLowerCase().includes(kw)))
.map(sg2 => sg2.value)
.sort(() => Math.random() - 0.5);
const matched = allMatched.slice(0, 50);
const loadGenreMix = async (genre: string) => {
setGenreMixLoading(true);
setGenreMixComplete(false);
setGenreMixSongs([]);
const perGenre = Math.max(1, Math.ceil(50 / matched.length));
const accumulated: SubsonicSong[] = [];
let resolved = 0;
await Promise.allSettled(matched.map(g =>
getRandomSongs(perGenre, g, 45000).then(songs => {
accumulated.push(...songs);
resolved++;
// Show first batch immediately; update on every subsequent resolve
setGenreMixSongs([...accumulated]);
if (resolved === 1) setGenreMixLoading(false);
}).catch(() => { resolved++; })
));
// Final shuffle once all requests are done
setGenreMixSongs(prev => {
const s = [...prev];
for (let i = s.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[s[i], s[j]] = [s[j], s[i]];
}
return s.slice(0, 50);
});
try {
const fetched = await getRandomSongs(50, genre, 45000);
setGenreMixSongs(fetched);
} catch {}
setGenreMixLoading(false);
setGenreMixComplete(true);
};
const shuffleDisplayedGenres = () => {
const shuffled = [...allAvailableGenres].sort(() => Math.random() - 0.5);
setDisplayedGenres(shuffled.slice(0, 20));
setSelectedGenre(null);
setGenreMixSongs([]);
setGenreMixComplete(false);
};
return (
<div className="content-body animate-fade-in">
@@ -183,8 +151,8 @@ const handlePlayAll = () => {
<RefreshCw size={18} className={loading ? 'spin' : ''} /> {t('randomMix.remix')}
</button>
{(() => {
const isGenreLoading = selectedSuperGenre && !genreMixComplete;
const isDisabled = loading || (selectedSuperGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
const isGenreLoading = selectedGenre && !genreMixComplete;
const isDisabled = loading || (selectedGenre ? !genreMixComplete || genreMixSongs.length === 0 : filteredSongs.length === 0);
return (
<button
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
@@ -215,9 +183,12 @@ const handlePlayAll = () => {
}}>
{/* Left: Blacklist */}
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{t('randomMix.filterPanelDesc')}
</p>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
<input
@@ -300,34 +271,47 @@ const handlePlayAll = () => {
{t('randomMix.genreMixTitle')}
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>{t('randomMix.genreMixDesc')}</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', alignItems: 'center' }}>
{serverGenres.length === 0 ? (
<div className="spinner" style={{ width: 14, height: 14 }} />
) : availableSuperGenres.length === 0 ? (
) : displayedGenres.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.genreMixNoGenres')}</span>
) : (
availableSuperGenres.map(sg => (
<button
key={sg.id}
className={`btn ${selectedSuperGenre === sg.id ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => { setSelectedSuperGenre(sg.id); loadGenreMix(sg.id); }}
disabled={genreMixLoading}
>
{sg.label}
</button>
))
<>
{displayedGenres.map(genre => (
<button
key={genre}
className={`btn ${selectedGenre === genre ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '4px 12px' }}
onClick={() => { setSelectedGenre(genre); loadGenreMix(genre); }}
disabled={genreMixLoading}
>
{genre}
</button>
))}
{allAvailableGenres.length > 20 && (
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={shuffleDisplayedGenres}
disabled={genreMixLoading}
data-tooltip={t('randomMix.shuffleGenres')}
>
<RefreshCw size={12} />
</button>
)}
</>
)}
</div>
</div>
</div>
{/* Genre Mix tracklist (shown when a super-genre is selected) */}
{/* Genre Mix tracklist (shown when a genre is selected) */}
{(genreMixLoading || genreMixSongs.length > 0) && (
<div style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{SUPER_GENRES.find(s => s.id === selectedSuperGenre)?.label} Mix
{selectedGenre} Mix
{genreMixLoading && <div className="spinner" style={{ width: 12, height: 12, borderWidth: 2 }} />}
</span>
</div>
@@ -381,7 +365,7 @@ const handlePlayAll = () => {
</div>
)}
{!selectedSuperGenre && (loading && songs.length === 0 ? (
{!selectedGenre && (loading && songs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
+73 -4
View File
@@ -4,7 +4,7 @@ import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown
} from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { open as openUrl } from '@tauri-apps/plugin-shell';
@@ -26,6 +26,32 @@ import Equalizer from '../components/Equalizer';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
const CONTRIBUTORS = [
{
github: 'jiezhuo',
since: '1.21',
contributions: [
'Chinese (Simplified) translation',
],
},
{
github: 'nullobject',
since: '1.22.0',
contributions: [
'Seek debounce & race condition fix (PR #7)',
'Waveform seekbar stability on position update (PR #8)',
],
},
{
github: 'trbn1',
since: '1.22.0',
contributions: [
'Replay Gain metadata propagation (PR #9)',
'songToTrack() — unified track construction across all sources',
],
},
] as const;
type Tab = 'playback' | 'library' | 'appearance' | 'shortcuts' | 'server' | 'about';
function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit<ServerProfile, 'id'>) => void; onCancel: () => void }) {
@@ -118,6 +144,7 @@ export default function Settings() {
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false);
useEffect(() => {
if (!auth.lastfmSessionKey || !auth.lastfmUsername) { setLfmUserInfo(null); return; }
@@ -1017,9 +1044,51 @@ export default function Settings() {
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>AI</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutAiCredit')}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span style={{ color: 'var(--text-muted)', minWidth: 56 }}>{t('settings.aboutContributorsLabel')}</span>
<span style={{ color: 'var(--text-secondary)' }}>{t('settings.aboutContributors')}</span>
<div>
<button
style={{ display: 'flex', width: '100%', alignItems: 'center', gap: '0.5rem', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textAlign: 'left' }}
onClick={() => setContributorsOpen(o => !o)}
>
<span style={{ color: 'var(--text-muted)', minWidth: 56, flexShrink: 0 }}>{t('settings.aboutContributorsLabel')}</span>
<span style={{ color: 'var(--text-secondary)', flex: 1 }}>{CONTRIBUTORS.length}</span>
<ChevronDown size={13} style={{ color: 'var(--text-muted)', transform: contributorsOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }} />
</button>
{contributorsOpen && (
<div style={{ marginTop: '0.75rem', display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{CONTRIBUTORS.map(c => (
<div key={c.github} style={{
display: 'flex', gap: '0.75rem', alignItems: 'flex-start',
background: 'var(--bg-elevated)', borderRadius: 'var(--radius-md)',
padding: '0.65rem 0.75rem',
boxShadow: 'inset 0 0 0 1px var(--border-subtle)',
}}>
<img
src={`https://github.com/${c.github}.png?size=48`}
width={36} height={36}
style={{ borderRadius: '50%', flexShrink: 0, marginTop: 2 }}
alt={c.github}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.3rem' }}>
<button
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--accent)', fontWeight: 600, fontSize: 13 }}
onClick={() => openUrl(`https://github.com/${c.github}`)}
>
@{c.github}
</button>
<span style={{ fontSize: 10, background: 'var(--accent-dim)', color: 'var(--accent)', padding: '1px 6px', borderRadius: 99, fontWeight: 600 }}>
v{c.since}
</span>
</div>
<ul style={{ margin: 0, padding: '0 0 0 1rem', fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{c.contributions.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
</div>
))}
</div>
)}
</div>
</div>