feat(settings): player bar layout — per-control visibility toggles (#627) (#721)

Adds a new sub-section under Settings → Personalisation (Advanced) that
hides individual controls in the player bar: Star rating, Favorite
(heart), Last.fm love, Equalizer, Mini player. Last.fm love still only
renders when a Last.fm session exists; the overflow row in the player
collapses when both Equalizer and Mini player are hidden.

- New `playerBarLayoutStore` (Zustand + persist, items[{id, visible}] +
  rehydrate sanitize) following the queueToolbar / playlistLayout
  pattern; defaults to all visible.
- New `PlayerBarLayoutCustomizer` reuses the same row + toggle pattern
  as the other personalisation customisers.
- Gates threaded through `PlayerTrackInfo` (3 controls), `PlayerBar`
  (EQ + Mini buttons), and `PlayerOverflowMenu` (EQ + Mini in the
  overflow row, with row-level conditional).
- `PersonalisationTab`: added as the last advanced sub-section so it
  only appears when the global Advanced Mode toggle is on.
- Settings search index gets entries for both Playlist page layout and
  Player bar (playlist row was missing).
- New i18n keys `settings.playerBar*` in all 9 locales.

Reuses kveld9's design from PR #627; not merged because the locale
split and the Advanced Mode refactor landed afterwards. Credited via
Co-Authored-By trailer + a new line in settingsCredits.ts under the
existing kveld9 entry.

Co-authored-by: Kveld. <kveld912@proton.me>
This commit is contained in:
Frank Stellmacher
2026-05-15 17:48:32 +02:00
committed by GitHub
parent ea6ac49885
commit 2d27428056
19 changed files with 314 additions and 41 deletions
+35
View File
@@ -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);
});
});
+58
View File
@@ -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<PlayerBarLayoutStore>()(
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;
},
}
)
);