mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user