mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 06:45:51 +00:00
feat(artist): add "add to queue" for the whole discography (#1321)
* feat(artist): enqueue whole discography from artist page * i18n(artist): add enqueue-all button strings * docs(changelog): artist discography enqueue (#1321)
This commit is contained in:
@@ -139,6 +139,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
|
||||
|
||||
### Artist page — add the whole discography to the queue
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
|
||||
|
||||
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -430,6 +430,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
|
||||
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
|
||||
'Word-by-word lyrics from the server via OpenSubsonic songLyrics v2, no third-party backend needed (PR #1265)',
|
||||
'Artist page — add the whole discography to the queue (PR #1321)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAlbumDetailBack } from '@/features/album';
|
||||
import {
|
||||
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
|
||||
Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
ListPlus, Loader2, Play, Radio, Share2, Shuffle, Users,
|
||||
} from 'lucide-react';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
|
||||
import { useOfflineStore } from '@/features/offline';
|
||||
@@ -35,6 +35,7 @@ interface Props {
|
||||
toggleStar: () => Promise<void>;
|
||||
handlePlayAll: () => void;
|
||||
handleShuffle: () => void;
|
||||
handleEnqueueAll: () => void;
|
||||
handleStartRadio: () => void;
|
||||
handleShareArtist: () => void;
|
||||
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
|
||||
@@ -100,7 +101,7 @@ function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
|
||||
|
||||
export default function ArtistDetailHero({
|
||||
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
|
||||
toggleStar, handlePlayAll, handleShuffle, handleEnqueueAll, handleStartRadio, handleShareArtist,
|
||||
handleImageUpload, playAllLoading, radioLoading, uploading,
|
||||
openedLink, openLink,
|
||||
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
|
||||
@@ -302,6 +303,15 @@ export default function ArtistDetailHero({
|
||||
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
|
||||
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={handleEnqueueAll}
|
||||
disabled={playAllLoading}
|
||||
aria-label={t('artistDetail.enqueue')}
|
||||
{...tooltipAttrs(t('artistDetail.enqueueTooltip'))}
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
import { useArtistDetailData } from '@/features/artist/hooks/useArtistDetailData';
|
||||
import { useArtistSimilarArtists } from '@/features/artist/hooks/useArtistSimilarArtists';
|
||||
import {
|
||||
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle,
|
||||
runArtistDetailStartRadio, runArtistDetailEnqueueAll,
|
||||
} from '@/features/artist/utils/runArtistDetailPlay';
|
||||
import { useOfflineBrowseContext } from '@/features/offline';
|
||||
import { offlineActionPolicy } from '@/features/offline';
|
||||
@@ -113,6 +114,9 @@ export default function ArtistDetail() {
|
||||
const handleShuffle = () => runArtistDetailShuffle({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
|
||||
});
|
||||
const handleEnqueueAll = () => runArtistDetailEnqueueAll({
|
||||
albums, serverId: activeServerId, setPlayAllLoading, enqueue,
|
||||
});
|
||||
const handleStartRadio = () => {
|
||||
if (!artist) return;
|
||||
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
|
||||
@@ -270,6 +274,7 @@ export default function ArtistDetail() {
|
||||
toggleStar={toggleStar}
|
||||
handlePlayAll={handlePlayAll}
|
||||
handleShuffle={handleShuffle}
|
||||
handleEnqueueAll={handleEnqueueAll}
|
||||
handleStartRadio={handleStartRadio}
|
||||
handleShareArtist={handleShareArtist}
|
||||
handleImageUpload={handleImageUpload}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import * as offlineMediaResolve from '@/features/offline';
|
||||
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from '@/features/artist/utils/runArtistDetailPlay';
|
||||
import {
|
||||
fetchArtistDetailTracks, runArtistDetailPlayTopSong, runArtistDetailEnqueueAll,
|
||||
} from '@/features/artist/utils/runArtistDetailPlay';
|
||||
|
||||
vi.mock('@/features/offline', () => ({
|
||||
resolveAlbum: vi.fn(),
|
||||
@@ -99,3 +101,39 @@ describe('runArtistDetailPlayTopSong', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runArtistDetailEnqueueAll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('enqueues the whole ordered discography', async () => {
|
||||
resolveAlbumMock
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[1],
|
||||
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
album: albums[0],
|
||||
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
|
||||
});
|
||||
const enqueue = vi.fn();
|
||||
const setPlayAllLoading = vi.fn();
|
||||
|
||||
await runArtistDetailEnqueueAll({ albums, serverId: 'srv-1', setPlayAllLoading, enqueue });
|
||||
|
||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||
expect(enqueue.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(['t1', 't2']);
|
||||
expect(setPlayAllLoading).toHaveBeenCalledWith(true);
|
||||
expect(setPlayAllLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('is a no-op with no albums', async () => {
|
||||
const enqueue = vi.fn();
|
||||
|
||||
await runArtistDetailEnqueueAll({ albums: [], serverId: 'srv-1', setPlayAllLoading: vi.fn(), enqueue });
|
||||
|
||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
|
||||
|
||||
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
|
||||
@@ -82,6 +82,24 @@ export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Pro
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailEnqueueDeps {
|
||||
albums: SubsonicAlbum[];
|
||||
serverId?: string | null;
|
||||
setPlayAllLoading: (v: boolean) => void;
|
||||
enqueue: (tracks: Track[]) => void;
|
||||
}
|
||||
|
||||
/** Append the whole artist discography to the play queue. */
|
||||
export async function runArtistDetailEnqueueAll(deps: RunArtistDetailEnqueueDeps): Promise<void> {
|
||||
const { albums, serverId, setPlayAllLoading, enqueue } = deps;
|
||||
if (albums.length === 0) return;
|
||||
await runBulkEnqueue({
|
||||
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
|
||||
setLoading: setPlayAllLoading,
|
||||
enqueue,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RunArtistDetailStartRadioDeps {
|
||||
artist: SubsonicArtist;
|
||||
t: TFunction;
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Пусни цялата дискография',
|
||||
shuffleTooltip: 'Разбъркай цялата дискография',
|
||||
radioTooltip: 'Стартирай радио на базата на този изпълнител',
|
||||
enqueue: 'Добави в опашката',
|
||||
enqueueTooltip: 'Добави цялата дискография в опашката',
|
||||
loading: 'Зареждане…',
|
||||
noRadio: 'Няма намерени подобни песни за този изпълнител.',
|
||||
notFound: 'Изпълнителят не е намерен.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Gesamte Diskografie abspielen',
|
||||
shuffleTooltip: 'Gesamte Diskografie zufällig abspielen',
|
||||
radioTooltip: 'Radio basierend auf diesem Künstler starten',
|
||||
enqueue: 'Einreihen',
|
||||
enqueueTooltip: 'Gesamte Diskografie zur Warteschlange hinzufügen',
|
||||
loading: 'Lädt…',
|
||||
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
|
||||
notFound: 'Künstler nicht gefunden.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Play the whole discography',
|
||||
shuffleTooltip: 'Shuffle the whole discography',
|
||||
radioTooltip: 'Start a radio based on this artist',
|
||||
enqueue: 'Enqueue',
|
||||
enqueueTooltip: 'Add the whole discography to queue',
|
||||
loading: 'Loading…',
|
||||
noRadio: 'No similar tracks found for this artist.',
|
||||
notFound: 'Artist not found.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Reproducir toda la discografía',
|
||||
shuffleTooltip: 'Reproducir toda la discografía en aleatorio',
|
||||
radioTooltip: 'Iniciar una radio basada en este artista',
|
||||
enqueue: 'Agregar a la Cola',
|
||||
enqueueTooltip: 'Agregar toda la discografía a la cola',
|
||||
loading: 'Cargando…',
|
||||
noRadio: 'No se encontraron pistas similares para este artista.',
|
||||
notFound: 'Artista no encontrado.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Lire toute la discographie',
|
||||
shuffleTooltip: 'Lire toute la discographie en aléatoire',
|
||||
radioTooltip: 'Démarrer une radio basée sur cet artiste',
|
||||
enqueue: 'Mettre en file',
|
||||
enqueueTooltip: 'Ajouter toute la discographie à la file d\'attente',
|
||||
loading: 'Chargement…',
|
||||
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
|
||||
notFound: 'Artiste introuvable.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'A teljes diszkográfia lejátszása',
|
||||
shuffleTooltip: 'A teljes diszkográfia keverése',
|
||||
radioTooltip: 'Rádió indítása az előadó alapján',
|
||||
enqueue: 'Sorba',
|
||||
enqueueTooltip: 'A teljes diszkográfia hozzáadása a sorhoz',
|
||||
loading: 'Betöltés…',
|
||||
noRadio: 'Nem található hasonló szám ehhez az előadóhoz.',
|
||||
notFound: 'Az előadó nem található.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Ascolta tutta la discografia',
|
||||
shuffleTooltip: 'Mescola tutta la discografia',
|
||||
radioTooltip: 'Crea una radio basata su questo artista',
|
||||
enqueue: 'Accoda',
|
||||
enqueueTooltip: 'Aggiungi tutta la discografia alla coda',
|
||||
loading: 'Caricamento…',
|
||||
noRadio: 'Non sono stati trovati brani simili per questo artista.',
|
||||
notFound: 'Artista non trovato.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'ディスコグラフィ全体を再生',
|
||||
shuffleTooltip: 'ディスコグラフィ全体をシャッフル',
|
||||
radioTooltip: 'このアーティストをもとにラジオを開始',
|
||||
enqueue: 'キューに追加',
|
||||
enqueueTooltip: 'ディスコグラフィ全体をキューに追加',
|
||||
loading: '読み込み中…',
|
||||
noRadio: 'このアーティストの類似トラックは見つかりませんでした。',
|
||||
notFound: 'アーティストが見つかりません。',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Spill hele diskografien',
|
||||
shuffleTooltip: 'Spill hele diskografien i tilfeldig rekkefølge',
|
||||
radioTooltip: 'Start en radio basert på denne artisten',
|
||||
enqueue: 'Legg i kø',
|
||||
enqueueTooltip: 'Legg hele diskografien i kø',
|
||||
loading: 'Laster…',
|
||||
noRadio: 'Ingen lignende spor funnet for denne artisten.',
|
||||
notFound: 'Artisten ble ikke funnet.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Volledige discografie afspelen',
|
||||
shuffleTooltip: 'Volledige discografie willekeurig afspelen',
|
||||
radioTooltip: 'Radio op basis van deze artiest starten',
|
||||
enqueue: 'In wachtrij',
|
||||
enqueueTooltip: 'Volledige discografie aan wachtrij toevoegen',
|
||||
loading: 'Laden…',
|
||||
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
|
||||
notFound: 'Artiest niet gevonden.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Odtwórz całą dyskografię',
|
||||
shuffleTooltip: 'Przetasuj całą dyskografię',
|
||||
radioTooltip: 'Rozpocznij radio w oparciu o tego wykonawcę',
|
||||
enqueue: 'Dodaj do kolejki',
|
||||
enqueueTooltip: 'Dodaj całą dyskografię do kolejki',
|
||||
loading: 'Ładowanie…',
|
||||
noRadio: 'Nie znaleziono podobnych utworów dla tego wykonawcy.',
|
||||
notFound: 'Wykonawca nie znaleziony.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Redă întreaga discografie',
|
||||
shuffleTooltip: 'Redă întreaga discografie amestecat',
|
||||
radioTooltip: 'Pornește un radio bazat pe acest artist',
|
||||
enqueue: 'Adaugă în coadă',
|
||||
enqueueTooltip: 'Adaugă întreaga discografie în coadă',
|
||||
loading: 'Se încarcă…',
|
||||
noRadio: 'Nicio piesă similară găsită pentru acest artist.',
|
||||
notFound: 'Artistul nu a fost găsit.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Воспроизвести всю дискографию',
|
||||
shuffleTooltip: 'Воспроизвести всю дискографию вперемешку',
|
||||
radioTooltip: 'Запустить радио на основе этого исполнителя',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить всю дискографию в очередь',
|
||||
loading: 'Загрузка…',
|
||||
noRadio: 'Похожие треки для этого исполнителя не найдены.',
|
||||
notFound: 'Исполнитель не найден.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: '播放全部作品',
|
||||
shuffleTooltip: '随机播放全部作品',
|
||||
radioTooltip: '基于该艺人开始电台',
|
||||
enqueue: '加入队列',
|
||||
enqueueTooltip: '将全部作品添加到播放队列',
|
||||
loading: '加载中…',
|
||||
noRadio: '未找到该艺术家的相似曲目。',
|
||||
notFound: '未找到艺术家。',
|
||||
|
||||
Reference in New Issue
Block a user