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 React, { memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, HardDriveDownload, Check } from 'lucide-react'; import { Play, ListPlus, HardDriveDownload, Check } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore'; import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey, getAlbum } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import CachedImage from './CachedImage'; import CachedImage from './CachedImage';
@@ -19,8 +20,10 @@ interface AlbumCardProps {
} }
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) { function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const isOffline = useOfflineStore(s => { const isOffline = useOfflineStore(s => {
const meta = s.albums[`${serverId}:${album.id}`]; const meta = s.albums[`${serverId}:${album.id}`];
@@ -94,9 +97,28 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
className="album-card-details-btn" className="album-card-details-btn"
onClick={e => { e.stopPropagation(); playAlbum(album.id); }} onClick={e => { e.stopPropagation(); playAlbum(album.id); }}
aria-label={`${album.name} abspielen`} aria-label={`${album.name} abspielen`}
data-tooltip={t('hero.playAlbum')}
data-tooltip-pos="top"
> >
<Play size={15} fill="currentColor" /> <Play size={15} fill="currentColor" />
</button> </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>
)} )}
</div> </div>
+14
View File
@@ -1649,6 +1649,12 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}> <div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${album.id}`))}>
<Play size={14} /> {t('contextMenu.openAlbum')} <Play size={14} /> {t('contextMenu.openAlbum')}
</div> </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-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}> <div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')} <User size={14} /> {t('contextMenu.goToArtist')}
@@ -1751,6 +1757,14 @@ export default function ContextMenu() {
{t('contextMenu.selectedAlbums', { count: albums.length })} {t('contextMenu.selectedAlbums', { count: albums.length })}
</div> </div>
<div className="context-menu-divider" /> <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 <div
className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`} className={`context-menu-item context-menu-item--submenu ${playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` ? 'active' : ''}`}
data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`} data-playlist-trigger-id={`multi-album:${albumIds.join(',')}`}
+6
View File
@@ -99,6 +99,8 @@ export const deTranslation = {
playNext: 'Als Nächstes abspielen', playNext: 'Als Nächstes abspielen',
addToQueue: 'Zur Warteschlange hinzufügen', addToQueue: 'Zur Warteschlange hinzufügen',
enqueueAlbum: 'Ganzes Album einreihen', enqueueAlbum: 'Ganzes Album einreihen',
enqueueAlbums_one: '{{count}} Album einreihen',
enqueueAlbums_other: '{{count}} Alben einreihen',
startRadio: 'Radio starten', startRadio: 'Radio starten',
instantMix: 'Instant Mix', instantMix: 'Instant Mix',
instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.', instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.',
@@ -303,6 +305,10 @@ export const deTranslation = {
selectionCount: '{{count}} ausgewählt', selectionCount: '{{count}} ausgewählt',
downloadZips: 'ZIPs herunterladen', downloadZips: 'ZIPs herunterladen',
addOffline: 'Offline hinzufügen', addOffline: 'Offline hinzufügen',
enqueueSelected_one: 'Einreihen ({{count}})',
enqueueSelected_other: 'Einreihen ({{count}})',
enqueueQueued_one: '{{count}} Album zur Warteschlange hinzugefügt',
enqueueQueued_other: '{{count}} Alben zur Warteschlange hinzugefügt',
downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}', downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}',
downloadZipDone: '{{count}} ZIP(s) heruntergeladen', downloadZipDone: '{{count}} ZIP(s) heruntergeladen',
downloadZipFailed: 'Download fehlgeschlagen: {{name}}', downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
+6
View File
@@ -100,6 +100,8 @@ export const enTranslation = {
playNext: 'Play Next', playNext: 'Play Next',
addToQueue: 'Add to Queue', addToQueue: 'Add to Queue',
enqueueAlbum: 'Enqueue Album', enqueueAlbum: 'Enqueue Album',
enqueueAlbums_one: 'Enqueue {{count}} Album',
enqueueAlbums_other: 'Enqueue {{count}} Albums',
startRadio: 'Start Radio', startRadio: 'Start Radio',
instantMix: 'Instant Mix', instantMix: 'Instant Mix',
instantMixFailed: 'Could not build Instant Mix — server or plugin error.', instantMixFailed: 'Could not build Instant Mix — server or plugin error.',
@@ -304,6 +306,10 @@ export const enTranslation = {
selectionCount: '{{count}} selected', selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs', downloadZips: 'Download ZIPs',
addOffline: 'Add Offline', addOffline: 'Add Offline',
enqueueSelected_one: 'Enqueue ({{count}})',
enqueueSelected_other: 'Enqueue ({{count}})',
enqueueQueued_one: 'Added {{count}} album to queue',
enqueueQueued_other: 'Added {{count}} albums to queue',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}', downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded', downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}', downloadZipFailed: 'Failed to download {{name}}',
+6
View File
@@ -100,6 +100,8 @@ export const esTranslation = {
playNext: 'Reproducir Siguiente', playNext: 'Reproducir Siguiente',
addToQueue: 'Agregar a la Cola', addToQueue: 'Agregar a la Cola',
enqueueAlbum: 'Agregar Álbum a la Cola', enqueueAlbum: 'Agregar Álbum a la Cola',
enqueueAlbums_one: 'Agregar {{count}} álbum a la cola',
enqueueAlbums_other: 'Agregar {{count}} álbumes a la cola',
startRadio: 'Iniciar Radio', startRadio: 'Iniciar Radio',
instantMix: 'Mezcla Instantánea', instantMix: 'Mezcla Instantánea',
instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.', instantMixFailed: 'No se pudo crear la Mezcla Instantánea — error del servidor o plugin.',
@@ -304,6 +306,10 @@ export const esTranslation = {
selectionCount: '{{count}} seleccionados', selectionCount: '{{count}} seleccionados',
downloadZips: 'Descargar ZIPs', downloadZips: 'Descargar ZIPs',
addOffline: 'Agregar Offline', addOffline: 'Agregar Offline',
enqueueSelected_one: 'A la cola ({{count}})',
enqueueSelected_other: 'A la cola ({{count}})',
enqueueQueued_one: '{{count}} álbum añadido a la cola',
enqueueQueued_other: '{{count}} álbumes añadidos a la cola',
downloadingZip: 'Descargando {{current}}/{{total}}: {{name}}', downloadingZip: 'Descargando {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) descargado(s)', downloadZipDone: '{{count}} ZIP(s) descargado(s)',
downloadZipFailed: 'Error al descargar {{name}}', downloadZipFailed: 'Error al descargar {{name}}',
+6
View File
@@ -99,6 +99,8 @@ export const frTranslation = {
playNext: 'Lire ensuite', playNext: 'Lire ensuite',
addToQueue: 'Ajouter à la file', addToQueue: 'Ajouter à la file',
enqueueAlbum: 'Mettre l\'album en file', enqueueAlbum: 'Mettre l\'album en file',
enqueueAlbums_one: 'Mettre {{count}} album en file',
enqueueAlbums_other: 'Mettre {{count}} albums en file',
startRadio: 'Démarrer la radio', startRadio: 'Démarrer la radio',
instantMix: 'Mix instantané', instantMix: 'Mix instantané',
instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.', instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.',
@@ -303,6 +305,10 @@ export const frTranslation = {
selectionCount: '{{count}} sélectionnés', selectionCount: '{{count}} sélectionnés',
downloadZips: 'Télécharger les ZIPs', downloadZips: 'Télécharger les ZIPs',
addOffline: 'Ajouter hors ligne', addOffline: 'Ajouter hors ligne',
enqueueSelected_one: 'Mettre en file ({{count}})',
enqueueSelected_other: 'Mettre en file ({{count}})',
enqueueQueued_one: '{{count}} album ajouté à la file',
enqueueQueued_other: '{{count}} albums ajoutés à la file',
downloadingZip: 'Téléchargement {{current}}/{{total}} : {{name}}', downloadingZip: 'Téléchargement {{current}}/{{total}} : {{name}}',
downloadZipDone: '{{count}} ZIP(s) téléchargé(s)', downloadZipDone: '{{count}} ZIP(s) téléchargé(s)',
downloadZipFailed: 'Échec du téléchargement de {{name}}', downloadZipFailed: 'Échec du téléchargement de {{name}}',
+6
View File
@@ -99,6 +99,8 @@ export const nbTranslation = {
playNext: 'Spill neste', playNext: 'Spill neste',
addToQueue: 'Legg til i kø', addToQueue: 'Legg til i kø',
enqueueAlbum: 'Legg albumet i kø', enqueueAlbum: 'Legg albumet i kø',
enqueueAlbums_one: 'Legg {{count}} album i kø',
enqueueAlbums_other: 'Legg {{count}} album i kø',
startRadio: 'Start radio', startRadio: 'Start radio',
instantMix: 'Instant Mix', instantMix: 'Instant Mix',
instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.', instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.',
@@ -303,6 +305,10 @@ export const nbTranslation = {
selectionCount: '{{count}} valgt', selectionCount: '{{count}} valgt',
downloadZips: 'Last ned ZIPs', downloadZips: 'Last ned ZIPs',
addOffline: 'Legg til offline', addOffline: 'Legg til offline',
enqueueSelected_one: 'Legg i kø ({{count}})',
enqueueSelected_other: 'Legg i kø ({{count}})',
enqueueQueued_one: '{{count}} album lagt til i køen',
enqueueQueued_other: '{{count}} album lagt til i køen',
downloadingZip: 'Laster ned {{current}}/{{total}}: {{name}}', downloadingZip: 'Laster ned {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) lastet ned', downloadZipDone: '{{count}} ZIP(s) lastet ned',
downloadZipFailed: 'Kunne ikke laste ned {{name}}', downloadZipFailed: 'Kunne ikke laste ned {{name}}',
+6
View File
@@ -99,6 +99,8 @@ export const nlTranslation = {
playNext: 'Volgende afspelen', playNext: 'Volgende afspelen',
addToQueue: 'Aan wachtrij toevoegen', addToQueue: 'Aan wachtrij toevoegen',
enqueueAlbum: 'Album in wachtrij', enqueueAlbum: 'Album in wachtrij',
enqueueAlbums_one: '{{count}} album in wachtrij',
enqueueAlbums_other: '{{count}} albums in wachtrij',
startRadio: 'Radio starten', startRadio: 'Radio starten',
instantMix: 'Instant Mix', instantMix: 'Instant Mix',
instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.', instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.',
@@ -302,6 +304,10 @@ export const nlTranslation = {
selectionCount: '{{count}} geselecteerd', selectionCount: '{{count}} geselecteerd',
downloadZips: 'ZIPs downloaden', downloadZips: 'ZIPs downloaden',
addOffline: 'Offline toevoegen', addOffline: 'Offline toevoegen',
enqueueSelected_one: 'In wachtrij ({{count}})',
enqueueSelected_other: 'In wachtrij ({{count}})',
enqueueQueued_one: '{{count}} album toegevoegd aan wachtrij',
enqueueQueued_other: '{{count}} albums toegevoegd aan wachtrij',
downloadingZip: 'Downloaden {{current}}/{{total}}: {{name}}', downloadingZip: 'Downloaden {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) gedownload', downloadZipDone: '{{count}} ZIP(s) gedownload',
downloadZipFailed: 'Downloaden van {{name}} mislukt', downloadZipFailed: 'Downloaden van {{name}} mislukt',
+12
View File
@@ -100,6 +100,10 @@ export const ruTranslation = {
playNext: 'Играть следующим', playNext: 'Играть следующим',
addToQueue: 'В конец очереди', addToQueue: 'В конец очереди',
enqueueAlbum: 'Альбом в очередь', enqueueAlbum: 'Альбом в очередь',
enqueueAlbums_one: '{{count}} альбом в очередь',
enqueueAlbums_few: '{{count}} альбома в очередь',
enqueueAlbums_many: '{{count}} альбомов в очередь',
enqueueAlbums_other: '{{count}} альбомов в очередь',
startRadio: 'Радио по похожим', startRadio: 'Радио по похожим',
instantMix: 'Instant Mix', instantMix: 'Instant Mix',
instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.', instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.',
@@ -314,6 +318,14 @@ export const ruTranslation = {
selectionCount: '{{count}} выбрано', selectionCount: '{{count}} выбрано',
downloadZips: 'Скачать ZIP-архивы', downloadZips: 'Скачать ZIP-архивы',
addOffline: 'Добавить офлайн', addOffline: 'Добавить офлайн',
enqueueSelected_one: 'В очередь ({{count}})',
enqueueSelected_few: 'В очередь ({{count}})',
enqueueSelected_many: 'В очередь ({{count}})',
enqueueSelected_other: 'В очередь ({{count}})',
enqueueQueued_one: '{{count}} альбом добавлен в очередь',
enqueueQueued_few: '{{count}} альбома добавлены в очередь',
enqueueQueued_many: '{{count}} альбомов добавлены в очередь',
enqueueQueued_other: '{{count}} альбомов добавлены в очередь',
downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}', downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP-архив(ов) загружено', downloadZipDone: '{{count}} ZIP-архив(ов) загружено',
downloadZipFailed: 'Не удалось скачать {{name}}', downloadZipFailed: 'Не удалось скачать {{name}}',
+6
View File
@@ -99,6 +99,8 @@ export const zhTranslation = {
playNext: '下一首播放', playNext: '下一首播放',
addToQueue: '添加到队列', addToQueue: '添加到队列',
enqueueAlbum: '专辑加入队列', enqueueAlbum: '专辑加入队列',
enqueueAlbums_one: '{{count}} 张专辑加入队列',
enqueueAlbums_other: '{{count}} 张专辑加入队列',
startRadio: '开始电台', startRadio: '开始电台',
instantMix: '即时混音', instantMix: '即时混音',
instantMixFailed: '无法生成即时混音 — 服务器或插件出错。', instantMixFailed: '无法生成即时混音 — 服务器或插件出错。',
@@ -302,6 +304,10 @@ export const zhTranslation = {
selectionCount: '{{count}} 已选择', selectionCount: '{{count}} 已选择',
downloadZips: '下载 ZIP', downloadZips: '下载 ZIP',
addOffline: '添加离线', addOffline: '添加离线',
enqueueSelected_one: '加入队列 ({{count}})',
enqueueSelected_other: '加入队列 ({{count}})',
enqueueQueued_one: '已将 {{count}} 张专辑加入队列',
enqueueQueued_other: '已将 {{count}} 张专辑加入队列',
downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}', downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}',
downloadZipDone: '已下载 {{count}} 个 ZIP', downloadZipDone: '已下载 {{count}} 个 ZIP',
downloadZipFailed: '下载 {{name}} 失败', downloadZipFailed: '下载 {{name}} 失败',
+22 -1
View File
@@ -4,6 +4,7 @@ import GenreFilterBar from '../components/GenreFilterBar';
import YearFilterButton from '../components/YearFilterButton'; import YearFilterButton from '../components/YearFilterButton';
import SortDropdown from '../components/SortDropdown'; import SortDropdown from '../components/SortDropdown';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { songToTrack } from '../store/playerStore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
@@ -13,7 +14,7 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path'; import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3 } from 'lucide-react'; import { CheckSquare2, Download, HardDriveDownload, ListMusic, Disc3, ListPlus } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
type CompFilter = 'all' | 'only' | 'hide'; type CompFilter = 'all' | 'only' | 'hide';
@@ -79,6 +80,22 @@ export default function Albums() {
const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id)); const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu); 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 = () => { const cycleCompFilter = () => {
setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all'); setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all');
@@ -210,6 +227,10 @@ export default function Albums() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? ( {selectionMode && selectedIds.size > 0 ? (
<> <>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleEnqueueSelected}>
<ListPlus size={15} />
{t('albums.enqueueSelected', { count: selectedIds.size })}
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}> <button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<HardDriveDownload size={15} /> <HardDriveDownload size={15} />
{t('albums.addOffline')} {t('albums.addOffline')}
+2
View File
@@ -459,6 +459,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px;
opacity: 0; opacity: 0;
transition: opacity var(--transition-base); transition: opacity var(--transition-base);
} }
@@ -488,6 +489,7 @@
background: var(--accent); background: var(--accent);
} }
/* ── Album card selection ── */ /* ── Album card selection ── */
.album-card--selectable { .album-card--selectable {
cursor: pointer; cursor: pointer;