mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(genre): play, shuffle and queue buttons on the genre view (#926)
* feat(genre): add paginated songs-by-genre API Wraps the Subsonic getSongsByGenre endpoint plus a fetchAllSongsByGenre helper that paginates until exhausted, capped to keep the queue and the burst of requests bounded for very large genres. * refactor(playback): extract shared bulk play/shuffle/enqueue helper A single fetchTracks-driven core (loading flag, empty guard, canonical shuffleArray) so async detail-page play buttons stop growing divergent copies. Artist detail now reuses it, dropping its weaker sort-random shuffle. * feat(genre): play, shuffle and queue buttons on the genre view Header buttons load the genre's songs and start ordered or shuffled playback, or append them to the queue. The slice is bounded to stay within the queue resolver's cache budget so every row resolves instead of rendering as a placeholder. Strings added across all nine locales. * docs(changelog): genre play/shuffle buttons (#926)
This commit is contained in:
committed by
GitHub
parent
6c74cae0b7
commit
e734a8fc43
@@ -1,16 +1,24 @@
|
||||
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
||||
import { getAlbumsByGenre, fetchAllSongsByGenre } from '../api/subsonicGenres';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Disc3 } from 'lucide-react';
|
||||
import { ArrowLeft, Disc3, Play, Shuffle, ListPlus, Loader2 } from 'lucide-react';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import AlbumCard from '../components/AlbumCard';
|
||||
import { songToTrack } from '../utils/playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '../utils/playback/runBulkPlay';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
// Bulk play/shuffle pulls a bounded slice of the genre. The queue resolver
|
||||
// (queueTrackResolver) holds a 500-entry LRU; seeding a larger queue evicts the
|
||||
// earliest tracks, which then render as "…"/0:00 placeholders until lazily
|
||||
// re-resolved. Keep the slice within that budget so the whole queue stays warm.
|
||||
const GENRE_QUEUE_CAP = 500;
|
||||
|
||||
export default function GenreDetail() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
@@ -23,7 +31,27 @@ export default function GenreDetail() {
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const playTrack = usePlayerStore(s => s.playTrack);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const fetchGenreTracks = useCallback(
|
||||
() => fetchAllSongsByGenre(genre, GENRE_QUEUE_CAP).then(songs => songs.map(songToTrack)),
|
||||
[genre],
|
||||
);
|
||||
const handlePlayAll = useCallback(
|
||||
() => runBulkPlayAll({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }),
|
||||
[fetchGenreTracks, playTrack],
|
||||
);
|
||||
const handleShuffleAll = useCallback(
|
||||
() => runBulkShuffle({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }),
|
||||
[fetchGenreTracks, playTrack],
|
||||
);
|
||||
const handleEnqueueAll = useCallback(
|
||||
() => runBulkEnqueue({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, enqueue }),
|
||||
[fetchGenreTracks, enqueue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setAlbums([]);
|
||||
@@ -70,6 +98,29 @@ export default function GenreDetail() {
|
||||
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
|
||||
</span>
|
||||
)}
|
||||
{!loading && albums.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
|
||||
<button className="btn btn-primary" onClick={handlePlayAll} disabled={bulkLoading}>
|
||||
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {t('common.play')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleShuffleAll}
|
||||
disabled={bulkLoading}
|
||||
data-tooltip={t('genres.shuffle')}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
disabled={bulkLoading}
|
||||
data-tooltip={t('genres.addToQueue')}
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
|
||||
|
||||
Reference in New Issue
Block a user