diff --git a/package-lock.json b/package-lock.json
index b0e9308a..8292084d 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",
@@ -33,6 +33,7 @@
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7",
+ "colorthief": "^3.3.1",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -45,6 +46,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
+ "@types/colorthief": "^2.6.1",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/papaparse": "^5.5.2",
@@ -1720,6 +1722,16 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/colorthief": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/@types/colorthief/-/colorthief-2.6.1.tgz",
+ "integrity": "sha512-3hlRky7ybPuQDNx3RV29P/CTvuC7zkk//Xt/NyOwNg5RhL/y54a0q4aW7MNMkvBkGJqX2g2igNCoCh3ZtK4gZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2050,6 +2062,23 @@
"node": "*"
}
},
+ "node_modules/colorthief": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/colorthief/-/colorthief-3.3.1.tgz",
+ "integrity": "sha512-a3qzYXy51h6p3725pV8rnJwUBGTtvYQge2pVhKJwL+vETUD5pCi6VKmQyu51pBHdUbu/BPEXbwFLS0GnxXNhGA==",
+ "license": "MIT",
+ "bin": {
+ "colorthief": "dist/cli.js"
+ },
+ "peerDependencies": {
+ "sharp": ">=0.33.0"
+ },
+ "peerDependenciesMeta": {
+ "sharp": {
+ "optional": true
+ }
+ }
+ },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
diff --git a/package.json b/package.json
index 866ac93e..37bc40bb 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@tauri-apps/plugin-updater": "^2.10.0",
"@tauri-apps/plugin-window-state": "^2.4.1",
"axios": "^1.7.7",
+ "colorthief": "^3.3.1",
"i18next": "^25.8.16",
"lucide-react": "^0.462.0",
"md5": "^2.3.0",
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
+ "@types/colorthief": "^2.6.1",
"@types/md5": "^2.3.5",
"@types/node": "^25.3.5",
"@types/papaparse": "^5.5.2",
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 (
-
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 859a47a3..2a6333ee 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 {
@@ -1152,6 +1167,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;
@@ -1394,6 +1464,9 @@
.player-volume-slider {
width: 100%;
+ display: block;
+ margin: 0;
+ vertical-align: middle;
}
.player-volume-pct {