diff --git a/docs/features/ui.md b/docs/features/ui.md index 6b265397..e4387cc8 100644 --- a/docs/features/ui.md +++ b/docs/features/ui.md @@ -206,7 +206,7 @@ The request was for the appearance choice Chrome offers on Linux, and it does no [`MiniPlayerApp`](../../src/MiniPlayerApp.tsx) + [`MiniPlayer`](../../src/components/views/MiniPlayer.tsx) ship a Spotify-style always-on-top widget. Launched from the picture-in-picture button in the PlayerBar via [`lib/miniPlayer.ts::openMiniPlayer`](../../src/lib/miniPlayer.ts). -- **Window** — second `WebviewWindow` (label `mini`), default 280×380 with `decorations: false` (we render our own top bar) and `alwaysOnTop: true`. Hides the main window on open; the mini's Maximize button restores it and closes the mini. +- **Window** — second `WebviewWindow` (label `mini`), default 280×380 with `decorations: false` (we render our own top bar) and `alwaysOnTop: true`. The main window stays visible; the mini's Maximize button brings it to the front. **Closing the mini hides it rather than destroying it**, and opening it again shows the same window: a mini destroyed and re-created under the same `mini` label received its first replies on the old webview's dead handle (`PostMessage failed … Invalid window handle`), so one opened after a track change sat on "No track playing" while the engine played. Its Canvas and motion cover are taken down while it is hidden and come back when it regains focus. - **Persistent bounds** — position + size are persisted in `app_setting['mini_player.bounds']` (JSON blob, machine-level) via debounced `onMoved` / `onResized` listeners in [`MiniPlayer.tsx`](../../src/components/views/MiniPlayer.tsx) (300 ms after the last gesture so SQLite isn't hammered at 60 Hz while dragging). On open, [`miniPlayer.ts::openMiniPlayer`](../../src/lib/miniPlayer.ts) restores the saved rectangle when it still overlaps an available monitor by at least 80 px on both axes (`availableMonitors()` check guards against monitor disconnects / resolution changes). Otherwise it falls back to anchoring bottom-right of the primary monitor (`currentMonitor` → physical size ÷ scale factor → logical px) with a 24 px edge margin so the OS taskbar / Dock isn't covered. - **Routing** — same Vite bundle, branched in [`main.tsx`](../../src/main.tsx) on `?mini=1` so the mini boots into a stripped-down provider tree (`Theme + Profile + Player` only — no `Library` / `Playlist` since the widget never browses). - **Cover-derived background** — [`lib/dominantColor.ts`](../../src/lib/dominantColor.ts) draws the artwork onto a 64×64 canvas, samples every 4th pixel, skips near-monochrome runs (white margins, black bars) so the average reflects the real hue, and produces a 3-stop gradient applied to the window background. diff --git a/src-tauri/crates/app/capabilities/default.json b/src-tauri/crates/app/capabilities/default.json index c82f2bc8..98365749 100644 --- a/src-tauri/crates/app/capabilities/default.json +++ b/src-tauri/crates/app/capabilities/default.json @@ -9,6 +9,7 @@ "core:window:allow-set-focus", "core:window:allow-set-always-on-top", "core:window:allow-show", + "core:window:allow-hide", "core:window:allow-unminimize", "core:window:allow-close", "core:window:allow-set-fullscreen", diff --git a/src/components/views/MiniPlayer.tsx b/src/components/views/MiniPlayer.tsx index 275d38c6..fd6419bb 100644 --- a/src/components/views/MiniPlayer.tsx +++ b/src/components/views/MiniPlayer.tsx @@ -273,7 +273,32 @@ export function MiniPlayer() { const canvasEnabled = useCanvasEnabled(); const reducedMotion = usePrefersReducedMotion(); const canvasPath = useTrackCanvas(currentTrack); - const canvasActive = canvasEnabled && !reducedMotion && !!canvasPath; + // A closed mini-player is only hidden (see the close handler), so its + // clips are taken down while it is parked instead of decoding for a + // window nobody sees, and come back when it is shown and focused. + const [parked, setParked] = useState(false); + useEffect(() => { + let unlisten: (() => void) | undefined; + let cancelled = false; + void getCurrentWindow() + .onFocusChanged(({ payload: focused }) => { + if (focused) setParked(false); + }) + .then((off) => { + if (cancelled) off(); + else unlisten = off; + }) + .catch((err) => { + console.error("[MiniPlayer] focus listener failed", err); + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + const clipsOn = !parked; + const canvasActive = + clipsOn && canvasEnabled && !reducedMotion && !!canvasPath; const motionCover = useAlbumMotionArtwork( currentTrack?.artist_name, currentTrack?.album_title, @@ -398,6 +423,10 @@ export function MiniPlayer() { } }; + // Both hide this window rather than close it: `openMiniPlayer` shows + // the same one again. A mini-player destroyed and re-created under the + // same label received its first replies on the old webview's dead + // handle, and opened on "No track playing" after a track change. const handleMaximize = async () => { try { const main = await TauriWindow.getByLabel("main"); @@ -406,7 +435,8 @@ export function MiniPlayer() { await main.unminimize(); await main.setFocus(); } - await getCurrentWindow().close(); + setParked(true); + await getCurrentWindow().hide(); } catch (err) { console.error("[MiniPlayer] maximize failed", err); } @@ -416,7 +446,8 @@ export function MiniPlayer() { try { const main = await TauriWindow.getByLabel("main"); if (main) await main.show(); - await getCurrentWindow().close(); + setParked(true); + await getCurrentWindow().hide(); } catch (err) { console.error("[MiniPlayer] close failed", err); } @@ -642,7 +673,7 @@ export function MiniPlayer() { `relative` box, so the clips and the crossfade land under the hover controls rather than over them. */} - {!canvasActive && ( + {clipsOn && !canvasActive && ( { /** * Open the always-on-top mini-player window. If it already exists, - * just bring it to the front instead of creating a duplicate. Hides - * the main window so the user gets a clean swap. + * just bring it to the front instead of creating a duplicate. + * + * It usually does: closing the mini-player hides it rather than + * destroying it (see `MiniPlayer`'s close handler). Re-creating a window + * under the same `mini` label left the new webview's first replies + * delivered to the old one's dead handle, so a mini-player opened after + * a track change sat on "No track playing" while the engine was playing + * (`PostMessage failed … Invalid window handle` in the log). Reused, it + * also opens instantly and is already up to date. + * + * The main window stays where it is. This used to try to hide it, but + * the capability never granted `window.hide`, so the call always failed + * and the main window has always stayed visible; that is the behaviour + * people know, so it is now the stated one. * * The mini-player loads the same bundle with `?mini=1` so * [`main.tsx`] can boot into a stripped-down provider tree. @@ -149,9 +161,4 @@ export async function openMiniPlayer(): Promise { win.once("tauri://error", (e) => reject(e.payload)); }); } - - // Hide the main window so we don't have two players visible at - // once — the mini-player has a Maximize button to restore it. - const main = await TauriWindow.getByLabel("main"); - if (main) await main.hide(); }