feat(mini-player): floating mini window — early alpha (#162)

A second webview window (label "mini") with a compact player: album art,
title, artist, prev/play/next, progress bar, pin-on-top toggle, expand
back to main, close. Main minimizes on open and restores when the mini
is hidden or closed. Spacebar toggles, arrow keys skip tracks.

Cross-window sync: main window subscribes to playerStore and pushes
`mini:sync` via emitTo on track / play-state change. Audio progress
already broadcasts to all windows via `audio:progress`. Control actions
(prev/next/toggle) come back via `mini:control`; main-window restore
goes through a direct Rust command because WebKitGTK pauses JS in a
minimized webview.

Tiling-WM detection reused from existing `is_tiling_wm()` — always-on-top
is skipped on Hyprland/Sway/i3 since it's ignored there anyway.

Early alpha: no queue expand, no lyrics, no EQ, no drag-snap. Scope kept
deliberately tight for v1; follow-ups planned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-18 23:32:20 +02:00
parent 72e193cf2c
commit cef2db92cb
8 changed files with 560 additions and 2 deletions
+18
View File
@@ -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 <MiniPlayer />;
}
// UI scaling is scoped to .main-content via an inner wrapper (see <main>
// 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