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 ?? ''} + + + + + control('prev')} data-tauri-drag-region="false"> + + + control('toggle')}> + {isPlaying ? : } + + control('next')}> + + + + + + {fmt(currentTime)} + + + + {fmt(duration)} + + + + + + {alwaysOnTop ? : } + + + + + + + + + + ); +} 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 */} + invoke('open_mini_player').catch(() => {})} + aria-label="Mini Player" + data-tooltip="Mini Player" + > + + + {/* Volume */} diff --git a/src/styles/components.css b/src/styles/components.css index c5948f0d..7cd43dc0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -8832,6 +8832,156 @@ html.no-compositing .fsr-lyric-line.fsrl-active .fsr-lyric-word.active { led-pulse, …) even when the app is minimized, causing constant GPU use. Pausing them cuts that to near zero while hidden without touching the running Rust/audio threads. */ +/* ─ Mini Player window ───────────────────────────────────────────────────── */ +.mini-player { + width: 100%; + height: 100vh; + display: grid; + grid-template-columns: 112px 1fr; + grid-template-rows: 1fr auto; + gap: 10px; + padding: 12px; + background: var(--bg-app); + color: var(--text-primary); + overflow: hidden; + user-select: none; + box-sizing: border-box; +} + +.mini-player__art { + grid-row: 1 / span 2; + aspect-ratio: 1; + width: 112px; + height: 112px; + background: var(--bg-card); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} +.mini-player__art img, +.mini-player__art-fallback { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.mini-player__art-fallback { + background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card)); +} + +.mini-player__body { + padding: 0 2px; + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.mini-player__titles { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.mini-player__title { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mini-player__artist { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mini-player__controls { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 2px; +} +.mini-player__btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background 0.12s; +} +.mini-player__btn:hover { + background: var(--bg-hover); +} +.mini-player__btn--primary { + width: 36px; + height: 36px; + background: var(--accent); + color: var(--ctp-crust); +} +.mini-player__btn--primary:hover { + background: var(--accent); + filter: brightness(1.08); +} + +.mini-player__progress { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-muted); +} +.mini-player__progress-time { + font-variant-numeric: tabular-nums; + min-width: 30px; +} +.mini-player__progress-track { + flex: 1; + height: 3px; + border-radius: 2px; + background: var(--ctp-surface1); + overflow: hidden; +} +.mini-player__progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.25s linear; +} + +.mini-player__toolbar { + grid-column: 2; + display: flex; + gap: 4px; + justify-content: flex-end; +} +.mini-player__tool { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.mini-player__tool:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +.mini-player__tool--active { + color: var(--accent); +} + html[data-app-hidden="true"] *, html[data-app-hidden="true"] *::before, html[data-app-hidden="true"] *::after { diff --git a/src/utils/miniPlayerBridge.ts b/src/utils/miniPlayerBridge.ts new file mode 100644 index 00000000..a6c0da4c --- /dev/null +++ b/src/utils/miniPlayerBridge.ts @@ -0,0 +1,107 @@ +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { listen, emitTo } from '@tauri-apps/api/event'; +import { usePlayerStore } from '../store/playerStore'; + +export const MINI_WINDOW_LABEL = 'mini'; + +export interface MiniSyncPayload { + track: { + id: string; + title: string; + artist: string; + album: string; + albumId?: string; + artistId?: string; + coverArt?: string; + duration?: number; + starred?: boolean; + } | null; + isPlaying: boolean; + isMobile: false; +} + +export type MiniControlAction = + | 'toggle' + | 'next' + | 'prev' + | 'show-main'; + +function snapshot(): MiniSyncPayload { + const s = usePlayerStore.getState(); + const t = s.currentTrack; + return { + track: t ? { + id: t.id, + title: t.title, + artist: t.artist, + album: t.album, + albumId: t.albumId, + artistId: t.artistId, + coverArt: t.coverArt, + duration: t.duration, + starred: !!t.starred, + } : null, + isPlaying: s.isPlaying, + isMobile: false, + }; +} + +/** + * Bridge initialised on the main window. Pushes track/state changes to the + * mini window whenever they matter, and handles control events coming back + * from the mini window. + * + * Returns a cleanup function. + */ +export function initMiniPlayerBridgeOnMain(): () => void { + // Only run on the main window + if (getCurrentWindow().label !== 'main') return () => {}; + + // Push state to the mini window on every relevant store change. + let last = ''; + const push = () => { + const payload = snapshot(); + const key = `${payload.track?.id ?? ''}|${payload.isPlaying}|${payload.track?.starred ?? ''}`; + if (key === last) return; + last = key; + emitTo(MINI_WINDOW_LABEL, 'mini:sync', payload).catch(() => {}); + }; + + const unsub = usePlayerStore.subscribe((state, prev) => { + if (state.currentTrack?.id !== prev.currentTrack?.id + || state.isPlaying !== prev.isPlaying + || state.currentTrack?.starred !== prev.currentTrack?.starred) { + push(); + } + }); + + // Push an initial snapshot whenever a new mini window announces itself. + const readyUnlisten = listen('mini:ready', () => { + last = ''; + push(); + }); + + // Receive control actions from the mini window. + const controlUnlisten = listen('mini:control', (e) => { + const action = e.payload; + const store = usePlayerStore.getState(); + switch (action) { + case 'toggle': store.togglePlay(); break; + case 'next': store.next(true); break; + case 'prev': store.previous(); break; + case 'show-main': { + const w = getCurrentWindow(); + w.unminimize().catch(() => {}); + w.show().catch(() => {}); + w.setFocus().catch(() => {}); + break; + } + } + }); + + return () => { + unsub(); + readyUnlisten.then(fn => fn()).catch(() => {}); + controlUnlisten.then(fn => fn()).catch(() => {}); + }; +}