mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-02 07:21:33 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76f99e6518 | |||
| f1e260e996 |
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Hyprland fullscreen overlay alignment by verifying compositor client bounds, preventing modal, stats, and sidebar content from shifting below the mpv window.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed macOS subtitle bars being uninteractive (no hover, Yomitan lookups, or drag) after autoplay starts with "wait for overlay to be ready" enabled, until the user clicked a subtitle. The overlay window is now activated when pointer recovery fires.
|
||||
- Fixed the macOS overlay and subtitles (and the subtitle sidebar) staying hidden after a modal closes until the user clicked the mpv window, and mpv keyboard shortcuts (e.g. pause/unpause) not working because focus was left in limbo. Focus is now restored to mpv when the last modal closes, so the overlay reappears above mpv and playback keys reach mpv again — including when mpv is in native fullscreen (the overlay no longer requires a manual click to come back).
|
||||
@@ -1,47 +0,0 @@
|
||||
## Highlights
|
||||
### Fixed
|
||||
|
||||
- **Linux Overlay Stacking (XWayland / Wayland)**
|
||||
- The overlay no longer drops behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions; subtitle hover, pause-on-hover, and Yomitan lookups now work correctly on those desktops.
|
||||
- Stacking is now focus-sensitive: overlay stays managed while mpv is windowed, switches to non-interactive mode in fullscreen, and automatically yields to foreground windows (Settings, Yomitan, other apps) rather than covering them.
|
||||
- Startup glitches are resolved — no more display-sized overlay flash or black screen before playback begins.
|
||||
|
||||
- **Hyprland Overlay Placement (0.55+ / Lua configs)**
|
||||
- Overlay placement now works on Hyprland 0.55+ installations that use the new Lua config format; SubMiner detects Lua mode and uses the correct `hl.window_rule` dispatcher automatically.
|
||||
|
||||
- **macOS Overlay**
|
||||
- Fixed the subtitle overlay remaining click-through after pause-until-ready releases playback; hovering and Yomitan lookups resume normally.
|
||||
- Restored automatic mpv focus after closing Settings, AniList setup, and other modal windows so subtitles and playback keybinds work without clicking the player.
|
||||
|
||||
- **Manual Overlay Startup**
|
||||
- Starting the visible overlay manually from mpv now correctly attaches to playback, syncs the overlay window to mpv bounds on Linux/X11, and loads the current primary and secondary subtitles before revealing.
|
||||
|
||||
- **Playlist Transitions**
|
||||
- Advancing to the next mpv playlist item no longer triggers a second startup and tokenization delay; the overlay stays warm and visible subtitles are preserved across the transition.
|
||||
|
||||
- **Windows Launcher**
|
||||
- The `SubMiner mpv` shortcut on Windows now attaches the video to an already-running background app instead of spawning a duplicate warmup process.
|
||||
|
||||
- **Mouse Keybindings**
|
||||
- Side mouse buttons (`MBTN_BACK`, `MBTN_FORWARD`) and other mouse buttons can now be captured in the keybinding settings and work correctly at runtime.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Troubleshooting Guides**
|
||||
- Hyprland overlay guide updated with both Lua (`hl.window_rule`) and legacy `hyprland.conf` window rule syntax, plus a note on automatic placement via `hyprctl`.
|
||||
- New KDE Plasma / Wayland section covering XWayland workarounds when launching mpv manually.
|
||||
- New Character Dictionary section covering name matching, inline portraits, and external-profile mode (no AniList login required).
|
||||
- Added a "See Also" index linking each feature to its own troubleshooting page.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildHyprlandPlacementDispatches,
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
findHyprlandWindowForPlacement,
|
||||
hasHyprlandWindowPlacementBoundsMismatch,
|
||||
shouldAttemptHyprlandWindowPlacement,
|
||||
} from './hyprland-window-placement';
|
||||
|
||||
@@ -83,8 +84,8 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to
|
||||
},
|
||||
),
|
||||
[
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
|
||||
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
|
||||
['dispatch', 'setprop', 'address:0xabc rounding 0'],
|
||||
['dispatch', 'setprop', 'address:0xabc border_size 0'],
|
||||
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
|
||||
@@ -116,8 +117,8 @@ test('buildHyprlandPlacementDispatches emits Lua dispatchers for Lua-config Hypr
|
||||
[
|
||||
['dispatch', 'hl.dsp.window.float({ action = "on", window = "address:0xabc" })'],
|
||||
['dispatch', 'hl.dsp.window.pin({ action = "off", window = "address:0xabc" })'],
|
||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xabc" })'],
|
||||
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xabc" })'],
|
||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xabc" })'],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xabc" })',
|
||||
@@ -177,8 +178,8 @@ test('buildHyprlandPlacementDispatches can update placement without raising z-or
|
||||
{ promote: false },
|
||||
),
|
||||
[
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
|
||||
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
|
||||
['dispatch', 'setprop', 'address:0xabc rounding 0'],
|
||||
['dispatch', 'setprop', 'address:0xabc border_size 0'],
|
||||
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
|
||||
@@ -286,8 +287,8 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['-j', 'status'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
|
||||
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
|
||||
['dispatch', 'setprop', 'address:0xmatch rounding 0'],
|
||||
['dispatch', 'setprop', 'address:0xmatch border_size 0'],
|
||||
['dispatch', 'setprop', 'address:0xmatch no_shadow 1'],
|
||||
@@ -340,8 +341,8 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['-j', 'status'],
|
||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
|
||||
['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'],
|
||||
['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'],
|
||||
[
|
||||
'dispatch',
|
||||
'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })',
|
||||
@@ -366,3 +367,97 @@ test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config H
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('hasHyprlandWindowPlacementBoundsMismatch compares compositor client bounds', () => {
|
||||
const mismatch = hasHyprlandWindowPlacementBoundsMismatch({
|
||||
title: 'SubMiner Overlay',
|
||||
platform: 'linux',
|
||||
env: {
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
|
||||
},
|
||||
pid: 456,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 3440,
|
||||
height: 1440,
|
||||
},
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
assert.equal(command, 'hyprctl');
|
||||
assert.deepEqual(args, ['-j', 'clients']);
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Overlay',
|
||||
mapped: true,
|
||||
floating: true,
|
||||
at: [0, 14],
|
||||
size: [3440, 1426],
|
||||
},
|
||||
]);
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.equal(mismatch, true);
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitle retries when compositor bounds stay misaligned', () => {
|
||||
let clientReads = 0;
|
||||
const calls: unknown[][] = [];
|
||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||
title: 'SubMiner Overlay',
|
||||
platform: 'linux',
|
||||
env: {
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
|
||||
},
|
||||
pid: 456,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 3440,
|
||||
height: 1440,
|
||||
},
|
||||
execFileSync: ((command: string, args: string[], options: unknown) => {
|
||||
calls.push([command, args, options]);
|
||||
if (args.join(' ') === '-j clients') {
|
||||
clientReads += 1;
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Overlay',
|
||||
mapped: true,
|
||||
floating: true,
|
||||
pinned: false,
|
||||
at: clientReads === 1 ? [10, 58] : [0, 14],
|
||||
size: clientReads === 1 ? [3420, 1372] : [3440, 1426],
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (args.join(' ') === '-j status') {
|
||||
return JSON.stringify({ configProvider: 'hyprlang' });
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.equal(placed, true);
|
||||
assert.equal(clientReads, 2);
|
||||
assert.deepEqual(
|
||||
calls
|
||||
.map(([, args]) => args)
|
||||
.filter(
|
||||
(args) =>
|
||||
Array.isArray(args) &&
|
||||
args[0] === 'dispatch' &&
|
||||
(args[1] === 'resizewindowpixel' || args[1] === 'movewindowpixel'),
|
||||
),
|
||||
[
|
||||
['dispatch', 'resizewindowpixel', 'exact 3440 1440,address:0xmatch'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
|
||||
['dispatch', 'resizewindowpixel', 'exact 3440 1440,address:0xmatch'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface HyprlandPlacementClient {
|
||||
address?: string;
|
||||
at?: [number, number];
|
||||
floating?: boolean;
|
||||
hidden?: boolean;
|
||||
initialTitle?: string;
|
||||
mapped?: boolean;
|
||||
pid?: number;
|
||||
pinned?: boolean;
|
||||
size?: [number, number];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
@@ -43,6 +45,10 @@ function parseHyprlandClients(output: string): HyprlandPlacementClient[] {
|
||||
return Array.isArray(parsed) ? (parsed as HyprlandPlacementClient[]) : [];
|
||||
}
|
||||
|
||||
function readHyprlandPlacementClients(run: ExecFileSync): HyprlandPlacementClient[] {
|
||||
return parseHyprlandClients(String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' })));
|
||||
}
|
||||
|
||||
export function findHyprlandWindowForPlacement(
|
||||
clients: HyprlandPlacementClient[],
|
||||
options: {
|
||||
@@ -96,18 +102,18 @@ export function buildHyprlandPlacementDispatches(
|
||||
const roundedBounds = roundPlacementBounds(bounds);
|
||||
if (roundedBounds) {
|
||||
if (configProvider === 'lua') {
|
||||
dispatches.push(
|
||||
luaWindowDispatch('move', windowAddress, [
|
||||
`x = ${roundedBounds.x}`,
|
||||
`y = ${roundedBounds.y}`,
|
||||
]),
|
||||
);
|
||||
dispatches.push(
|
||||
luaWindowDispatch('resize', windowAddress, [
|
||||
`x = ${roundedBounds.width}`,
|
||||
`y = ${roundedBounds.height}`,
|
||||
]),
|
||||
);
|
||||
dispatches.push(
|
||||
luaWindowDispatch('move', windowAddress, [
|
||||
`x = ${roundedBounds.x}`,
|
||||
`y = ${roundedBounds.y}`,
|
||||
]),
|
||||
);
|
||||
dispatches.push(luaWindowSetProp(windowAddress, 'rounding', '0'));
|
||||
dispatches.push(luaWindowSetProp(windowAddress, 'border_size', '0'));
|
||||
dispatches.push(luaWindowSetProp(windowAddress, 'no_shadow', '1'));
|
||||
@@ -116,13 +122,13 @@ export function buildHyprlandPlacementDispatches(
|
||||
} else {
|
||||
dispatches.push([
|
||||
'dispatch',
|
||||
'movewindowpixel',
|
||||
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`,
|
||||
'resizewindowpixel',
|
||||
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`,
|
||||
]);
|
||||
dispatches.push([
|
||||
'dispatch',
|
||||
'resizewindowpixel',
|
||||
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`,
|
||||
'movewindowpixel',
|
||||
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`,
|
||||
]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]);
|
||||
@@ -181,6 +187,91 @@ function roundPlacementBounds(
|
||||
: null;
|
||||
}
|
||||
|
||||
function isFiniteTuple(value: unknown): value is [number, number] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length >= 2 &&
|
||||
typeof value[0] === 'number' &&
|
||||
typeof value[1] === 'number' &&
|
||||
Number.isFinite(value[0]) &&
|
||||
Number.isFinite(value[1])
|
||||
);
|
||||
}
|
||||
|
||||
export function getHyprlandClientPlacementBounds(
|
||||
client: HyprlandPlacementClient,
|
||||
): HyprlandPlacementBounds | null {
|
||||
if (!isFiniteTuple(client.at) || !isFiniteTuple(client.size)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return roundPlacementBounds({
|
||||
x: client.at[0],
|
||||
y: client.at[1],
|
||||
width: client.size[0],
|
||||
height: client.size[1],
|
||||
});
|
||||
}
|
||||
|
||||
export function hyprlandPlacementBoundsMatch(
|
||||
actual: HyprlandPlacementBounds | null,
|
||||
target: HyprlandPlacementBounds | null,
|
||||
tolerancePx = 1,
|
||||
): boolean {
|
||||
const roundedActual = roundPlacementBounds(actual);
|
||||
const roundedTarget = roundPlacementBounds(target);
|
||||
if (!roundedActual || !roundedTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.abs(roundedActual.x - roundedTarget.x) <= tolerancePx &&
|
||||
Math.abs(roundedActual.y - roundedTarget.y) <= tolerancePx &&
|
||||
Math.abs(roundedActual.width - roundedTarget.width) <= tolerancePx &&
|
||||
Math.abs(roundedActual.height - roundedTarget.height) <= tolerancePx
|
||||
);
|
||||
}
|
||||
|
||||
function clientMatchesPlacementBounds(
|
||||
client: HyprlandPlacementClient,
|
||||
bounds: HyprlandPlacementBounds,
|
||||
): boolean | null {
|
||||
const actual = getHyprlandClientPlacementBounds(client);
|
||||
return actual ? hyprlandPlacementBoundsMatch(actual, bounds) : null;
|
||||
}
|
||||
|
||||
export function hasHyprlandWindowPlacementBoundsMismatch(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pid?: number;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): boolean {
|
||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetBounds = roundPlacementBounds(options.bounds);
|
||||
if (!targetBounds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
try {
|
||||
const client = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), {
|
||||
pid: options.pid ?? process.pid,
|
||||
title: options.title,
|
||||
});
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
return clientMatchesPlacementBounds(client, targetBounds) === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
@@ -196,9 +287,7 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
try {
|
||||
const clients = parseHyprlandClients(
|
||||
String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' })),
|
||||
);
|
||||
const clients = readHyprlandPlacementClients(run);
|
||||
const client = findHyprlandWindowForPlacement(clients, {
|
||||
pid: options.pid ?? process.pid,
|
||||
title: options.title,
|
||||
@@ -208,6 +297,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
}
|
||||
|
||||
const configProvider = detectHyprlandConfigProvider(run);
|
||||
const targetBounds = roundPlacementBounds(options.bounds);
|
||||
const shouldVerifyBounds =
|
||||
targetBounds !== null && clientMatchesPlacementBounds(client, targetBounds) === false;
|
||||
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, {
|
||||
configProvider,
|
||||
promote: options.promote,
|
||||
@@ -215,6 +307,28 @@ export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
for (const args of dispatches) {
|
||||
run('hyprctl', args, { stdio: 'ignore' });
|
||||
}
|
||||
if (shouldVerifyBounds) {
|
||||
try {
|
||||
const refreshedClient = findHyprlandWindowForPlacement(readHyprlandPlacementClients(run), {
|
||||
pid: options.pid ?? process.pid,
|
||||
title: options.title,
|
||||
});
|
||||
if (
|
||||
refreshedClient &&
|
||||
targetBounds &&
|
||||
clientMatchesPlacementBounds(refreshedClient, targetBounds) === false
|
||||
) {
|
||||
for (const args of buildHyprlandPlacementDispatches(refreshedClient, targetBounds, {
|
||||
configProvider,
|
||||
promote: options.promote,
|
||||
})) {
|
||||
run('hyprctl', args, { stdio: 'ignore' });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort reconciliation: the initial placement dispatches already ran.
|
||||
}
|
||||
}
|
||||
return dispatches.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,22 @@ test('normalizeOverlayWindowBoundsForPlatform returns original geometry outside
|
||||
assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'linux', null), geometry);
|
||||
});
|
||||
|
||||
test('normalizeOverlayWindowBoundsForPlatform compensates Linux content insets', () => {
|
||||
assert.deepEqual(
|
||||
normalizeOverlayWindowBoundsForPlatform(
|
||||
{ x: 0, y: 0, width: 3440, height: 1440 },
|
||||
'linux',
|
||||
null,
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }),
|
||||
getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }),
|
||||
},
|
||||
),
|
||||
{ x: 0, y: -14, width: 3440, height: 1454 },
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeOverlayWindowBoundsForPlatform returns original geometry on Windows when screen is unavailable', () => {
|
||||
const geometry = { x: 150, y: 90, width: 1200, height: 675 };
|
||||
assert.deepEqual(normalizeOverlayWindowBoundsForPlatform(geometry, 'win32', null), geometry);
|
||||
|
||||
@@ -7,11 +7,56 @@ type ScreenDipConverter = {
|
||||
) => Electron.Rectangle;
|
||||
};
|
||||
|
||||
type ContentBoundsWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
getBounds: () => Electron.Rectangle;
|
||||
getContentBounds: () => Electron.Rectangle;
|
||||
};
|
||||
|
||||
function resolveContentAlignedBounds(
|
||||
geometry: WindowGeometry,
|
||||
window?: ContentBoundsWindow | null,
|
||||
): WindowGeometry {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
let outer: Electron.Rectangle;
|
||||
let content: Electron.Rectangle;
|
||||
try {
|
||||
outer = window.getBounds();
|
||||
content = window.getContentBounds();
|
||||
} catch {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
const leftInset = content.x - outer.x;
|
||||
const topInset = content.y - outer.y;
|
||||
const rightInset = outer.x + outer.width - (content.x + content.width);
|
||||
const bottomInset = outer.y + outer.height - (content.y + content.height);
|
||||
const insets = [leftInset, topInset, rightInset, bottomInset];
|
||||
if (insets.some((inset) => !Number.isFinite(inset) || inset < 0)) {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
return {
|
||||
x: geometry.x - leftInset,
|
||||
y: geometry.y - topInset,
|
||||
width: geometry.width + leftInset + rightInset,
|
||||
height: geometry.height + topInset + bottomInset,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOverlayWindowBoundsForPlatform(
|
||||
geometry: WindowGeometry,
|
||||
platform: NodeJS.Platform,
|
||||
screen: ScreenDipConverter | null,
|
||||
window?: ContentBoundsWindow | null,
|
||||
): WindowGeometry {
|
||||
if (platform === 'linux') {
|
||||
return resolveContentAlignedBounds(geometry, window);
|
||||
}
|
||||
|
||||
if (platform !== 'win32' || !screen) {
|
||||
return geometry;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { ensureOverlayWindowLevel } from './overlay-window';
|
||||
import { ensureOverlayWindowLevel, updateOverlayWindowBounds } from './overlay-window';
|
||||
import {
|
||||
handleOverlayWindowBeforeInputEvent,
|
||||
handleOverlayWindowBlurred,
|
||||
@@ -288,3 +288,37 @@ test('ensureOverlayWindowLevel promotes Linux overlay above fullscreen mpv witho
|
||||
'move-top',
|
||||
]);
|
||||
});
|
||||
|
||||
test('updateOverlayWindowBounds aligns Linux overlay content bounds to mpv geometry', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
const originalHyprlandSignature = process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
delete process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||
|
||||
const calls: Array<{ x: number; y: number; width: number; height: number }> = [];
|
||||
try {
|
||||
updateOverlayWindowBounds({ x: 0, y: 0, width: 3440, height: 1440 }, {
|
||||
isDestroyed: () => false,
|
||||
getTitle: () => 'SubMiner Overlay',
|
||||
getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }),
|
||||
getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }),
|
||||
setBounds: (bounds: { x: number; y: number; width: number; height: number }) => {
|
||||
calls.push(bounds);
|
||||
},
|
||||
} as never);
|
||||
} finally {
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
if (originalHyprlandSignature === undefined) {
|
||||
delete process.env.HYPRLAND_INSTANCE_SIGNATURE;
|
||||
} else {
|
||||
process.env.HYPRLAND_INSTANCE_SIGNATURE = originalHyprlandSignature;
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, [{ x: 0, y: -14, width: 3440, height: 1454 }]);
|
||||
});
|
||||
|
||||
@@ -56,7 +56,12 @@ export function updateOverlayWindowBounds(
|
||||
} = {},
|
||||
): void {
|
||||
if (!geometry || !window || window.isDestroyed()) return;
|
||||
const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen);
|
||||
const bounds = normalizeOverlayWindowBoundsForPlatform(
|
||||
geometry,
|
||||
process.platform,
|
||||
screen,
|
||||
window,
|
||||
);
|
||||
window.setBounds(bounds);
|
||||
ensureHyprlandWindowFloatingByTitle({
|
||||
title: window.getTitle(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { WindowGeometry } from '../../types';
|
||||
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
||||
const DEFAULT_STATS_WINDOW_HEIGHT = 700;
|
||||
export const STATS_WINDOW_TITLE = 'SubMiner Stats';
|
||||
const STATS_POST_SHOW_RECONCILE_DELAYS_MS = [50, 150, 300, 600] as const;
|
||||
|
||||
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
@@ -26,6 +27,14 @@ type StatsNativeConfirmDialogPresenter<WindowT> = {
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
Partial<Pick<BrowserWindow, 'showInactive'>>;
|
||||
type StatsWindowReconcileScheduler = {
|
||||
setTimeout: (
|
||||
callback: () => void,
|
||||
delayMs: number,
|
||||
) => {
|
||||
unref?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
||||
return (
|
||||
@@ -189,6 +198,16 @@ export function presentStatsWindow(
|
||||
window.focus();
|
||||
}
|
||||
|
||||
export function scheduleStatsWindowPostShowReconciles(
|
||||
reconcile: () => void,
|
||||
scheduler: StatsWindowReconcileScheduler = globalThis,
|
||||
): void {
|
||||
for (const delayMs of STATS_POST_SHOW_RECONCILE_DELAYS_MS) {
|
||||
const timeout = scheduler.setTimeout(reconcile, delayMs);
|
||||
timeout.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
||||
query: Record<string, string>;
|
||||
} {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
} from './stats-window-runtime';
|
||||
@@ -402,3 +403,39 @@ test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
|
||||
|
||||
assert.deepEqual(calls, ['show', 'focus']);
|
||||
});
|
||||
|
||||
test('scheduleStatsWindowPostShowReconciles retries placement after a reused hidden window is remapped', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
scheduleStatsWindowPostShowReconciles(
|
||||
() => {
|
||||
calls.push('reconcile');
|
||||
},
|
||||
{
|
||||
setTimeout: (callback, delayMs) => {
|
||||
calls.push(`timer:${delayMs}`);
|
||||
callback();
|
||||
return {
|
||||
unref: () => {
|
||||
calls.push(`unref:${delayMs}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'timer:50',
|
||||
'reconcile',
|
||||
'unref:50',
|
||||
'timer:150',
|
||||
'reconcile',
|
||||
'unref:150',
|
||||
'timer:300',
|
||||
'reconcile',
|
||||
'unref:300',
|
||||
'timer:600',
|
||||
'reconcile',
|
||||
'unref:600',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
promoteStatsWindowLevel,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
STATS_WINDOW_TITLE,
|
||||
@@ -58,6 +59,25 @@ function syncStatsWindowBounds(
|
||||
return outerBounds;
|
||||
}
|
||||
|
||||
function reconcileStatsWindowBounds(window: BrowserWindow, options: StatsWindowOptions): void {
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return;
|
||||
}
|
||||
const placementBounds = syncStatsWindowBounds(window, options.resolveBounds());
|
||||
if (placementBounds) {
|
||||
ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStatsWindowBoundsReconcile(
|
||||
window: BrowserWindow,
|
||||
options: StatsWindowOptions,
|
||||
): void {
|
||||
scheduleStatsWindowPostShowReconciles(() => {
|
||||
reconcileStatsWindowBounds(window, options);
|
||||
});
|
||||
}
|
||||
|
||||
function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): void {
|
||||
const bounds = options.resolveBounds();
|
||||
let placementBounds = syncStatsWindowBounds(window, bounds);
|
||||
@@ -71,6 +91,8 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
}
|
||||
options.onVisibilityChanged?.(true);
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
reconcileStatsWindowBounds(window, options);
|
||||
scheduleStatsWindowBoundsReconcile(window, options);
|
||||
}
|
||||
|
||||
export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||
|
||||
+52
-2
@@ -65,6 +65,8 @@ import {
|
||||
tickLinuxOverlayPointerInteraction,
|
||||
} from './main/runtime/linux-overlay-pointer-interaction';
|
||||
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
|
||||
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
|
||||
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
|
||||
import { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state';
|
||||
import { mergeAiConfig } from './ai/config';
|
||||
|
||||
@@ -396,6 +398,8 @@ import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from './core/services/youtube/generate';
|
||||
import { hasHyprlandWindowPlacementBoundsMismatch } from './core/services/hyprland-window-placement';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './core/services/overlay-window-bounds';
|
||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
@@ -946,6 +950,25 @@ const bootServices = createMainBootServices({
|
||||
return createOverlayModalRuntimeService(buildHandler(), {
|
||||
onModalStateChange: (isActive: boolean) =>
|
||||
overlayModalInputState.handleModalInputStateChange(isActive),
|
||||
// On macOS, after the last modal closes the post-close visibility sync hides the overlay
|
||||
// when neither it nor mpv is focused, and keyboard focus is left in limbo (mpv keys like
|
||||
// shift-to-unpause stop working). Programmatically focusing mpv from our background helper
|
||||
// is refused by macOS (most visibly in native fullscreen), so instead resign SubMiner's
|
||||
// active status — exactly what a manual click does — handing focus back to the previously
|
||||
// active app (mpv). The overlay is already hidden at this point, so app.hide() hides nothing
|
||||
// visible; once mpv is focused the tracker re-shows the overlay above it.
|
||||
onFinalModalClosed: () => {
|
||||
void restoreMacOSMpvFocusAfterModalClose({
|
||||
platform: process.platform,
|
||||
focusMpv: async () => {
|
||||
app.hide();
|
||||
},
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
updateVisibleOverlayVisibility: () =>
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
warn: (message, details) => logger.warn(message, details),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
createAppState,
|
||||
@@ -1259,6 +1282,16 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
||||
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
// Renderer-side recovery alone cannot wake subtitle hover on macOS: the overlay only
|
||||
// receives mouse-moved events while it is the key window of the frontmost app, and mpv
|
||||
// is frontmost once playback starts. Activate the overlay window first so the broadcast's
|
||||
// setIgnoreMouseEvents toggling actually takes effect, mirroring a manual subtitle click.
|
||||
focusMacOSOverlayWindow({
|
||||
platform: process.platform,
|
||||
getOverlayWindow: () => overlayManager.getMainWindow(),
|
||||
stealAppFocus: () => app.focus({ steal: true }),
|
||||
warn: (message, details) => logger.warn(message, details),
|
||||
});
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||
},
|
||||
isSignalTargetReady: (signal) =>
|
||||
@@ -5429,16 +5462,33 @@ function maybeExitLinuxFullscreenOverrideForTrackedGeometry(geometry: WindowGeom
|
||||
syncLinuxVisibleOverlayMpvFullscreenMode(false);
|
||||
}
|
||||
|
||||
function hasHyprlandOverlayWindowPlacementMismatch(geometry: WindowGeometry): boolean {
|
||||
if (process.platform !== 'linux') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [overlayManager.getMainWindow(), overlayManager.getModalWindow()].some((window) => {
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
return hasHyprlandWindowPlacementBoundsMismatch({
|
||||
title: window.getTitle(),
|
||||
bounds: normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen, window),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
getCurrentOverlayWindowBounds: () => lastOverlayWindowGeometry,
|
||||
shouldRefreshUnchangedGeometry: (geometry) =>
|
||||
shouldExitLinuxFullscreenOverrideForGeometry(geometry) ||
|
||||
(process.platform === 'linux' &&
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
(hasLiveOverlayWindowBoundsMismatch(
|
||||
[overlayManager.getMainWindow(), overlayManager.getModalWindow()],
|
||||
geometry,
|
||||
)),
|
||||
) ||
|
||||
hasHyprlandOverlayWindowPlacementMismatch(geometry))),
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
afterSetOverlayWindowBounds: () => {
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
|
||||
@@ -230,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.isFocused(), true);
|
||||
assert.deepEqual(calls, ['bounds:10,20,300,200', 'bounds:10,20,300,200']);
|
||||
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { WindowGeometry } from '../types';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
const MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS = 50;
|
||||
|
||||
function requestOverlayApplicationFocus(): void {
|
||||
try {
|
||||
@@ -144,6 +145,24 @@ export function createOverlayModalRuntimeService(
|
||||
window.moveTop();
|
||||
};
|
||||
|
||||
const reconcileModalWindowBounds = (window: BrowserWindow): void => {
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (!modalWindow || modalWindow !== window || window.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
};
|
||||
|
||||
const scheduleModalWindowBoundsReconcile = (window: BrowserWindow): void => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return;
|
||||
}
|
||||
reconcileModalWindowBounds(window);
|
||||
}, MODAL_POST_SHOW_BOUNDS_RECONCILE_DELAY_MS);
|
||||
timeout.unref?.();
|
||||
};
|
||||
|
||||
const sendOrQueueForWindow = (
|
||||
window: BrowserWindow,
|
||||
sendNow: (window: BrowserWindow) => void,
|
||||
@@ -187,6 +206,8 @@ export function createOverlayModalRuntimeService(
|
||||
if (!window.webContents.isFocused()) {
|
||||
window.webContents.focus();
|
||||
}
|
||||
reconcileModalWindowBounds(window);
|
||||
scheduleModalWindowBoundsReconcile(window);
|
||||
};
|
||||
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
@@ -198,6 +219,8 @@ export function createOverlayModalRuntimeService(
|
||||
if (window.isVisible()) {
|
||||
window.focus();
|
||||
window.webContents.focus();
|
||||
reconcileModalWindowBounds(window);
|
||||
scheduleModalWindowBoundsReconcile(window);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { focusMacOSMpvProcess } from './macos-mpv-focus';
|
||||
|
||||
test('focusMacOSMpvProcess fronts the running mpv process with osascript', async () => {
|
||||
const calls: Array<{ command: string; args: string[]; timeout?: number }> = [];
|
||||
|
||||
await focusMacOSMpvProcess({
|
||||
execFile: (command, args, options, callback) => {
|
||||
calls.push({ command, args, timeout: options.timeout });
|
||||
callback(null);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.command, '/usr/bin/osascript');
|
||||
assert.equal(calls[0]?.timeout, 2000);
|
||||
assert.deepEqual(calls[0]?.args, [
|
||||
'-e',
|
||||
'tell application "System Events" to set frontmost of the first process whose name is "mpv" to true',
|
||||
]);
|
||||
});
|
||||
|
||||
test('focusMacOSMpvProcess rejects when osascript fails', async () => {
|
||||
await assert.rejects(
|
||||
focusMacOSMpvProcess({
|
||||
execFile: (_command, _args, _options, callback) => {
|
||||
callback(new Error('not allowed'));
|
||||
},
|
||||
}),
|
||||
/not allowed/,
|
||||
);
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import { execFile as nodeExecFile } from 'node:child_process';
|
||||
|
||||
const FOCUS_MPV_PROCESS_SCRIPT =
|
||||
'tell application "System Events" to set frontmost of the first process whose name is "mpv" to true';
|
||||
|
||||
type ExecFileForMacOSFocus = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { timeout: number },
|
||||
callback: (error: Error | null) => void,
|
||||
) => void;
|
||||
|
||||
export type MacOSMpvFocusDeps = {
|
||||
execFile?: ExecFileForMacOSFocus;
|
||||
};
|
||||
|
||||
export async function focusMacOSMpvProcess(deps: MacOSMpvFocusDeps = {}): Promise<void> {
|
||||
const execFile: ExecFileForMacOSFocus =
|
||||
deps.execFile ??
|
||||
((command, args, options, callback) => {
|
||||
nodeExecFile(command, args, options, (error) => {
|
||||
callback(error);
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile('/usr/bin/osascript', ['-e', FOCUS_MPV_PROCESS_SCRIPT], { timeout: 2000 }, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { focusMacOSOverlayWindow } from './macos-overlay-window-focus';
|
||||
|
||||
function createOverlayWindowStub(
|
||||
overrides: Partial<{
|
||||
destroyed: boolean;
|
||||
visible: boolean;
|
||||
focused: boolean;
|
||||
webContentsFocused: boolean;
|
||||
}> = {},
|
||||
calls: string[] = [],
|
||||
) {
|
||||
return {
|
||||
isDestroyed: () => overrides.destroyed ?? false,
|
||||
isVisible: () => overrides.visible ?? true,
|
||||
isFocused: () => overrides.focused ?? false,
|
||||
setFocusable: (focusable: boolean) => {
|
||||
calls.push(`setFocusable:${focusable}`);
|
||||
},
|
||||
focus: () => {
|
||||
calls.push('window.focus');
|
||||
},
|
||||
webContents: {
|
||||
isFocused: () => overrides.webContentsFocused ?? false,
|
||||
focus: () => {
|
||||
calls.push('webContents.focus');
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('focusMacOSOverlayWindow activates the overlay window on macOS', () => {
|
||||
const calls: string[] = [];
|
||||
const overlayWindow = createOverlayWindowStub({}, calls);
|
||||
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => overlayWindow,
|
||||
stealAppFocus: () => calls.unshift('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['app.focus', 'setFocusable:true', 'window.focus', 'webContents.focus']);
|
||||
});
|
||||
|
||||
test('focusMacOSOverlayWindow skips re-focusing the web contents when already focused', () => {
|
||||
const calls: string[] = [];
|
||||
const overlayWindow = createOverlayWindowStub({ webContentsFocused: true }, calls);
|
||||
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => overlayWindow,
|
||||
stealAppFocus: () => calls.unshift('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['app.focus', 'setFocusable:true', 'window.focus']);
|
||||
});
|
||||
|
||||
test('focusMacOSOverlayWindow no-ops on non-macOS platforms', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'win32',
|
||||
getOverlayWindow: () => createOverlayWindowStub({}, calls),
|
||||
stealAppFocus: () => calls.push('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('focusMacOSOverlayWindow no-ops when the overlay is already focused', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => createOverlayWindowStub({ focused: true }, calls),
|
||||
stealAppFocus: () => calls.push('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('focusMacOSOverlayWindow no-ops when the overlay is hidden, destroyed, or missing', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => createOverlayWindowStub({ visible: false }, calls),
|
||||
stealAppFocus: () => calls.push('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => createOverlayWindowStub({ destroyed: true }, calls),
|
||||
stealAppFocus: () => calls.push('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => null,
|
||||
stealAppFocus: () => calls.push('app.focus'),
|
||||
warn: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('focusMacOSOverlayWindow still focuses the window when stealing app focus throws', () => {
|
||||
const calls: string[] = [];
|
||||
const overlayWindow = createOverlayWindowStub({}, calls);
|
||||
|
||||
focusMacOSOverlayWindow({
|
||||
platform: 'darwin',
|
||||
getOverlayWindow: () => overlayWindow,
|
||||
stealAppFocus: () => {
|
||||
throw new Error('steal failed');
|
||||
},
|
||||
warn: (message) => calls.push(`warn:${message}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'warn:Failed to steal app focus for overlay window',
|
||||
'setFocusable:true',
|
||||
'window.focus',
|
||||
'webContents.focus',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
type FocusableOverlayWebContents = {
|
||||
isFocused: () => boolean;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
type FocusableOverlayWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
isFocused: () => boolean;
|
||||
setFocusable?: (focusable: boolean) => void;
|
||||
focus: () => void;
|
||||
webContents: FocusableOverlayWebContents;
|
||||
};
|
||||
|
||||
export type MacOSOverlayWindowFocusDeps = {
|
||||
platform: NodeJS.Platform;
|
||||
getOverlayWindow: () => FocusableOverlayWindow | null;
|
||||
stealAppFocus: () => void;
|
||||
warn: (message: string, details?: unknown) => void;
|
||||
};
|
||||
|
||||
// macOS only delivers mouse-moved/hover events to the key window of the frontmost application.
|
||||
// After autoplay warmup completes mpv is the frontmost process, so the transparent overlay window
|
||||
// receives no pointer events until the user physically clicks a subtitle (which activates the app
|
||||
// via acceptFirstMouse). Renderer-side pointer recovery can toggle setIgnoreMouseEvents but cannot
|
||||
// make the window key, so it cannot wake hover on its own. Activating the overlay window from the
|
||||
// main process reproduces that manual click and keeps subtitles interactive.
|
||||
// (Modal close takes the opposite path — see restoreMacOSMpvFocusAfterModalClose — because the user
|
||||
// needs keyboard focus back on mpv, with the overlay floating passively above it.)
|
||||
export function focusMacOSOverlayWindow(deps: MacOSOverlayWindowFocusDeps): void {
|
||||
if (deps.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayWindow = deps.getOverlayWindow();
|
||||
if (!overlayWindow || overlayWindow.isDestroyed() || !overlayWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (overlayWindow.isFocused()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
deps.stealAppFocus();
|
||||
} catch (error) {
|
||||
deps.warn('Failed to steal app focus for overlay window', error);
|
||||
}
|
||||
|
||||
overlayWindow.setFocusable?.(true);
|
||||
overlayWindow.focus();
|
||||
if (!overlayWindow.webContents.isFocused()) {
|
||||
overlayWindow.webContents.focus();
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,37 @@ test('live overlay bounds mismatch forces refresh after window manager restore d
|
||||
);
|
||||
});
|
||||
|
||||
test('live overlay bounds mismatch compares content bounds when compositor adds insets', () => {
|
||||
const geometry = { x: 0, y: 0, width: 3440, height: 1440 };
|
||||
|
||||
assert.equal(
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
[
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ ...geometry }),
|
||||
getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }),
|
||||
},
|
||||
],
|
||||
geometry,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
hasLiveOverlayWindowBoundsMismatch(
|
||||
[
|
||||
{
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 0, y: -14, width: 3440, height: 1454 }),
|
||||
getContentBounds: () => ({ ...geometry }),
|
||||
},
|
||||
],
|
||||
geometry,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensure overlay window level handler delegates to core', () => {
|
||||
const calls: string[] = [];
|
||||
const ensureLevel = createEnsureOverlayWindowLevelHandler({
|
||||
|
||||
@@ -3,12 +3,25 @@ import type { WindowGeometry } from '../../types';
|
||||
type OverlayBoundsWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
getBounds: () => WindowGeometry;
|
||||
getContentBounds?: () => WindowGeometry;
|
||||
};
|
||||
|
||||
function sameGeometry(a: WindowGeometry | null | undefined, b: WindowGeometry): boolean {
|
||||
return a?.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
||||
}
|
||||
|
||||
function getWindowAlignmentBounds(window: OverlayBoundsWindow): WindowGeometry | null {
|
||||
try {
|
||||
return window.getContentBounds?.() ?? window.getBounds();
|
||||
} catch {
|
||||
try {
|
||||
return window.getBounds();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function hasLiveOverlayWindowBoundsMismatch(
|
||||
windows: Array<OverlayBoundsWindow | null | undefined>,
|
||||
geometry: WindowGeometry,
|
||||
@@ -17,7 +30,7 @@ export function hasLiveOverlayWindowBoundsMismatch(
|
||||
if (!window || window.isDestroyed()) {
|
||||
return false;
|
||||
}
|
||||
return !sameGeometry(window.getBounds(), geometry);
|
||||
return !sameGeometry(getWindowAlignmentBounds(window), geometry);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user