From d49424e95b8d2b7f2d5a173feb799fd94c48079a Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:49:09 +0200 Subject: [PATCH] feat(settings): add a Compact buttons appearance toggle (#1189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- CHANGELOG.md | 6 ++ src/App.tsx | 5 ++ src/components/AlbumHeader.tsx | 17 +++--- src/components/SelectionToggleButton.test.tsx | 47 +++++++++++++++ src/components/SelectionToggleButton.tsx | 44 ++++++++++++++ src/components/WikipediaIcon.tsx | 7 +++ .../artistDetail/ArtistDetailHero.tsx | 44 +++++++------- .../favorites/FavoritesSongsSectionHeader.tsx | 18 ++++-- src/components/playlist/PlaylistHero.tsx | 23 ++++---- .../playlist/PlaylistSuggestions.tsx | 5 +- src/components/playlists/PlaylistsHeader.tsx | 18 +++--- .../playlists/PlaylistsNewFolderButton.tsx | 4 +- src/components/randomMix/RandomMixHeader.tsx | 11 ++-- src/components/settings/AppearanceTab.tsx | 30 +++++++++- src/components/tracks/TracksPageChrome.tsx | 10 ++-- src/config/settingsCredits.ts | 1 + src/locales/de/settings.ts | 5 ++ src/locales/en/settings.ts | 5 ++ src/locales/es/settings.ts | 5 ++ src/locales/fr/settings.ts | 5 ++ src/locales/hu/settings.ts | 5 ++ src/locales/ja/settings.ts | 5 ++ src/locales/nb/settings.ts | 5 ++ src/locales/nl/settings.ts | 5 ++ src/locales/ro/settings.ts | 5 ++ src/locales/ru/settings.ts | 5 ++ src/locales/zh/settings.ts | 5 ++ src/pages/Albums.tsx | 20 +++---- src/pages/Artists.tsx | 21 ++++--- src/pages/ComposerDetail.tsx | 14 +++-- src/pages/GenreDetail.tsx | 7 ++- src/pages/InternetRadio.tsx | 10 ++-- src/pages/LosslessAlbums.tsx | 20 +++---- src/pages/MostPlayed.tsx | 6 +- src/pages/NewReleases.tsx | 20 +++---- src/pages/RandomAlbums.tsx | 31 +++++----- src/store/themeStore.test.ts | 19 +++++- src/store/themeStore.ts | 5 ++ src/styles/components/compact-buttons.css | 59 +++++++++++++++++++ src/styles/components/index.css | 1 + 40 files changed, 438 insertions(+), 140 deletions(-) create mode 100644 src/components/SelectionToggleButton.test.tsx create mode 100644 src/components/SelectionToggleButton.tsx create mode 100644 src/components/WikipediaIcon.tsx create mode 100644 src/styles/components/compact-buttons.css diff --git a/CHANGELOG.md b/CHANGELOG.md index e7bf8104..c80b89b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 7109a497..eae0b414 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index cef34bd2..c23fe639 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -475,7 +475,7 @@ export default function AlbumHeader({ ) : ( -
+
{onShuffleAll && ( )} @@ -548,7 +548,7 @@ export default function AlbumHeader({ onClick={onDownload} {...tooltipAttrs(t('albumDetail.downloadTooltip'))} > - {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''} + {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatMb(totalSize)}` : ''} ) )} @@ -562,28 +562,31 @@ export default function AlbumHeader({ ) : offlineStatus === 'cached' ? ( ) : ( ) )} diff --git a/src/components/SelectionToggleButton.test.tsx b/src/components/SelectionToggleButton.test.tsx new file mode 100644 index 00000000..701f01cf --- /dev/null +++ b/src/components/SelectionToggleButton.test.tsx @@ -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( + , + ); + 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( + {}} + 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( + {}} + selectLabel="Multi-select" + cancelLabel="Cancel" + />, + ); + expect(document.querySelector('.toolbar-btn-label')?.textContent).toBe('Multi-select'); + }); +}); diff --git a/src/components/SelectionToggleButton.tsx b/src/components/SelectionToggleButton.tsx new file mode 100644 index 00000000..9dda07de --- /dev/null +++ b/src/components/SelectionToggleButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/WikipediaIcon.tsx b/src/components/WikipediaIcon.tsx new file mode 100644 index 00000000..bb692ae4 --- /dev/null +++ b/src/components/WikipediaIcon.tsx @@ -0,0 +1,7 @@ +export default function WikipediaIcon({ size = 16 }: { size?: number }) { + return ( + + ); +} diff --git a/src/components/artistDetail/ArtistDetailHero.tsx b/src/components/artistDetail/ArtistDetailHero.tsx index fcad1fff..d7b6892d 100644 --- a/src/components/artistDetail/ArtistDetailHero.tsx +++ b/src/components/artistDetail/ArtistDetailHero.tsx @@ -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({ />
-
+
{(info?.lastFmUrl || artist.name) && (
{info?.lastFmUrl && ( @@ -251,7 +252,7 @@ export default function ArtistDetailHero({ {...tooltipAttrs(t('artistDetail.lastfmTooltip'))} > - {openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'} + {openedLink === 'lastfm' ? t('artistDetail.openedInBrowser') : 'Last.fm'} )}
)} @@ -269,16 +270,17 @@ export default function ArtistDetailHero({ )}
-
+
{albums.length > 0 && ( <> )} @@ -308,7 +310,7 @@ export default function ArtistDetailHero({ {...tooltipAttrs(t('artistDetail.radioTooltip'))} > {radioLoading ?
: } - {!isMobile && (radioLoading ? t('artistDetail.loading') : t('artistDetail.radio'))} + {!isMobile && {radioLoading ? t('artistDetail.loading') : t('artistDetail.radio')}} {id && artist && ( )} diff --git a/src/components/favorites/FavoritesSongsSectionHeader.tsx b/src/components/favorites/FavoritesSongsSectionHeader.tsx index 681514e9..698ffff0 100644 --- a/src/components/favorites/FavoritesSongsSectionHeader.tsx +++ b/src/components/favorites/FavoritesSongsSectionHeader.tsx @@ -67,10 +67,12 @@ export default function FavoritesSongsSectionHeader({
{/* Action Buttons */} -
+
{/* Filter Toggle Button */} {(selectedArtist || selectedGenres.length > 0 || yearRange[0] !== minYear || yearRange[1] !== currentYear) && ( )} diff --git a/src/components/playlist/PlaylistHero.tsx b/src/components/playlist/PlaylistHero.tsx index 21bfefda..24829bf8 100644 --- a/src/components/playlist/PlaylistHero.tsx +++ b/src/components/playlist/PlaylistHero.tsx @@ -148,15 +148,16 @@ export default function PlaylistHero({ )} {saving && }
-
+
)} {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 ? : } - {t('playlists.importCSV')} + {t('playlists.importCSV')} )} {actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && ( @@ -205,8 +208,8 @@ export default function PlaylistHero({ {activeZip.total ? Math.round((activeZip.bytes / activeZip.total) * 100) : '…'}%
) : ( - ) )} @@ -235,22 +238,22 @@ export default function PlaylistHero({ {offlineStatus === 'downloading' ? ( <>
- {t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })} + {t('albumDetail.offlineDownloading', { n: offlineProgress?.done ?? 0, total: offlineProgress?.total ?? 0 })} ) : offlineStatus === 'queued' ? ( <> - {t('albumDetail.offlineQueued')} + {t('albumDetail.offlineQueued')} ) : offlineStatus === 'cached' ? ( <> - {t('playlists.removeOffline')} + {t('playlists.removeOffline')} ) : ( <> - {t('playlists.cacheOffline')} + {t('playlists.cacheOffline')} )} diff --git a/src/components/playlist/PlaylistSuggestions.tsx b/src/components/playlist/PlaylistSuggestions.tsx index a3aa7366..0b70cef3 100644 --- a/src/components/playlist/PlaylistSuggestions.tsx +++ b/src/components/playlist/PlaylistSuggestions.tsx @@ -65,7 +65,7 @@ export default function PlaylistSuggestions({ return (
-
+

{t('playlists.suggestions')}

{t('playlists.suggestionsHint')} @@ -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')} > - {t('playlists.refreshSuggestions')} + {t('playlists.refreshSuggestions')}
diff --git a/src/components/playlists/PlaylistsHeader.tsx b/src/components/playlists/PlaylistsHeader.tsx index b3bdbe8c..315a1971 100644 --- a/src/components/playlists/PlaylistsHeader.tsx +++ b/src/components/playlists/PlaylistsHeader.tsx @@ -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')} -
+
{policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<> {creating ? ( <> @@ -72,8 +72,8 @@ export default function PlaylistsHeader({ ) : ( - )} {!creating && isNavidromeServer && ( @@ -83,8 +83,8 @@ export default function PlaylistsHeader({ setSmartFilters(defaultSmartFilters); setGenreQuery(''); setCreatingSmart(v => !v); - }}> - {t('smartPlaylists.create')} + }} aria-label={t('smartPlaylists.create')} data-tooltip={t('smartPlaylists.create')}> + {t('smartPlaylists.create')} )} @@ -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" > - {t('playlists.deleteSelected')} + {t('playlists.deleteSelected')} ); })()}
diff --git a/src/components/playlists/PlaylistsNewFolderButton.tsx b/src/components/playlists/PlaylistsNewFolderButton.tsx index f45fb749..f768b63e 100644 --- a/src/components/playlists/PlaylistsNewFolderButton.tsx +++ b/src/components/playlists/PlaylistsNewFolderButton.tsx @@ -61,8 +61,8 @@ export default function PlaylistsNewFolderButton() { } return ( - ); } diff --git a/src/components/randomMix/RandomMixHeader.tsx b/src/components/randomMix/RandomMixHeader.tsx index 1a04c1ce..06b9a269 100644 --- a/src/components/randomMix/RandomMixHeader.tsx +++ b/src/components/randomMix/RandomMixHeader.tsx @@ -28,28 +28,31 @@ export default function RandomMixHeader({

{t('randomMix.title')}

-
+
diff --git a/src/components/settings/AppearanceTab.tsx b/src/components/settings/AppearanceTab.tsx index 955ef5c1..e3983ad9 100644 --- a/src/components/settings/AppearanceTab.tsx +++ b/src/components/settings/AppearanceTab.tsx @@ -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() {
+ + } + > +
+ + + +
+ {(['large', 'small'] as const).map(size => ( + + ))} +
+
+
+
+
+
); } diff --git a/src/components/tracks/TracksPageChrome.tsx b/src/components/tracks/TracksPageChrome.tsx index c8d787f2..df232dd8 100644 --- a/src/components/tracks/TracksPageChrome.tsx +++ b/src/components/tracks/TracksPageChrome.tsx @@ -166,12 +166,12 @@ export default function TracksPageChrome({ )}

-
- - +
diff --git a/src/pages/Artists.tsx b/src/pages/Artists.tsx index 84d6c786..3dda0c87 100644 --- a/src/pages/Artists.tsx +++ b/src/pages/Artists.tsx @@ -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() { )} - +
diff --git a/src/pages/ComposerDetail.tsx b/src/pages/ComposerDetail.tsx index 1335d27b..7b9f5fe3 100644 --- a/src/pages/ComposerDetail.tsx +++ b/src/pages/ComposerDetail.tsx @@ -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() { {t('composerDetail.workCount', { count: albums.length })}
-
+
{wikiUrl && (
-
)} @@ -225,11 +226,12 @@ export default function ComposerDetail() { )} diff --git a/src/pages/GenreDetail.tsx b/src/pages/GenreDetail.tsx index 6223e2ba..92859101 100644 --- a/src/pages/GenreDetail.tsx +++ b/src/pages/GenreDetail.tsx @@ -166,10 +166,12 @@ export default function GenreDetail() {

{genre}

{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')} > {bulkLoading ? : } - {t('common.play')} + {t('common.play')} -
)} diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index a865b135..987713e6 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -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() { )} - +
diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index 0aa561e5..83712a36 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -157,10 +157,11 @@ export default function MostPlayed() {
@@ -173,11 +174,12 @@ export default function MostPlayed() {
{topArtists.length === 0 && ( diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index 46a8f470..3698ffbd 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -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() { ) : ( )} - +
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index dac5a72b..5e966094 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -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() {
{selectionMode && selectedIds.size > 0 ? ( <> - - ) : ( @@ -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')} > - {t('randomAlbums.refresh')} + {t('randomAlbums.refresh')} )} - +
diff --git a/src/store/themeStore.test.ts b/src/store/themeStore.test.ts index 8cf4e376..c92dd046 100644 --- a/src/store/themeStore.test.ts +++ b/src/store/themeStore.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { getScheduledTheme } from './themeStore'; +import { getScheduledTheme, useThemeStore } from './themeStore'; type SchedState = Parameters[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'); + }); +}); diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 99375b11..613a9d1c 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -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()( 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, diff --git a/src/styles/components/compact-buttons.css b/src/styles/components/compact-buttons.css new file mode 100644 index 00000000..5fd8f981 --- /dev/null +++ b/src/styles/components/compact-buttons.css @@ -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`, ~44–54px) 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; +} diff --git a/src/styles/components/index.css b/src/styles/components/index.css index b29817e5..add3c5d7 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -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';