Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/app/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 36 additions & 5 deletions src/components/views/MiniPlayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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 && (
<MotionCoverOverlay
artist={currentTrack.artist_name}
album={currentTrack.album_title}
Expand All @@ -652,7 +683,7 @@ export function MiniPlayer() {
)}
<CanvasStage
path={canvasPath}
enabled={canvasEnabled && !reducedMotion}
enabled={clipsOn && canvasEnabled && !reducedMotion}
rounded="xl"
/>
<CoverSlideshow
Expand Down
21 changes: 14 additions & 7 deletions src/lib/miniPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,20 @@ async function boundsAreVisible(bounds: MiniPlayerBounds): Promise<boolean> {

/**
* 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.
Expand Down Expand Up @@ -149,9 +161,4 @@ export async function openMiniPlayer(): Promise<void> {
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();
}
Loading