diff --git a/package-lock.json b/package-lock.json index b0e9308a..ad5eb055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.41.0", + "version": "1.42.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.41.0", + "version": "1.42.1", "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", diff --git a/src/App.tsx b/src/App.tsx index 1ab0e088..1fdde255 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -145,6 +145,7 @@ function AppShell() { const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + const { floatingPlayerBar } = useThemeStore(); // Mini player → main: route requests dispatched as `psy:navigate` // CustomEvents from the bridge land here so React Router can take over. @@ -351,7 +352,7 @@ function AppShell() { return (
({}); + + 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; @@ -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}%)`, }; - return ( -
+
+
+
+
{t('settings.floatingPlayerBar')}
+
{t('settings.floatingPlayerBarSub')}
+
+ +
diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 2bcb427a..6b045011 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -26,6 +26,8 @@ interface ThemeState { setShowRemainingTime: (v: boolean) => void; expandReplayGain: boolean; setExpandReplayGain: (v: boolean) => void; + floatingPlayerBar: boolean; + setFloatingPlayerBar: (v: boolean) => void; } export function getScheduledTheme(state: Pick): string { @@ -67,6 +69,8 @@ export const useThemeStore = create()( setShowRemainingTime: (v) => set({ showRemainingTime: v }), expandReplayGain: false, setExpandReplayGain: (v) => set({ expandReplayGain: v }), + floatingPlayerBar: false, + setFloatingPlayerBar: (v) => set({ floatingPlayerBar: v }), }), { name: 'psysonic_theme', diff --git a/src/styles/layout.css b/src/styles/layout.css index 707e52b5..b2446711 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -33,6 +33,13 @@ 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) ─── */ :root { --titlebar-height: 32px; @@ -46,6 +53,14 @@ "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 */ .app-shell[data-titlebar]::before, .app-shell[data-titlebar]::after { @@ -1174,6 +1189,61 @@ 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 { display: flex; align-items: center; @@ -1416,6 +1486,9 @@ .player-volume-slider { width: 100%; + display: block; + margin: 0; + vertical-align: middle; } .player-volume-pct {