feat(settings): add a Compact buttons appearance toggle (#1189)

* feat(ui): prototype compact hero and toolbar buttons (Large/Small appearance setting)

* feat(settings): rename action-button size toggle to "Compact buttons"

Promote the hero-button prototype to a real, app-wide setting.

- rename heroButtonSize → buttonSize, data-hero-buttons → data-button-size,
  hero-action-bar/hero-btn-label → compact-action-bar/compact-btn-label
- relabel "Hero buttons" → "Compact buttons" and broaden the description
  (action + toolbar buttons across detail pages and browse views) in all 11 locales
- move the feature CSS out of cover-lightbox.css into its own compact-buttons.css
- add tests: themeStore buttonSize toggle, SelectionToggleButton

* docs(changelog): compact buttons appearance toggle (#1189)

* docs(credits): compact buttons appearance toggle (#1189)
This commit is contained in:
Psychotoxical
2026-06-25 14:49:09 +02:00
committed by GitHub
parent 8b89596fcf
commit d49424e95b
40 changed files with 438 additions and 140 deletions
+6
View File
@@ -115,6 +115,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Album details now surface every genre a release spans instead of just the first one: the main genre shows inline with a **+N** chip that opens the full, clickable list, each genre linking to its genre page.
* Genres combine album and track tags (matching the genre browser) and read from the local library index when it is ready, so they also work offline.
### Compact buttons — switch action and toolbar buttons to icon-only
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1189](https://github.com/Psychotoxical/psysonic/pull/1189)**
* New **Compact buttons** setting under Settings → Appearance. Switch the action and toolbar buttons between large labelled buttons and small icon-only ones — across album, artist and playlist headers, the shared browse toolbars (sort, filters, multi-select), and the Most Played sort/filter controls. Defaults to large, so nothing changes unless you turn it on. On phones the album header keeps its large touch targets.
## Changed
+5
View File
@@ -17,6 +17,7 @@ export default function App() {
useThemeStore(s => s.theme);
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const buttonSize = useThemeStore(s => s.buttonSize);
const installedThemes = useInstalledThemesStore(s => s.themes);
// Document-attribute hooks are shared between both window kinds — each
@@ -81,6 +82,10 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
useEffect(() => {
document.documentElement.setAttribute('data-button-size', buttonSize);
}, [buttonSize]);
// Hide all inline track-preview buttons when the user opts out — single
// CSS hook (`html[data-track-previews="off"]`) instead of conditional
// rendering in every tracklist. Per-location toggles use additional
+10 -7
View File
@@ -475,7 +475,7 @@ export default function AlbumHeader({
</div>
</div>
) : (
<div className="album-detail-actions">
<div className="album-detail-actions compact-action-bar">
<div className="album-detail-actions-primary">
<button
className="btn btn-primary"
@@ -483,7 +483,7 @@ export default function AlbumHeader({
onClick={onPlayAll}
{...tooltipAttrs(t('albumDetail.playTooltip'))}
>
<Play size={15} /> {t('common.play', 'Reproducir')}
<Play size={15} /> <span className="compact-btn-label">{t('common.play', 'Reproducir')}</span>
</button>
{onShuffleAll && (
<button
@@ -528,7 +528,7 @@ export default function AlbumHeader({
onClick={onBio}
{...tooltipAttrs(t('albumDetail.artistBioTooltip'))}
>
<Highlighter size={16} /> {t('albumDetail.artistBio')}
<Highlighter size={16} /> <span className="compact-btn-label">{t('albumDetail.artistBio')}</span>
</button>
)}
@@ -548,7 +548,7 @@ export default function AlbumHeader({
onClick={onDownload}
{...tooltipAttrs(t('albumDetail.downloadTooltip'))}
>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}
<Download size={16} /> <span className="compact-btn-label">{t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''}</span>
</button>
)
)}
@@ -562,28 +562,31 @@ export default function AlbumHeader({
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--queued"
onClick={onCacheOffline}
aria-label={t('albumDetail.offlineQueued')}
data-tooltip={t('albumDetail.removeFromOfflineQueue')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
<span className="compact-btn-label">{t('albumDetail.offlineQueued')}</span>
</button>
) : offlineStatus === 'cached' ? (
<button
className="btn btn-surface offline-cache-btn offline-cache-btn--cached"
onClick={onRemoveOffline}
aria-label={t('albumDetail.offlineCached')}
data-tooltip={t('albumDetail.removeOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.offlineCached')}
<span className="compact-btn-label">{t('albumDetail.offlineCached')}</span>
</button>
) : (
<button
className="btn btn-surface offline-cache-btn"
onClick={onCacheOffline}
aria-label={t('albumDetail.cacheOffline')}
data-tooltip={t('albumDetail.cacheOffline')}
>
<HardDriveDownload size={16} />
{t('albumDetail.cacheOffline')}
<span className="compact-btn-label">{t('albumDetail.cacheOffline')}</span>
</button>
)
)}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SelectionToggleButton from './SelectionToggleButton';
describe('SelectionToggleButton', () => {
it('shows the select label and calls onToggle on click', async () => {
const onToggle = vi.fn();
const user = userEvent.setup();
render(
<SelectionToggleButton
active={false}
onToggle={onToggle}
selectLabel="Multi-select"
cancelLabel="Cancel selection"
/>,
);
const btn = screen.getByRole('button', { name: 'Multi-select' });
await user.click(btn);
expect(onToggle).toHaveBeenCalledOnce();
});
it('shows the cancel label and active styling when active', () => {
render(
<SelectionToggleButton
active
onToggle={() => {}}
selectLabel="Multi-select"
cancelLabel="Cancel selection"
/>,
);
const btn = screen.getByRole('button', { name: 'Cancel selection' });
expect(btn).toHaveClass('btn-sort-active');
});
it('keeps the label in a toolbar-btn-label span for compact-mode hiding', () => {
render(
<SelectionToggleButton
active={false}
onToggle={() => {}}
selectLabel="Multi-select"
cancelLabel="Cancel"
/>,
);
expect(document.querySelector('.toolbar-btn-label')?.textContent).toBe('Multi-select');
});
});
+44
View File
@@ -0,0 +1,44 @@
import { CheckSquare2 } from 'lucide-react';
interface Props {
/** Whether selection mode is currently active. */
active: boolean;
onToggle: () => void;
/** Label when not selecting (e.g. "Multi-select"). */
selectLabel: string;
/** Label while selecting (e.g. "Cancel selection"). */
cancelLabel: string;
/** Tooltip when inactive — defaults to `selectLabel`. */
startTooltip?: string;
iconSize?: number;
}
/**
* Shared multi-select toggle for browse-page toolbars (Albums, Artists,
* New Releases, Random Albums, Lossless Albums). The label sits in a
* `toolbar-btn-label` span so the existing mobile / compact-mode rule can
* collapse it to icon-only while keeping the icon + tooltip + aria-label.
*/
export default function SelectionToggleButton({
active,
onToggle,
selectLabel,
cancelLabel,
startTooltip,
iconSize = 15,
}: Props) {
const label = active ? cancelLabel : selectLabel;
return (
<button
className={`btn btn-surface${active ? ' btn-sort-active' : ''}`}
onClick={onToggle}
aria-label={label}
data-tooltip={active ? cancelLabel : (startTooltip ?? selectLabel)}
data-tooltip-pos="bottom"
style={active ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : undefined}
>
<CheckSquare2 size={iconSize} />
<span className="toolbar-btn-label">{label}</span>
</button>
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function WikipediaIcon({ size = 16 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.09 13.119c-.936 1.932-2.217 4.548-2.853 5.728-.616 1.074-1.127.931-1.532.029-1.406-3.321-4.293-9.144-5.651-12.409-.251-.601-.441-.987-.619-1.139-.181-.15-.554-.24-1.122-.271C.103 5.033 0 4.982 0 4.898v-.455l.052-.045c.924-.005 5.401 0 5.401 0l.051.045v.434c0 .119-.075.176-.225.176l-.564.031c-.485.029-.727.164-.727.436 0 .135.053.33.166.601 1.082 2.646 4.818 10.521 4.818 10.521l.136.046 2.411-4.81-.482-1.067-1.658-3.264s-.318-.654-.428-.872c-.728-1.443-.712-1.518-1.447-1.617-.207-.023-.313-.05-.313-.149v-.468l.06-.045h4.292l.113.037v.451c0 .105-.076.15-.227.15l-.308.047c-.792.061-.661.381-.136 1.422l1.582 3.252 1.758-3.504c.293-.64.233-.801.111-.947-.07-.084-.305-.22-.812-.24l-.201-.021c-.052 0-.098-.015-.145-.051-.045-.031-.067-.076-.067-.129v-.427l.061-.045c1.247-.008 4.043 0 4.043 0l.059.045v.436c0 .121-.059.178-.193.178-.646.03-.782.095-1.023.439-.12.186-.375.589-.646 1.039l-2.301 4.273-.065.135 2.792 5.712.17.048 4.396-10.438c.154-.422.129-.722-.064-.895-.197-.172-.346-.273-.857-.295l-.42-.016c-.061 0-.105-.014-.152-.045-.043-.029-.072-.075-.072-.119v-.436l.059-.045h4.961l.041.045v.437c0 .119-.074.18-.209.18-.648.03-1.127.18-1.443.421-.314.255-.557.616-.736 1.067 0 0-4.043 9.258-5.426 12.339-.525 1.007-1.053.917-1.503-.031-.571-1.171-1.773-3.786-2.646-5.71l.053-.036z" />
</svg>
);
}
@@ -2,7 +2,7 @@ import React, { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAlbumDetailBack } from '../../hooks/useAlbumDetailBack';
import {
ArrowLeft, Camera, Check, ExternalLink, HardDriveDownload, Heart,
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '../../api/subsonicTypes';
@@ -17,6 +17,7 @@ import { useCachedUrl } from '../CachedImage';
import { useCoverLightboxSrc } from '../../cover/lightbox';
import type { CoverArtRef } from '../../cover/types';
import LastfmIcon from '../LastfmIcon';
import WikipediaIcon from '../WikipediaIcon';
import StarRating from '../StarRating';
import { tooltipAttrs } from '../tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
@@ -241,7 +242,7 @@ export default function ArtistDetailHero({
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{(info?.lastFmUrl || artist.name) && (
<div className="artist-detail-links">
{info?.lastFmUrl && (
@@ -251,7 +252,7 @@ export default function ArtistDetailHero({
{...tooltipAttrs(t('artistDetail.lastfmTooltip'))}
>
<LastfmIcon size={14} />
{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}
<span className="compact-btn-label">{openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'}</span>
</button>
)}
<button
@@ -259,8 +260,8 @@ export default function ArtistDetailHero({
onClick={() => openLink(wikiUrl, 'wiki')}
{...tooltipAttrs(t('artistDetail.wikipediaTooltip'))}
>
<ExternalLink size={14} />
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
<WikipediaIcon size={14} />
<span className="compact-btn-label">{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}</span>
</button>
</div>
)}
@@ -269,16 +270,17 @@ export default function ArtistDetailHero({
<button
className="artist-ext-link"
onClick={toggleStar}
aria-label={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Heart size={14} fill={isStarred ? "currentColor" : "none"} />
{t('artistDetail.favorite')}
<span className="compact-btn-label">{t('artistDetail.favorite')}</span>
</button>
)}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', marginTop: '1.5rem', flexWrap: 'wrap' }}>
{albums.length > 0 && (
<>
<button
@@ -288,7 +290,7 @@ export default function ArtistDetailHero({
{...tooltipAttrs(t('artistDetail.playAllTooltip'))}
>
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Play size={16} />}
{t('artistDetail.playAll')}
<span className="compact-btn-label">{t('artistDetail.playAll')}</span>
</button>
<button
className="btn btn-surface"
@@ -297,7 +299,7 @@ export default function ArtistDetailHero({
{...tooltipAttrs(t('artistDetail.shuffleTooltip'))}
>
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
{!isMobile && t('artistDetail.shuffle')}
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
</button>
</>
)}
@@ -308,7 +310,7 @@ export default function ArtistDetailHero({
{...tooltipAttrs(t('artistDetail.radioTooltip'))}
>
{radioLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Radio size={16} />}
{!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))}
{!isMobile && <span className="compact-btn-label">{radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}</span>}
</button>
{id && artist && (
<button
@@ -353,6 +355,7 @@ export default function ArtistDetailHero({
? <Check size={16} />
: <HardDriveDownload size={16} />}
{!isMobile && (
<span className="compact-btn-label">{
artistOfflineStatus === 'downloading' && artistOfflineProgress
? t('artistDetail.offlineDownloading', {
done: artistOfflineProgress.done,
@@ -363,6 +366,7 @@ export default function ArtistDetailHero({
: artistOfflineStatus === 'cached'
? t('artistDetail.offlineCached')
: t('artistDetail.cacheOffline')
}</span>
)}
</button>
)}
@@ -67,10 +67,12 @@ export default function FavoritesSongsSectionHeader({
</div>
{/* Action Buttons */}
<div className="favorites-songs-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<div className="favorites-songs-toolbar compact-action-bar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary"
disabled={targetSongs.length === 0}
aria-label={inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
data-tooltip={inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
onClick={() => {
if (targetSongs.length === 0) return;
const tracks = targetSongs.map(songToTrack);
@@ -78,11 +80,13 @@ export default function FavoritesSongsSectionHeader({
}}
>
<Play size={15} />
{inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
<span className="compact-btn-label">{inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}</span>
</button>
<button
className="btn btn-surface"
disabled={targetSongs.length === 0}
aria-label={inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
data-tooltip={inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
onClick={() => {
if (targetSongs.length === 0) return;
const tracks = targetSongs.map(songToTrack);
@@ -90,21 +94,25 @@ export default function FavoritesSongsSectionHeader({
}}
>
<ListPlus size={15} />
{inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
<span className="compact-btn-label">{inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}</span>
</button>
{/* Filter Toggle Button */}
<button
className={`btn ${showFilters || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear ? 'btn-primary' : 'btn-surface'}`}
onClick={() => setShowFilters(v => !v)}
aria-label={t('common.filters')}
data-tooltip={t('common.filters')}
>
<SlidersHorizontal size={14} />
{t('common.filters')}
<span className="compact-btn-label">{t('common.filters')}</span>
</button>
{(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear) && (
<button
className="btn btn-ghost"
aria-label={t('common.clearAll')}
data-tooltip={t('common.clearAll')}
onClick={() => {
setSelectedArtist(null);
setSelectedGenres([]);
@@ -114,7 +122,7 @@ export default function FavoritesSongsSectionHeader({
}}
>
<X size={13} />
{t('common.clearAll')}
<span className="compact-btn-label">{t('common.clearAll')}</span>
</button>
)}
+13 -10
View File
@@ -148,15 +148,16 @@ export default function PlaylistHero({
)}
{saving && <Loader2 size={12} className="spin-slow" style={{ display: 'inline', marginLeft: 4 }} />}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions compact-action-bar">
<div className="album-detail-actions-primary">
<button
className="btn btn-primary"
disabled={songs.length === 0}
onClick={handlePlayAll}
aria-label={t('playlists.playTooltip')}
data-tooltip={t('playlists.playTooltip')}
>
<Play size={15} /> {t('common.play', 'Reproducir')}
<Play size={15} /> <span className="compact-btn-label">{t('common.play', 'Reproducir')}</span>
</button>
<button
className="btn btn-ghost"
@@ -179,9 +180,10 @@ export default function PlaylistHero({
<button
className={`btn btn-ghost ${searchOpen ? 'active' : ''}`}
onClick={() => { setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
aria-label={t('playlists.addSongsTooltip')}
data-tooltip={t('playlists.addSongsTooltip')}
>
<Search size={16} /> {t('playlists.addSongs')}
<Search size={16} /> <span className="compact-btn-label">{t('playlists.addSongs')}</span>
</button>
)}
{actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && (
@@ -189,10 +191,11 @@ export default function PlaylistHero({
className="btn btn-ghost"
onClick={handleImportCsv}
disabled={csvImporting}
aria-label={t('playlists.importCSVTooltip')}
data-tooltip={t('playlists.importCSVTooltip')}
>
{csvImporting ? <Loader2 size={16} className="spin-slow" /> : <FileUp size={16} />}
{t('playlists.importCSV')}
<span className="compact-btn-label">{t('playlists.importCSV')}</span>
</button>
)}
{actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && (
@@ -205,8 +208,8 @@ export default function PlaylistHero({
<span className="download-progress-pct">{activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> {t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''}
<button className="btn btn-ghost" onClick={handleDownload} aria-label={t('playlists.downloadZip')} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> <span className="compact-btn-label">{t('playlists.downloadZip')}{songs.reduce((acc, s) => acc + (s.size ?? 0), 0) > 0 ? ` · ${formatSize(songs.reduce((acc, s) => acc + (s.size ?? 0), 0))}` : ''}</span>
</button>
)
)}
@@ -235,22 +238,22 @@ export default function PlaylistHero({
{offlineStatus === 'downloading' ? (
<>
<div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} />
{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}
<span className="compact-btn-label">{t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })}</span>
</>
) : offlineStatus === 'queued' ? (
<>
<HardDriveDownload size={16} />
{t('albumDetail.offlineQueued')}
<span className="compact-btn-label">{t('albumDetail.offlineQueued')}</span>
</>
) : offlineStatus === 'cached' ? (
<>
<Trash2 size={16} />
{t('playlists.removeOffline')}
<span className="compact-btn-label">{t('playlists.removeOffline')}</span>
</>
) : (
<>
<HardDriveDownload size={16} />
{t('playlists.cacheOffline')}
<span className="compact-btn-label">{t('playlists.cacheOffline')}</span>
</>
)}
</button>
@@ -65,7 +65,7 @@ export default function PlaylistSuggestions({
return (
<div className="playlist-suggestions tracklist" data-preview-loc="suggestions">
<div className="playlist-suggestions-header">
<div className="playlist-suggestions-header compact-action-bar">
<div className="playlist-suggestions-title">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('playlists.suggestions')}</h2>
<span className="playlist-suggestions-hint">{t('playlists.suggestionsHint')}</span>
@@ -74,10 +74,11 @@ export default function PlaylistSuggestions({
className="btn btn-surface"
onClick={() => loadSuggestions(songs)}
disabled={loadingSuggestions || songs.length === 0}
aria-label={t('playlists.refreshSuggestions')}
data-tooltip={t('playlists.refreshSuggestions')}
>
<RefreshCw size={14} className={loadingSuggestions ? 'spin-slow' : ''} />
{t('playlists.refreshSuggestions')}
<span className="compact-btn-label">{t('playlists.refreshSuggestions')}</span>
</button>
</div>
+10 -8
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckSquare2, Plus, Trash2 } from 'lucide-react';
import { CheckSquare2, Plus, Sparkles, Trash2 } from 'lucide-react';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import {
defaultSmartFilters, type SmartFilters,
@@ -48,7 +48,7 @@ export default function PlaylistsHeader({
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-start' }}>
<div className="compact-action-bar" style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-start' }}>
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
@@ -72,8 +72,8 @@ export default function PlaylistsHeader({
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }}>
<Plus size={15} /> {t('playlists.newPlaylist')}
<button className="btn btn-primary" onClick={() => { setCreatingSmart(false); setCreating(true); }} aria-label={t('playlists.newPlaylist')} data-tooltip={t('playlists.newPlaylist')}>
<Plus size={15} /> <span className="compact-btn-label">{t('playlists.newPlaylist')}</span>
</button>
)}
{!creating && isNavidromeServer && (
@@ -83,8 +83,8 @@ export default function PlaylistsHeader({
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
setCreatingSmart(v => !v);
}}>
<Plus size={15} /> {t('smartPlaylists.create')}
}} aria-label={t('smartPlaylists.create')} data-tooltip={t('smartPlaylists.create')}>
<Sparkles size={15} /> <span className="compact-btn-label">{t('smartPlaylists.create')}</span>
</button>
)}
</>
@@ -98,25 +98,27 @@ export default function PlaylistsHeader({
className="btn btn-danger"
onClick={handleDeleteSelected}
disabled={deletableCount === 0}
aria-label={t('playlists.deleteSelected')}
data-tooltip={deletableCount === selectedIds.size
? undefined
: t('playlists.deleteSelectedPartial', { n: deletableCount, total: selectedIds.size })}
data-tooltip-pos="bottom"
>
<Trash2 size={15} />
{t('playlists.deleteSelected')}
<span className="compact-btn-label">{t('playlists.deleteSelected')}</span>
</button>
);
})()}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
aria-label={selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
<span className="compact-btn-label">{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}</span>
</button>
</div>
</div>
@@ -61,8 +61,8 @@ export default function PlaylistsNewFolderButton() {
}
return (
<button className="btn btn-surface" onClick={() => setCreating(true)}>
<FolderPlus size={15} /> {t('playlists.folders.newFolder')}
<button className="btn btn-surface" onClick={() => setCreating(true)} aria-label={t('playlists.folders.newFolder')} data-tooltip={t('playlists.folders.newFolder')}>
<FolderPlus size={15} /> <span className="compact-btn-label">{t('playlists.folders.newFolder')}</span>
</button>
);
}
+7 -4
View File
@@ -28,28 +28,31 @@ export default function RandomMixHeader({
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1 className="page-title">{t('randomMix.title')}</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div className="compact-action-bar" style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-surface"
onClick={onRefresh}
disabled={selectedGenre ? genreMixLoading : loading}
aria-label={selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
data-tooltip={selectedGenre
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
: t('randomMix.remixTooltip')
}
>
<RefreshCw size={18} className={(selectedGenre ? genreMixLoading : loading) ? 'spin' : ''} />
{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}
<span className="compact-btn-label">{selectedGenre ? t('randomMix.remixGenre', { genre: selectedGenre }) : t('randomMix.remix')}</span>
</button>
<button
className={`btn ${isGenreLoading ? 'btn-surface' : 'btn-primary'}`}
onClick={onPlayAll}
disabled={isPlayDisabled}
aria-label={t('randomMix.playAll')}
data-tooltip={t('randomMix.playAll')}
>
{isGenreLoading ? (
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongsLength, randomMixSize)} / {randomMixSize}</>
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> <span className="compact-btn-label">{Math.min(genreMixSongsLength, randomMixSize)} / {randomMixSize}</span></>
) : (
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
<><Play size={18} fill="currentColor" /> <span className="compact-btn-label">{t('randomMix.playAll')}</span></>
)}
</button>
</div>
+29 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { invoke } from '@tauri-apps/api/core';
import { LayoutGrid, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
import { LayoutGrid, Maximize2, Palette, Sliders, Type, ZoomIn } from 'lucide-react';
import { useAuthStore } from '../../store/authStore';
import {
LIBRARY_GRID_MAX_COLUMNS_MAX,
@@ -308,6 +308,34 @@ export function AppearanceTab() {
</SettingsGroup>
</div>
</SettingsSubSection>
<SettingsSubSection
title={t('settings.buttonSizeTitle')}
icon={<Maximize2 size={16} />}
>
<div className="settings-card">
<SettingsGroup>
<SettingsSubCard>
<SettingsField
label={t('settings.buttonSizeLabel')}
desc={t('settings.buttonSizeDesc')}
>
<div style={{ display: 'flex', gap: 8 }}>
{(['large', 'small'] as const).map(size => (
<button
key={size}
className={`btn ${theme.buttonSize === size ? 'btn-primary' : 'btn-ghost'}`}
onClick={() => theme.setButtonSize(size)}
>
{t(`settings.buttonSize_${size}`)}
</button>
))}
</div>
</SettingsField>
</SettingsSubCard>
</SettingsGroup>
</div>
</SettingsSubSection>
</>
);
}
+5 -5
View File
@@ -166,12 +166,12 @@ export default function TracksPageChrome({
</>
)}
</p>
<div className="tracks-hero-actions">
<button className="btn btn-primary" onClick={() => playSongNow(hero)}>
<Play size={16} fill="currentColor" /> {t('tracks.playSong')}
<div className="tracks-hero-actions compact-action-bar">
<button className="btn btn-primary" onClick={() => playSongNow(hero)} aria-label={t('tracks.playSong')} data-tooltip={t('tracks.playSong')}>
<Play size={16} fill="currentColor" /> <span className="compact-btn-label">{t('tracks.playSong')}</span>
</button>
<button className="btn btn-surface" onClick={() => enqueue([songToTrack(hero)])}>
<ListPlus size={16} /> {t('tracks.enqueueSong')}
<button className="btn btn-surface" onClick={() => enqueue([songToTrack(hero)])} aria-label={t('tracks.enqueueSong')} data-tooltip={t('tracks.enqueueSong')}>
<ListPlus size={16} /> <span className="compact-btn-label">{t('tracks.enqueueSong')}</span>
</button>
<button
className="btn btn-surface"
+1
View File
@@ -387,6 +387,7 @@ const CONTRIBUTOR_ENTRIES = [
'Per-device equalizer profiles — opt-in, remembers and restores the EQ for each audio output device (PR #1146)',
'Hungarian (Magyar) translation (PR #1149)',
'Theme scheduler — follow the OS light/dark setting as an alternative to the time-based day/night schedule (PR #1163)',
'Compact buttons — appearance toggle that switches action and toolbar buttons to icon-only across detail headers and browse views, app-wide (PR #1189)',
],
},
{
+5
View File
@@ -648,6 +648,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Aktive Zeile oben mit sanfter Animation.',
seekbarStyle: 'Seekbar-Stil',
seekbarStyleDesc: 'Aussehen der Wiedergabe-Seekbar auswählen',
buttonSizeTitle: 'Kompakte Buttons',
buttonSizeLabel: 'Buttongröße',
buttonSizeDesc: 'Verkleinert die Aktions- und Toolbar-Buttons auf Symbole — in Detailseiten und Übersichten.',
buttonSize_large: 'Groß',
buttonSize_small: 'Klein',
seekbarTruewave: 'Echte Wellenform',
seekbarPseudowave: 'Pseudo-Wellenform',
seekbarLinedot: 'Linie & Punkt',
+5
View File
@@ -715,6 +715,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Active line anchors near the top with smooth spring transitions.',
seekbarStyle: 'Seekbar Style',
seekbarStyleDesc: 'Choose the look of the player seek bar',
buttonSizeTitle: 'Compact buttons',
buttonSizeLabel: 'Button size',
buttonSizeDesc: 'Shrink the action and toolbar buttons to icons on detail pages and browse views.',
buttonSize_large: 'Large',
buttonSize_small: 'Small',
seekbarTruewave: 'Truewave',
seekbarPseudowave: 'Pseudowave',
seekbarLinedot: 'Line & Dot',
+5
View File
@@ -647,6 +647,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'La línea activa se ancla arriba con animación suave.',
seekbarStyle: 'Estilo de Barra de Progreso',
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
buttonSizeTitle: 'Botones compactos',
buttonSizeLabel: 'Tamaño de los botones',
buttonSizeDesc: 'Reduce los botones de acción y de barra de herramientas a iconos en las páginas de detalle y las vistas de exploración.',
buttonSize_large: 'Grande',
buttonSize_small: 'Pequeño',
seekbarTruewave: 'Forma de Onda Real',
seekbarPseudowave: 'Forma de Onda Pseudo',
seekbarLinedot: 'Línea y Punto',
+5
View File
@@ -635,6 +635,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: "La ligne active reste en haut avec une animation fluide.",
seekbarStyle: 'Style de la barre de lecture',
seekbarStyleDesc: 'Choisir l\'apparence de la barre de progression',
buttonSizeTitle: 'Boutons compacts',
buttonSizeLabel: 'Taille des boutons',
buttonSizeDesc: 'Réduit les boutons d\'action et de barre d\'outils à des icônes sur les pages de détail et les vues de navigation.',
buttonSize_large: 'Grande',
buttonSize_small: 'Petite',
seekbarTruewave: 'Forme d\'onde réelle',
seekbarPseudowave: 'Forme d\'onde pseudo',
seekbarLinedot: 'Ligne & point',
+5
View File
@@ -715,6 +715,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Az aktív sor a tetejéhez közel rögzül, sima rugós átmenetekkel.',
seekbarStyle: 'Keresősáv stílusa',
seekbarStyleDesc: 'Válaszd ki a lejátszó keresősávjának kinézetét',
buttonSizeTitle: 'Kompakt gombok',
buttonSizeLabel: 'Gombméret',
buttonSizeDesc: 'Ikonokká zsugorítja a műveleti és eszköztár gombokat a részletoldalakon és a böngészőnézetekben.',
buttonSize_large: 'Nagy',
buttonSize_small: 'Kicsi',
seekbarTruewave: 'Truewave',
seekbarPseudowave: 'Pseudowave',
seekbarLinedot: 'Vonal és pont',
+5
View File
@@ -709,6 +709,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'アクティブ行を上部付近に固定し、滑らかなスプリング遷移を使います。',
seekbarStyle: 'シークバー形式',
seekbarStyleDesc: 'プレイヤーシークバーの見た目を選びます',
buttonSizeTitle: 'コンパクトボタン',
buttonSizeLabel: 'ボタンサイズ',
buttonSizeDesc: '詳細ページや一覧ビューで、アクションボタンやツールバーボタンをアイコンのみに縮小します。',
buttonSize_large: '大',
buttonSize_small: '小',
seekbarTruewave: 'Truewave',
seekbarPseudowave: 'Pseudowave',
seekbarLinedot: 'Line & Dot',
+5
View File
@@ -634,6 +634,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Aktiv linje forankres øverst med jevn animasjon.',
seekbarStyle: 'Søkefelt-stil',
seekbarStyleDesc: 'Velg utseendet på avspillingssøkefeltet',
buttonSizeTitle: 'Kompakte knapper',
buttonSizeLabel: 'Knappestørrelse',
buttonSizeDesc: 'Krymper handlings- og verktøylinjeknappene til ikoner på detaljsider og oversikter.',
buttonSize_large: 'Stor',
buttonSize_small: 'Liten',
seekbarTruewave: 'Ekte bølgeform',
seekbarPseudowave: 'Pseudo-bølgeform',
seekbarLinedot: 'Linje & punkt',
+5
View File
@@ -635,6 +635,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Actieve regel verankerd bovenaan met vloeiende animatie.',
seekbarStyle: 'Zoekbalkstijl',
seekbarStyleDesc: 'Kies het uiterlijk van de afspeelbalk',
buttonSizeTitle: 'Compacte knoppen',
buttonSizeLabel: 'Knopgrootte',
buttonSizeDesc: 'Verkleint de actie- en werkbalkknoppen tot pictogrammen op detailpagina\'s en overzichten.',
buttonSize_large: 'Groot',
buttonSize_small: 'Klein',
seekbarTruewave: 'Echte golfvorm',
seekbarPseudowave: 'Pseudo-golfvorm',
seekbarLinedot: 'Lijn & punt',
+5
View File
@@ -650,6 +650,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Linia activă este ancorată aproape de vârf cu animații netede.',
seekbarStyle: 'Stilul barei de redare',
seekbarStyleDesc: 'Alege aspectul barei de redare a playerului',
buttonSizeTitle: 'Butoane compacte',
buttonSizeLabel: 'Dimensiunea butoanelor',
buttonSizeDesc: 'Reduce butoanele de acțiune și din bara de instrumente la pictograme pe paginile de detalii și vizualizările de navigare.',
buttonSize_large: 'Mari',
buttonSize_small: 'Mici',
seekbarTruewave: 'Truewave',
seekbarPseudowave: 'Pseudowave',
seekbarLinedot: 'Linie & Punct',
+5
View File
@@ -735,6 +735,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: 'Активная строка фиксируется сверху с плавной анимацией.',
seekbarStyle: 'Стиль прогресс-бара',
seekbarStyleDesc: 'Выбор внешнего вида полосы воспроизведения',
buttonSizeTitle: 'Компактные кнопки',
buttonSizeLabel: 'Размер кнопок',
buttonSizeDesc: 'Уменьшает кнопки действий и панели инструментов до значков на страницах сведений и в списках.',
buttonSize_large: 'Большие',
buttonSize_small: 'Маленькие',
seekbarTruewave: 'Реальная форма волны',
seekbarPseudowave: 'Псевдо форма волны',
seekbarLinedot: 'Линия и точка',
+5
View File
@@ -634,6 +634,11 @@ export const settings = {
sidebarLyricsStyleAppleDesc: '活动行固定在顶部,带有平滑弹簧动画。',
seekbarStyle: '进度条样式',
seekbarStyleDesc: '选择播放进度条的外观',
buttonSizeTitle: '紧凑按钮',
buttonSizeLabel: '按钮大小',
buttonSizeDesc: '在详情页和浏览视图中将操作按钮和工具栏按钮缩小为图标。',
buttonSize_large: '大',
buttonSize_small: '小',
seekbarTruewave: '真实波形',
seekbarPseudowave: '伪波形',
seekbarLinedot: '线条与点',
+9 -11
View File
@@ -22,7 +22,8 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/ui/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react';
import { Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react';
import SelectionToggleButton from '../components/SelectionToggleButton';
import FilterQuickClear from '../components/FilterQuickClear';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { useRangeSelection } from '../hooks/useRangeSelection';
@@ -445,16 +446,13 @@ export default function Albums() {
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
<span className="toolbar-btn-label">{selectionMode ? t('albums.cancelSelect') : t('albums.select')}</span>
</button>
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
+10 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { LayoutGrid, List, Images, CheckSquare2 } from 'lucide-react';
import { LayoutGrid, List, Images } from 'lucide-react';
import SelectionToggleButton from '../components/SelectionToggleButton';
import StarFilterButton from '../components/StarFilterButton';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { usePlayerStore } from '../store/playerStore';
@@ -361,16 +362,14 @@ export default function Artists() {
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('artists.select')}
cancelLabel={t('artists.cancelSelect')}
startTooltip={t('artists.startSelect')}
iconSize={20}
/>
</div>
</div>
+8 -6
View File
@@ -8,7 +8,8 @@ import AlbumCard from '../components/AlbumCard';
import { ArtistHeroCover } from '../cover/artistHero';
import { coverArtRef } from '../cover/ref';
import { useCoverLightboxSrc } from '../cover/lightbox';
import { ArrowLeft, Users, ExternalLink, Heart, Feather, Share2 } from 'lucide-react';
import { ArrowLeft, Users, Heart, Feather, Share2 } from 'lucide-react';
import WikipediaIcon from '../components/WikipediaIcon';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -211,12 +212,12 @@ export default function ComposerDetail() {
<span>{t('composerDetail.workCount', { count: albums.length })}</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<div className="compact-action-bar" style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{wikiUrl && (
<div className="artist-detail-links">
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')}>
<ExternalLink size={14} />
{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}
<button className="artist-ext-link" onClick={() => openLink(wikiUrl, 'wiki')} aria-label={t('artistDetail.wikipediaTooltip')} data-tooltip={t('artistDetail.wikipediaTooltip')}>
<WikipediaIcon size={14} />
<span className="compact-btn-label">{openedLink === 'wiki' ? t('artistDetail.openedInBrowser') : 'Wikipedia'}</span>
</button>
</div>
)}
@@ -225,11 +226,12 @@ export default function ComposerDetail() {
<button
className="artist-ext-link"
onClick={toggleStar}
aria-label={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
data-tooltip={isStarred ? t('artistDetail.favoriteRemove') : t('artistDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Heart size={14} fill={isStarred ? 'currentColor' : 'none'} />
{t('artistDetail.favorite')}
<span className="compact-btn-label">{t('artistDetail.favorite')}</span>
</button>
)}
+5 -2
View File
@@ -166,10 +166,12 @@ export default function GenreDetail() {
<button
className="btn btn-ghost"
onClick={handleBack}
aria-label={t('genres.back')}
data-tooltip={t('genres.back')}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.25rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
<span className="toolbar-btn-label">{t('genres.back')}</span>
</button>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{headerCount != null && headerCount > 0 && (
@@ -185,12 +187,13 @@ export default function GenreDetail() {
className="btn btn-primary long-press-play-btn"
{...pressBind}
disabled={bulkLoading}
aria-label={t('genres.playTooltip')}
data-tooltip={t('genres.playTooltip')}
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon" style={{ gap: '0.35rem' }}>
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} fill="currentColor" />}
{t('common.play')}
<span className="toolbar-btn-label">{t('common.play')}</span>
</span>
</button>
<button
+5 -5
View File
@@ -230,12 +230,12 @@ export default function InternetRadio() {
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('radio.title')}</h1>
{canManage && (
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)}>
<Search size={14} /> {t('radio.browseDirectory')}
<div className="compact-action-bar" style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={() => setBrowseOpen(true)} aria-label={t('radio.browseDirectory')} data-tooltip={t('radio.browseDirectory')}>
<Search size={14} /> <span className="compact-btn-label">{t('radio.browseDirectory')}</span>
</button>
<button className="btn btn-primary" onClick={() => setModalStation('new')}>
<Plus size={15} /> {t('radio.addStation')}
<button className="btn btn-primary" onClick={() => setModalStation('new')} aria-label={t('radio.addStation')} data-tooltip={t('radio.addStation')}>
<Plus size={15} /> <span className="compact-btn-label">{t('radio.addStation')}</span>
</button>
</div>
)}
+9 -11
View File
@@ -18,7 +18,8 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { showToast } from '../utils/ui/toast';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { CheckSquare2, Download, HardDriveDownload, ListPlus } from 'lucide-react';
import { Download, HardDriveDownload, ListPlus } from 'lucide-react';
import SelectionToggleButton from '../components/SelectionToggleButton';
import { albumGridWarmCovers } from '../cover/layoutSizes';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import OverlayScrollArea from '../components/OverlayScrollArea';
@@ -330,16 +331,13 @@ export default function LosslessAlbums() {
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
+4 -2
View File
@@ -157,10 +157,11 @@ export default function MostPlayed() {
<button
className="btn btn-surface mp-sort-btn"
onClick={() => setSortAsc(v => !v)}
aria-label={sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}
data-tooltip={sortAsc ? t('mostPlayed.sortMost') : t('mostPlayed.sortLeast')}
>
{sortAsc ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
{sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}
<span className="compact-btn-label">{sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}</span>
<ArrowUpDown size={12} style={{ opacity: 0.45 }} />
</button>
</div>
@@ -173,11 +174,12 @@ export default function MostPlayed() {
<button
className={`btn btn-surface mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
onClick={() => setFilterCompilations(v => !v)}
aria-label={t('mostPlayed.filterCompilations')}
data-tooltip={t('mostPlayed.filterCompilations')}
data-tooltip-pos="left"
>
<UsersRound size={14} />
{t('mostPlayed.filterCompilationsShort')}
<span className="compact-btn-label">{t('mostPlayed.filterCompilationsShort')}</span>
</button>
</div>
{topArtists.length === 0 && (
+9 -11
View File
@@ -5,7 +5,8 @@ import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { Download, HardDriveDownload } from 'lucide-react';
import SelectionToggleButton from '../components/SelectionToggleButton';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
@@ -271,16 +272,13 @@ export default function NewReleases() {
) : (
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
+15 -16
View File
@@ -6,7 +6,8 @@ import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { RefreshCw, Download, HardDriveDownload } from 'lucide-react';
import SelectionToggleButton from '../components/SelectionToggleButton';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
@@ -314,13 +315,13 @@ export default function RandomAlbums() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
{selectionMode && selectedIds.size > 0 ? (
<>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline}>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline} aria-label={t('albums.addOffline')} data-tooltip={t('albums.addOffline')}>
<HardDriveDownload size={15} />
{t('albums.addOffline')}
<span className="toolbar-btn-label">{t('albums.addOffline')}</span>
</button>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips} aria-label={t('albums.downloadZips')} data-tooltip={t('albums.downloadZips')}>
<Download size={15} />
{t('albums.downloadZips')}
<span className="toolbar-btn-label">{t('albums.downloadZips')}</span>
</button>
</>
) : (
@@ -330,23 +331,21 @@ export default function RandomAlbums() {
className="btn btn-surface"
onClick={handleRefresh}
disabled={loading}
aria-label={t('randomAlbums.refresh')}
data-tooltip={t('randomAlbums.refresh')}
>
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
{t('randomAlbums.refresh')}
<span className="toolbar-btn-label">{t('randomAlbums.refresh')}</span>
</button>
</>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--text-on-accent)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('albums.cancelSelect') : t('albums.select')}
</button>
<SelectionToggleButton
active={selectionMode}
onToggle={toggleSelectionMode}
selectLabel={t('albums.select')}
cancelLabel={t('albums.cancelSelect')}
startTooltip={t('albums.startSelect')}
/>
</div>
</div>
</div>
+18 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getScheduledTheme } from './themeStore';
import { getScheduledTheme, useThemeStore } from './themeStore';
type SchedState = Parameters<typeof getScheduledTheme>[0];
@@ -77,3 +77,20 @@ describe('getScheduledTheme', () => {
});
});
});
describe('buttonSize', () => {
afterEach(() => {
useThemeStore.getState().setButtonSize('large');
});
it('defaults to large', () => {
expect(useThemeStore.getState().buttonSize).toBe('large');
});
it('setButtonSize switches to small and back to large', () => {
useThemeStore.getState().setButtonSize('small');
expect(useThemeStore.getState().buttonSize).toBe('small');
useThemeStore.getState().setButtonSize('large');
expect(useThemeStore.getState().buttonSize).toBe('large');
});
});
+5
View File
@@ -43,6 +43,9 @@ interface ThemeState {
setEnablePlaylistCoverPhoto: (v: boolean) => void;
showBitrate: boolean;
setShowBitrate: (v: boolean) => void;
/** Compact (icon-only) vs. large action/toolbar buttons across detail pages and browse views. */
buttonSize: 'large' | 'small';
setButtonSize: (v: 'large' | 'small') => void;
showRemainingTime: boolean;
setShowRemainingTime: (v: boolean) => void;
expandReplayGain: boolean;
@@ -102,6 +105,8 @@ export const useThemeStore = create<ThemeState>()(
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
showBitrate: true,
setShowBitrate: (v) => set({ showBitrate: v }),
buttonSize: 'large',
setButtonSize: (v) => set({ buttonSize: v }),
showRemainingTime: false,
setShowRemainingTime: (v) => set({ showRemainingTime: v }),
expandReplayGain: false,
+59
View File
@@ -0,0 +1,59 @@
/* Compact buttons (Settings Appearance Compact buttons)
Toggled by `html[data-button-size="small"]` (themeStore `buttonSize`).
Reusable opt-in: a button row adds `compact-action-bar`, and each button
wraps its text in a `compact-btn-label` span. In Small the labels hide and
the buttons collapse to icon-only across detail-page hero rows, shared
browse toolbars (`mainstage-inpage-toolbar` `toolbar-btn-label`) and the
standalone Most Played sort/filter buttons.
These rules are viewport-agnostic, so the toggle applies on mobile too. The
one deliberate exception is the AlbumHeader's mobile layout: it renders its
own large touch-target buttons (`album-icon-btn`, ~4454px) instead of a
`compact-action-bar`, so Small never shrinks them below the touch target. */
html[data-button-size="small"] .compact-action-bar {
gap: 6px;
}
html[data-button-size="small"] .compact-action-bar .btn,
html[data-button-size="small"] .compact-action-bar .artist-ext-link {
padding: 5px;
gap: 5px;
min-height: 0;
min-width: 0;
}
html[data-button-size="small"] .compact-action-bar .btn svg,
html[data-button-size="small"] .compact-action-bar .artist-ext-link svg {
width: 15px;
height: 15px;
}
/* Labelled buttons (Play, Bio, Download, Offline, …) become icon-only in Small. */
html[data-button-size="small"] .compact-action-bar .compact-btn-label {
display: none;
}
/* Album header keeps its inner primary cluster tight in Small too. */
html[data-button-size="small"] .album-detail-actions-primary {
gap: 5px;
}
/* Browse-page toolbars (All Albums style: Sort, Year, Genre, Favorites, Lossless,
Sampler, multi-select) collapse to icon-only in Small, reusing the same pattern
as the mobile toolbar labels live in `.toolbar-btn-label`, the icon + tooltip
+ aria-label stay. One rule covers every shared `mainstage-inpage-toolbar`. */
html[data-button-size="small"] .mainstage-inpage-toolbar .toolbar-btn-label {
display: none;
}
/* Once labels are hidden, keep all toolbar buttons square / equal size. */
html[data-button-size="small"] .mainstage-inpage-toolbar .btn {
padding: 0.5rem;
}
/* Most Played has its own standalone sort/filter buttons (not in a shared bar). */
html[data-button-size="small"] .mp-sort-btn .compact-btn-label,
html[data-button-size="small"] .mp-filter-btn .compact-btn-label {
display: none;
}
+1
View File
@@ -11,6 +11,7 @@
@import './album-detail.css';
@import './offline-library.css';
@import './cover-lightbox.css';
@import './compact-buttons.css';
@import './offline-cache-button.css';
@import './download-folder-modal.css';
@import './song-info-modal.css';