From 2318f9e07aa0a6ca2a9f4709f7afc44101ff513a Mon Sep 17 00:00:00 2001 From: Frank Stellmacher Date: Tue, 21 Apr 2026 22:39:46 +0200 Subject: [PATCH] feat(albums): add 'Enqueue' option to album cover, context menu and multi-select toolbar (closes #253) (#256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per bcorporaal's request in #253: making a queue from multiple albums required either replacing the queue (Play) or going into each album detail page first. Three new entry points cover the common workflows: 1. Hover button on the album cover next to Play. Both buttons share the same accent style and size so the secondary action is just as readable as the primary — first attempt with a darker pill was hard to see and unbalanced the hover. 2. Context-menu entry "Enqueue Album" between "Open Album" and "Go to Artist". The i18n key already existed (was used by the album-song sub-context); just wiring up the action. 3. Multi-select toolbar in Albums.tsx: new "Enqueue (N)" button placed before "Add Offline" so power users selecting several albums no longer have to right-click. Plus a context-menu entry "Enqueue N Albums" for the same flow when a multi-album right-click happens anywhere else. All multi-album fetches use Promise.all(getAlbum(...)) — Navidrome handles parallel requests instantly (per memory note from PR #246). i18n: 4 new keys per locale (3 with pluralisation: contextMenu.enqueueAlbums, albums.enqueueSelected, albums.enqueueQueued). Co-authored-by: Psychotoxical Co-authored-by: Claude Opus 4.7 (1M context) --- src/components/AlbumCard.tsx | 28 +++++++++++++++++++++++++--- src/components/ContextMenu.tsx | 14 ++++++++++++++ src/locales/de.ts | 6 ++++++ src/locales/en.ts | 6 ++++++ src/locales/es.ts | 6 ++++++ src/locales/fr.ts | 6 ++++++ src/locales/nb.ts | 6 ++++++ src/locales/nl.ts | 6 ++++++ src/locales/ru.ts | 12 ++++++++++++ src/locales/zh.ts | 6 ++++++ src/pages/Albums.tsx | 23 ++++++++++++++++++++++- src/styles/components.css | 2 ++ 12 files changed, 117 insertions(+), 4 deletions(-) diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 60617d5a..aee9a1f7 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -1,8 +1,9 @@ import React, { memo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Play, HardDriveDownload, Check } from 'lucide-react'; -import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; -import { usePlayerStore } from '../store/playerStore'; +import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic'; +import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; import CachedImage from './CachedImage'; @@ -19,8 +20,10 @@ interface AlbumCardProps { } function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) { + const { t } = useTranslation(); const navigate = useNavigate(); const openContextMenu = usePlayerStore(s => s.openContextMenu); + const enqueue = usePlayerStore(s => s.enqueue); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isOffline = useOfflineStore(s => { const meta = s.albums[`${serverId}:${album.id}`]; @@ -94,9 +97,28 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating className="album-card-details-btn" onClick={e => { e.stopPropagation(); playAlbum(album.id); }} aria-label={`${album.name} abspielen`} + data-tooltip={t('hero.playAlbum')} + data-tooltip-pos="top" > + )} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 25af6088..149f69ed 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -1649,6 +1649,12 @@ export default function ContextMenu() {
handleAction(() => navigate(`/album/${album.id}`))}> {t('contextMenu.openAlbum')}
+
handleAction(async () => { + const albumData = await getAlbum(album.id); + enqueue(albumData.songs.map(songToTrack)); + })}> + {t('contextMenu.enqueueAlbum')} +
handleAction(() => navigate(`/artist/${album.artistId}`))}> {t('contextMenu.goToArtist')} @@ -1751,6 +1757,14 @@ export default function ContextMenu() { {t('contextMenu.selectedAlbums', { count: albums.length })}
+
handleAction(async () => { + // Parallel — Navidrome handles concurrent getAlbum requests fine. + const results = await Promise.all(albums.map(a => getAlbum(a.id))); + const allTracks = results.flatMap(r => r.songs.map(songToTrack)); + enqueue(allTracks); + })}> + {t('contextMenu.enqueueAlbums', { count: albums.length })} +
selectedIds.has(a.id)); const openContextMenu = usePlayerStore(state => state.openContextMenu); + const enqueue = usePlayerStore(state => state.enqueue); + + const handleEnqueueSelected = async () => { + if (selectedAlbums.length === 0) return; + try { + // Parallel — Navidrome handles concurrent getAlbum requests fine. + const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null))); + const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []); + if (tracks.length > 0) { + enqueue(tracks); + showToast(t('albums.enqueueQueued', { count: selectedAlbums.length }), 2500, 'info'); + } + } finally { + clearSelection(); + } + }; const cycleCompFilter = () => { setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all'); @@ -210,6 +227,10 @@ export default function Albums() {
{selectionMode && selectedIds.size > 0 ? ( <> +