diff --git a/CHANGELOG.md b/CHANGELOG.md index 7faa937a..e80663b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -181,6 +181,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * **Offline Library** lists every cached album across all saved servers, not only the currently active one. Cover art loads from each album's source server; play and enqueue switch to that server when needed. * Sidebar entry, mobile **More** menu, disconnect auto-navigation, and the offline banner treat **any** cached content as available offline. With multiple servers, cards show which server the album came from. +### Settings → Personalisation — player bar layout + +**By [@kveld9](https://github.com/kveld9) + [@Psychotoxical](https://github.com/Psychotoxical), inspired by [@kveld9](https://github.com/kveld9)'s [PR #627](https://github.com/Psychotoxical/psysonic/pull/627), PR [#TBD](https://github.com/Psychotoxical/psysonic)** + +* New sub-section that hides individual controls in the player bar: **Star rating**, **Favorite (heart)**, **Last.fm love**, **Equalizer**, **Mini player**. Last.fm love is still only shown when a Last.fm session exists; the overflow row only renders when either Equalizer or Mini player is enabled. Persisted via a new `playerBarLayoutStore` (Zustand + localStorage, same `items[{id, visible}]` pattern as the queue toolbar and playlist layout); all toggles on by default. +* Lives under the **Advanced** group in Personalisation (only visible when the global Advanced Mode toggle is on). +* i18n: `settings.playerBar*` across all 9 locales. + ## Changed ### Backend — Cargo workspace with 5 domain crates (Rust refactor) diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 24e83e16..3ec162c2 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -38,10 +38,17 @@ import { PlayerVolume } from './playerBar/PlayerVolume'; import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu'; import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar'; import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu'; +import { + usePlayerBarLayoutStore, + type PlayerBarLayoutItemId, +} from '../store/playerBarLayoutStore'; export default function PlayerBar() { const { t } = useTranslation(); const navigatePlaybackLibrary = usePlaybackLibraryNavigate(); + const layoutItems = usePlayerBarLayoutStore(s => s.items); + const isLayoutVisible = (id: PlayerBarLayoutItemId) => + layoutItems.find(i => i.id === id)?.visible !== false; const [eqOpen, setEqOpen] = useState(false); const [showVolPct, setShowVolPct] = useState(false); const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime); @@ -282,23 +289,27 @@ export default function PlayerBar() { ) : ( <> - + {isLayoutVisible('equalizer') && ( + + )} - + {isLayoutVisible('miniPlayer') && ( + + )} ; @@ -29,6 +33,11 @@ export function PlayerOverflowMenu({ volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct, handleVolume, handleVolumeWheel, volumeStyle, t, }: Props) { + const layoutItems = usePlayerBarLayoutStore(s => s.items); + const isLayoutVisible = (id: PlayerBarLayoutItemId) => + layoutItems.find(i => i.id === id)?.visible !== false; + const showEqualizer = isLayoutVisible('equalizer'); + const showMiniPlayer = isLayoutVisible('miniPlayer'); return createPortal(
- {utilityMenuMode === 'full' && ( + {utilityMenuMode === 'full' && (showEqualizer || showMiniPlayer) && (
- - + {showEqualizer && ( + + )} + {showMiniPlayer && ( + + )}
)} s.items); + const isLayoutVisible = (id: PlayerBarLayoutItemId) => + layoutItems.find(i => i.id === id)?.visible !== false; + return (
!isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} /> )} - {currentTrack && !isRadio && !showPreviewMeta && ( + {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && ( { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }} @@ -156,7 +164,7 @@ export function PlayerTrackInfo({ )}
- {currentTrack && !isRadio && ( + {currentTrack && !isRadio && isLayoutVisible('favorite') && ( )} - {currentTrack && !isRadio && lastfmSessionKey && ( + {currentTrack && !isRadio && lastfmSessionKey && isLayoutVisible('lastfmLove') && ( + } + > + + ); } diff --git a/src/components/settings/PlayerBarLayoutCustomizer.tsx b/src/components/settings/PlayerBarLayoutCustomizer.tsx new file mode 100644 index 00000000..97dde109 --- /dev/null +++ b/src/components/settings/PlayerBarLayoutCustomizer.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react'; +import LastfmIcon from '../LastfmIcon'; +import { + usePlayerBarLayoutStore, + type PlayerBarLayoutItemId, +} from '../../store/playerBarLayoutStore'; + +const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record = { + starRating: 'settings.playerBarStarRating', + favorite: 'settings.playerBarFavorite', + lastfmLove: 'settings.playerBarLastfmLove', + equalizer: 'settings.playerBarEqualizer', + miniPlayer: 'settings.playerBarMiniPlayer', +}; + +const PLAYER_BAR_LAYOUT_ICONS: Record = { + starRating: , + favorite: , + lastfmLove: ( + + + + ), + equalizer: , + miniPlayer: , +}; + +export function PlayerBarLayoutCustomizer() { + const { t } = useTranslation(); + const items = usePlayerBarLayoutStore(s => s.items); + const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem); + + return ( +
+ {items.map((it) => { + const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]); + return ( +
+ {PLAYER_BAR_LAYOUT_ICONS[it.id]} + {label} + +
+ ); + })} +
+ ); +} diff --git a/src/components/settings/settingsTabs.ts b/src/components/settings/settingsTabs.ts index 343619b4..70324e62 100644 --- a/src/components/settings/settingsTabs.ts +++ b/src/components/settings/settingsTabs.ts @@ -46,6 +46,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [ { tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' }, { tab: 'personalisation',titleKey: 'settings.homeCustomizerTitle', keywords: 'home page customize sections' }, { tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' }, + { tab: 'personalisation',titleKey: 'settings.playlistLayoutTitle', keywords: 'playlist page layout add songs import csv download zip cache offline suggestions controls hide show' }, + { tab: 'personalisation',titleKey: 'settings.playerBarTitle', keywords: 'player bar playback favorites stars rating lastfm love equalizer mini player controls hide show' }, { tab: 'appearance', titleKey: 'settings.libraryGridMaxColumnsTitle', keywords: 'grid columns album artist playlist cards layout appearance performance scroll paint' }, { tab: 'library', titleKey: 'settings.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' }, { tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' }, diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index a24fe3bc..8e0b6508 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -169,6 +169,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Community themes — redesign pass: removed 5 overlapping / eye-straining palettes, added 8 new dark themes (Obsidian Black, Carbon Grey, Volcanic Dark, Forest Green, Violet Haze, Copper Oxide, Sakura Night, Obsidian Gold) (PR #490)', 'Customizable queue toolbar — drag-and-drop button reordering, per-button visibility toggles, optional separator, persisted across restarts (PR #534)', 'Playlist page layout — per-element visibility toggles (Add Songs, Import CSV, Download ZIP, Cache Offline, Suggestions) under Settings → Personalisation (PR #556)', + 'Player bar layout — per-control visibility toggles (Star rating, Favorite, Last.fm love, Equalizer, Mini player) under Settings → Personalisation (PR #627)', ], }, { diff --git a/src/locales/de/settings.ts b/src/locales/de/settings.ts index ff9d51a7..d6661531 100644 --- a/src/locales/de/settings.ts +++ b/src/locales/de/settings.ts @@ -273,6 +273,13 @@ export const settings = { playlistLayoutTitle: 'Playlist-Seitenlayout', playlistLayoutDesc: 'Einzelne Elemente auf der Playlist-Seite ein- oder ausblenden.', playlistLayoutReset: 'Zurücksetzen', + playerBarTitle: 'Player-Leiste', + playerBarReset: 'Zurücksetzen', + playerBarStarRating: 'Sterne-Bewertung', + playerBarFavorite: 'Favorit (Herz)', + playerBarLastfmLove: 'Last.fm Love', + playerBarEqualizer: 'Equalizer', + playerBarMiniPlayer: 'Mini-Player', advancedMode: 'Erweitert', advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.', advancedBadge: 'Erweitert', diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 0537da93..22ae18f7 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -276,6 +276,13 @@ export const settings = { playlistLayoutTitle: 'Playlist page layout', playlistLayoutDesc: 'Show or hide individual elements on the playlist page.', playlistLayoutReset: 'Reset to default', + playerBarTitle: 'Player bar', + playerBarReset: 'Reset to default', + playerBarStarRating: 'Star rating', + playerBarFavorite: 'Favorite (heart)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Equalizer', + playerBarMiniPlayer: 'Mini player', advancedMode: 'Advanced', advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.', advancedBadge: 'Advanced', diff --git a/src/locales/es/settings.ts b/src/locales/es/settings.ts index 3f90cdb9..f40138f1 100644 --- a/src/locales/es/settings.ts +++ b/src/locales/es/settings.ts @@ -273,6 +273,13 @@ export const settings = { playlistLayoutTitle: 'Diseño de la página de playlists', playlistLayoutDesc: 'Mostrar u ocultar elementos individuales en la página de playlists.', playlistLayoutReset: 'Restablecer a predeterminado', + playerBarTitle: 'Barra de reproducción', + playerBarReset: 'Restablecer a predeterminado', + playerBarStarRating: 'Calificación con estrellas', + playerBarFavorite: 'Favorito (corazón)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Ecualizador', + playerBarMiniPlayer: 'Mini reproductor', advancedMode: 'Avanzado', advancedModeTooltip: 'Mostrar opciones avanzadas en todas las pestañas de ajustes. Aportes de la comunidad que no reflejan necesariamente la filosofía de diseño de los mantenedores de Psysonic.', advancedBadge: 'Avanzado', diff --git a/src/locales/fr/settings.ts b/src/locales/fr/settings.ts index 1492db6b..8347f9fc 100644 --- a/src/locales/fr/settings.ts +++ b/src/locales/fr/settings.ts @@ -271,6 +271,13 @@ export const settings = { playlistLayoutTitle: 'Mise en page de la playlist', playlistLayoutDesc: 'Afficher ou masquer des éléments individuels sur la page de la playlist.', playlistLayoutReset: 'Réinitialiser', + playerBarTitle: 'Barre de lecture', + playerBarReset: 'Réinitialiser', + playerBarStarRating: 'Note par étoiles', + playerBarFavorite: 'Favori (cœur)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Égaliseur', + playerBarMiniPlayer: 'Mini-lecteur', advancedMode: 'Avancé', advancedModeTooltip: 'Afficher les options avancées dans tous les onglets des paramètres. Contributions de la communauté qui ne reflètent pas nécessairement la philosophie de conception des mainteneurs de Psysonic.', advancedBadge: 'Avancé', diff --git a/src/locales/nb/settings.ts b/src/locales/nb/settings.ts index 249e1df8..7647fc44 100644 --- a/src/locales/nb/settings.ts +++ b/src/locales/nb/settings.ts @@ -270,6 +270,13 @@ export const settings = { playlistLayoutTitle: 'Sideoppsett for spilleliste', playlistLayoutDesc: 'Vis eller skjul individuelle elementer på spillelistesiden.', playlistLayoutReset: 'Tilbakestill til standard', + playerBarTitle: 'Spillerlinje', + playerBarReset: 'Tilbakestill til standard', + playerBarStarRating: 'Stjernevurdering', + playerBarFavorite: 'Favoritt (hjerte)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Equalizer', + playerBarMiniPlayer: 'Miniavspiller', advancedMode: 'Avansert', advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.', advancedBadge: 'Avansert', diff --git a/src/locales/nl/settings.ts b/src/locales/nl/settings.ts index 1f7f358a..67b7b915 100644 --- a/src/locales/nl/settings.ts +++ b/src/locales/nl/settings.ts @@ -271,6 +271,13 @@ export const settings = { playlistLayoutTitle: 'Afspeellijst pagina-indeling', playlistLayoutDesc: 'Individuele elementen op de afspeellijstpagina tonen of verbergen.', playlistLayoutReset: 'Standaard herstellen', + playerBarTitle: 'Speelbalk', + playerBarReset: 'Standaard herstellen', + playerBarStarRating: 'Sterbeoordeling', + playerBarFavorite: 'Favoriet (hart)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Equalizer', + playerBarMiniPlayer: 'Mini-speler', advancedMode: 'Geavanceerd', advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.', advancedBadge: 'Geavanceerd', diff --git a/src/locales/ro/settings.ts b/src/locales/ro/settings.ts index 558a2f96..7e69bdf7 100644 --- a/src/locales/ro/settings.ts +++ b/src/locales/ro/settings.ts @@ -276,6 +276,13 @@ export const settings = { playlistLayoutTitle: 'Aspect pagină playlist', playlistLayoutDesc: 'Afișează sau ascunde elemente individuale pe pagina playlistului.', playlistLayoutReset: 'Resetare la implicit', + playerBarTitle: 'Bara de redare', + playerBarReset: 'Resetare la implicit', + playerBarStarRating: 'Evaluare cu stele', + playerBarFavorite: 'Favorit (inimă)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Egalizator', + playerBarMiniPlayer: 'Mini player', advancedMode: 'Avansat', advancedModeTooltip: 'Afișează opțiuni avansate în toate filele de setări. Contribuții ale comunității care nu reflectă neapărat filozofia de design a mentenanților Psysonic.', advancedBadge: 'Avansat', diff --git a/src/locales/ru/settings.ts b/src/locales/ru/settings.ts index f5f79457..24af8c3f 100644 --- a/src/locales/ru/settings.ts +++ b/src/locales/ru/settings.ts @@ -287,6 +287,13 @@ export const settings = { playlistLayoutTitle: 'Макет страницы плейлиста', playlistLayoutDesc: 'Показать или скрыть отдельные элементы на странице плейлиста.', playlistLayoutReset: 'Сбросить', + playerBarTitle: 'Панель плеера', + playerBarReset: 'Сбросить', + playerBarStarRating: 'Оценка звёздами', + playerBarFavorite: 'Избранное (сердечко)', + playerBarLastfmLove: 'Last.fm love', + playerBarEqualizer: 'Эквалайзер', + playerBarMiniPlayer: 'Мини-плеер', advancedMode: 'Дополнительно', advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.', advancedBadge: 'Дополнительно', diff --git a/src/locales/zh/settings.ts b/src/locales/zh/settings.ts index 63c147e6..e91c9ed8 100644 --- a/src/locales/zh/settings.ts +++ b/src/locales/zh/settings.ts @@ -271,6 +271,13 @@ export const settings = { playlistLayoutTitle: '播放列表页面布局', playlistLayoutDesc: '在播放列表页面上显示或隐藏各个元素。', playlistLayoutReset: '重置为默认', + playerBarTitle: '播放器栏', + playerBarReset: '重置为默认', + playerBarStarRating: '星级评分', + playerBarFavorite: '收藏(心形)', + playerBarLastfmLove: 'Last.fm 喜欢', + playerBarEqualizer: '均衡器', + playerBarMiniPlayer: '迷你播放器', advancedMode: '高级', advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。', advancedBadge: '高级', diff --git a/src/store/playerBarLayoutStore.test.ts b/src/store/playerBarLayoutStore.test.ts new file mode 100644 index 00000000..6c1dc32f --- /dev/null +++ b/src/store/playerBarLayoutStore.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + DEFAULT_PLAYER_BAR_LAYOUT_ITEMS, + usePlayerBarLayoutStore, +} from './playerBarLayoutStore'; + +describe('playerBarLayoutStore', () => { + beforeEach(() => { + usePlayerBarLayoutStore.getState().reset(); + }); + + it('starts with all five items visible in declared order', () => { + const items = usePlayerBarLayoutStore.getState().items; + expect(items.map(i => i.id)).toEqual([ + 'starRating', 'favorite', 'lastfmLove', 'equalizer', 'miniPlayer', + ]); + expect(items.every(i => i.visible)).toBe(true); + }); + + it('toggleItem flips the matching id without disturbing the others', () => { + usePlayerBarLayoutStore.getState().toggleItem('equalizer'); + const items = usePlayerBarLayoutStore.getState().items; + expect(items.find(i => i.id === 'equalizer')?.visible).toBe(false); + expect(items.find(i => i.id === 'starRating')?.visible).toBe(true); + expect(items.find(i => i.id === 'miniPlayer')?.visible).toBe(true); + }); + + it('reset restores defaults after toggles', () => { + const { toggleItem, reset } = usePlayerBarLayoutStore.getState(); + toggleItem('favorite'); + toggleItem('lastfmLove'); + reset(); + expect(usePlayerBarLayoutStore.getState().items).toEqual(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS); + }); +}); diff --git a/src/store/playerBarLayoutStore.ts b/src/store/playerBarLayoutStore.ts new file mode 100644 index 00000000..de7931b6 --- /dev/null +++ b/src/store/playerBarLayoutStore.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type PlayerBarLayoutItemId = + | 'starRating' + | 'favorite' + | 'lastfmLove' + | 'equalizer' + | 'miniPlayer'; + +export interface PlayerBarLayoutItemConfig { + id: PlayerBarLayoutItemId; + visible: boolean; +} + +export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [ + { id: 'starRating', visible: true }, + { id: 'favorite', visible: true }, + { id: 'lastfmLove', visible: true }, + { id: 'equalizer', visible: true }, + { id: 'miniPlayer', visible: true }, +]; + +interface PlayerBarLayoutStore { + items: PlayerBarLayoutItemConfig[]; + setItems: (items: PlayerBarLayoutItemConfig[]) => void; + toggleItem: (id: PlayerBarLayoutItemId) => void; + reset: () => void; +} + +export const usePlayerBarLayoutStore = create()( + persist( + (set) => ({ + items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS, + + setItems: (items) => set({ items }), + + toggleItem: (id) => set((s) => ({ + items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it), + })), + + reset: () => set({ items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS }), + }), + { + name: 'psysonic_player_bar_layout', + onRehydrateStorage: () => (state) => { + if (!state) return; + const knownIds = new Set(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS.map(i => i.id)); + const safe = (state.items ?? []) + .filter((i): i is PlayerBarLayoutItemConfig => + i != null && typeof i.id === 'string' && knownIds.has(i.id as PlayerBarLayoutItemId)); + const seen = new Set(safe.map(i => i.id)); + const missing = DEFAULT_PLAYER_BAR_LAYOUT_ITEMS.filter(i => !seen.has(i.id)); + state.items = missing.length > 0 ? [...safe, ...missing] : safe; + }, + } + ) +);