feat(albums): add 'Enqueue' option to album cover, context menu and multi-select toolbar (closes #253) (#256)

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 <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frank Stellmacher
2026-04-21 22:39:46 +02:00
committed by GitHub
parent f7a721b7b1
commit 2318f9e07a
12 changed files with 117 additions and 4 deletions
+25 -3
View File
@@ -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"
>
<Play size={15} fill="currentColor" />
</button>
<button
className="album-card-details-btn"
onClick={async e => {
e.stopPropagation();
try {
const data = await getAlbum(album.id);
enqueue(data.songs.map(songToTrack));
} catch {
// Network failure — silent (toast would be too noisy for a hover action)
}
}}
aria-label={t('contextMenu.enqueueAlbum')}
data-tooltip={t('contextMenu.enqueueAlbum')}
data-tooltip-pos="top"
>
<ListPlus size={15} />
</button>
</div>
)}
</div>
+14
View File
@@ -1649,6 +1649,12 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
const albumData = await getAlbum(album.id);
enqueue(albumData.songs.map(songToTrack));
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbum')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
@@ -1751,6 +1757,14 @@ export default function ContextMenu() {
{t('contextMenu.selectedAlbums', { count: albums.length })}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => 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);
})}>
<ListPlus size={14} /> {t('contextMenu.enqueueAlbums', { count: albums.length })}
</div>
<div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}