Repository navigation
Expand file tree
/
Copy pathminiPlayer.ts
More file actions
232 lines (223 loc) Β· 8.92 KB
/
Copy pathminiPlayer.ts
File metadata and controls
232 lines (223 loc) Β· 8.92 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
import { LogicalPosition, LogicalSize } from "@tauri-apps/api/dpi";
import {
type Monitor,
Window as TauriWindow,
availableMonitors,
currentMonitor,
monitorFromPoint,
} from "@tauri-apps/api/window";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
clearMiniPlayerBounds,
syncWebviewMemory,
getMiniPlayerBounds,
type MiniPlayerBounds,
} from "./tauri/preferences";
import { launchedInSoftware } from "./effects";
const MINI_LABEL = "mini";
const DEFAULT_WIDTH = 280;
const DEFAULT_HEIGHT = 380;
const MIN_WIDTH = 240;
const MIN_HEIGHT = 320;
/** Margin from the screen edge (logical pixels). Matches Spotify's
* bottom-right offset, leaving room for the Windows taskbar / macOS
* Dock. Smaller offsets feel cramped against the corner. */
const EDGE_MARGIN = 24;
/** Minimum overlap (logical pixels) the saved window must keep with
* at least one monitor before we trust the position. A few pixels
* is enough β we just need to prevent restoring fully off-screen
* after a monitor disconnect or resolution change. */
const MIN_VISIBLE_OVERLAP = 80;
/**
* Does the proposed bounds rectangle intersect any of the available
* monitors by at least `MIN_VISIBLE_OVERLAP` on both axes? Used to
* sanity-check restored positions β Windows is happy to place a
* window at (1800, 200) even if the secondary monitor that owned
* those coordinates has since been unplugged.
*/
async function boundsAreVisible(bounds: MiniPlayerBounds): Promise<boolean> {
try {
const monitors = await availableMonitors();
for (const m of monitors) {
const scale = m.scaleFactor || 1;
// Convert monitor physical rect to logical so it matches the
// logical-pixel bounds we persist.
const mx = m.position.x / scale;
const my = m.position.y / scale;
const mw = m.size.width / scale;
const mh = m.size.height / scale;
const overlapX =
Math.min(bounds.x + bounds.width, mx + mw) - Math.max(bounds.x, mx);
const overlapY =
Math.min(bounds.y + bounds.height, my + mh) - Math.max(bounds.y, my);
if (overlapX >= MIN_VISIBLE_OVERLAP && overlapY >= MIN_VISIBLE_OVERLAP) {
return true;
}
}
} catch (err) {
console.warn("[miniPlayer] availableMonitors query failed", err);
}
return false;
}
/**
* Where a `width` Γ `height` mini-player goes by default: the
* bottom-right corner of `on`, or of the calling window's monitor when
* `on` is not given (Spotify-style), or `null` when no monitor can be
* read. `monitor.position` is the logical origin of the monitor in the
* virtual desktop space β non-zero on secondary monitors and negative when
* a monitor is placed to the left of (or above) the primary β so it is
* added to land on the right monitor instead of snapping to the primary's
* bottom-right corner.
*/
async function defaultCorner(
width: number,
height: number,
on?: Monitor | null,
): Promise<{ x: number; y: number } | null> {
try {
const monitor = on ?? (await currentMonitor());
if (!monitor) return null;
const scale = monitor.scaleFactor || 1;
const logicalX = monitor.position.x / scale;
const logicalY = monitor.position.y / scale;
const logicalW = monitor.size.width / scale;
const logicalH = monitor.size.height / scale;
return {
x: Math.round(logicalX + logicalW - width - EDGE_MARGIN),
y: Math.round(logicalY + logicalH - height - EDGE_MARGIN),
};
} catch (err) {
console.warn("[miniPlayer] monitor query failed", err);
return null;
}
}
/**
* The monitor holding the centre of `win`, or `null` when that point is
* on no monitor (a window left off-screen). The centre rather than a
* corner, so a window straddling two screens counts as on the one it
* mostly covers. Positions are physical, as `monitorFromPoint` takes them.
*/
async function monitorOf(win: TauriWindow): Promise<Monitor | null> {
try {
const position = await win.outerPosition();
const size = await win.outerSize();
return await monitorFromPoint(
position.x + size.width / 2,
position.y + size.height / 2,
);
} catch (err) {
console.warn("[miniPlayer] mini-player monitor query failed", err);
return null;
}
}
/**
* Put the mini-player back to its default size, in the default corner β
* from the Settings action or a double-click on its drag handle. The saved
* bounds are forgotten, so a closed mini-player opens there next time; an
* open or hidden one is moved at once (and un-maximised first), after
* which its own move listener saves the default like any other move.
*
* The corner is that of the monitor the mini-player is on, read before it
* is resized, so a reset from the main window on another screen leaves it
* where it was. Only a mini-player on no monitor at all β the off-screen
* case a reset is for β goes to the calling window's monitor.
*/
export async function resetMiniPlayerBounds(): Promise<void> {
await clearMiniPlayerBounds();
const win = await TauriWindow.getByLabel(MINI_LABEL);
if (!win) return;
const monitor = await monitorOf(win);
if (await win.isMaximized()) await win.unmaximize();
await win.setSize(new LogicalSize(DEFAULT_WIDTH, DEFAULT_HEIGHT));
const corner = await defaultCorner(DEFAULT_WIDTH, DEFAULT_HEIGHT, monitor);
if (corner) await win.setPosition(new LogicalPosition(corner.x, corner.y));
}
/**
* Open the always-on-top mini-player window. If it already exists,
* 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.
*
* Position + size are restored from `app_setting['mini_player.bounds']`
* when the saved rectangle still intersects an available monitor; we
* fall back to a Spotify-style bottom-right corner anchor otherwise
* (monitor disconnected, resolution change, or first launch).
*/
export async function openMiniPlayer(): Promise<void> {
const existing = await TauriWindow.getByLabel(MINI_LABEL);
if (existing) {
await existing.show();
await existing.unminimize();
await existing.setFocus();
void syncWebviewMemory().catch(() => {});
} else {
let x: number | undefined;
let y: number | undefined;
let width = DEFAULT_WIDTH;
let height = DEFAULT_HEIGHT;
// 1) Try the persisted bounds, but only if they still land on a
// real monitor. Stale coordinates (laptop docked at the office,
// undocked at home) silently fall through to the corner anchor.
try {
const saved = await getMiniPlayerBounds();
if (saved && (await boundsAreVisible(saved))) {
x = Math.round(saved.x);
y = Math.round(saved.y);
width = Math.max(MIN_WIDTH, Math.round(saved.width));
height = Math.max(MIN_HEIGHT, Math.round(saved.height));
}
} catch (err) {
console.warn("[miniPlayer] restore bounds failed", err);
}
// 2) No usable saved position β the default corner.
if (x == null || y == null) {
const corner = await defaultCorner(width, height);
x = corner?.x;
y = corner?.y;
}
const win = new WebviewWindow(MINI_LABEL, {
// The renderer is the process's, so the main window's launch hint
// holds here too: the bootstrap stamps the effects level for the
// mini-player's first paint as it did for the main window's.
url: launchedInSoftware()
? "index.html?mini=1&renderer=software"
: "index.html?mini=1",
title: "WaveFlow",
width,
height,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
alwaysOnTop: true,
// We render our own title bar (pin / drag-dots / close) inside
// the webview so the widget stays compact and on-brand.
decorations: false,
transparent: false,
resizable: true,
x,
y,
// Only center when we couldn't compute a corner position.
center: x == null || y == null,
skipTaskbar: false,
});
// Surface init errors via the standard event channel β `new` is
// sync but the underlying create is async on the Rust side.
await new Promise<void>((resolve, reject) => {
win.once("tauri://created", () => resolve());
win.once("tauri://error", (e) => reject(e.payload));
});
}
}