Merge pull request #216 from kveld9/feat/floating-bar-toggle-v2

feat: floating player bar with toggle
This commit is contained in:
Frank Stellmacher
2026-04-19 19:43:14 +02:00
committed by GitHub
14 changed files with 155 additions and 5 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.41.0", "version": "1.42.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "psysonic", "name": "psysonic",
"version": "1.41.0", "version": "1.42.1",
"dependencies": { "dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10", "@fontsource-variable/figtree": "^5.2.10",
+2 -1
View File
@@ -145,6 +145,7 @@ function AppShell() {
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const { floatingPlayerBar } = useThemeStore();
// Mini player → main: route requests dispatched as `psy:navigate` // Mini player → main: route requests dispatched as `psy:navigate`
// CustomEvents from the bridge land here so React Router can take over. // CustomEvents from the bridge land here so React Router can take over.
@@ -351,7 +352,7 @@ function AppShell() {
return ( return (
<div <div
className="app-shell" className={`app-shell ${floatingPlayerBar ? 'floating-player' : ''}`}
data-mobile={isMobile || undefined} data-mobile={isMobile || undefined}
data-mobile-player={isMobilePlayer || undefined} data-mobile-player={isMobilePlayer || undefined}
data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined} data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen && !isTilingWm) || undefined}
+47 -2
View File
@@ -101,6 +101,40 @@ export default function PlayerBar() {
setUserRatingOverride: s.setUserRatingOverride, setUserRatingOverride: s.setUserRatingOverride,
}))); })));
const { lastfmSessionKey } = useAuthStore(); const { lastfmSessionKey } = useAuthStore();
const { floatingPlayerBar } = useThemeStore();
const [floatingStyle, setFloatingStyle] = useState<React.CSSProperties>({});
useEffect(() => {
if (!floatingPlayerBar) return;
const updatePosition = () => {
const sidebar = document.querySelector('.sidebar') as HTMLElement;
const queue = document.querySelector('.queue-panel') as HTMLElement;
const leftOffset = sidebar ? sidebar.getBoundingClientRect().right : 0;
const rightOffset = queue ? window.innerWidth - queue.getBoundingClientRect().left : 0;
setFloatingStyle({
left: leftOffset + 24,
right: rightOffset + 24,
width: 'auto',
});
};
updatePosition();
const observer = new ResizeObserver(updatePosition);
const sidebar = document.querySelector('.sidebar');
const queue = document.querySelector('.queue-panel');
if (sidebar) observer.observe(sidebar);
if (queue) observer.observe(queue);
window.addEventListener('resize', updatePosition);
return () => {
observer.disconnect();
window.removeEventListener('resize', updatePosition);
};
}, [floatingPlayerBar]);
const isRadio = !!currentRadio; const isRadio = !!currentRadio;
@@ -150,8 +184,13 @@ export default function PlayerBar() {
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
}; };
return ( const playerBarContent = (
<footer className="player-bar" role="region" aria-label={t('player.regionLabel')}> <footer
className={`player-bar ${floatingPlayerBar ? 'floating' : ''}`}
style={floatingPlayerBar ? floatingStyle : undefined}
role="region"
aria-label={t('player.regionLabel')}
>
{/* Track Info */} {/* Track Info */}
<div className="player-track-info"> <div className="player-track-info">
@@ -410,4 +449,10 @@ export default function PlayerBar() {
</footer> </footer>
); );
if (floatingPlayerBar) {
return createPortal(playerBarContent, document.body);
}
return playerBarContent;
} }
+2
View File
@@ -736,6 +736,8 @@ export const deTranslation = {
playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht', playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht',
showBitrate: 'Bitrate anzeigen', showBitrate: 'Bitrate anzeigen',
showBitrateSub: 'Audio-Bitrate in Track-Listen anzeigen', showBitrateSub: 'Audio-Bitrate in Track-Listen anzeigen',
floatingPlayerBar: 'Schwebende Player-Leiste',
floatingPlayerBarSub: 'Player-Leiste über dem Inhalt schweben lassen',
uiScaleTitle: 'Interface-Skalierung', uiScaleTitle: 'Interface-Skalierung',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
+2
View File
@@ -738,6 +738,8 @@ export const enTranslation = {
playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view', playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view',
showBitrate: 'Show Bitrate', showBitrate: 'Show Bitrate',
showBitrateSub: 'Display audio bitrate in track listings', showBitrateSub: 'Display audio bitrate in track listings',
floatingPlayerBar: 'Floating Player Bar',
floatingPlayerBarSub: 'Keep the player bar floating above content',
uiScaleTitle: 'Interface Scale', uiScaleTitle: 'Interface Scale',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
+2
View File
@@ -729,6 +729,8 @@ export const esTranslation = {
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists', playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
showBitrate: 'Mostrar Bitrate', showBitrate: 'Mostrar Bitrate',
showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas', showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas',
floatingPlayerBar: 'Barra del Reproductor Flotante',
floatingPlayerBarSub: 'Mantener la barra del reproductor flotando sobre el contenido',
uiScaleTitle: 'Escala de Interfaz', uiScaleTitle: 'Escala de Interfaz',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
+2
View File
@@ -724,6 +724,8 @@ export const frTranslation = {
playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists', playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists',
showBitrate: 'Afficher le Débit', showBitrate: 'Afficher le Débit',
showBitrateSub: 'Afficher le débit audio dans les listes de pistes', showBitrateSub: 'Afficher le débit audio dans les listes de pistes',
floatingPlayerBar: 'Barre de Lecteur Flottante',
floatingPlayerBarSub: 'Garder la barre du lecteur flottante au-dessus du contenu',
uiScaleTitle: "Mise à l'échelle de l'interface", uiScaleTitle: "Mise à l'échelle de l'interface",
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
+2
View File
@@ -723,6 +723,8 @@ export const nbTranslation = {
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning', playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
showBitrate: 'Vis Bitrate', showBitrate: 'Vis Bitrate',
showBitrateSub: 'Vis audio-bitrate i sporlister', showBitrateSub: 'Vis audio-bitrate i sporlister',
floatingPlayerBar: 'Flytende Spillerlinje',
floatingPlayerBarSub: 'Hold spillerlinjen flytende over innholdet',
uiScaleTitle: 'Grensesnittskala', uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
+2
View File
@@ -723,6 +723,8 @@ export const nlTranslation = {
playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave', playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave',
showBitrate: 'Toon Bitrate', showBitrate: 'Toon Bitrate',
showBitrateSub: 'Toon audio bitrate in tracklijsten', showBitrateSub: 'Toon audio bitrate in tracklijsten',
floatingPlayerBar: 'Zwevende Spelerbalk',
floatingPlayerBarSub: 'Houd de spelerbalk zwevend boven de inhoud',
uiScaleTitle: 'Interface schaal', uiScaleTitle: 'Interface schaal',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
+2
View File
@@ -753,6 +753,8 @@ export const ruTranslation = {
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста', playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
showBitrate: 'Показывать Битрейт', showBitrate: 'Показывать Битрейт',
showBitrateSub: 'Отображать битрейт аудио в списках треков', showBitrateSub: 'Отображать битрейт аудио в списках треков',
floatingPlayerBar: 'Плавающая панель плеера',
floatingPlayerBarSub: 'Держать панель плеера плавающей над содержимым',
uiScaleTitle: 'Масштаб интерфейса', uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб', uiScaleLabel: 'Масштаб',
}, },
+2
View File
@@ -719,6 +719,8 @@ export const zhTranslation = {
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格', playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
showBitrate: '显示比特率', showBitrate: '显示比特率',
showBitrateSub: '在曲目列表中显示音频比特率', showBitrateSub: '在曲目列表中显示音频比特率',
floatingPlayerBar: '浮动播放栏',
floatingPlayerBarSub: '保持播放栏悬浮在内容上方',
uiScaleTitle: '界面缩放', uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放', uiScaleLabel: '缩放',
}, },
+11
View File
@@ -1774,6 +1774,17 @@ export default function Settings() {
<span className="toggle-track" /> <span className="toggle-track" />
</label> </label>
</div> </div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.floatingPlayerBar')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.floatingPlayerBarSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.floatingPlayerBar} onChange={e => theme.setFloatingPlayerBar(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div> </div>
</section> </section>
+4
View File
@@ -26,6 +26,8 @@ interface ThemeState {
setShowRemainingTime: (v: boolean) => void; setShowRemainingTime: (v: boolean) => void;
expandReplayGain: boolean; expandReplayGain: boolean;
setExpandReplayGain: (v: boolean) => void; setExpandReplayGain: (v: boolean) => void;
floatingPlayerBar: boolean;
setFloatingPlayerBar: (v: boolean) => void;
} }
export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string { export function getScheduledTheme(state: Pick<ThemeState, 'enableThemeScheduler' | 'theme' | 'themeDay' | 'themeNight' | 'timeDayStart' | 'timeNightStart'>): string {
@@ -67,6 +69,8 @@ export const useThemeStore = create<ThemeState>()(
setShowRemainingTime: (v) => set({ showRemainingTime: v }), setShowRemainingTime: (v) => set({ showRemainingTime: v }),
expandReplayGain: false, expandReplayGain: false,
setExpandReplayGain: (v) => set({ expandReplayGain: v }), setExpandReplayGain: (v) => set({ expandReplayGain: v }),
floatingPlayerBar: false,
setFloatingPlayerBar: (v) => set({ floatingPlayerBar: v }),
}), }),
{ {
name: 'psysonic_theme', name: 'psysonic_theme',
+73
View File
@@ -33,6 +33,13 @@
background: var(--bg-app); background: var(--bg-app);
} }
/* When player bar is floating, content extends to bottom */
.app-shell.floating-player {
grid-template-rows: minmax(0, 1fr);
grid-template-areas:
"sidebar main queue";
}
/* ─── Custom title bar (Linux only — decorations: false) ─── */ /* ─── Custom title bar (Linux only — decorations: false) ─── */
:root { :root {
--titlebar-height: 32px; --titlebar-height: 32px;
@@ -46,6 +53,14 @@
"player player player"; "player player player";
} }
/* When player bar is floating with titlebar, content extends to bottom */
.app-shell[data-titlebar].floating-player {
grid-template-rows: var(--titlebar-height) minmax(0, 1fr);
grid-template-areas:
"titlebar titlebar titlebar"
"sidebar main queue";
}
/* Resize grips — replace the native GTK grips lost when decorations: false */ /* Resize grips — replace the native GTK grips lost when decorations: false */
.app-shell[data-titlebar]::before, .app-shell[data-titlebar]::before,
.app-shell[data-titlebar]::after { .app-shell[data-titlebar]::after {
@@ -1174,6 +1189,61 @@
contain: layout paint; contain: layout paint;
} }
/* Floating player bar styles - positioning handled by ResizeObserver */
.player-bar.floating {
position: fixed;
bottom: 12px;
/* left/right handled dynamically by JS */
z-index: 1000;
border-radius: 50px;
width: auto;
min-width: 100px;
max-width: none;
grid-area: unset;
padding: 0 24px 0 8px;
gap: 16px;
background: var(--bg-player);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.8),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Internal element adjustments for floating mode */
.player-bar.floating .player-track-info {
flex: 0 0 auto;
min-width: 240px;
max-width: 320px;
}
.player-bar.floating .player-album-art-wrap {
border-radius: 50%;
}
.player-bar.floating .player-album-art,
.player-bar.floating .player-album-art-placeholder {
border-radius: 0;
}
.player-bar.floating .player-buttons {
flex-shrink: 0;
gap: 16px;
}
.player-bar.floating .player-waveform-section {
flex: 1;
min-width: 200px;
}
.player-bar.floating .player-volume-section {
flex: 0 0 auto;
min-width: 120px;
display: flex;
align-items: center;
gap: 8px;
}
.player-track-info { .player-track-info {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1416,6 +1486,9 @@
.player-volume-slider { .player-volume-slider {
width: 100%; width: 100%;
display: block;
margin: 0;
vertical-align: middle;
} }
.player-volume-pct { .player-volume-pct {