diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 6fc8b240..6e9268b2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,7 +3,7 @@ "identifier": "default", "description": "Default capabilities for Psysonic", "platforms": ["linux", "macOS", "windows"], - "windows": ["main"], + "windows": ["main", "mini"], "permissions": [ "core:default", "shell:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c52a858..6694791f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2597,6 +2597,103 @@ fn is_tiling_wm_cmd() -> bool { } } +// ── Mini Player window ────────────────────────────────────────────────────── +// Secondary always-on-top window with minimal playback controls. Uses the +// same frontend bundle as the main window; disambiguated by window label +// "mini". On tiling WMs (Hyprland, Sway, i3, …) always-on-top is ignored, so +// we fall back to a regular window there. + +/// Open (or toggle) the mini player window. Creates it on first call; on +/// subsequent calls, hides it if visible, shows + focuses it if hidden. +/// Opening the mini player minimizes the main window; hiding the mini player +/// restores the main window. +#[tauri::command] +fn open_mini_player(app: tauri::AppHandle) -> Result<(), String> { + if let Some(win) = app.get_webview_window("mini") { + let visible = win.is_visible().unwrap_or(false); + if visible { + win.hide().map_err(|e| e.to_string())?; + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } + } else { + win.show().map_err(|e| e.to_string())?; + let _ = win.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.minimize(); + } + } + return Ok(()); + } + + let use_always_on_top = { + #[cfg(target_os = "linux")] + { !is_tiling_wm() } + #[cfg(not(target_os = "linux"))] + { true } + }; + + let win = tauri::WebviewWindowBuilder::new( + &app, + "mini", + tauri::WebviewUrl::App("index.html".into()), + ) + .title("Psysonic Mini") + .inner_size(340.0, 140.0) + .min_inner_size(320.0, 120.0) + .resizable(true) + .decorations(true) + .always_on_top(use_always_on_top) + .skip_taskbar(false) + .build() + .map_err(|e| format!("failed to build mini player window: {e}"))?; + + let _ = win.set_focus(); + if let Some(main) = app.get_webview_window("main") { + let _ = main.minimize(); + } + Ok(()) +} + +/// Hide the mini player window if it exists and restore the main window. +/// Does not destroy the mini window so its state is preserved for next open. +#[tauri::command] +fn close_mini_player(app: tauri::AppHandle) -> Result<(), String> { + if let Some(win) = app.get_webview_window("mini") { + win.hide().map_err(|e| e.to_string())?; + } + if let Some(main) = app.get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } + Ok(()) +} + +/// Unminimize + show + focus the main window. Called from the mini player's +/// "expand" button. Can't rely on a JS event bridge here because the main +/// window's JS is paused while minimized on WebKitGTK. +#[tauri::command] +fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { + if let Some(main) = app.get_webview_window("main") { + main.unminimize().map_err(|e| e.to_string())?; + main.show().map_err(|e| e.to_string())?; + main.set_focus().map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Toggle always-on-top on the mini player window. +#[tauri::command] +fn set_mini_player_always_on_top(app: tauri::AppHandle, on_top: bool) -> Result<(), String> { + if let Some(win) = app.get_webview_window("mini") { + win.set_always_on_top(on_top).map_err(|e| e.to_string())?; + } + Ok(()) +} + pub fn run() { // Linux: second `psysonic --player …` forwards over D-Bus before heavy startup. #[cfg(target_os = "linux")] @@ -2794,6 +2891,16 @@ pub fn run() { // Let JS decide: minimize to tray or exit, based on user setting. let _ = window.emit("window:close-requested", ()); } + } else if window.label() == "mini" { + // Native close on the mini: hide instead of destroying so + // state is preserved, and restore the main window. + api.prevent_close(); + let _ = window.hide(); + if let Some(main) = window.app_handle().get_webview_window("main") { + let _ = main.unminimize(); + let _ = main.show(); + let _ = main.set_focus(); + } } } }) @@ -2809,6 +2916,10 @@ pub fn run() { set_linux_webkit_smooth_scrolling, no_compositing_mode, is_tiling_wm_cmd, + open_mini_player, + close_mini_player, + set_mini_player_always_on_top, + show_main_window, register_global_shortcut, unregister_global_shortcut, mpris_set_metadata, diff --git a/src/App.tsx b/src/App.tsx index fc9dd3c9..5207df86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,6 +38,8 @@ import InternetRadio from './pages/InternetRadio'; import FolderBrowser from './pages/FolderBrowser'; import DeviceSync from './pages/DeviceSync'; import NowPlayingPage from './pages/NowPlaying'; +import MiniPlayer from './components/MiniPlayer'; +import { initMiniPlayerBridgeOnMain } from './utils/miniPlayerBridge'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; import SongInfoModal from './components/SongInfoModal'; @@ -944,6 +946,12 @@ export default function App() { const font = useFontStore(s => s.font); const [exportPickerOpen, setExportPickerOpen] = useState(false); + // Mini Player window: detected via Tauri window label. Rendered without + // router / sidebar / full audio listeners — it just listens for state + sends + // control events. Label is read synchronously from a global set in main.tsx + // so the initial render picks the right tree. + const isMiniWindow = typeof window !== 'undefined' && (window as any).__PSY_WINDOW_LABEL__ === 'mini'; + useEffect(() => { document.documentElement.setAttribute('data-theme', effectiveTheme); }, [effectiveTheme]); @@ -952,6 +960,16 @@ export default function App() { document.documentElement.setAttribute('data-font', font); }, [font]); + // Main window only: push playback state to mini window + handle control events. + useEffect(() => { + if (isMiniWindow) return; + return initMiniPlayerBridgeOnMain(); + }, [isMiniWindow]); + + if (isMiniWindow) { + return ; + } + // UI scaling is scoped to .main-content via an inner wrapper (see
// below). Sidebar, queue, player bar and (Linux) custom title bar stay 1:1 // because they live in separate grid cells. Document-level zoom is not used diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx new file mode 100644 index 00000000..c08cf4ca --- /dev/null +++ b/src/components/MiniPlayer.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { emit, listen } from '@tauri-apps/api/event'; +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { Play, Pause, SkipBack, SkipForward, Pin, PinOff, Maximize2, X } from 'lucide-react'; +import CachedImage from './CachedImage'; +import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; +import type { MiniSyncPayload, MiniControlAction } from '../utils/miniPlayerBridge'; + +interface ProgressPayload { + current_time: number; + duration: number; +} + +function fmt(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) seconds = 0; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export default function MiniPlayer() { + const [state, setState] = useState({ track: null, isPlaying: false, isMobile: false }); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [alwaysOnTop, setAlwaysOnTop] = useState(true); + const ticker = useRef(null); + + // Announce to main window that we're mounted; it replies with a snapshot. + useEffect(() => { + emit('mini:ready', {}).catch(() => {}); + }, []); + + // Keyboard: Space → toggle, ← / → → prev / next. Ignore when typing. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const tgt = e.target as HTMLElement | null; + const tag = tgt?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tgt?.isContentEditable) return; + if (e.key === ' ' || e.code === 'Space') { + e.preventDefault(); + emit('mini:control', 'toggle').catch(() => {}); + } else if (e.key === 'ArrowRight') { + emit('mini:control', 'next').catch(() => {}); + } else if (e.key === 'ArrowLeft') { + emit('mini:control', 'prev').catch(() => {}); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + // Subscribe to state + progress from the main window / Rust. + useEffect(() => { + const unSync = listen('mini:sync', (e) => { + setState(e.payload); + if (e.payload.track?.duration) setDuration(e.payload.track.duration); + }); + const unProgress = listen('audio:progress', (e) => { + setCurrentTime(e.payload.current_time); + if (e.payload.duration > 0) setDuration(e.payload.duration); + }); + const unEnded = listen('audio:ended', () => setCurrentTime(0)); + return () => { + unSync.then(fn => fn()).catch(() => {}); + unProgress.then(fn => fn()).catch(() => {}); + unEnded.then(fn => fn()).catch(() => {}); + if (ticker.current) window.clearInterval(ticker.current); + }; + }, []); + + const control = (action: MiniControlAction) => emit('mini:control', action).catch(() => {}); + + const toggleOnTop = async () => { + const next = !alwaysOnTop; + setAlwaysOnTop(next); + try { await invoke('set_mini_player_always_on_top', { onTop: next }); } catch {} + }; + + const closeMini = async () => { + try { await invoke('close_mini_player'); } catch {} + }; + + const showMain = () => invoke('show_main_window').catch(() => {}); + + const { track, isPlaying } = state; + const progress = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0; + + return ( +
+
+ {track?.coverArt ? ( + + ) : ( +
+ )} +
+ +
+
+
+ {track?.title ?? '—'} +
+
+ {track?.artist ?? ''} +
+
+ +
+ + + +
+ +
+
{fmt(currentTime)}
+
+
+
+
{fmt(duration)}
+
+
+ +
+ + + +
+
+ ); +} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index 24449989..2289c01e 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -2,8 +2,10 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from ' import { createPortal } from 'react-dom'; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, - Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast + Square, Repeat, Repeat1, Maximize2, SlidersVertical, X, Heart, Cast, + PictureInPicture2, } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; import { usePlayerStore } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; import { useAuthStore } from '../store/authStore'; @@ -310,6 +312,16 @@ export default function PlayerBar() { + {/* Mini Player */} + + {/* Volume */}