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
@@ -179,6 +179,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Genre detail — play or shuffle a whole genre
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#926](https://github.com/Psychotoxical/psysonic/pull/926)**
|
||||||
|
|
||||||
|
* Play, Shuffle and Add-to-queue buttons on a genre page start the whole genre in one click — in order or randomized — instead of adding each album by hand. Suggested by Apollosport.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
### CI — hot-path coverage gates block merges
|
### CI — hot-path coverage gates block merges
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { fetchAllSongsByGenre, getSongsByGenre } from './subsonicGenres';
|
||||||
|
import type { SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('./subsonicClient', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
libraryFilterParams: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function songs(n: number, startId = 0): SubsonicSong[] {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({ id: String(startId + i), title: `t${startId + i}` }) as SubsonicSong);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getSongsByGenre', () => {
|
||||||
|
beforeEach(() => apiMock.mockReset());
|
||||||
|
|
||||||
|
it('normalizes a single-song response into an array', async () => {
|
||||||
|
apiMock.mockResolvedValue({ songsByGenre: { song: { id: '1', title: 'only' } } });
|
||||||
|
expect(await getSongsByGenre('Metal')).toEqual([{ id: '1', title: 'only' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when the genre has no songs', async () => {
|
||||||
|
apiMock.mockResolvedValue({ songsByGenre: {} });
|
||||||
|
expect(await getSongsByGenre('Empty')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchAllSongsByGenre', () => {
|
||||||
|
beforeEach(() => apiMock.mockReset());
|
||||||
|
|
||||||
|
it('paginates until a short page signals the end', async () => {
|
||||||
|
apiMock
|
||||||
|
.mockResolvedValueOnce({ songsByGenre: { song: songs(500) } })
|
||||||
|
.mockResolvedValueOnce({ songsByGenre: { song: songs(30, 500) } });
|
||||||
|
const all = await fetchAllSongsByGenre('Metal');
|
||||||
|
expect(all).toHaveLength(530);
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(apiMock.mock.calls[0][1]).toMatchObject({ offset: 0, count: 500 });
|
||||||
|
expect(apiMock.mock.calls[1][1]).toMatchObject({ offset: 500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the cap without fetching further pages', async () => {
|
||||||
|
apiMock.mockResolvedValue({ songsByGenre: { song: songs(500) } });
|
||||||
|
const all = await fetchAllSongsByGenre('Huge', 10);
|
||||||
|
expect(all).toHaveLength(10);
|
||||||
|
expect(apiMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api, libraryFilterParams } from './subsonicClient';
|
import { api, libraryFilterParams } from './subsonicClient';
|
||||||
import type { SubsonicAlbum, SubsonicGenre } from './subsonicTypes';
|
import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from './subsonicTypes';
|
||||||
|
|
||||||
export async function getGenres(): Promise<SubsonicGenre[]> {
|
export async function getGenres(): Promise<SubsonicGenre[]> {
|
||||||
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view');
|
||||||
@@ -21,3 +21,32 @@ export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Pr
|
|||||||
if (!raw) return [];
|
if (!raw) return [];
|
||||||
return Array.isArray(raw) ? raw : [raw];
|
return Array.isArray(raw) ? raw : [raw];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single page of songs for a genre (Subsonic `getSongsByGenre`, supported by Navidrome). */
|
||||||
|
export async function getSongsByGenre(genre: string, count = 500, offset = 0): Promise<SubsonicSong[]> {
|
||||||
|
const data = await api<{ songsByGenre: { song: SubsonicSong | SubsonicSong[] } }>('getSongsByGenre.view', {
|
||||||
|
genre,
|
||||||
|
count,
|
||||||
|
offset,
|
||||||
|
_t: Date.now(),
|
||||||
|
...libraryFilterParams(),
|
||||||
|
});
|
||||||
|
const raw = data.songsByGenre?.song;
|
||||||
|
if (!raw) return [];
|
||||||
|
return Array.isArray(raw) ? raw : [raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every song in a genre, paginated until exhausted. Capped to keep the queue and the
|
||||||
|
* burst of server requests bounded for very large genres (a handful of sequential pages).
|
||||||
|
*/
|
||||||
|
export async function fetchAllSongsByGenre(genre: string, cap = 5000): Promise<SubsonicSong[]> {
|
||||||
|
const PAGE = 500;
|
||||||
|
const songs: SubsonicSong[] = [];
|
||||||
|
for (let offset = 0; songs.length < cap; offset += PAGE) {
|
||||||
|
const page = await getSongsByGenre(genre, PAGE, offset);
|
||||||
|
songs.push(...page);
|
||||||
|
if (page.length < PAGE) break;
|
||||||
|
}
|
||||||
|
return songs.length > cap ? songs.slice(0, cap) : songs;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'Keine Alben in diesem Genre gefunden.',
|
albumsEmpty: 'Keine Alben in diesem Genre gefunden.',
|
||||||
loadMore: 'Mehr laden',
|
loadMore: 'Mehr laden',
|
||||||
back: 'Zurück',
|
back: 'Zurück',
|
||||||
|
shuffle: 'Zufallswiedergabe',
|
||||||
|
addToQueue: 'Zur Warteschlange',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'No albums found for this genre.',
|
albumsEmpty: 'No albums found for this genre.',
|
||||||
loadMore: 'Load more',
|
loadMore: 'Load more',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
|
shuffle: 'Shuffle',
|
||||||
|
addToQueue: 'Add to queue',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'No se encontraron álbumes para este género.',
|
albumsEmpty: 'No se encontraron álbumes para este género.',
|
||||||
loadMore: 'Cargar más',
|
loadMore: 'Cargar más',
|
||||||
back: 'Volver',
|
back: 'Volver',
|
||||||
|
shuffle: 'Aleatorio',
|
||||||
|
addToQueue: 'Agregar a la cola',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'Aucun album trouvé pour ce genre.',
|
albumsEmpty: 'Aucun album trouvé pour ce genre.',
|
||||||
loadMore: 'Charger plus',
|
loadMore: 'Charger plus',
|
||||||
back: 'Retour',
|
back: 'Retour',
|
||||||
|
shuffle: 'Aléatoire',
|
||||||
|
addToQueue: 'Ajouter à la file',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'Ingen album funnet for denne sjangeren.',
|
albumsEmpty: 'Ingen album funnet for denne sjangeren.',
|
||||||
loadMore: 'Last mer',
|
loadMore: 'Last mer',
|
||||||
back: 'Tilbake',
|
back: 'Tilbake',
|
||||||
|
shuffle: 'Bland',
|
||||||
|
addToQueue: 'Legg til i kø',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'Geen albums gevonden voor dit genre.',
|
albumsEmpty: 'Geen albums gevonden voor dit genre.',
|
||||||
loadMore: 'Meer laden',
|
loadMore: 'Meer laden',
|
||||||
back: 'Terug',
|
back: 'Terug',
|
||||||
|
shuffle: 'Willekeurig',
|
||||||
|
addToQueue: 'Aan wachtrij toevoegen',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'Niciun album găsit pentru acest gen.',
|
albumsEmpty: 'Niciun album găsit pentru acest gen.',
|
||||||
loadMore: 'Încarcă mai mult',
|
loadMore: 'Încarcă mai mult',
|
||||||
back: 'Înapoi',
|
back: 'Înapoi',
|
||||||
|
shuffle: 'Amestecă',
|
||||||
|
addToQueue: 'Adaugă la coadă',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ export const genres = {
|
|||||||
albumsEmpty: 'В этом жанре альбомов нет.',
|
albumsEmpty: 'В этом жанре альбомов нет.',
|
||||||
loadMore: 'Ещё',
|
loadMore: 'Ещё',
|
||||||
back: 'Назад',
|
back: 'Назад',
|
||||||
|
shuffle: 'Перемешать',
|
||||||
|
addToQueue: 'В очередь',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ export const genres = {
|
|||||||
albumsEmpty: '未找到该流派的专辑。',
|
albumsEmpty: '未找到该流派的专辑。',
|
||||||
loadMore: '加载更多',
|
loadMore: '加载更多',
|
||||||
back: '返回',
|
back: '返回',
|
||||||
|
shuffle: '随机播放',
|
||||||
|
addToQueue: '添加到队列',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { getAlbumsByGenre } from '../api/subsonicGenres';
|
import { getAlbumsByGenre, fetchAllSongsByGenre } from '../api/subsonicGenres';
|
||||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useAuthStore } from '../store/authStore';
|
||||||
|
import { usePlayerStore } from '../store/playerStore';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
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 { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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() {
|
export default function GenreDetail() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
@@ -23,7 +31,27 @@ export default function GenreDetail() {
|
|||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [bulkLoading, setBulkLoading] = useState(false);
|
||||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
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(() => {
|
useEffect(() => {
|
||||||
setAlbums([]);
|
setAlbums([]);
|
||||||
@@ -70,6 +98,29 @@ export default function GenreDetail() {
|
|||||||
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
|
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
|
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
|||||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||||
import type { Track } from '../../store/playerStoreTypes';
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
|
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||||
|
|
||||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||||
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
|
||||||
@@ -20,28 +21,13 @@ export interface RunArtistDetailPlayDeps {
|
|||||||
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||||
if (albums.length === 0) return;
|
if (albums.length === 0) return;
|
||||||
setPlayAllLoading(true);
|
await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||||
try {
|
|
||||||
const tracks = await fetchAllTracks(albums);
|
|
||||||
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
|
||||||
} finally {
|
|
||||||
setPlayAllLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||||
if (albums.length === 0) return;
|
if (albums.length === 0) return;
|
||||||
setPlayAllLoading(true);
|
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||||
try {
|
|
||||||
const tracks = await fetchAllTracks(albums);
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
|
|
||||||
playTrack(shuffled[0], shuffled);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setPlayAllLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunArtistDetailStartRadioDeps {
|
export interface RunArtistDetailStartRadioDeps {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from './runBulkPlay';
|
||||||
|
|
||||||
|
function track(id: string): Track {
|
||||||
|
return { id, title: id } as Track;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runBulkPlayAll', () => {
|
||||||
|
it('plays the first track with the full list as queue and toggles loading', async () => {
|
||||||
|
const tracks = [track('a'), track('b'), track('c')];
|
||||||
|
const setLoading = vi.fn();
|
||||||
|
const playTrack = vi.fn();
|
||||||
|
await runBulkPlayAll({ fetchTracks: async () => tracks, setLoading, playTrack });
|
||||||
|
expect(playTrack).toHaveBeenCalledWith(tracks[0], tracks);
|
||||||
|
expect(setLoading.mock.calls).toEqual([[true], [false]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start playback for an empty genre but still clears loading', async () => {
|
||||||
|
const setLoading = vi.fn();
|
||||||
|
const playTrack = vi.fn();
|
||||||
|
await runBulkPlayAll({ fetchTracks: async () => [], setLoading, playTrack });
|
||||||
|
expect(playTrack).not.toHaveBeenCalled();
|
||||||
|
expect(setLoading).toHaveBeenLastCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears loading even when fetching throws', async () => {
|
||||||
|
const setLoading = vi.fn();
|
||||||
|
await expect(
|
||||||
|
runBulkPlayAll({ fetchTracks: async () => { throw new Error('boom'); }, setLoading, playTrack: vi.fn() }),
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
expect(setLoading).toHaveBeenLastCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runBulkShuffle', () => {
|
||||||
|
it('plays a permutation of the same tracks, head consistent with the queue', async () => {
|
||||||
|
const tracks = Array.from({ length: 25 }, (_, i) => track(String(i)));
|
||||||
|
const playTrack = vi.fn();
|
||||||
|
await runBulkShuffle({ fetchTracks: async () => tracks, setLoading: vi.fn(), playTrack });
|
||||||
|
const [head, queue] = playTrack.mock.calls[0];
|
||||||
|
expect(queue).toHaveLength(tracks.length);
|
||||||
|
expect(queue[0]).toBe(head);
|
||||||
|
expect([...queue].map(t => t.id).sort()).toEqual([...tracks].map(t => t.id).sort());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runBulkEnqueue', () => {
|
||||||
|
it('enqueues all fetched tracks', async () => {
|
||||||
|
const tracks = [track('a'), track('b')];
|
||||||
|
const enqueue = vi.fn();
|
||||||
|
await runBulkEnqueue({ fetchTracks: async () => tracks, setLoading: vi.fn(), enqueue });
|
||||||
|
expect(enqueue).toHaveBeenCalledWith(tracks);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips enqueue when there are no tracks', async () => {
|
||||||
|
const enqueue = vi.fn();
|
||||||
|
await runBulkEnqueue({ fetchTracks: async () => [], setLoading: vi.fn(), enqueue });
|
||||||
|
expect(enqueue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Track } from '../../store/playerStoreTypes';
|
||||||
|
import { shuffleArray } from './shuffleArray';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared "play / shuffle / enqueue a fetched track list" core for detail pages whose
|
||||||
|
* tracks are loaded asynchronously (Artist, Genre, …). The caller supplies `fetchTracks`;
|
||||||
|
* everything else — loading flag, empty guard, shuffle — is uniform here so each page does
|
||||||
|
* not grow its own divergent copy.
|
||||||
|
*/
|
||||||
|
export interface RunBulkPlayDeps {
|
||||||
|
fetchTracks: () => Promise<Track[]>;
|
||||||
|
setLoading: (v: boolean) => void;
|
||||||
|
playTrack: (track: Track, queue: Track[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBulkPlayAll(deps: RunBulkPlayDeps): Promise<void> {
|
||||||
|
const { fetchTracks, setLoading, playTrack } = deps;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tracks = await fetchTracks();
|
||||||
|
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBulkShuffle(deps: RunBulkPlayDeps): Promise<void> {
|
||||||
|
const { fetchTracks, setLoading, playTrack } = deps;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const shuffled = shuffleArray(await fetchTracks());
|
||||||
|
if (shuffled.length > 0) playTrack(shuffled[0], shuffled);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunBulkEnqueueDeps {
|
||||||
|
fetchTracks: () => Promise<Track[]>;
|
||||||
|
setLoading: (v: boolean) => void;
|
||||||
|
enqueue: (tracks: Track[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBulkEnqueue(deps: RunBulkEnqueueDeps): Promise<void> {
|
||||||
|
const { fetchTracks, setLoading, enqueue } = deps;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tracks = await fetchTracks();
|
||||||
|
if (tracks.length > 0) enqueue(tracks);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user