mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-09 15:13:32 -07:00
fix(overlay): fix macOS overlay interactivity and focus after autoplay (#106)
This commit is contained in:
@@ -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`.
|
|
||||||
+31
@@ -65,6 +65,8 @@ import {
|
|||||||
tickLinuxOverlayPointerInteraction,
|
tickLinuxOverlayPointerInteraction,
|
||||||
} from './main/runtime/linux-overlay-pointer-interaction';
|
} from './main/runtime/linux-overlay-pointer-interaction';
|
||||||
import { createLinuxX11CursorPointReader } from './main/runtime/linux-x11-cursor-point';
|
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 { resolveFreshPlaybackPaused } from './main/runtime/playback-paused-state';
|
||||||
import { mergeAiConfig } from './ai/config';
|
import { mergeAiConfig } from './ai/config';
|
||||||
|
|
||||||
@@ -946,6 +948,25 @@ const bootServices = createMainBootServices({
|
|||||||
return createOverlayModalRuntimeService(buildHandler(), {
|
return createOverlayModalRuntimeService(buildHandler(), {
|
||||||
onModalStateChange: (isActive: boolean) =>
|
onModalStateChange: (isActive: boolean) =>
|
||||||
overlayModalInputState.handleModalInputStateChange(isActive),
|
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,
|
createAppState,
|
||||||
@@ -1259,6 +1280,16 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
|||||||
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
|
||||||
return;
|
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);
|
broadcastToOverlayWindows(IPC_CHANNELS.event.overlayPointerRecoveryRequest);
|
||||||
},
|
},
|
||||||
isSignalTargetReady: (signal) =>
|
isSignalTargetReady: (signal) =>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user