mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
ea6ac49885
commit
2d27428056
@@ -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.
|
* **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.
|
* 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
|
## Changed
|
||||||
|
|
||||||
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
### Backend — Cargo workspace with 5 domain crates (Rust refactor)
|
||||||
|
|||||||
@@ -38,10 +38,17 @@ import { PlayerVolume } from './playerBar/PlayerVolume';
|
|||||||
import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu';
|
import { PlayerOverflowMenu } from './playerBar/PlayerOverflowMenu';
|
||||||
import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar';
|
import { useFloatingPlayerBar } from '../hooks/useFloatingPlayerBar';
|
||||||
import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu';
|
import { useUtilityOverflowMenu } from '../hooks/useUtilityOverflowMenu';
|
||||||
|
import {
|
||||||
|
usePlayerBarLayoutStore,
|
||||||
|
type PlayerBarLayoutItemId,
|
||||||
|
} from '../store/playerBarLayoutStore';
|
||||||
|
|
||||||
export default function PlayerBar() {
|
export default function PlayerBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigatePlaybackLibrary = usePlaybackLibraryNavigate();
|
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 [eqOpen, setEqOpen] = useState(false);
|
||||||
const [showVolPct, setShowVolPct] = useState(false);
|
const [showVolPct, setShowVolPct] = useState(false);
|
||||||
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
const [localShowRemaining, setLocalShowRemaining] = useState(() => useThemeStore.getState().showRemainingTime);
|
||||||
@@ -282,6 +289,7 @@ export default function PlayerBar() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{isLayoutVisible('equalizer') && (
|
||||||
<button
|
<button
|
||||||
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
|
||||||
onClick={() => setEqOpen(v => !v)}
|
onClick={() => setEqOpen(v => !v)}
|
||||||
@@ -290,7 +298,9 @@ export default function PlayerBar() {
|
|||||||
>
|
>
|
||||||
<SlidersVertical size={15} />
|
<SlidersVertical size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLayoutVisible('miniPlayer') && (
|
||||||
<button
|
<button
|
||||||
className="player-btn player-btn-sm"
|
className="player-btn player-btn-sm"
|
||||||
onClick={() => invoke('open_mini_player').catch(() => {})}
|
onClick={() => invoke('open_mini_player').catch(() => {})}
|
||||||
@@ -299,6 +309,7 @@ export default function PlayerBar() {
|
|||||||
>
|
>
|
||||||
<PictureInPicture2 size={15} />
|
<PictureInPicture2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<PlayerVolume
|
<PlayerVolume
|
||||||
volume={volume}
|
volume={volume}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { PictureInPicture2, SlidersVertical } from 'lucide-react';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { PlayerVolume } from './PlayerVolume';
|
import { PlayerVolume } from './PlayerVolume';
|
||||||
|
import {
|
||||||
|
usePlayerBarLayoutStore,
|
||||||
|
type PlayerBarLayoutItemId,
|
||||||
|
} from '../../store/playerBarLayoutStore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
utilityMenuRef: React.RefObject<HTMLDivElement | null>;
|
utilityMenuRef: React.RefObject<HTMLDivElement | null>;
|
||||||
@@ -29,6 +33,11 @@ export function PlayerOverflowMenu({
|
|||||||
volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct,
|
volume, setVolume, premuteVolumeRef, showVolPct, setShowVolPct,
|
||||||
handleVolume, handleVolumeWheel, volumeStyle, t,
|
handleVolume, handleVolumeWheel, volumeStyle, t,
|
||||||
}: Props) {
|
}: 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(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
|
className={`player-overflow-menu${utilityMenuMode === 'volume' ? ' player-overflow-menu--volume-only' : ''}`}
|
||||||
@@ -36,8 +45,9 @@ export function PlayerOverflowMenu({
|
|||||||
style={utilityMenuStyle}
|
style={utilityMenuStyle}
|
||||||
onWheel={handleVolumeWheel}
|
onWheel={handleVolumeWheel}
|
||||||
>
|
>
|
||||||
{utilityMenuMode === 'full' && (
|
{utilityMenuMode === 'full' && (showEqualizer || showMiniPlayer) && (
|
||||||
<div className="player-overflow-menu-row">
|
<div className="player-overflow-menu-row">
|
||||||
|
{showEqualizer && (
|
||||||
<button
|
<button
|
||||||
className={`player-overflow-menu-btn${eqOpen ? ' active' : ''}`}
|
className={`player-overflow-menu-btn${eqOpen ? ' active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -48,6 +58,8 @@ export function PlayerOverflowMenu({
|
|||||||
<SlidersVertical size={14} />
|
<SlidersVertical size={14} />
|
||||||
{t('player.equalizer')}
|
{t('player.equalizer')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{showMiniPlayer && (
|
||||||
<button
|
<button
|
||||||
className="player-overflow-menu-btn"
|
className="player-overflow-menu-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -58,6 +70,7 @@ export function PlayerOverflowMenu({
|
|||||||
<PictureInPicture2 size={14} />
|
<PictureInPicture2 size={14} />
|
||||||
{t('player.miniPlayer')}
|
{t('player.miniPlayer')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<PlayerVolume
|
<PlayerVolume
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import LastfmIcon from '../LastfmIcon';
|
|||||||
import MarqueeText from '../MarqueeText';
|
import MarqueeText from '../MarqueeText';
|
||||||
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
import { OpenArtistRefInline } from '../OpenArtistRefInline';
|
||||||
import StarRating from '../StarRating';
|
import StarRating from '../StarRating';
|
||||||
|
import {
|
||||||
|
usePlayerBarLayoutStore,
|
||||||
|
type PlayerBarLayoutItemId,
|
||||||
|
} from '../../store/playerBarLayoutStore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
currentTrack: Track | null;
|
currentTrack: Track | null;
|
||||||
@@ -48,6 +52,10 @@ export function PlayerTrackInfo({
|
|||||||
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
|
userRatingOverrides, setUserRatingOverride, toggleFullscreen,
|
||||||
navigate, openContextMenu, t,
|
navigate, openContextMenu, t,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||||
|
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||||
|
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player-track-info">
|
<div className="player-track-info">
|
||||||
<div
|
<div
|
||||||
@@ -142,7 +150,7 @@ export function PlayerTrackInfo({
|
|||||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTrack && !isRadio && !showPreviewMeta && (
|
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && (
|
||||||
<StarRating
|
<StarRating
|
||||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||||
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
onChange={r => { setUserRatingOverride(currentTrack.id, r); setRating(currentTrack.id, r).catch(() => {}); }}
|
||||||
@@ -156,7 +164,7 @@ export function PlayerTrackInfo({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTrack && !isRadio && (
|
{currentTrack && !isRadio && isLayoutVisible('favorite') && (
|
||||||
<button
|
<button
|
||||||
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
className={`player-btn player-btn-sm player-star-btn${isStarred ? ' is-starred' : ''}`}
|
||||||
onClick={toggleStar}
|
onClick={toggleStar}
|
||||||
@@ -167,7 +175,7 @@ export function PlayerTrackInfo({
|
|||||||
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
<Heart size={15} fill={isStarred ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{currentTrack && !isRadio && lastfmSessionKey && (
|
{currentTrack && !isRadio && lastfmSessionKey && isLayoutVisible('lastfmLove') && (
|
||||||
<button
|
<button
|
||||||
className="player-btn player-btn-sm player-love-btn"
|
className="player-btn player-btn-sm player-love-btn"
|
||||||
onClick={toggleLastfmLove}
|
onClick={toggleLastfmLove}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { LayoutGrid, ListMusic, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
|
import { Disc3, LayoutGrid, ListMusic, ListTodo, PanelLeft, RotateCcw, Users } from 'lucide-react';
|
||||||
import { useArtistLayoutStore } from '../../store/artistLayoutStore';
|
import { useArtistLayoutStore } from '../../store/artistLayoutStore';
|
||||||
import { useHomeStore } from '../../store/homeStore';
|
import { useHomeStore } from '../../store/homeStore';
|
||||||
|
import { usePlayerBarLayoutStore } from '../../store/playerBarLayoutStore';
|
||||||
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
import { usePlaylistLayoutStore } from '../../store/playlistLayoutStore';
|
||||||
import { useQueueToolbarStore } from '../../store/queueToolbarStore';
|
import { useQueueToolbarStore } from '../../store/queueToolbarStore';
|
||||||
import { useSidebarStore } from '../../store/sidebarStore';
|
import { useSidebarStore } from '../../store/sidebarStore';
|
||||||
import SettingsSubSection from '../SettingsSubSection';
|
import SettingsSubSection from '../SettingsSubSection';
|
||||||
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
|
import { ArtistLayoutCustomizer } from './ArtistLayoutCustomizer';
|
||||||
import { HomeCustomizer } from './HomeCustomizer';
|
import { HomeCustomizer } from './HomeCustomizer';
|
||||||
|
import { PlayerBarLayoutCustomizer } from './PlayerBarLayoutCustomizer';
|
||||||
import { PlaylistLayoutCustomizer } from './PlaylistLayoutCustomizer';
|
import { PlaylistLayoutCustomizer } from './PlaylistLayoutCustomizer';
|
||||||
import { QueueToolbarCustomizer } from './QueueToolbarCustomizer';
|
import { QueueToolbarCustomizer } from './QueueToolbarCustomizer';
|
||||||
import { SidebarCustomizer } from './SidebarCustomizer';
|
import { SidebarCustomizer } from './SidebarCustomizer';
|
||||||
@@ -113,6 +115,26 @@ export function PersonalisationTab() {
|
|||||||
>
|
>
|
||||||
<PlaylistLayoutCustomizer />
|
<PlaylistLayoutCustomizer />
|
||||||
</SettingsSubSection>
|
</SettingsSubSection>
|
||||||
|
|
||||||
|
<SettingsSubSection
|
||||||
|
title={t('settings.playerBarTitle')}
|
||||||
|
icon={<Disc3 size={16} />}
|
||||||
|
advanced
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ fontSize: 12, color: 'var(--text-muted)', padding: '2px 6px' }}
|
||||||
|
onClick={() => usePlayerBarLayoutStore.getState().reset()}
|
||||||
|
data-tooltip={t('settings.playerBarReset')}
|
||||||
|
aria-label={t('settings.playerBarReset')}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PlayerBarLayoutCustomizer />
|
||||||
|
</SettingsSubSection>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PlayerBarLayoutItemId, string> = {
|
||||||
|
starRating: 'settings.playerBarStarRating',
|
||||||
|
favorite: 'settings.playerBarFavorite',
|
||||||
|
lastfmLove: 'settings.playerBarLastfmLove',
|
||||||
|
equalizer: 'settings.playerBarEqualizer',
|
||||||
|
miniPlayer: 'settings.playerBarMiniPlayer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> = {
|
||||||
|
starRating: <Star size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||||
|
favorite: <Heart size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||||
|
lastfmLove: (
|
||||||
|
<span style={{ color: 'var(--text-muted)', display: 'inline-flex', flexShrink: 0 }} aria-hidden>
|
||||||
|
<LastfmIcon size={16} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
equalizer: <SlidersVertical size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||||
|
miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PlayerBarLayoutCustomizer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const items = usePlayerBarLayoutStore(s => s.items);
|
||||||
|
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-card" style={{ padding: '4px 0' }}>
|
||||||
|
{items.map((it) => {
|
||||||
|
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||||
|
return (
|
||||||
|
<div key={it.id} className="sidebar-customizer-row">
|
||||||
|
{PLAYER_BAR_LAYOUT_ICONS[it.id]}
|
||||||
|
<span style={{ flex: 1, fontSize: 14, opacity: it.visible ? 1 : 0.45 }}>{label}</span>
|
||||||
|
<label className="toggle-switch" aria-label={label}>
|
||||||
|
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
|
||||||
|
<span className="toggle-track" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ export const SETTINGS_INDEX: SearchIndexEntry[] = [
|
|||||||
{ tab: 'personalisation',titleKey: 'settings.artistLayoutTitle', keywords: 'artist page layout sections order' },
|
{ 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.homeCustomizerTitle', keywords: 'home page customize sections' },
|
||||||
{ tab: 'personalisation',titleKey: 'settings.queueToolbarTitle', keywords: 'queue toolbar buttons reorder customize shuffle save load' },
|
{ 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: '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.randomMixTitle', keywords: 'random mix blacklist genre keywords filter audiobook' },
|
||||||
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
{ tab: 'library', titleKey: 'settings.ratingsSectionTitle', keywords: 'ratings stars skip threshold manual' },
|
||||||
|
|||||||
@@ -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)',
|
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -273,6 +273,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Playlist-Seitenlayout',
|
playlistLayoutTitle: 'Playlist-Seitenlayout',
|
||||||
playlistLayoutDesc: 'Einzelne Elemente auf der Playlist-Seite ein- oder ausblenden.',
|
playlistLayoutDesc: 'Einzelne Elemente auf der Playlist-Seite ein- oder ausblenden.',
|
||||||
playlistLayoutReset: 'Zurücksetzen',
|
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',
|
advancedMode: 'Erweitert',
|
||||||
advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.',
|
advancedModeTooltip: 'Erweiterte Optionen in allen Settings-Tabs anzeigen. Community-Beiträge, die nicht zwingend der Design-Philosophie der Maintainer von Psysonic entsprechen.',
|
||||||
advancedBadge: 'Erweitert',
|
advancedBadge: 'Erweitert',
|
||||||
|
|||||||
@@ -276,6 +276,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Playlist page layout',
|
playlistLayoutTitle: 'Playlist page layout',
|
||||||
playlistLayoutDesc: 'Show or hide individual elements on the playlist page.',
|
playlistLayoutDesc: 'Show or hide individual elements on the playlist page.',
|
||||||
playlistLayoutReset: 'Reset to default',
|
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',
|
advancedMode: 'Advanced',
|
||||||
advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.',
|
advancedModeTooltip: 'Show advanced options across all Settings tabs. Community contributions that do not necessarily reflect the design philosophy of the Psysonic maintainers.',
|
||||||
advancedBadge: 'Advanced',
|
advancedBadge: 'Advanced',
|
||||||
|
|||||||
@@ -273,6 +273,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Diseño de la página de playlists',
|
playlistLayoutTitle: 'Diseño de la página de playlists',
|
||||||
playlistLayoutDesc: 'Mostrar u ocultar elementos individuales en la página de playlists.',
|
playlistLayoutDesc: 'Mostrar u ocultar elementos individuales en la página de playlists.',
|
||||||
playlistLayoutReset: 'Restablecer a predeterminado',
|
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',
|
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.',
|
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',
|
advancedBadge: 'Avanzado',
|
||||||
|
|||||||
@@ -271,6 +271,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Mise en page de la playlist',
|
playlistLayoutTitle: 'Mise en page de la playlist',
|
||||||
playlistLayoutDesc: 'Afficher ou masquer des éléments individuels sur la page de la playlist.',
|
playlistLayoutDesc: 'Afficher ou masquer des éléments individuels sur la page de la playlist.',
|
||||||
playlistLayoutReset: 'Réinitialiser',
|
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é',
|
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.',
|
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é',
|
advancedBadge: 'Avancé',
|
||||||
|
|||||||
@@ -270,6 +270,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Sideoppsett for spilleliste',
|
playlistLayoutTitle: 'Sideoppsett for spilleliste',
|
||||||
playlistLayoutDesc: 'Vis eller skjul individuelle elementer på spillelistesiden.',
|
playlistLayoutDesc: 'Vis eller skjul individuelle elementer på spillelistesiden.',
|
||||||
playlistLayoutReset: 'Tilbakestill til standard',
|
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',
|
advancedMode: 'Avansert',
|
||||||
advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.',
|
advancedModeTooltip: 'Vis avanserte alternativer i alle innstillinger-faner. Bidrag fra fellesskapet som ikke nødvendigvis gjenspeiler design-filosofien til Psysonic-vedlikeholderne.',
|
||||||
advancedBadge: 'Avansert',
|
advancedBadge: 'Avansert',
|
||||||
|
|||||||
@@ -271,6 +271,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Afspeellijst pagina-indeling',
|
playlistLayoutTitle: 'Afspeellijst pagina-indeling',
|
||||||
playlistLayoutDesc: 'Individuele elementen op de afspeellijstpagina tonen of verbergen.',
|
playlistLayoutDesc: 'Individuele elementen op de afspeellijstpagina tonen of verbergen.',
|
||||||
playlistLayoutReset: 'Standaard herstellen',
|
playlistLayoutReset: 'Standaard herstellen',
|
||||||
|
playerBarTitle: 'Speelbalk',
|
||||||
|
playerBarReset: 'Standaard herstellen',
|
||||||
|
playerBarStarRating: 'Sterbeoordeling',
|
||||||
|
playerBarFavorite: 'Favoriet (hart)',
|
||||||
|
playerBarLastfmLove: 'Last.fm love',
|
||||||
|
playerBarEqualizer: 'Equalizer',
|
||||||
|
playerBarMiniPlayer: 'Mini-speler',
|
||||||
advancedMode: 'Geavanceerd',
|
advancedMode: 'Geavanceerd',
|
||||||
advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.',
|
advancedModeTooltip: 'Toon geavanceerde opties in alle instellingstabbladen. Bijdragen van de community die niet noodzakelijk de ontwerpfilosofie van de Psysonic-onderhouders weerspiegelen.',
|
||||||
advancedBadge: 'Geavanceerd',
|
advancedBadge: 'Geavanceerd',
|
||||||
|
|||||||
@@ -276,6 +276,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Aspect pagină playlist',
|
playlistLayoutTitle: 'Aspect pagină playlist',
|
||||||
playlistLayoutDesc: 'Afișează sau ascunde elemente individuale pe pagina playlistului.',
|
playlistLayoutDesc: 'Afișează sau ascunde elemente individuale pe pagina playlistului.',
|
||||||
playlistLayoutReset: 'Resetare la implicit',
|
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',
|
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.',
|
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',
|
advancedBadge: 'Avansat',
|
||||||
|
|||||||
@@ -287,6 +287,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: 'Макет страницы плейлиста',
|
playlistLayoutTitle: 'Макет страницы плейлиста',
|
||||||
playlistLayoutDesc: 'Показать или скрыть отдельные элементы на странице плейлиста.',
|
playlistLayoutDesc: 'Показать или скрыть отдельные элементы на странице плейлиста.',
|
||||||
playlistLayoutReset: 'Сбросить',
|
playlistLayoutReset: 'Сбросить',
|
||||||
|
playerBarTitle: 'Панель плеера',
|
||||||
|
playerBarReset: 'Сбросить',
|
||||||
|
playerBarStarRating: 'Оценка звёздами',
|
||||||
|
playerBarFavorite: 'Избранное (сердечко)',
|
||||||
|
playerBarLastfmLove: 'Last.fm love',
|
||||||
|
playerBarEqualizer: 'Эквалайзер',
|
||||||
|
playerBarMiniPlayer: 'Мини-плеер',
|
||||||
advancedMode: 'Дополнительно',
|
advancedMode: 'Дополнительно',
|
||||||
advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.',
|
advancedModeTooltip: 'Показывать расширенные параметры во всех вкладках настроек. Вклады сообщества, которые не обязательно соответствуют дизайн-философии мейнтейнеров Psysonic.',
|
||||||
advancedBadge: 'Дополнительно',
|
advancedBadge: 'Дополнительно',
|
||||||
|
|||||||
@@ -271,6 +271,13 @@ export const settings = {
|
|||||||
playlistLayoutTitle: '播放列表页面布局',
|
playlistLayoutTitle: '播放列表页面布局',
|
||||||
playlistLayoutDesc: '在播放列表页面上显示或隐藏各个元素。',
|
playlistLayoutDesc: '在播放列表页面上显示或隐藏各个元素。',
|
||||||
playlistLayoutReset: '重置为默认',
|
playlistLayoutReset: '重置为默认',
|
||||||
|
playerBarTitle: '播放器栏',
|
||||||
|
playerBarReset: '重置为默认',
|
||||||
|
playerBarStarRating: '星级评分',
|
||||||
|
playerBarFavorite: '收藏(心形)',
|
||||||
|
playerBarLastfmLove: 'Last.fm 喜欢',
|
||||||
|
playerBarEqualizer: '均衡器',
|
||||||
|
playerBarMiniPlayer: '迷你播放器',
|
||||||
advancedMode: '高级',
|
advancedMode: '高级',
|
||||||
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
|
advancedModeTooltip: '在所有设置选项卡中显示高级选项。由社区贡献,未必体现 Psysonic 维护者的设计理念。',
|
||||||
advancedBadge: '高级',
|
advancedBadge: '高级',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user