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
@@ -4,6 +4,7 @@ import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
||||
|
||||
async function fetchAllTracks(albums: SubsonicAlbum[]): Promise<Track[]> {
|
||||
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> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
try {
|
||||
const tracks = await fetchAllTracks(albums);
|
||||
if (tracks.length > 0) playTrack(tracks[0], tracks);
|
||||
} finally {
|
||||
setPlayAllLoading(false);
|
||||
}
|
||||
await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
}
|
||||
|
||||
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||
const { albums, setPlayAllLoading, playTrack } = deps;
|
||||
if (albums.length === 0) return;
|
||||
setPlayAllLoading(true);
|
||||
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);
|
||||
}
|
||||
await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack });
|
||||
}
|
||||
|
||||
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