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();
}