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", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2490b2c1..24a78387 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,19 @@ 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); + // 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(); + } + } +} + /// Authenticate with Navidrome's own REST API and return a Bearer token. async fn navidrome_token(server_url: &str, username: &str, password: &str) -> Result { @@ -826,6 +839,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. @@ -951,6 +975,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 c4b89f0e..f1947972 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'; @@ -73,6 +75,17 @@ function RequireAuth({ children }: { children: React.ReactNode }) { 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); @@ -87,9 +100,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; @@ -248,16 +268,18 @@ function AppShell() { const isMobilePlayer = isMobile && location.pathname === '/now-playing'; return ( -
e.preventDefault()} > + {IS_LINUX && useCustomTitlebar && !isWindowFullscreen && } {!isMobile && ( s.currentTrack); + const isPlaying = usePlayerStore(s => s.isPlaying); + + return ( +
+ Psysonic + +
+ {currentTrack && ( + <> + {isPlaying ? '▶' : '⏸'} + + {currentTrack.artist && `${currentTrack.artist} – `}{currentTrack.title} + + + )} +
+ +
+ + + +
+
+ ); +} diff --git a/src/locales/de.ts b/src/locales/de.ts index f837c474..8bd291cd 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -437,6 +437,8 @@ export const deTranslation = { minimizeToTrayDesc: 'Beim Schließen des Fensters läuft Psysonic weiter im System-Tray statt zu beenden.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Zeigt den aktuell gespielten Titel im Discord-Profil an. Discord muss dafür geöffnet sein.', + useCustomTitlebar: 'Eigene Titelleiste', + useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.', discordAppleCovers: 'Cover über Apple Music für Discord laden', discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.', nowPlayingEnabled: 'Im Livefenster anzeigen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 37f4527e..0a8fdc4d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -438,6 +438,8 @@ export const enTranslation = { minimizeToTrayDesc: 'When closing the window, keep Psysonic running in the system tray instead of quitting.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Show the currently playing track on your Discord profile. Requires Discord to be running.', + useCustomTitlebar: 'Custom title bar', + useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.', discordAppleCovers: 'Fetch covers from Apple Music for Discord', discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.', nowPlayingEnabled: 'Show in Now Playing', diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 929c57c7..8ba36968 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -134,6 +134,7 @@ export default function Home() { {isVisible('mostPlayed') && ( 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 }), 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');