From d49137475fe6cbd5db62f8df42cf1843c7de6d2d Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 21:12:17 +0200 Subject: [PATCH 1/6] feat(titlebar): custom Linux title bar with now-playing display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set_decorations(false) on Linux at startup via #[cfg(target_os = "linux")] - New TitleBar component: drag region + minimize/maximize/close buttons - Currently playing track (▶/⏸ + artist – title) shown in the center - app-shell grid gains a 32px titlebar row when data-titlebar is set - IS_LINUX utility constant via navigator.platform - macOS and Windows are completely unaffected Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/lib.rs | 11 +++++ src/App.tsx | 6 ++- src/components/TitleBar.tsx | 55 ++++++++++++++++++++++ src/styles/layout.css | 92 +++++++++++++++++++++++++++++++++++++ src/utils/platform.ts | 2 + 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src/components/TitleBar.tsx create mode 100644 src/utils/platform.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2490b2c1..ae69b939 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -826,6 +826,17 @@ pub fn run() { })) .setup(|app| { + // ── Custom title bar on Linux ───────────────────────────────── + // Remove OS window decorations so the React TitleBar component + // takes over. macOS and Windows keep their native decorations. + #[cfg(target_os = "linux")] + { + use tauri::Manager; + if let Some(win) = app.get_webview_window("main") { + let _ = win.set_decorations(false); + } + } + // ── System tray ─────────────────────────────────────────────── // Always build on startup; the frontend calls toggle_tray_icon(false) // immediately after load if the user has disabled the tray icon. diff --git a/src/App.tsx b/src/App.tsx index c4b89f0e..26ddb851 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -51,6 +51,8 @@ import GenreDetail from './pages/GenreDetail'; import ExportPickerModal from './components/ExportPickerModal'; import ChangelogModal from './components/ChangelogModal'; import AppUpdater from './components/AppUpdater'; +import TitleBar from './components/TitleBar'; +import { IS_LINUX } from './utils/platform'; import { version } from '../package.json'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; @@ -248,16 +250,18 @@ function AppShell() { const isMobilePlayer = isMobile && location.pathname === '/now-playing'; return ( -
e.preventDefault()} > + {IS_LINUX && } {!isMobile && ( s.currentTrack); + const isPlaying = usePlayerStore(s => s.isPlaying); + + return ( +
+ Psysonic + +
+ {currentTrack && ( + <> + {isPlaying ? '▶' : '⏸'} + + {currentTrack.artist && `${currentTrack.artist} – `}{currentTrack.title} + + + )} +
+ +
+ + + +
+
+ ); +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 245137d4..180152e5 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -27,6 +27,98 @@ background: var(--bg-app); } +/* ─── Custom title bar (Linux only — decorations: false) ─── */ +:root { + --titlebar-height: 32px; +} + +.app-shell[data-titlebar] { + grid-template-rows: var(--titlebar-height) 1fr var(--player-height); + grid-template-areas: + "titlebar titlebar titlebar" + "sidebar main queue" + "player player player"; +} + +.titlebar { + grid-area: titlebar; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-sidebar); + border-bottom: 1px solid var(--border-subtle); + padding: 0 6px 0 12px; + height: var(--titlebar-height); + user-select: none; +} + +.titlebar-title { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.02em; + pointer-events: none; + flex: 0 0 auto; +} + +.titlebar-track { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 6px; + max-width: 40%; + pointer-events: none; +} + +.titlebar-track-state { + font-size: 9px; + color: var(--accent); + flex-shrink: 0; +} + +.titlebar-track-text { + font-size: 12px; + color: var(--text-secondary); + max-width: 100%; +} + +.titlebar-controls { + display: flex; + gap: 2px; + flex: 0 0 auto; +} + +.titlebar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 24px; + border: none; + background: transparent; + color: var(--text-muted); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.titlebar-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.titlebar-btn-close:hover { + background: var(--danger); + color: #fff; +} + +/* Resizer handles must start below the titlebar */ +.app-shell[data-titlebar] .resizer { + top: var(--titlebar-height); +} + /* ─── Resizer Handles ─── */ .resizer { position: absolute; diff --git a/src/utils/platform.ts b/src/utils/platform.ts new file mode 100644 index 00000000..91e1c5e2 --- /dev/null +++ b/src/utils/platform.ts @@ -0,0 +1,2 @@ +/** True when running on Linux (WebKitGTK). Used to show the custom title bar. */ +export const IS_LINUX = navigator.platform.toLowerCase().includes('linux'); From 45fa606ae1462579b147573f4b14ae6c6d5772a8 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 21:47:29 +0200 Subject: [PATCH 2/6] feat(titlebar): make custom title bar optional via Settings toggle - New authStore field useCustomTitlebar (default: true) - set_window_decorations Tauri command toggles native decorations at runtime - Settings toggle visible only on Linux (no restart required) - i18n keys added to EN + DE Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/lib.rs | 9 +++++++++ src/App.tsx | 11 +++++++++-- src/locales/de.ts | 2 ++ src/locales/en.ts | 2 ++ src/pages/Home.tsx | 1 + src/pages/Settings.tsx | 16 ++++++++++++++++ src/store/authStore.ts | 4 ++++ 7 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ae69b939..5d29900e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,14 @@ fn exit_app(app_handle: tauri::AppHandle) { app_handle.exit(0); } +/// Toggle native window decorations at runtime (Linux custom title bar opt-out). +#[tauri::command] +fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { + if let Some(win) = app_handle.get_webview_window("main") { + let _ = win.set_decorations(enabled); + } +} + /// Authenticate with Navidrome's own REST API and return a Bearer token. async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { @@ -962,6 +970,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ greet, exit_app, + set_window_decorations, register_global_shortcut, unregister_global_shortcut, mpris_set_metadata, diff --git a/src/App.tsx b/src/App.tsx index 26ddb851..7ce99a77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -89,9 +89,16 @@ function AppShell() { const isLoggedIn = useAuthStore(s => s.isLoggedIn); const activeServerId = useAuthStore(s => s.activeServerId); const setMusicFolders = useAuthStore(s => s.setMusicFolders); + const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineAlbums = useOfflineStore(s => s.albums); const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + // Sync custom titlebar preference with native decorations on Linux + useEffect(() => { + if (!IS_LINUX) return; + invoke('set_window_decorations', { enabled: !useCustomTitlebar }).catch(() => {}); + }, [useCustomTitlebar]); + useEffect(() => { if (!isLoggedIn || !activeServerId) return; let cancelled = false; @@ -254,14 +261,14 @@ function AppShell() { className="app-shell" data-mobile={isMobile || undefined} data-mobile-player={isMobilePlayer || undefined} - data-titlebar={IS_LINUX || undefined} + data-titlebar={(IS_LINUX && useCustomTitlebar) || undefined} style={{ '--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'), '--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px') } as React.CSSProperties} onContextMenu={e => e.preventDefault()} > - {IS_LINUX && } + {IS_LINUX && useCustomTitlebar && } {!isMobile && ( loadMore('frequent', mostPlayed, setMostPlayed)} moreText={t('home.loadMore')} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index a496a526..d6fc9bad 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -19,6 +19,7 @@ import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; import ThemePicker from '../components/ThemePicker'; import { useAuthStore, ServerProfile } from '../store/authStore'; +import { IS_LINUX } from '../utils/platform'; import { useThemeStore } from '../store/themeStore'; import { useFontStore, FontId } from '../store/fontStore'; import { useKeybindingsStore, KeyAction, formatKeyCode, DEFAULT_BINDINGS } from '../store/keybindingsStore'; @@ -615,6 +616,21 @@ export default function Settings() {
+ {IS_LINUX && ( + <> +
+
+
+
{t('settings.useCustomTitlebar')}
+
{t('settings.useCustomTitlebarDesc')}
+
+ +
+ + )}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index c820890b..b1d9a9d8 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -42,6 +42,7 @@ interface AuthState { minimizeToTray: boolean; discordRichPresence: boolean; enableAppleMusicCoversDiscord: boolean; + useCustomTitlebar: boolean; nowPlayingEnabled: boolean; lyricsServerFirst: boolean; showFullscreenLyrics: boolean; @@ -105,6 +106,7 @@ interface AuthState { setMinimizeToTray: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; setEnableAppleMusicCoversDiscord: (v: boolean) => void; + setUseCustomTitlebar: (v: boolean) => void; setNowPlayingEnabled: (v: boolean) => void; setLyricsServerFirst: (v: boolean) => void; setShowFullscreenLyrics: (v: boolean) => void; @@ -156,6 +158,7 @@ export const useAuthStore = create()( minimizeToTray: false, discordRichPresence: false, enableAppleMusicCoversDiscord: false, + useCustomTitlebar: true, nowPlayingEnabled: false, lyricsServerFirst: true, showFullscreenLyrics: true, @@ -240,6 +243,7 @@ export const useAuthStore = create()( setMinimizeToTray: (v) => set({ minimizeToTray: v }), setDiscordRichPresence: (v) => set({ discordRichPresence: v }), setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }), + setUseCustomTitlebar: (v) => set({ useCustomTitlebar: v }), setNowPlayingEnabled: (v) => set({ nowPlayingEnabled: v }), setLyricsServerFirst: (v: boolean) => set({ lyricsServerFirst: v }), setShowFullscreenLyrics: (v: boolean) => set({ showFullscreenLyrics: v }), From 2c3c89f07875bde489365738e8f6f6e01bee7491 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 21:50:49 +0200 Subject: [PATCH 3/6] fix(titlebar): add minimize and toggleMaximize window capabilities Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/capabilities/default.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f4c7f568..2ca2fe5a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -28,6 +28,8 @@ "window-state:allow-restore-state", "core:window:allow-set-title", "core:window:allow-close", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", "core:window:allow-hide", "core:window:allow-show", "core:window:allow-set-fullscreen", From 900853fedc22af46fc0626953cc97b47ddc0fa8d Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 22:01:42 +0200 Subject: [PATCH 4/6] fix(titlebar): re-focus window after re-enabling native decorations on GTK GTK re-stacks the window when set_decorations(true) is called, causing it to lose focus and sink behind other windows. Call set_focus() immediately after to bring it back to the front. Co-Authored-By: Claude Sonnet 4.6 --- src-tauri/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5d29900e..24a78387 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,11 @@ fn exit_app(app_handle: tauri::AppHandle) { fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) { if let Some(win) = app_handle.get_webview_window("main") { let _ = win.set_decorations(enabled); + // Re-enabling native decorations on GTK causes the window manager to + // re-stack the window, which drops focus. Bring it back immediately. + if enabled { + let _ = win.set_focus(); + } } } From 5ed1b58d67a10eb62bcb4b476997f91ac5de2136 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 22:05:31 +0200 Subject: [PATCH 5/6] feat(titlebar): hide custom title bar in native fullscreen (F11) Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7ce99a77..167b5121 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,6 +75,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) { function AppShell() { const { t } = useTranslation(); const isMobile = useIsMobile(); + const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); @@ -261,14 +262,14 @@ function AppShell() { className="app-shell" data-mobile={isMobile || undefined} data-mobile-player={isMobilePlayer || undefined} - data-titlebar={(IS_LINUX && useCustomTitlebar) || undefined} + data-titlebar={(IS_LINUX && useCustomTitlebar && !isWindowFullscreen) || undefined} style={{ '--sidebar-width': isMobile ? '0px' : (isSidebarCollapsed ? '72px' : 'clamp(200px, 15vw, 220px)'), '--queue-width': isMobile ? '0px' : (isQueueVisible ? `${queueWidth}px` : '0px') } as React.CSSProperties} onContextMenu={e => e.preventDefault()} > - {IS_LINUX && useCustomTitlebar && } + {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && } {!isMobile && ( win.setFullscreen(!fs)); + win.isFullscreen().then(fs => { + win.setFullscreen(!fs); + setIsWindowFullscreen(!fs); + }); break; } } From 47cea7e3d66d5942f9a43bc9a225c75987ab8fa0 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 22:07:52 +0200 Subject: [PATCH 6/6] fix(titlebar): detect fullscreen via onResized+isFullscreen instead of manual state Manually tracking setIsWindowFullscreen was unreliable (e.g. WM-triggered fullscreen bypasses the keybinding handler). Now listens to window resize events and queries actual fullscreen state after each resize. Tested on CachyOS + KDE Plasma and Fedora + GNOME. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 167b5121..f1947972 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,6 +76,16 @@ function AppShell() { const { t } = useTranslation(); const isMobile = useIsMobile(); const [isWindowFullscreen, setIsWindowFullscreen] = useState(false); + + useEffect(() => { + if (!IS_LINUX) return; + const win = getCurrentWindow(); + let unlisten: (() => void) | undefined; + win.onResized(() => { + win.isFullscreen().then(setIsWindowFullscreen).catch(() => {}); + }).then(u => { unlisten = u; }); + return () => { unlisten?.(); }; + }, []); const isFullscreenOpen = usePlayerStore(s => s.isFullscreenOpen); const toggleFullscreen = usePlayerStore(s => s.toggleFullscreen); const isQueueVisible = usePlayerStore(s => s.isQueueVisible); @@ -407,10 +417,7 @@ function TauriEventBridge() { case 'fullscreen-player': toggleFullscreen(); break; case 'native-fullscreen': { const win = getCurrentWindow(); - win.isFullscreen().then(fs => { - win.setFullscreen(!fs); - setIsWindowFullscreen(!fs); - }); + win.isFullscreen().then(fs => win.setFullscreen(!fs)); break; } }