fix(overlay): Linux X11/XWayland stacking, stale pause state, multi-copy selector (#101)

This commit is contained in:
2026-05-31 20:59:18 -07:00
committed by GitHub
parent b46b8dfa41
commit e1ea464bc9
103 changed files with 6314 additions and 353 deletions
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed manual visible-overlay startup from mpv so SubMiner attaches to playback, keeps the Linux/X11 overlay window shape synced with mpv bounds, and primes the current primary/secondary subtitles before showing the overlay.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed mpv-plugin multi-line copy and mine shortcuts so they open the overlay digit selector instead of dispatching a missing-count action that immediately selects one line.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle hover auto-pause using stale pause state, which could briefly advance a paused mpv video on Linux/X11 or XWayland.
+11
View File
@@ -0,0 +1,11 @@
type: fixed
area: overlay
- Fixed the overlay intermittently dropping behind mpv on KDE Plasma and other non-Hyprland/Sway Wayland sessions, where it lost pause-on-hover and Yomitan lookups.
- SubMiner now forces XWayland for itself (`--ozone-platform-hint=x11`) and for every mpv it launches (Jellyfin/YouTube/launcher), binds the windowed overlay to tracked mpv with `WM_TRANSIENT_FOR`, and swaps to a focusable-false X11 override-redirect overlay while tracked mpv is fullscreen.
- Made Linux overlay stacking focus-sensitive: managed while mpv is windowed, override-redirect only while mpv is fullscreen, and released/hidden when another X11/XWayland app takes focus (detected via `_NET_ACTIVE_WINDOW`/`xprop`, falling back to `xdotool getactivewindow`) so normal foreground windows can cover it. The overlay is shown inactive so hovering subtitles does not reveal Plasma panels/docks over fullscreen mpv.
- Restored subtitle hover, pause-on-hover, and Yomitan lookups under X11/XWayland by tracking the cursor in the main process (Electron cannot forward mouse-move through a click-through window on X11), keeping the primary and secondary subtitle bars and the open sidebar as separate interactive regions while the space between them passes through to mpv — and reporting hit regions as soon as the first subtitle is measured so it is clickable immediately. Matches Windows/macOS.
- Kept normal foreground windows above the overlay while they are open, including SubMiner Settings, Yomitan Settings, AniList setup, Jellyfin setup, first-run setup, and unrelated apps, so hovering over subtitles or the sidebar under those windows no longer restacks the overlay in front.
- Right-clicking non-interactive overlay subtitles now raises tracked mpv together with the overlay, then keeps the overlay above mpv before toggling pause/play.
- Kept overlay bounds reconciled across fullscreen/windowed, minimize/restore, and modal transitions so the subtitle sidebar and secondary subtitles stay inside the mpv window, and ensured only one visible overlay window exists at a time.
- Avoided startup overlay glitches: the first reveal waits until the renderer has painted, the overlay stays hidden until the X11 tracker reports real mpv geometry, and the fullscreen restack hide/show is skipped when leaving fullscreen — preventing black flashes and a display-sized overlay that blocks the desktop before warmups finish.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: playback
- Reused the already-warm SubMiner overlay after mpv advances to the next playlist item, avoiding a second pause-until-ready tokenization gate and preserving visible subtitles across the transition, including overlays shown manually while auto-start visibility is disabled.
+1
View File
@@ -4,3 +4,4 @@ area: troubleshooting
- Updated the Hyprland overlay troubleshooting with current Lua (`hl.window_rule`) config and the legacy `hyprland.conf` window rules, and noted SubMiner attempts automatic placement via `hyprctl`.
- Added a Character Dictionary troubleshooting section covering name matching, inline portraits, and external-profile mode (no AniList auth required).
- Added a "See Also" index linking each feature's own troubleshooting page.
- Added troubleshooting for KDE/Wayland and other non-Hyprland/Sway Wayland sessions, including how to force XWayland when launching mpv manually.
+33 -4
View File
@@ -326,14 +326,14 @@ The Jimaku API has rate limits. If you see 429 errors, wait for the retry durati
### Linux
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors are not supported — both mpv and SubMiner must run under X11 or Xwayland instead.
- **X11 / Xwayland**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway — both mpv and SubMiner must be running under X11/Xwayland for window tracking to work.
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively — both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway — both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath.
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
### Hyprland
SubMiner's overlay is a transparent, frameless, always-on-top Electron window. SubMiner tries to apply the floating, borderless, no-shadow, and no-blur properties itself each time it places the overlay. It detects Hyprland's active config provider and uses Lua `hl.dsp.window.*` dispatchers for recent Hyprland Lua configs, or the legacy dispatcher syntax for older hyprlang configs. On many configurations that is enough, but if your Hyprland version doesn't honor those runtime dispatches — or a broad rule in your config forces opacity/blur on every window — add explicit window rules so the overlay is exempt. You also need `pass` bindings to forward global shortcuts to SubMiner (see below).
SubMiner's overlay is a transparent, frameless Electron window that must be kept above mpv. SubMiner tries to apply the floating, borderless, no-shadow, and no-blur properties itself each time it places the overlay. It detects Hyprland's active config provider and uses Lua `hl.dsp.window.*` dispatchers for recent Hyprland Lua configs, or the legacy dispatcher syntax for older hyprlang configs. On many configurations that is enough, but if your Hyprland version doesn't honor those runtime dispatches — or a broad rule in your config forces opacity/blur on every window — add explicit window rules so the overlay is exempt. You also need `pass` bindings to forward global shortcuts to SubMiner (see below).
**Overlay is not transparent or has a visible border**
@@ -385,6 +385,35 @@ SubMiner watches mpv's `fullscreen` property and refreshes the overlay geometry
For more details, see the Hyprland docs on [global keybinds](https://wiki.hypr.land/Configuring/Binds/#global-keybinds) and [window rules](https://wiki.hypr.land/Configuring/Window-Rules/).
### KDE Plasma & other Wayland compositors
On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and others), the overlay can only stay above mpv when both processes run under **XWayland** — the Wayland protocol forbids clients from controlling window stacking, so the overlay's "always on top" becomes a no-op on a native Wayland surface.
SubMiner handles this automatically:
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too — Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
- During startup and fullscreen transitions, SubMiner waits for tracked mpv geometry before showing the visible overlay and skips the fullscreen restack hide/show path after mpv leaves fullscreen. That avoids a temporary full-screen overlay or black window while the subtitle tokenizer and Yomitan warmups finish.
- If the subtitle sidebar is open during a windowed/fullscreen transition, SubMiner restores it on the replacement overlay window. Subtitle hit regions are also refreshed as soon as the first measured subtitle line is reported, so hover and Yomitan lookup should work on the first visible line.
Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner uses root `_NET_ACTIVE_WINDOW` from `xprop` for focus detection and falls back to `xdotool getactivewindow` when that signal is unavailable.
**Overlay sits behind mpv / pause-on-hover and Yomitan stop working**
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
**Overlay stays above an unrelated foreground app**
SubMiner can only detect focus for X11/Xwayland windows in this mode. If a native Wayland app covers mpv but the overlay stays visible, run that app under Xwayland too or use Hyprland/Sway native support. Generic X11 cannot observe native Wayland foreground windows.
### macOS
- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility.
+1
View File
@@ -19,6 +19,7 @@ The desktop app keeps `src/main.ts` as composition root and pushes behavior into
- [Domains](./domains.md) - who owns what
- [Layering](./layering.md) - how modules should depend on each other
- [Subtitle Overlay Priming](./subtitle-overlay-priming.md) - visible-overlay subtitle startup flow
- Public contributor summary: [`docs-site/architecture.md`](../../docs-site/architecture.md)
## Current Shape
@@ -0,0 +1,83 @@
<!-- read_when: changing visible overlay startup, Linux/X11 overlay window shape, mpv subtitle callbacks, or subtitle tokenization emission -->
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-01
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
Visible-overlay subtitle priming fills the overlay from mpv's current subtitle properties before
waiting for the next live mpv subtitle event. This avoids a stale or blank overlay when the user
manually shows the visible overlay while playback is already sitting on a subtitle.
On Linux/X11, visible-overlay show and later mpv bounds refreshes restore the Electron window shape
to the full current overlay bounds. Electron's `BrowserWindow.setShape()` applies a bounding shape,
not an input-only region; stale shapes can leave a mapped 1920x1080 overlay with smaller X11 shape
extents such as `800x600+0+0`, so renderer and websocket subtitle state are correct while bottom
subtitles do not draw.
## Entry Points
- `src/main.ts` calls `primeCurrentSubtitleForVisibleOverlay()` when manual visible-overlay show
paths run.
- `src/main.ts` calls `restoreVisibleOverlayWindowShapeForShow()` before visible-overlay show
actions on Linux, and `resetVisibleOverlayInputState()` restores a full shape instead of applying
an empty shape.
- `src/main.ts` also restores the Linux/X11 shape after applying mpv overlay bounds, so a newly
created 800x600 hidden Electron window cannot keep clipping after it is resized to mpv geometry.
- `primeCurrentSubtitleForVisibleOverlay()` delegates to
`primeVisibleOverlaySubtitleFromMpv()` in `src/main/runtime/current-subtitle-snapshot.ts`.
- `restoreVisibleOverlayWindowShapeForShow()` delegates to `restoreLinuxOverlayWindowShape()` in
`src/main/runtime/linux-overlay-window-shape.ts`.
- Inputs are callback deps, not globals: `getMpvClient`, `setCurrentSubText`,
`getCurrentSubtitleData`, `consumeCachedSubtitle`, `onSubtitleChange`,
`refreshCurrentSubtitle`, `emitSubtitle`, optional secondary-subtitle callbacks, and `logDebug`.
## Primary Subtitle Flow
1. Read the connected mpv client through `getMpvClient()`. Exit if no connected client.
2. Request mpv `sub-text`. On failure, log a
`[visible-overlay-subtitle-prime] failed to read sub-text` debug line and exit.
3. Normalize non-string `sub-text` to `''`, then call `setCurrentSubText(text)` so app state
matches mpv before any overlay emission.
4. Empty text: call `onSubtitleChange(text)`, emit `{ text, tokens: null }`, then prime secondary
subtitles.
5. Current cached payload: if `getCurrentSubtitleData()?.text === text`, call
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
payload.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background
prefetch work and re-centers prefetch around the live playback time.
## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
annotated subtitle payload to overlay windows and subtitle websocket listeners.
- Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written.
## Linux/X11 Window Shape
- `restoreLinuxOverlayWindowShape()` reads `BrowserWindow.getBounds()` and calls `setShape()` with
one full-window rectangle: `{ x: 0, y: 0, width, height }`.
- Restore the shape after `setBounds()`/mpv geometry updates, not only before showing the overlay.
Manual startup can create the hidden overlay at Electron's default 800x600 size before the window
tracker applies the real mpv bounds.
- Do not use `setShape([])` as a passive reset for the visible overlay. On the tested X11/XWayland
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping.
## Config And Migration
No config or schema migration. This workflow reuses existing mpv properties, overlay IPC events,
subtitle tokenization cache, and prefetch controls.
+1
View File
@@ -13,6 +13,7 @@ Read when: finding internal docs or checking verification status
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
+39
View File
@@ -10,6 +10,7 @@ import { getAppControlSocketPath } from '../src/shared/app-control';
import { withProcessExitIntercept } from './test-support/exit-intercept.js';
import {
buildConfiguredMpvDefaultArgs,
buildRuntimeExtraScriptOptParts,
buildMpvBackendArgs,
buildMpvEnv,
cleanupPlaybackSession,
@@ -22,6 +23,7 @@ import {
runAppCommandCaptureOutput,
resolveLauncherRuntimePluginPath,
resolveLauncherRuntimePluginPlan,
shouldResolveAniSkipMetadataForLaunch,
shouldResolveAniSkipMetadata,
stopOverlay,
startOverlay,
@@ -374,6 +376,43 @@ test('resolveLauncherRuntimePluginPlan reports missing bundled plugin when no in
assert.match(plan.errorMessage ?? '', /Packaged mpv plugin assets were not found/);
});
test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate', () => {
assert.deepEqual(
buildRuntimeExtraScriptOptParts('/tmp/video.mkv', 'file', {
startPaused: true,
runtimePluginConfig: {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
}),
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
);
});
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
assert.equal(
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
socketPath: '/tmp/subminer.sock',
binaryPath: '',
backend: 'auto',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: false,
aniskipButtonKey: 'TAB',
}),
false,
);
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
+61 -51
View File
@@ -5,6 +5,12 @@ import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
getLinuxDesktopEnv,
shouldForceX11MpvBackend as shouldForceX11MpvBackendForBackend,
} from '../src/shared/mpv-x11-backend.js';
import {
isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand,
@@ -458,39 +464,8 @@ export function detectBackend(
fail('Could not detect display backend');
}
type LinuxDesktopEnv = {
xdgCurrentDesktop: string;
xdgSessionDesktop: string;
hasWayland: boolean;
};
function getLinuxDesktopEnv(env: NodeJS.ProcessEnv): LinuxDesktopEnv {
const xdgCurrentDesktop = (env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (env.XDG_SESSION_TYPE || '').toLowerCase();
return {
xdgCurrentDesktop,
xdgSessionDesktop,
hasWayland: Boolean(env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland',
};
}
function shouldForceX11MpvBackend(args: Pick<Args, 'backend'>, env: NodeJS.ProcessEnv): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
return false;
}
const linuxDesktopEnv = getLinuxDesktopEnv(env);
const supportedWaylandBackend =
Boolean(env.HYPRLAND_INSTANCE_SIGNATURE || env.SWAYSOCK) ||
linuxDesktopEnv.xdgCurrentDesktop.includes('hyprland') ||
linuxDesktopEnv.xdgCurrentDesktop.includes('sway') ||
linuxDesktopEnv.xdgSessionDesktop.includes('hyprland') ||
linuxDesktopEnv.xdgSessionDesktop.includes('sway');
return (
args.backend === 'x11' ||
(args.backend === 'auto' && linuxDesktopEnv.hasWayland && !supportedWaylandBackend)
);
return shouldForceX11MpvBackendForBackend(args.backend, env);
}
function resolveAppBinaryCandidate(candidate: string, pathModule: PathModule = path): string {
@@ -862,6 +837,50 @@ export function shouldResolveAniSkipMetadata(
return !isYoutubeTarget(target);
}
type StartMpvOptions = {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
};
export function shouldResolveAniSkipMetadataForLaunch(
target: string,
targetKind: 'file' | 'url',
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
runtimePluginConfig?: PluginRuntimeConfig,
): boolean {
if (runtimePluginConfig?.aniskipEnabled === false) {
return false;
}
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
}
export function buildRuntimeExtraScriptOptParts(
target: string,
targetKind: 'file' | 'url',
options?: Pick<
StartMpvOptions,
'startPaused' | 'disableYoutubeSubtitleAutoLoad' | 'runtimePluginConfig'
>,
): string[] {
const launcherOwnsAutoplayReadyInitialPause =
options?.startPaused === true &&
options.runtimePluginConfig?.autoStart === true &&
options.runtimePluginConfig.autoStartVisibleOverlay === true &&
options.runtimePluginConfig.autoStartPauseUntilReady === true;
return [
...(launcherOwnsAutoplayReadyInitialPause
? ['subminer-auto_start_pause_until_ready_owns_initial_pause=yes']
: []),
...(targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: []),
];
}
export async function startMpv(
target: string,
targetKind: 'file' | 'url',
@@ -869,12 +888,7 @@ export async function startMpv(
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: {
startPaused?: boolean;
disableYoutubeSubtitleAutoLoad?: boolean;
runtimePluginPath?: string | null;
runtimePluginConfig?: PluginRuntimeConfig;
},
options?: StartMpvOptions,
): Promise<void> {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
@@ -932,15 +946,15 @@ export async function startMpv(
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles)
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
target,
targetKind,
preloadedSubtitles,
options?.runtimePluginConfig,
)
? await resolveAniSkipMetadataForFile(target)
: null;
const extraScriptOpts =
targetKind === 'url' &&
isYoutubeTarget(target) &&
options?.disableYoutubeSubtitleAutoLoad === true
? ['subminer-auto_start_pause_until_ready=no']
: [];
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
const runtimeScriptOpts = options?.runtimePluginConfig
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
@@ -1344,11 +1358,7 @@ export function buildMpvEnv(
return env;
}
delete env.WAYLAND_DISPLAY;
delete env.HYPRLAND_INSTANCE_SIGNATURE;
delete env.SWAYSOCK;
env.XDG_SESSION_TYPE = 'x11';
return env;
return applyX11EnvOverrides(env);
}
export function buildMpvBackendArgs(
@@ -1358,7 +1368,7 @@ export function buildMpvBackendArgs(
if (!shouldForceX11MpvBackend(args, baseEnv)) {
return [];
}
return ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'];
return [...MPV_X11_BACKEND_ARGS];
}
export function buildConfiguredMpvDefaultArgs(
+5
View File
@@ -559,6 +559,7 @@ test(
socketPath: smokeCase.socketPath,
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
aniskipEnabled: false,
},
}),
);
@@ -582,6 +583,10 @@ test(
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(
(mpvFirstArgs as string[]).find((arg) => arg.startsWith('--script-opts=')) ?? '',
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
});
},
+2 -2
View File
File diff suppressed because one or more lines are too long
+53 -2
View File
@@ -2,6 +2,7 @@ local M = {}
local AUTO_START_SOCKET_RETRY_DELAY_SECONDS = 0.2
local AUTO_START_SOCKET_RETRY_MAX_ATTEMPTS = 25
local WARM_END_FILE_HIDE_DELAY_SECONDS = 0.25
function M.create(ctx)
local mp = ctx.mp
@@ -58,6 +59,40 @@ function M.create(ctx)
end)
end
local function clear_pending_visible_overlay_hide()
local timer = state.pending_visible_overlay_hide_timer
if timer and timer.kill then
timer:kill()
end
state.pending_visible_overlay_hide_timer = nil
state.pending_visible_overlay_hide_generation = (state.pending_visible_overlay_hide_generation or 0) + 1
end
local resolve_auto_start_visible_overlay_enabled
local function hide_visible_overlay_after_end_file()
if state.visible_overlay_requested == true and not resolve_auto_start_visible_overlay_enabled() then
return
end
if not state.auto_play_ready_signal_seen then
process.hide_visible_overlay()
return
end
clear_pending_visible_overlay_hide()
local generation = (state.pending_visible_overlay_hide_generation or 0) + 1
state.pending_visible_overlay_hide_generation = generation
state.pending_visible_overlay_hide_timer = mp.add_timeout(WARM_END_FILE_HIDE_DELAY_SECONDS, function()
if state.pending_visible_overlay_hide_generation ~= generation then
return
end
state.pending_visible_overlay_hide_timer = nil
if state.overlay_running then
process.hide_visible_overlay()
end
end)
end
local function resolve_auto_start_enabled()
local raw_auto_start = opts.auto_start
if raw_auto_start == nil then
@@ -69,6 +104,14 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_auto_start, false)
end
resolve_auto_start_visible_overlay_enabled = function()
local raw_visible_overlay = opts.auto_start_visible_overlay
if raw_visible_overlay == nil then
raw_visible_overlay = opts["auto-start-visible-overlay"]
end
return options_helper.coerce_bool(raw_visible_overlay, false)
end
local function next_auto_start_retry_generation()
state.auto_start_retry_generation = (state.auto_start_retry_generation or 0) + 1
return state.auto_start_retry_generation
@@ -103,6 +146,11 @@ function M.create(ctx)
return true
end
local function should_rearm_pause_until_ready(same_media_loaded)
return not same_media_loaded
and not (state.overlay_running and state.auto_play_ready_signal_seen == true)
end
local function start_overlay_when_socket_ready(generation, media_identity, same_media_loaded, attempt)
if generation ~= state.auto_start_retry_generation then
return
@@ -137,7 +185,7 @@ function M.create(ctx)
process.start_overlay({
auto_start_trigger = true,
socket_path = opts.socket_path,
rearm_pause_until_ready = not same_media_loaded,
rearm_pause_until_ready = should_rearm_pause_until_ready(same_media_loaded),
})
-- Give the overlay process a moment to initialize before querying AniSkip.
schedule_aniskip_fetch("overlay-start", 0.8)
@@ -155,6 +203,7 @@ function M.create(ctx)
end
local function on_file_loaded()
clear_pending_visible_overlay_hide()
local media_identity = resolve_media_identity()
local media_title = resolve_media_title()
local retry_generation = next_auto_start_retry_generation()
@@ -242,6 +291,8 @@ function M.create(ctx)
aniskip.clear_aniskip_state()
hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
clear_pending_visible_overlay_hide()
state.auto_play_ready_signal_seen = false
state.current_media_identity = nil
state.current_media_title = nil
state.pending_reload_media_identity = nil
@@ -277,7 +328,7 @@ function M.create(ctx)
state.app_managed_playback_pending = false
state.app_managed_playback_active = false
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay()
hide_visible_overlay_after_end_file()
end
end)
mp.register_event("shutdown", function()
+1
View File
@@ -33,6 +33,7 @@ function M.load(options_lib, default_socket_path)
auto_start = false,
auto_start_visible_overlay = false,
auto_start_pause_until_ready = true,
auto_start_pause_until_ready_owns_initial_pause = false,
auto_start_pause_until_ready_timeout_seconds = 15,
osd_messages = true,
log_level = "info",
+37 -1
View File
@@ -39,6 +39,9 @@ function M.create(ctx)
end
return "show-visible-overlay"
end
if state.visible_overlay_requested == true then
return nil
end
return "hide-visible-overlay"
end
@@ -50,6 +53,25 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
local function resolve_pause_until_ready_owns_initial_pause()
local raw_owns_initial_pause = opts.auto_start_pause_until_ready_owns_initial_pause
if raw_owns_initial_pause == nil then
raw_owns_initial_pause = opts["auto-start-pause-until-ready-owns-initial-pause"]
end
return options_helper.coerce_bool(raw_owns_initial_pause, false)
end
local function consume_pause_until_ready_initial_pause_ownership()
if state.auto_play_ready_initial_pause_ownership_consumed then
return false
end
if not resolve_pause_until_ready_owns_initial_pause() then
return false
end
state.auto_play_ready_initial_pause_ownership_consumed = true
return true
end
local function resolve_texthooker_enabled(override_value)
if override_value ~= nil then
return options_helper.coerce_bool(override_value, false)
@@ -260,7 +282,8 @@ function M.create(ctx)
clear_auto_play_ready_osd_timer()
end
if not was_armed then
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true
state.auto_play_ready_should_resume_playback = consume_pause_until_ready_initial_pause_ownership()
or mp.get_property_native("pause") ~= true
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
@@ -290,6 +313,7 @@ function M.create(ctx)
end
local function notify_auto_play_ready()
state.auto_play_ready_signal_seen = true
local released_ready_gate = release_auto_play_ready_gate("tokenization-ready")
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
state.force_ready_overlay_restore = false
@@ -601,6 +625,7 @@ function M.create(ctx)
end
state.overlay_running = false
state.auto_play_ready_signal_seen = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed")
@@ -653,6 +678,7 @@ function M.create(ctx)
state.overlay_running = false
state.texthooker_running = false
state.auto_play_ready_signal_seen = false
disarm_auto_play_ready_gate()
show_osd("Stopped")
end
@@ -709,6 +735,14 @@ function M.create(ctx)
end)
return
end
if not state.overlay_running then
state.suppress_ready_overlay_restore = false
disarm_auto_play_ready_gate({ resume_playback = false })
start_overlay({
show_visible_overlay = true,
})
return
end
state.suppress_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
@@ -773,6 +807,7 @@ function M.create(ctx)
state.overlay_running = false
state.texthooker_running = false
state.auto_play_ready_signal_seen = false
state.suppress_ready_overlay_restore = false
state.force_ready_overlay_restore = true
disarm_auto_play_ready_gate({ resume_playback = false })
@@ -795,6 +830,7 @@ function M.create(ctx)
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.overlay_running = false
state.auto_play_ready_signal_seen = false
subminer_log(
"error",
"process",
+4
View File
@@ -33,6 +33,10 @@ function M.new()
auto_play_ready_should_resume_playback = false,
auto_play_ready_timeout = nil,
auto_play_ready_osd_timer = nil,
auto_play_ready_signal_seen = false,
auto_play_ready_initial_pause_ownership_consumed = false,
pending_visible_overlay_hide_timer = nil,
pending_visible_overlay_hide_generation = 0,
suppress_ready_overlay_restore = false,
force_ready_overlay_restore = false,
visible_overlay_requested = nil,
+173 -8
View File
@@ -13,6 +13,7 @@ local function run_plugin_scenario(config)
property_sets = {},
periodic_timers = {},
timeouts = {},
timeout_handles = {},
}
local function make_mp_stub()
@@ -139,15 +140,17 @@ local function run_plugin_scenario(config)
recorded.timeouts[#recorded.timeouts + 1] = seconds
local timeout = {
killed = false,
callback = callback,
}
function timeout:kill()
self.killed = true
end
local delay = tonumber(seconds) or 0
if callback and delay < 5 then
if callback and delay < 5 and not config.defer_timeouts then
callback()
end
recorded.timeout_handles[#recorded.timeout_handles + 1] = timeout
return timeout
end
@@ -612,6 +615,15 @@ local function fire_event(recorded, name, ...)
end
end
local function fire_pending_timeouts(recorded)
for _, timeout in ipairs(recorded.timeout_handles or {}) do
if not timeout.killed and timeout.callback then
timeout.killed = true
timeout.callback()
end
end
end
local function fire_observer(recorded, name, value)
local listeners = recorded.observers[name] or {}
for _, listener in ipairs(listeners) do
@@ -647,13 +659,88 @@ do
assert_true(recorded ~= nil, "plugin failed to load for cold-start scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-start"] ~= nil, "subminer-start script message not registered")
recorded.script_messages["subminer-start"]("texthooker=no")
assert_true(find_start_call(recorded.async_calls) ~= nil, "expected cold-start to invoke --start command when process is absent")
assert_true(
find_start_call(recorded.async_calls) ~= nil,
"expected cold-start to invoke --start command when process is absent"
)
assert_true(
not has_sync_command(recorded.sync_calls, "ps"),
"expected cold-start start command to avoid synchronous process list scan"
)
end
do
local scenario = {
process_list = "",
defer_timeouts = true,
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/episode-01.mkv",
media_title = "Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for warm playlist visibility scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/episode-02.mkv"
scenario.media_title = "Episode 2"
fire_event(recorded, "file-loaded")
fire_pending_timeouts(recorded)
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"warm playlist advance should cancel the end-file hide before it hides the next video's overlay"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"warm playlist visibility reuse should not issue another --start command"
)
end
do
local scenario = {
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "no",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
path = "/media/manual-episode-01.mkv",
media_title = "Manual Episode 1",
files = {
[binary_path] = true,
},
}
local recorded, err = run_plugin_scenario(scenario)
assert_true(recorded ~= nil, "plugin failed to load for manual warm playlist visibility scenario: " .. tostring(err))
recorded.script_messages["subminer-toggle"]()
recorded.script_messages["subminer-autoplay-ready"]()
fire_event(recorded, "end-file", { reason = "eof" })
scenario.path = "/media/manual-episode-02.mkv"
scenario.media_title = "Manual Episode 2"
fire_event(recorded, "file-loaded")
assert_true(
count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0,
"manual visible overlay should remain visible across warm playlist auto-start reattach"
)
assert_true(
count_start_calls(recorded.async_calls) == 1,
"manual warm playlist visibility reuse should not issue another --start command"
)
end
do
local scenario = {
process_list = "",
@@ -714,13 +801,13 @@ do
"new media after prior playback should reuse the running overlay"
)
assert_true(
count_property_set(recorded.property_sets, "pause", true) == 2,
"new media after prior playback should re-arm pause-until-ready"
count_property_set(recorded.property_sets, "pause", true) == 1,
"new media after prior ready playback should not re-arm pause-until-ready"
)
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
count_property_set(recorded.property_sets, "pause", false) == 2,
"new media after prior playback should resume only after readiness"
count_property_set(recorded.property_sets, "pause", false) == 1,
"new media after prior ready playback should not wait for another readiness signal"
)
end
@@ -1800,6 +1887,61 @@ do
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
auto_start_pause_until_ready_owns_initial_pause = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for launcher-owned pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"launcher-owned initial pause should resume when autoplay-ready arrives"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
auto_start_pause_until_ready_owns_initial_pause = "yes",
auto_start_pause_until_ready_timeout_seconds = 0.1,
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
paused = true,
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for launcher-owned pause timeout scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"launcher-owned initial pause should resume when autoplay-ready timeout fires"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
@@ -1992,7 +2134,9 @@ do
option_overrides = {
binary_path = binary_path,
auto_start = "no",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
files = {
[binary_path] = true,
},
@@ -2000,9 +2144,30 @@ do
assert_true(recorded ~= nil, "plugin failed to load for manual toggle command scenario: " .. tostring(err))
assert_true(recorded.script_messages["subminer-toggle"] ~= nil, "subminer-toggle script message not registered")
recorded.script_messages["subminer-toggle"]()
local start_call = find_start_call(recorded.async_calls)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 1,
"script-message toggle should issue explicit visible-overlay toggle command"
start_call ~= nil,
"first manual toggle from a stopped overlay should start SubMiner with mpv attachment"
)
assert_true(
call_has_arg(start_call, "--managed-playback"),
"first manual toggle should attach managed playback so subtitles reach the overlay"
)
assert_true(
call_has_arg(start_call, "--socket") and call_has_arg(start_call, "/tmp/subminer-socket"),
"first manual toggle should pass the active mpv socket to SubMiner"
)
assert_true(
call_has_arg(start_call, "--show-visible-overlay"),
"first manual toggle should start directly into visible overlay state"
)
assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"),
"first manual toggle should not start hidden"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle-visible-overlay") == 0,
"first manual toggle should not issue a bare visible-overlay toggle before mpv is attached"
)
assert_true(
count_control_calls(recorded.async_calls, "--toggle") == 0,
+66
View File
@@ -143,6 +143,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
activatePlaybackWindowForOverlayInteraction: () => false,
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => [],
@@ -247,6 +248,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
getSecondarySubMode: () => 'hover',
getMpvClient: () => null,
focusMainWindow: () => {},
activatePlaybackWindowForOverlayInteraction: () => false,
runSubsyncManual: async () => ({ ok: true, message: 'ok' }),
getAnkiConnectStatus: () => false,
getRuntimeOptions: () => ({}),
@@ -312,6 +314,28 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
assert.equal(deps.getPlaybackPaused(), true);
});
test('createIpcDepsRuntime ignores overlay content reports from stale visible renderers', () => {
const mainWindow = { id: 'main', isDestroyed: () => false } as never;
const staleWindow = { id: 'stale', isDestroyed: () => false } as never;
const reports: unknown[] = [];
const deps = createIpcDepsRuntime({
getMainWindow: () => mainWindow,
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
} as unknown as Parameters<typeof createIpcDepsRuntime>[0]);
const report = deps.reportOverlayContentBounds as (
payload: unknown,
senderWindow: unknown,
) => void;
report({ source: 'stale' }, staleWindow);
report({ source: 'main' }, mainWindow);
report({ source: 'missing' }, null);
assert.deepEqual(reports, [{ source: 'main' }]);
});
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -334,6 +358,27 @@ test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction activ
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
});
test('registerIpcHandlers passes sender window to overlay content bounds reports', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const senderWindows: unknown[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
reportOverlayContentBounds: ((_payload: unknown, senderWindow: unknown) => {
senderWindows.push(senderWindow);
}) as IpcServiceDeps['reportOverlayContentBounds'],
}),
registrar,
);
const handler = handlers.on.get(IPC_CHANNELS.command.reportOverlayContentBounds);
assert.equal(typeof handler, 'function');
handler?.({}, { layer: 'visible' });
assert.deepEqual(senderWindows, [null]);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -608,6 +653,27 @@ test('registerIpcHandlers exposes subtitle sidebar snapshot request', async () =
assert.deepEqual(await handler!({}), snapshot);
});
test('registerIpcHandlers exposes playback window activation request', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
activatePlaybackWindowForOverlayInteraction: async () => {
calls.push('activate');
return true;
},
}),
registrar,
);
const handler = handlers.handle.get(
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
);
assert.ok(handler);
assert.equal(await handler!({}), true);
assert.deepEqual(calls, ['activate']);
});
test('registerIpcHandlers forwards yomitan lookup tracking commands to immersion tracker', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
+48 -6
View File
@@ -49,6 +49,10 @@ export interface IpcServiceDeps {
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayInteractiveHint?: (
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -58,7 +62,8 @@ export interface IpcServiceDeps {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -81,6 +86,7 @@ export interface IpcServiceDeps {
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
@@ -89,7 +95,10 @@ export interface IpcServiceDeps {
getRuntimeOptions: () => unknown;
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => unknown;
reportOverlayContentBounds: (payload: unknown) => void;
reportOverlayContentBounds: (
payload: unknown,
senderWindow: ElectronBrowserWindow | null,
) => void;
getAnilistStatus: () => unknown;
clearAnilistToken: () => void;
openAnilistSetup: () => void;
@@ -229,6 +238,10 @@ export interface IpcDepsRuntimeOptions {
active: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
onOverlayInteractiveHint?: (
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -236,7 +249,8 @@ export interface IpcDepsRuntimeOptions {
getCurrentSubtitleRaw: () => string;
getCurrentSubtitleAss: () => string;
getSubtitleSidebarSnapshot?: () => Promise<SubtitleSidebarSnapshot>;
getPlaybackPaused: () => boolean | null;
getSubtitleSidebarOpen?: () => boolean;
getPlaybackPaused: () => boolean | null | Promise<boolean | null>;
getSubtitlePosition: () => unknown;
getSubtitleStyle: () => unknown;
saveSubtitlePosition: (position: SubtitlePosition) => void;
@@ -254,6 +268,7 @@ export interface IpcDepsRuntimeOptions {
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
activatePlaybackWindowForOverlayInteraction?: () => boolean | Promise<boolean>;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
onYoutubePickerResolve: (
request: YoutubePickerResolveRequest,
@@ -296,6 +311,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalClosed: options.onOverlayModalClosed,
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp,
@@ -310,6 +326,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getCurrentSubtitleRaw: options.getCurrentSubtitleRaw,
getCurrentSubtitleAss: options.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: options.getSubtitleSidebarSnapshot,
getSubtitleSidebarOpen: options.getSubtitleSidebarOpen ?? (() => false),
getPlaybackPaused: options.getPlaybackPaused,
getSubtitlePosition: options.getSubtitlePosition,
getSubtitleStyle: options.getSubtitleStyle,
@@ -342,13 +359,21 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.focus();
},
activatePlaybackWindowForOverlayInteraction:
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
runSubsyncManual: options.runSubsyncManual,
onYoutubePickerResolve: options.onYoutubePickerResolve,
getAnkiConnectStatus: options.getAnkiConnectStatus,
getRuntimeOptions: options.getRuntimeOptions,
setRuntimeOption: options.setRuntimeOption,
cycleRuntimeOption: options.cycleRuntimeOption,
reportOverlayContentBounds: options.reportOverlayContentBounds,
reportOverlayContentBounds: (payload, senderWindow) => {
const mainWindow = options.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) return;
if (!senderWindow || senderWindow !== (mainWindow as unknown as ElectronBrowserWindow))
return;
options.reportOverlayContentBounds(payload);
},
getAnilistStatus: options.getAnilistStatus,
clearAnilistToken: options.clearAnilistToken,
openAnilistSetup: options.openAnilistSetup,
@@ -526,6 +551,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return await deps.getSubtitleSidebarSnapshot();
});
ipc.handle(IPC_CHANNELS.request.getSubtitleSidebarOpen, () => {
return deps.getSubtitleSidebarOpen?.() ?? false;
});
ipc.handle(IPC_CHANNELS.request.getPlaybackPaused, () => {
return deps.getPlaybackPaused();
});
@@ -628,6 +657,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
deps.focusMainWindow();
});
ipc.handle(IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction, async () => {
return (await deps.activatePlaybackWindowForOverlayInteraction?.()) ?? false;
});
ipc.handle(IPC_CHANNELS.request.runSubsyncManual, async (_event, request: unknown) => {
const parsedRequest = parseSubsyncManualRunRequest(request);
if (!parsedRequest) {
@@ -668,8 +701,17 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.cycleRuntimeOption(parsedId, parsedDirection);
});
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (_event: unknown, payload: unknown) => {
deps.reportOverlayContentBounds(payload);
ipc.on(IPC_CHANNELS.command.reportOverlayContentBounds, (event: unknown, payload: unknown) => {
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.reportOverlayContentBounds(payload, senderWindow);
});
ipc.on(IPC_CHANNELS.command.reportOverlayInteractive, (event: unknown, interactive: unknown) => {
if (typeof interactive !== 'boolean') return;
const senderWindow =
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayInteractiveHint?.(interactive, senderWindow);
});
ipc.handle(IPC_CHANNELS.request.getAnilistStatus, () => {
@@ -58,6 +58,50 @@ test('overlay measurement store keeps latest payload for visible layer', () => {
assert.equal(store.getLatestByLayer('visible')?.contentRect?.width, 400);
});
test('overlay measurement store clears stale visible measurements', () => {
const store = createOverlayContentMeasurementStore({
now: () => 1000,
warn: () => {
// noop
},
});
store.report({
layer: 'visible',
measuredAtMs: 900,
viewport: { width: 1280, height: 720 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
interactiveRects: [{ x: 50, y: 60, width: 400, height: 80 }],
});
assert.notEqual(store.getLatestByLayer('visible'), null);
store.clear('visible');
assert.equal(store.getLatestByLayer('visible'), null);
});
test('sanitizeOverlayContentMeasurement preserves separate interactive rects', () => {
const measurement = sanitizeOverlayContentMeasurement(
{
layer: 'visible',
measuredAtMs: 100,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 50, y: 60, width: 400, height: 80 },
interactiveRects: [
{ x: 50, y: 60, width: 400, height: 80 },
{ x: 100, y: 900, width: 500, height: 90 },
],
},
500,
);
assert.deepEqual(measurement?.interactiveRects, [
{ x: 50, y: 60, width: 400, height: 80 },
{ x: 100, y: 900, width: 500, height: 90 },
]);
});
test('overlay measurement store rate-limits invalid payload warnings', () => {
let now = 1_000;
const warnings: string[] = [];
@@ -5,6 +5,7 @@ const logger = createLogger('main:overlay-content-measurement');
const MAX_VIEWPORT = 10000;
const MAX_RECT_DIMENSION = 10000;
const MAX_RECT_OFFSET = 50000;
const MAX_INTERACTIVE_RECTS = 8;
const MAX_FUTURE_TIMESTAMP_MS = 60_000;
const INVALID_LOG_THROTTLE_MS = 10_000;
@@ -26,6 +27,7 @@ export function sanitizeOverlayContentMeasurement(
width?: unknown;
height?: unknown;
} | null;
interactiveRects?: unknown;
};
if (candidate.layer !== 'visible') {
@@ -53,11 +55,21 @@ export function sanitizeOverlayContentMeasurement(
return null;
}
let interactiveRects: OverlayContentRect[] | undefined;
if (candidate.interactiveRects !== undefined) {
const sanitizedRects = sanitizeOverlayInteractiveRects(candidate.interactiveRects);
if (!sanitizedRects) {
return null;
}
interactiveRects = sanitizedRects;
}
return {
layer: candidate.layer,
measuredAtMs,
viewport: { width: viewportWidth, height: viewportHeight },
contentRect,
...(interactiveRects !== undefined ? { interactiveRects } : {}),
};
}
@@ -94,6 +106,22 @@ function sanitizeOverlayContentRect(rect: unknown): OverlayContentRect | null {
return { x, y, width, height };
}
function sanitizeOverlayInteractiveRects(rects: unknown): OverlayContentRect[] | null {
if (!Array.isArray(rects) || rects.length > MAX_INTERACTIVE_RECTS) {
return null;
}
const sanitized: OverlayContentRect[] = [];
for (const rect of rects) {
const sanitizedRect = sanitizeOverlayContentRect(rect);
if (!sanitizedRect) {
return null;
}
sanitized.push(sanitizedRect);
}
return sanitized;
}
function readFiniteInRange(value: unknown, min: number, max: number): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return Number.NaN;
@@ -140,7 +168,12 @@ export function createOverlayContentMeasurementStore(options?: {
return latestByLayer[layer];
}
function clear(layer: OverlayLayer): void {
latestByLayer[layer] = null;
}
return {
clear,
getLatestByLayer,
report,
};
+273 -2
View File
@@ -62,6 +62,9 @@ function createMainWindowRecorder(options: { emitShowImmediately?: boolean } = {
setAlwaysOnTop: (flag: boolean) => {
calls.push(`always-on-top:${flag}`);
},
setFullScreen: (fullscreen: boolean) => {
calls.push(`fullscreen:${fullscreen}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
@@ -259,6 +262,50 @@ test('non-native passive overlay stays click-through after subsequent visibility
assert.ok(calls.includes('mouse-ignore:true:forward'));
});
test('non-native shaped input region stays mouse-enabled without focusing the overlay', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: false,
nonNativeInputRegionActive: true,
showOverlayLoadingOsd: () => {},
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
});
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -306,7 +353,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
assert.ok(!calls.includes('focus'));
});
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
test('untracked Linux overlay stays hidden when no tracker exists', () => {
const { window, calls } = createMainWindowRecorder();
let trackerWarning = false;
@@ -341,7 +388,8 @@ test('untracked non-macOS overlay shows passively when no tracker exists', () =>
} as never);
assert.equal(trackerWarning, false);
assert.ok(calls.includes('show-inactive'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
assert.ok(!calls.includes('focus'));
assert.ok(!calls.includes('osd'));
@@ -384,6 +432,184 @@ test('passive Linux visible overlay does not take keyboard focus', () => {
assert.ok(!calls.includes('focus'));
});
test('passive Linux tracked overlay releases global topmost when mpv loses focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('fullscreen:false'));
assert.ok(calls.includes('all-workspaces:false:plain'));
assert.ok(!calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('passive Linux fullscreen override overlay hides when mpv loses focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
hideNonNativeOverlayWhenTargetUnfocused: true,
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('always-on-top:false'));
assert.ok(calls.includes('hide'));
assert.ok(!calls.includes('ensure-level'));
assert.ok(!calls.includes('enforce-order'));
});
test('Linux active overlay interaction does not focus the overlay over fullscreen mpv', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => true,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
assert.ok(!calls.includes('focus'));
});
test('Linux active hover keeps global topmost when mpv loses focus and overlay is not focused', () => {
const { window, calls, setFocused } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
isTargetWindowFocused: () => false,
};
window.show();
setFocused(false);
calls.length = 0;
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
overlayInteractionActive: true,
} as never);
assert.ok(calls.includes('mouse-ignore:false:plain'));
assert.ok(!calls.includes('always-on-top:false'));
assert.ok(!calls.includes('all-workspaces:false:plain'));
assert.ok(calls.includes('ensure-level'));
assert.ok(calls.includes('enforce-order'));
});
test('tracked non-macOS overlay reapplies bounds after first show', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
@@ -604,6 +830,51 @@ test('Windows visible overlay waits for content-ready before first reveal', () =
assert.ok(calls.includes('show-inactive'));
});
test('Linux visible overlay waits for content-ready before first reveal', () => {
const { window, calls, setContentReady } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
};
setContentReady(false);
const run = () =>
updateVisibleOverlayVisibility({
visibleOverlayVisible: true,
mainWindow: window as never,
windowTracker: tracker as never,
trackerNotReadyWarningShown: false,
setTrackerNotReadyWarningShown: () => {},
updateVisibleOverlayBounds: () => {
calls.push('update-bounds');
},
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
syncPrimaryOverlayWindowLayer: () => {
calls.push('sync-layer');
},
enforceOverlayLayerOrder: () => {
calls.push('enforce-order');
},
syncOverlayShortcuts: () => {
calls.push('sync-shortcuts');
},
isMacOSPlatform: false,
isWindowsPlatform: false,
} as never);
run();
assert.ok(!calls.includes('show-inactive'));
assert.ok(!calls.includes('show'));
setContentReady(true);
run();
assert.ok(calls.includes('show-inactive'));
});
test('tracked Windows overlay refresh rebinds while already visible', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
+37 -21
View File
@@ -18,6 +18,10 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
function releaseOverlayWindowLevel(window: BrowserWindow): void {
window.setAlwaysOnTop(false);
const fullscreenWindow = window as BrowserWindow & {
setFullScreen?: (fullscreen: boolean) => void;
};
fullscreenWindow.setFullScreen?.(false);
const allWorkspacesWindow = window as BrowserWindow & {
setVisibleOnAllWorkspaces?: (
visible: boolean,
@@ -64,6 +68,7 @@ export function updateVisibleOverlayVisibility(args: {
visibleOverlayVisible: boolean;
modalActive?: boolean;
forceMousePassthrough?: boolean;
nonNativeInputRegionActive?: boolean;
suspendVisibleOverlay?: boolean;
overlayInteractionActive?: boolean;
mainWindow: BrowserWindow | null;
@@ -87,6 +92,7 @@ export function updateVisibleOverlayVisibility(args: {
markOverlayLoadingOsdShown?: () => void;
resetOverlayLoadingOsdSuppression?: () => void;
resolveFallbackBounds?: () => WindowGeometry;
hideNonNativeOverlayWhenTargetUnfocused?: boolean;
}): void {
if (!args.mainWindow || args.mainWindow.isDestroyed()) {
return;
@@ -120,9 +126,9 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): boolean => {
const forceMousePassthrough = args.forceMousePassthrough === true;
const wasVisible = mainWindow.isVisible();
const isVisibleOverlayFocused =
overlayInteractionActive ||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
const isVisibleOverlayWindowFocused =
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
const isVisibleOverlayFocused = overlayInteractionActive || isVisibleOverlayWindowFocused;
const windowTracker = args.windowTracker;
const canReportMacOSTargetMinimized =
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
@@ -181,12 +187,23 @@ export function updateVisibleOverlayVisibility(args: {
!isTrackedWindowsTargetMinimized &&
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
const isNonNativePassiveOverlay =
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
const isNonNativeOverlay = !args.isWindowsPlatform && !args.isMacOSPlatform;
const isNonNativePassiveOverlay = isNonNativeOverlay && !overlayInteractionActive;
const hasNonNativeInputRegion =
isNonNativePassiveOverlay && args.nonNativeInputRegionActive === true;
const isTrackedNonNativeTargetFocused =
!args.isWindowsPlatform && !args.isMacOSPlatform && !!args.windowTracker
? (args.windowTracker.isTargetWindowFocused?.() ?? true)
: true;
const shouldReleaseNonNativeOverlayLevel =
isNonNativeOverlay &&
!!args.windowTracker &&
!isVisibleOverlayFocused &&
!isTrackedNonNativeTargetFocused;
const shouldIgnoreMouseEvents =
shouldUseMacOSMousePassthrough ||
forceMousePassthrough ||
isNonNativePassiveOverlay ||
(isNonNativePassiveOverlay && !hasNonNativeInputRegion) ||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
const shouldKeepTrackedWindowsOverlayTopmost =
@@ -214,6 +231,11 @@ export function updateVisibleOverlayVisibility(args: {
// On Windows, z-order is enforced by the OS via the owner window mechanism
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
// without any manual z-order management.
} else if (shouldReleaseNonNativeOverlayLevel) {
releaseOverlayWindowLevel(mainWindow);
if (args.hideNonNativeOverlayWhenTargetUnfocused && wasVisible) {
mainWindow.hide();
}
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
args.ensureOverlayWindowLevel(mainWindow);
} else {
@@ -223,7 +245,6 @@ export function updateVisibleOverlayVisibility(args: {
const hasWebContents =
typeof (mainWindow as unknown as { webContents?: unknown }).webContents === 'object';
if (
args.isWindowsPlatform &&
hasWebContents &&
!isOverlayWindowContentReady(mainWindow as unknown as import('electron').BrowserWindow)
) {
@@ -238,7 +259,11 @@ export function updateVisibleOverlayVisibility(args: {
setOverlayWindowOpacity(mainWindow, 0);
}
mainWindow.showInactive();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
if (hasNonNativeInputRegion) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
if (args.isWindowsPlatform) {
scheduleWindowsOverlayReveal(
mainWindow,
@@ -277,16 +302,7 @@ export function updateVisibleOverlayVisibility(args: {
mainWindow.focus();
}
if (
!args.isWindowsPlatform &&
!args.isMacOSPlatform &&
!forceMousePassthrough &&
overlayInteractionActive
) {
mainWindow.focus();
}
return !shouldReleaseMacOSOverlayLevel;
return !shouldReleaseNonNativeOverlayLevel;
};
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
@@ -385,9 +401,9 @@ export function updateVisibleOverlayVisibility(args: {
return;
}
args.setTrackerNotReadyWarningShown(false);
args.syncPrimaryOverlayWindowLayer('visible');
showPassiveVisibleOverlay();
args.enforceOverlayLayerOrder();
mainWindow.setIgnoreMouseEvents(true, { forward: true });
releaseOverlayWindowLevel(mainWindow);
mainWindow.hide();
args.syncOverlayShortcuts();
return;
}
@@ -10,6 +10,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
assert.equal(options.title, 'SubMiner Overlay');
assert.equal(options.backgroundColor, '#00000000');
assert.equal(options.paintWhenInitiallyHidden, true);
assert.equal(options.webPreferences?.sandbox, false);
assert.equal(options.webPreferences?.backgroundThrottling, false);
});
@@ -41,6 +42,59 @@ test('Linux visible overlay window allows compositor resize for mpv-sized placem
}
});
test('Linux visible overlay window stays managed so native apps can cover it', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
yomitanSession: null,
});
const modalOptions = buildOverlayWindowOptions('modal', {
isDev: false,
yomitanSession: null,
});
assert.equal(visibleOptions.alwaysOnTop, false);
assert.equal(visibleOptions.focusable, true);
assert.equal(modalOptions.focusable, true);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Linux fullscreen visible overlay window uses X11 override-redirect-friendly options', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
const visibleOptions = buildOverlayWindowOptions('visible', {
isDev: false,
linuxX11FullscreenOverlay: true,
yomitanSession: null,
});
assert.equal(visibleOptions.alwaysOnTop, true);
assert.equal(visibleOptions.focusable, false);
assert.equal(visibleOptions.resizable, false);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('Windows visible overlay window config does not start as always-on-top', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
+1 -13
View File
@@ -69,23 +69,11 @@ export function handleOverlayWindowBlurred(options: {
onVisibleOverlayBlur?: () => void;
platform?: NodeJS.Platform;
}): boolean {
const platform = options.platform ?? process.platform;
if (platform === 'win32' && options.kind === 'visible') {
if (options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
if (platform === 'darwin' && options.kind === 'visible') {
options.onVisibleOverlayBlur?.();
return false;
}
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
return false;
}
options.ensureOverlayWindowLevel();
if (options.kind === 'visible' && options.windowVisible) {
options.moveWindowTop();
}
return true;
}
+10 -3
View File
@@ -11,12 +11,18 @@ export function buildOverlayWindowOptions(
kind: OverlayWindowKind,
options: {
isDev: boolean;
linuxX11FullscreenOverlay?: boolean;
yomitanSession?: Session | null;
},
): BrowserWindowConstructorOptions {
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
const shouldStartAlwaysOnTop = !(process.platform === 'win32' && kind === 'visible');
const shouldAllowCompositorResize = process.platform === 'linux' && kind === 'visible';
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
const isLinuxFullscreenOverlay =
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
const shouldStartAlwaysOnTop =
!(process.platform === 'win32' && kind === 'visible') &&
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
return {
show: false,
@@ -26,13 +32,14 @@ export function buildOverlayWindowOptions(
x: 0,
y: 0,
transparent: true,
paintWhenInitiallyHidden: true,
backgroundColor: '#00000000',
frame: false,
alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true,
resizable: shouldAllowCompositorResize,
hasShadow: false,
focusable: true,
focusable: !isLinuxFullscreenOverlay,
acceptFirstMouse: true,
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
webPreferences: {
+81 -18
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ensureOverlayWindowLevel } from './overlay-window';
import {
handleOverlayWindowBeforeInputEvent,
handleOverlayWindowBlurred,
@@ -166,6 +167,49 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred skips Linux visible overlay restacking after focus loss', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
platform: 'linux',
});
assert.equal(handled, false);
assert.deepEqual(calls, []);
});
test('handleOverlayWindowBlurred notifies Linux visible overlay blur callback without restacking', () => {
const calls: string[] = [];
const handled = handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-level');
},
moveWindowTop: () => {
calls.push('move-top');
},
onVisibleOverlayBlur: () => {
calls.push('visible-blur');
},
platform: 'linux',
});
assert.equal(handled, false);
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
const calls: string[] = [];
@@ -189,25 +233,9 @@ test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback wi
assert.deepEqual(calls, ['visible-blur']);
});
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
test('handleOverlayWindowBlurred preserves modal window stacking', () => {
const calls: string[] = [];
assert.equal(
handleOverlayWindowBlurred({
kind: 'visible',
windowVisible: true,
isOverlayVisible: () => true,
ensureOverlayWindowLevel: () => {
calls.push('ensure-visible');
},
moveWindowTop: () => {
calls.push('move-visible');
},
platform: 'linux',
}),
true,
);
assert.equal(
handleOverlayWindowBlurred({
kind: 'modal',
@@ -223,5 +251,40 @@ test('handleOverlayWindowBlurred preserves active visible/modal window stacking'
true,
);
assert.deepEqual(calls, ['ensure-visible', 'move-visible', 'ensure-modal']);
assert.deepEqual(calls, ['ensure-modal']);
});
test('ensureOverlayWindowLevel promotes Linux overlay above fullscreen mpv without changing workspaces', () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
ensureOverlayWindowLevel({
getTitle: () => 'SubMiner Overlay',
moveTop: () => calls.push('move-top'),
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
},
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
calls.push(
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
);
},
} as never);
} finally {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
assert.deepEqual(calls, [
'always-on-top:true:screen-saver:1',
'all-workspaces:true:fullscreen',
'move-top',
]);
});
+13 -3
View File
@@ -78,7 +78,9 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
window.moveTop();
return;
}
window.setAlwaysOnTop(true);
// Linux/X11 overlays start managed and only assert topmost while mpv owns the overlay layer.
// Focus loss releases this again so native Wayland apps can cover the overlay on KDE.
window.setAlwaysOnTop(true, 'screen-saver', 1);
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
window.moveTop();
@@ -106,13 +108,16 @@ export function createOverlayWindow(
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (kind: OverlayWindowKind) => void;
onWindowClosed: (kind: OverlayWindowKind, window: BrowserWindow) => void;
yomitanSession?: Session | null;
},
): BrowserWindow {
const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options));
window.setSkipTaskbar(true);
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
OVERLAY_WINDOW_CONTENT_READY_FLAG
] = false;
@@ -172,7 +177,7 @@ export function createOverlayWindow(
window.hide();
window.on('closed', () => {
options.onWindowClosed(kind);
options.onWindowClosed(kind, window);
});
window.on('blur', () => {
@@ -192,6 +197,11 @@ export function createOverlayWindow(
});
});
window.on('focus', () => {
if (window.isDestroyed() || kind !== 'visible') return;
options.onVisibleWindowFocused?.();
});
if (options.isDev && kind === 'visible') {
window.webContents.openDevTools({ mode: 'detach' });
}
@@ -516,3 +516,28 @@ test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session
},
});
});
test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-count multi-line actions', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
copySubtitleMultiple: 'Ctrl+Shift+C',
mineSentenceMultiple: 'Ctrl+Shift+S',
}),
keybindings: [],
platform: 'linux',
});
const artifact = buildPluginSessionBindingsArtifact({
bindings: result.bindings,
warnings: result.warnings,
numericSelectionTimeoutMs: 2500,
});
const byActionId = new Map(
artifact.bindings.flatMap((binding) =>
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
),
);
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
});
+7
View File
@@ -358,6 +358,13 @@ function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionB
return binding;
}
if (
(binding.actionId === 'copySubtitleMultiple' || binding.actionId === 'mineSentenceMultiple') &&
binding.payload?.count === undefined
) {
return binding;
}
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
}
@@ -103,7 +103,7 @@ test('subtitle processing falls back to plain subtitle when tokenization returns
assert.deepEqual(emitted, [{ text: 'fallback', tokens: null }]);
});
test('subtitle processing can refresh current subtitle without text change', async () => {
test('subtitle processing ignores duplicate current subtitle refresh without cache invalidation', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
@@ -119,10 +119,57 @@ test('subtitle processing can refresh current subtitle without text change', asy
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
});
test('subtitle processing coalesces refresh requests while current subtitle is processing', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
let resolveTokenization: ((value: SubtitleData | null) => void) | undefined;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return await new Promise<SubtitleData | null>((resolve) => {
resolveTokenization = () => resolve({ text, tokens: [] });
});
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
controller.refreshCurrentSubtitle();
controller.refreshCurrentSubtitle('same');
assert.ok(resolveTokenization);
resolveTokenization({ text: 'same', tokens: [] });
await flushMicrotasks();
await flushMicrotasks();
assert.equal(tokenizeCalls, 1);
assert.deepEqual(emitted, [{ text: 'same', tokens: [] }]);
});
test('subtitle processing refresh re-tokenizes after cache invalidation', async () => {
const emitted: SubtitleData[] = [];
let tokenizeCalls = 0;
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
return { text, tokens: [{ value: tokenizeCalls } as never] };
},
emitSubtitle: (payload) => emitted.push(payload),
});
controller.onSubtitleChange('same');
await flushMicrotasks();
controller.invalidateTokenizationCache();
controller.refreshCurrentSubtitle();
await flushMicrotasks();
assert.equal(tokenizeCalls, 2);
assert.deepEqual(emitted, [
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [] },
{ text: 'same', tokens: [{ value: 1 } as never] },
{ text: 'same', tokens: [{ value: 2 } as never] },
]);
});
@@ -27,9 +27,10 @@ export function createSubtitleProcessingController(
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
let latestText = '';
let lastEmittedText = '';
let cacheGeneration = 0;
let lastEmittedGeneration = 0;
let processing = false;
let staleDropCount = 0;
let refreshRequested = false;
const tokenizationCache = new Map<string, SubtitleData>();
const now = deps.now ?? (() => Date.now());
@@ -65,19 +66,19 @@ export function createSubtitleProcessingController(
void (async () => {
while (true) {
const text = latestText;
const forceRefresh = refreshRequested;
refreshRequested = false;
const generation = cacheGeneration;
const startedAtMs = now();
if (!text.trim()) {
deps.emitSubtitle({ text, tokens: null });
lastEmittedText = text;
lastEmittedGeneration = generation;
break;
}
let output: SubtitleData = { text, tokens: null };
try {
const cachedTokenized = forceRefresh ? null : getCachedTokenization(text);
const cachedTokenized = getCachedTokenization(text);
if (cachedTokenized) {
output = cachedTokenized;
} else {
@@ -99,8 +100,16 @@ export function createSubtitleProcessingController(
continue;
}
if (generation !== cacheGeneration) {
deps.logDebug?.(
`Dropped stale subtitle tokenization result after cache invalidation; elapsed=${now() - startedAtMs}ms`,
);
continue;
}
deps.emitSubtitle(output);
lastEmittedText = text;
lastEmittedGeneration = generation;
deps.logDebug?.(
`Subtitle tokenization delivered; elapsed=${now() - startedAtMs}ms, staleDrops=${staleDropCount}`,
);
@@ -112,7 +121,10 @@ export function createSubtitleProcessingController(
})
.finally(() => {
processing = false;
if (refreshRequested || latestText !== lastEmittedText) {
if (
latestText !== lastEmittedText ||
(latestText.trim() && cacheGeneration !== lastEmittedGeneration)
) {
processLatest();
}
});
@@ -133,11 +145,17 @@ export function createSubtitleProcessingController(
if (!latestText.trim()) {
return;
}
refreshRequested = true;
if (
processing ||
(latestText === lastEmittedText && cacheGeneration === lastEmittedGeneration)
) {
return;
}
processLatest();
},
invalidateTokenizationCache: () => {
tokenizationCache.clear();
cacheGeneration += 1;
},
preCacheTokenization: (text: string, data: SubtitleData) => {
setCachedTokenization(text, data);
@@ -150,7 +168,7 @@ export function createSubtitleProcessingController(
latestText = text;
lastEmittedText = text;
refreshRequested = false;
lastEmittedGeneration = cacheGeneration;
return cached;
},
hasCachedSubtitle: (text: string) => {
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldForceX11ElectronBackend } from './electron-backend';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
try {
run();
} finally {
if (original) Object.defineProperty(process, 'platform', original);
}
}
test('shouldForceX11ElectronBackend forces X11 on Linux except Hyprland/Sway', () => {
withPlatform('linux', () => {
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
assert.equal(shouldForceX11ElectronBackend({ WAYLAND_DISPLAY: 'wayland-0' }), true);
// Even an explicit Wayland hint is overridden to x11 on unsupported compositors.
assert.equal(shouldForceX11ElectronBackend({ ELECTRON_OZONE_PLATFORM_HINT: 'wayland' }), true);
// Hyprland/Sway keep native Wayland (guard reports explicit wayland hints elsewhere).
assert.equal(shouldForceX11ElectronBackend({ HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), false);
assert.equal(shouldForceX11ElectronBackend({ SWAYSOCK: '/tmp/sway.sock' }), false);
});
});
test('shouldForceX11ElectronBackend is false off Linux', () => {
withPlatform('darwin', () => {
assert.equal(shouldForceX11ElectronBackend({ XDG_CURRENT_DESKTOP: 'KDE' }), false);
});
withPlatform('win32', () => {
assert.equal(shouldForceX11ElectronBackend({}), false);
});
});
+21 -10
View File
@@ -1,27 +1,38 @@
import { CliArgs, shouldStartApp } from '../../cli/args';
import { createLogger } from '../../logger';
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
const logger = createLogger('core:electron-backend');
function getElectronOzonePlatformHint(): string | null {
const hint = process.env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
function getElectronOzonePlatformHint(env: NodeJS.ProcessEnv = process.env): string | null {
const hint = env.ELECTRON_OZONE_PLATFORM_HINT?.trim().toLowerCase();
if (hint) return hint;
const ozone = process.env.OZONE_PLATFORM?.trim().toLowerCase();
const ozone = env.OZONE_PLATFORM?.trim().toLowerCase();
if (ozone) return ozone;
return null;
}
function shouldPreferWaylandBackend(): boolean {
return Boolean(process.env.HYPRLAND_INSTANCE_SIGNATURE || process.env.SWAYSOCK);
/**
* Should the Electron app be pinned to the X11/XWayland ozone backend? True on Linux
* unless we're on a natively-supported Wayland compositor (Hyprland/Sway) or the user
* explicitly opted into the (unsupported) Wayland backend — which is reported by
* {@link enforceUnsupportedWaylandMode} instead.
*
* The overlay relies on `setAlwaysOnTop`/`moveTop` to stay above mpv; those are no-ops
* under a native Wayland surface, so XWayland is required for parity with Win/macOS. An
* explicit `ELECTRON_OZONE_PLATFORM_HINT=wayland` is still overridden to x11 here (the
* Electron Wayland backend is unsupported); the Hyprland/Sway case is left untouched so
* {@link enforceUnsupportedWaylandMode} can report it.
*/
export function shouldForceX11ElectronBackend(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') return false;
return !isSupportedWaylandCompositor(env);
}
export function forceX11Backend(args: CliArgs): void {
if (process.platform !== 'linux') return;
if (!shouldStartApp(args)) return;
if (shouldPreferWaylandBackend()) return;
const hint = getElectronOzonePlatformHint();
if (hint === 'x11') return;
if (!shouldForceX11ElectronBackend()) return;
if (getElectronOzonePlatformHint() === 'x11') return;
process.env.ELECTRON_OZONE_PLATFORM_HINT = 'x11';
process.env.OZONE_PLATFORM = 'x11';
+5 -1
View File
@@ -1,5 +1,9 @@
export { generateDefaultConfigFile } from './config-gen';
export { enforceUnsupportedWaylandMode, forceX11Backend } from './electron-backend';
export {
enforceUnsupportedWaylandMode,
forceX11Backend,
shouldForceX11ElectronBackend,
} from './electron-backend';
export { resolveKeybindings } from './keybindings';
export { resolveConfiguredShortcuts } from './shortcut-config';
export { showDesktopNotification } from './notification';
+652 -32
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -58,6 +58,7 @@ export interface MainIpcRuntimeServiceDepsParams {
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -66,8 +67,10 @@ export interface MainIpcRuntimeServiceDepsParams {
getCurrentSubtitleRaw: IpcDepsRuntimeOptions['getCurrentSubtitleRaw'];
getCurrentSubtitleAss: IpcDepsRuntimeOptions['getCurrentSubtitleAss'];
getSubtitleSidebarSnapshot?: IpcDepsRuntimeOptions['getSubtitleSidebarSnapshot'];
getSubtitleSidebarOpen?: IpcDepsRuntimeOptions['getSubtitleSidebarOpen'];
getPlaybackPaused: IpcDepsRuntimeOptions['getPlaybackPaused'];
focusMainWindow?: IpcDepsRuntimeOptions['focusMainWindow'];
activatePlaybackWindowForOverlayInteraction?: IpcDepsRuntimeOptions['activatePlaybackWindowForOverlayInteraction'];
getSubtitlePosition: IpcDepsRuntimeOptions['getSubtitlePosition'];
getSubtitleStyle: IpcDepsRuntimeOptions['getSubtitleStyle'];
saveSubtitlePosition: IpcDepsRuntimeOptions['saveSubtitlePosition'];
@@ -236,6 +239,7 @@ export function createMainIpcRuntimeServiceDeps(
onOverlayModalClosed: params.onOverlayModalClosed,
onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
@@ -244,6 +248,7 @@ export function createMainIpcRuntimeServiceDeps(
getCurrentSubtitleRaw: params.getCurrentSubtitleRaw,
getCurrentSubtitleAss: params.getCurrentSubtitleAss,
getSubtitleSidebarSnapshot: params.getSubtitleSidebarSnapshot,
getSubtitleSidebarOpen: params.getSubtitleSidebarOpen,
getPlaybackPaused: params.getPlaybackPaused,
getSubtitlePosition: params.getSubtitlePosition,
getSubtitleStyle: params.getSubtitleStyle,
@@ -260,6 +265,7 @@ export function createMainIpcRuntimeServiceDeps(
saveControllerConfig: params.saveControllerConfig,
saveControllerPreference: params.saveControllerPreference,
focusMainWindow: params.focusMainWindow ?? (() => {}),
activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction,
getSecondarySubMode: params.getSecondarySubMode,
getMpvClient: params.getMpvClient,
runSubsyncManual: params.runSubsyncManual,
+197 -6
View File
@@ -72,6 +72,35 @@ test('manual visible overlay toggles only release current-media autoplay when hi
);
});
test('all visible overlay hide paths clear stale overlay input state', () => {
const source = readMainSource();
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setVisibleBlock);
assert.ok(toggleBlock);
assert.ok(setOverlayBlock);
assert.match(
setVisibleBlock,
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
});
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -109,7 +138,7 @@ test('subtitle change re-prioritizes prefetch around live playback before tokeni
);
});
test('autoplay subtitle prime prefers cached annotated payload before raw fallback', () => {
test('autoplay subtitle prime emits cached annotations and avoids raw fallback overlay flashes', () => {
const source = readMainSource();
const actionBlock = source.match(
/function emitAutoplayPrimedSubtitle\([\s\S]*?\): boolean \{(?<body>[\s\S]*?)\n\}/,
@@ -122,16 +151,124 @@ test('autoplay subtitle prime prefers cached annotated payload before raw fallba
);
assert.match(actionBlock, /if \(cachedPayload\) \{/);
assert.match(actionBlock, /emitSubtitlePayload\(cachedPayload\);/);
assert.match(
actionBlock,
/const rawPayload = withCurrentSubtitleTiming\(\{ text, tokens: null \}\);/,
);
assert.doesNotMatch(actionBlock, /withCurrentSubtitleTiming\(\{ text, tokens: null \}\)/);
assert.doesNotMatch(actionBlock, /broadcastToOverlayWindows\('subtitle:set', rawPayload\)/);
assert.match(actionBlock, /subtitleProcessingController\.onSubtitleChange\(text\);/);
assert.ok(
actionBlock.indexOf('consumeCachedSubtitle(text)') <
actionBlock.indexOf('withCurrentSubtitleTiming({ text, tokens: null })'),
actionBlock.indexOf('subtitleProcessingController.onSubtitleChange(text);'),
);
});
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
const source = readMainSource();
const gateBlock = source.match(
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(gateBlock);
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
});
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
const source = readMainSource();
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
assert.ok(
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
);
});
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
const source = readMainSource();
const openedBlock = source.match(
/onOverlayModalOpened:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const closedBlock = source.match(
/onOverlayModalClosed:\s*\(modal,\s*senderWindow\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const depsBlock = source.match(/getSubtitleSidebarOpen:\s*\(\)\s*=>\s*(?<body>[^\n,]+)/)?.groups
?.body;
assert.ok(openedBlock);
assert.ok(closedBlock);
assert.ok(depsBlock);
assert.match(openedBlock, /if \(modal === 'subtitle-sidebar'/);
assert.match(openedBlock, /subtitleSidebarRequestedOpen = true;/);
assert.match(closedBlock, /if \(modal === 'subtitle-sidebar'/);
assert.match(closedBlock, /subtitleSidebarRequestedOpen = false;/);
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
});
test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
const source = readMainSource();
const warmReleaseBlock = source.match(
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const currentPayloadBlock = source.match(
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(warmReleaseBlock);
assert.match(
warmReleaseBlock,
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
);
assert.doesNotMatch(warmReleaseBlock, /__warm__/);
assert.ok(currentPayloadBlock);
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
});
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
const source = readMainSource();
const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const resetBlock = source.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.ok(resetBlock);
assert.match(actionBlock, /resetVisibleOverlayInputState\(\);/);
assert.match(resetBlock, /overlayContentMeasurementStore\.clear\('visible'\);/);
assert.ok(
actionBlock.indexOf('resetVisibleOverlayInputState();') <
actionBlock.indexOf('createMainWindow();'),
);
});
test('Linux visible overlay recreation avoids display fallback before tracked geometry exists', () => {
const source = readMainSource();
const actionBlock = source.match(
/function createLinuxVisibleOverlayWindowForCurrentMode\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /const trackedGeometry = getCurrentTrackedOverlayGeometry\(\);/);
assert.match(actionBlock, /if \(trackedGeometry\) \{/);
assert.match(actionBlock, /overlayManager\.setOverlayWindowBounds\(trackedGeometry\);/);
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
});
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -169,6 +306,60 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
});
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.match(
setBlock,
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
);
assert.match(
toggleBlock,
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
);
});
test('Linux visible overlay show/reset does not leave an empty X11 window shape', () => {
const source = readMainSource();
const resetBlock = source.match(
/function resetVisibleOverlayInputState\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(resetBlock);
assert.ok(setBlock);
assert.match(resetBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match(
setBlock,
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
);
});
test('Linux visible overlay bounds refresh restores X11 shape after applying mpv geometry', () => {
const source = readMainSource();
const afterBoundsBlock = source.match(
/afterSetOverlayWindowBounds:\s*\(\) => \{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(afterBoundsBlock);
assert.match(afterBoundsBlock, /restoreLinuxOverlayWindowShape\(mainWindow\);/);
assert.ok(
afterBoundsBlock.indexOf('restoreLinuxOverlayWindowShape(mainWindow);') <
afterBoundsBlock.indexOf('ensureOverlayWindowLevel(mainWindow);'),
);
});
test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
+5
View File
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
getModalActive: () => boolean;
getVisibleOverlayVisible: () => boolean;
getForceMousePassthrough: () => boolean;
getNonNativeInputRegionActive?: () => boolean;
getSuspendVisibleOverlay?: () => boolean;
getOverlayInteractionActive?: () => boolean;
getWindowTracker: () => BaseWindowTracker | null;
@@ -30,6 +31,7 @@ export interface OverlayVisibilityRuntimeDeps {
isWindowsPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
resolveFallbackBounds: () => WindowGeometry;
hideNonNativeOverlayWhenTargetUnfocused?: () => boolean;
}
export interface OverlayVisibilityRuntimeService {
@@ -53,6 +55,7 @@ export function createOverlayVisibilityRuntimeService(
visibleOverlayVisible,
modalActive: deps.getModalActive(),
forceMousePassthrough,
nonNativeInputRegionActive: deps.getNonNativeInputRegionActive?.() ?? false,
suspendVisibleOverlay,
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
mainWindow,
@@ -86,6 +89,8 @@ export function createOverlayVisibilityRuntimeService(
resetOverlayLoadingOsdSuppression: () => {
lastOverlayLoadingOsdAtMs = null;
},
hideNonNativeOverlayWhenTargetUnfocused:
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});
},
@@ -311,3 +311,58 @@ test('autoplay ready gate drops deferred readiness after media changes before fl
assert.deepEqual(commands, []);
});
test('autoplay ready gate passes the pending subtitle signal to the readiness predicate', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReadyText: string | null = null;
let observedText: string | null = null;
let observedRequestedAtMs: number | null = null;
let now = 1_000;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: ((signal: { payload: { text: string }; requestedAtMs: number }) => {
observedText = signal.payload.text;
observedRequestedAtMs = signal.requestedAtMs;
return targetReadyText === signal.payload.text;
}) as never,
now: () => now,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(observedText, '字幕');
assert.equal(observedRequestedAtMs, 1_000);
assert.deepEqual(commands, []);
now = 2_000;
targetReadyText = '字幕';
gate.flushPendingAutoplayReadySignal();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(observedRequestedAtMs, 1_000);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
});
+66 -34
View File
@@ -7,6 +7,15 @@ type MpvClientLike = {
send: (payload: { command: Array<string | boolean> }) => void;
};
type AutoplayReadyOptions = { forceWhilePaused?: boolean };
export type AutoplayReadySignal = {
mediaPath: string;
payload: SubtitleData;
requestedAtMs: number;
options?: AutoplayReadyOptions;
};
export type AutoplayReadyGateDeps = {
isAppOwnedFlowInFlight: () => boolean;
getCurrentMediaPath: () => string | null;
@@ -14,7 +23,8 @@ export type AutoplayReadyGateDeps = {
getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
isSignalTargetReady?: () => boolean;
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
now?: () => number;
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
logDebug: (message: string) => void;
};
@@ -22,11 +32,8 @@ export type AutoplayReadyGateDeps = {
export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: {
mediaPath: string;
payload: SubtitleData;
options?: { forceWhilePaused?: boolean };
} | null = null;
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
const now = deps.now ?? (() => Date.now());
const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null;
@@ -34,7 +41,8 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
autoPlayReadySignalGeneration += 1;
};
const isSignalTargetReady = (): boolean => deps.isSignalTargetReady?.() ?? true;
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
deps.isSignalTargetReady?.(signal) ?? true;
const getSignalMediaPath = (): string =>
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
@@ -45,23 +53,23 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
autoPlayReadySignalGeneration += 1;
};
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
): void => {
if (deps.isAppOwnedFlowInFlight()) {
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
return;
}
if (!payload.text.trim()) {
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
if (
pendingAutoplayReadySignal &&
pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
pendingAutoplayReadySignal.payload.text === signal.payload.text &&
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
) {
return;
}
pendingAutoplayReadySignal = signal;
};
const mediaPath = getSignalMediaPath();
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
const mediaPath = signal.mediaPath;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true,
forceWhilePaused: signal.options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs,
});
let releaseUnpauseSent = false;
@@ -129,18 +137,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
})();
};
if (duplicateMediaSignal) {
pendingAutoplayReadySignal = null;
return;
}
if (!isSignalTargetReady()) {
pendingAutoplayReadySignal = { mediaPath, payload, options };
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${mediaPath}`,
);
return;
}
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
@@ -148,20 +144,56 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
attemptRelease(playbackGeneration, 0);
};
const maybeReleaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
if (autoPlayReadySignalMediaPath === signal.mediaPath) {
pendingAutoplayReadySignal = null;
return;
}
if (!isSignalTargetReady(signal)) {
setPendingAutoplayReadySignal(signal);
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
);
return;
}
releaseAutoplayReadySignal(signal);
};
const maybeSignalPluginAutoplayReady = (
payload: SubtitleData,
options?: AutoplayReadyOptions,
): void => {
if (deps.isAppOwnedFlowInFlight()) {
deps.logDebug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
return;
}
if (!payload.text.trim()) {
return;
}
maybeReleaseAutoplayReadySignal({
mediaPath: getSignalMediaPath(),
payload,
requestedAtMs: now(),
options,
});
};
const flushPendingAutoplayReadySignal = (): void => {
if (!pendingAutoplayReadySignal || !isSignalTargetReady()) {
if (!pendingAutoplayReadySignal) {
return;
}
const pendingSignal = pendingAutoplayReadySignal;
pendingAutoplayReadySignal = null;
if (getSignalMediaPath() !== pendingSignal.mediaPath) {
pendingAutoplayReadySignal = null;
deps.logDebug(
`[autoplay-ready] dropped deferred signal for stale media ${pendingSignal.mediaPath}`,
);
return;
}
maybeSignalPluginAutoplayReady(pendingSignal.payload, pendingSignal.options);
maybeReleaseAutoplayReadySignal(pendingSignal);
};
return {
@@ -1,7 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SubtitleData } from '../../types';
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
import {
primeVisibleOverlaySubtitleFromMpv,
resolveCurrentSubtitleForRenderer,
} from './current-subtitle-snapshot';
function withTiming(payload: SubtitleData): SubtitleData {
return {
@@ -58,3 +61,95 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
assert.equal(payload.startTime, 1);
assert.deepEqual(payload.tokens, [{ text: '新' }]);
});
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
const calls: string[] = [];
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
calls.push(`request:${name}`);
return '国内外から';
},
}),
setCurrentSubText: (text) => calls.push(`set:${text}`),
getCurrentSubtitleData: () => null,
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
});
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
});
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
const calls: string[] = [];
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => ({
connected: true,
requestProperty: async () => '字幕',
}),
setCurrentSubText: (text) => calls.push(`set:${text}`),
getCurrentSubtitleData: () => cachedPayload,
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
emitSubtitle: (payload) => calls.push(`emit:${payload.text}:${payload.tokens?.length ?? 0}`),
});
assert.deepEqual(calls, ['set:字幕', 'emit:字幕:1', 'refresh:字幕']);
});
test('visible overlay subtitle prime clears stale subtitle when mpv has no current text', async () => {
const calls: string[] = [];
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => ({
connected: true,
requestProperty: async () => '',
}),
setCurrentSubText: (text) => calls.push(`set:${text}`),
getCurrentSubtitleData: () => ({ text: 'old', tokens: null }),
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
emitSubtitle: (payload) => calls.push(`emit:${payload.text}:${payload.tokens}`),
});
assert.deepEqual(calls, ['set:', 'change:', 'emit::null']);
});
test('visible overlay subtitle prime refreshes secondary subtitle when available', async () => {
const calls: string[] = [];
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
calls.push(`request:${name}`);
return name === 'secondary-sub-text' ? 'from abroad' : '国内外から';
},
}),
setCurrentSubText: (text) => calls.push(`set:${text}`),
getCurrentSubtitleData: () => null,
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
setCurrentSecondarySubText: (text) => calls.push(`set-secondary:${text}`),
emitSecondarySubtitle: (text) => calls.push(`emit-secondary:${text}`),
});
assert.deepEqual(calls, [
'request:sub-text',
'set:国内外から',
'refresh:国内外から',
'request:secondary-sub-text',
'set-secondary:from abroad',
'emit-secondary:from abroad',
]);
});
@@ -1,5 +1,10 @@
import type { SubtitleData } from '../../types';
type CurrentSubtitleMpvClient = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubText: string;
currentSubtitleData: SubtitleData | null;
@@ -27,3 +32,81 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
tokens: null,
});
}
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
getMpvClient: () => CurrentSubtitleMpvClient | null;
setCurrentSubText: (text: string) => void;
getCurrentSubtitleData: () => SubtitleData | null;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (text: string) => void;
emitSubtitle: (payload: SubtitleData) => void;
setCurrentSecondarySubText?: (text: string) => void;
emitSecondarySubtitle?: (text: string) => void;
logDebug?: (message: string) => void;
}): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected) {
return;
}
let subTextRaw: unknown;
try {
subTextRaw = await client.requestProperty('sub-text');
} catch (error) {
deps.logDebug?.(
`[visible-overlay-subtitle-prime] failed to read sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
return;
}
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
deps.setCurrentSubText(text);
const primeSecondarySubtitle = async (): Promise<void> => {
if (!deps.setCurrentSecondarySubText && !deps.emitSecondarySubtitle) {
return;
}
try {
const secondarySubTextRaw = await client.requestProperty('secondary-sub-text');
const secondaryText = typeof secondarySubTextRaw === 'string' ? secondarySubTextRaw : '';
deps.setCurrentSecondarySubText?.(secondaryText);
deps.emitSecondarySubtitle?.(secondaryText);
} catch (error) {
deps.logDebug?.(
`[visible-overlay-subtitle-prime] failed to read secondary-sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
if (!text.trim()) {
deps.onSubtitleChange(text);
deps.emitSubtitle({ text, tokens: null });
await primeSecondarySubtitle();
return;
}
const currentPayload = deps.getCurrentSubtitleData();
if (currentPayload?.text === text) {
deps.emitSubtitle(currentPayload);
deps.refreshCurrentSubtitle(text);
await primeSecondarySubtitle();
return;
}
const cachedPayload = deps.consumeCachedSubtitle(text);
if (cachedPayload) {
deps.onSubtitleChange(text);
deps.emitSubtitle(cachedPayload);
await primeSecondarySubtitle();
return;
}
deps.refreshCurrentSubtitle(text);
await primeSecondarySubtitle();
}
@@ -16,13 +16,19 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => false,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
@@ -30,6 +36,8 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
@@ -39,8 +47,11 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
}
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('sync-overlay-mode:true'));
assert.ok(!calls.includes('fullscreen:true'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('showInactive'));
assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('ensureOverlayWindowLevel'));
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
@@ -50,7 +61,46 @@ test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work
}
});
test('linux mpv fullscreen overlay refresh update schedules a fresh burst when fullscreen exits', async () => {
test('linux mpv fullscreen overlay refresh remembers mode even when overlay is hidden', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () => null,
getVisibleOverlayVisible: () => false,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('sync-overlay-mode:true') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(calls.includes('sync-overlay-mode:true'));
assert.ok(!calls.includes('updateVisibleOverlayVisibility'));
assert.ok(!calls.includes('ensureOverlayWindowLevel'));
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh updates mode without hide/show when fullscreen exits', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
@@ -65,8 +115,14 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => true,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
@@ -74,6 +130,8 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
};
@@ -84,9 +142,125 @@ test('linux mpv fullscreen overlay refresh update schedules a fresh burst when f
assert.equal(typeof nextCancel, 'function');
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('showInactive'));
assert.ok(calls.includes('ensureOverlayWindowLevel'));
assert.ok(calls.includes('sync-overlay-mode:false'));
assert.ok(!calls.includes('fullscreen:false'));
assert.equal(calls.includes('hide'), false);
assert.equal(calls.includes('showInactive'), false);
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
assert.equal(calls.includes('ensureOverlayWindowLevel'), false);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh restores click-through after restacking', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => false,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('mouse-ignore:true:forward') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
const showIndex = calls.indexOf('showInactive');
const passthroughIndex = calls.indexOf('mouse-ignore:true:forward');
const levelIndex = calls.indexOf('ensureOverlayWindowLevel');
const syncIndex = calls.indexOf('sync-overlay-mode:true');
assert.ok(syncIndex >= 0);
assert.ok(showIndex >= 0);
assert.ok(syncIndex < showIndex);
assert.ok(passthroughIndex > showIndex);
assert.ok(levelIndex > passthroughIndex);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh preserves active subtitle interaction after restacking', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst(true, {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isFullScreen: () => false,
isDestroyed: () => false,
isVisible: () => true,
setFullScreen: (fullscreen: boolean) => calls.push(`fullscreen:${fullscreen}`),
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) =>
calls.push(
`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`,
),
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
syncVisibleOverlayMpvFullscreenMode: (fullscreen: boolean) =>
calls.push(`sync-overlay-mode:${fullscreen}`),
getOverlayInteractionActive: () => true,
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('mouse-ignore:false:plain') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
const showIndex = calls.indexOf('showInactive');
const interactiveIndex = calls.indexOf('mouse-ignore:false:plain');
assert.ok(showIndex >= 0);
assert.ok(interactiveIndex > showIndex);
assert.equal(calls.includes('mouse-ignore:true:forward'), false);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
@@ -2,6 +2,7 @@ type LinuxMpvFullscreenOverlayWindow = {
hide: () => void;
isDestroyed: () => boolean;
isVisible: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
showInactive: () => void;
};
@@ -13,6 +14,8 @@ export type LinuxMpvFullscreenOverlayRefreshDeps = {
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
syncVisibleOverlayMpvFullscreenMode?: (fullscreen: boolean) => void;
getOverlayInteractionActive?: () => boolean;
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
};
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
@@ -28,13 +31,21 @@ function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
}
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
fullscreen: boolean,
deps: LinuxMpvFullscreenOverlayRefreshDeps,
): void {
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
if (process.platform !== 'linux') {
return;
}
deps.syncVisibleOverlayMpvFullscreenMode?.(fullscreen);
if (!deps.overlayManager.getVisibleOverlayVisible()) {
return;
}
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
if (!fullscreen) {
return;
}
const mainWindow = deps.overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
@@ -43,10 +54,16 @@ function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
mainWindow.hide();
mainWindow.showInactive();
if (deps.getOverlayInteractionActive?.() === true) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
deps.ensureOverlayWindowLevel(mainWindow);
}
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
isFullscreen: boolean,
deps: LinuxMpvFullscreenOverlayRefreshDeps,
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
if (process.platform !== 'linux') {
@@ -59,7 +76,7 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(isFullscreen, deps);
}, delayMs);
refreshTimeout.unref?.();
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
@@ -68,13 +85,13 @@ export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
}
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
_isFullscreen: boolean,
isFullscreen: boolean,
deps: LinuxMpvFullscreenOverlayRefreshDeps,
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
cancelCurrentBurst?.();
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(isFullscreen, deps);
}
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
@@ -0,0 +1,461 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyLinuxOverlayInputShape,
applyLinuxOverlayPointerInteractionMousePassthrough,
type LinuxOverlayPointerInteractionDeps,
isCursorOverSubtitle,
type ForegroundSuppressionGraceState,
mapOverlayMeasurementForPointerInteraction,
resolveDesiredOverlayInteractive,
resolveForegroundSuppressionWithGrace,
shouldSuppressPointerInteractionForForegroundWindow,
tickLinuxOverlayPointerInteraction,
} from './linux-overlay-pointer-interaction';
const BOUNDS = { x: 100, y: 100, width: 1920, height: 1080 };
const MEASUREMENT = {
viewport: { width: 1920, height: 1080 },
contentRect: { x: 800, y: 900, width: 320, height: 80 },
};
test('isCursorOverSubtitle hit-tests the subtitle rect in screen coords (1:1 scale)', () => {
// Subtitle rect maps to screen [900..1220] x [1000..1080] (+100 window origin).
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, MEASUREMENT), true);
assert.equal(isCursorOverSubtitle({ x: 500, y: 1040 }, BOUNDS, MEASUREMENT), false);
assert.equal(isCursorOverSubtitle({ x: 1000, y: 500 }, BOUNDS, MEASUREMENT), false);
});
test('isCursorOverSubtitle scales viewport px to window px', () => {
// Window is 2x the reported viewport → rect doubles.
const scaled = { ...BOUNDS, width: 3840, height: 2160 };
// contentRect.x*2=1600 +100 origin → left ~1700; a point at 1700,1900 is inside.
assert.equal(isCursorOverSubtitle({ x: 1700, y: 1900 }, scaled, MEASUREMENT), true);
});
test('isCursorOverSubtitle returns false without a content rect', () => {
assert.equal(
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
viewport: MEASUREMENT.viewport,
contentRect: null,
}),
false,
);
assert.equal(isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, null), false);
});
test('isCursorOverSubtitle falls back to content rect when interactive rects are empty', () => {
assert.equal(
isCursorOverSubtitle({ x: 1000, y: 1040 }, BOUNDS, {
...MEASUREMENT,
interactiveRects: [],
}),
true,
);
});
function makeDeps(overrides: Partial<LinuxOverlayPointerInteractionDeps>): {
deps: LinuxOverlayPointerInteractionDeps;
state: { active: boolean };
} {
const state = { active: false };
const deps: LinuxOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
}),
getCursorScreenPoint: () => ({ x: 1000, y: 1040 }),
getSubtitleMeasurement: () => MEASUREMENT,
getRendererInteractiveHint: () => false,
shouldSuspend: () => false,
getInteractionActive: () => state.active,
setInteractionActive: (active) => {
state.active = active;
},
...overrides,
};
return { deps, state };
}
test('resolveDesiredOverlayInteractive: interactive over subtitle, passthrough off it', () => {
assert.equal(resolveDesiredOverlayInteractive(makeDeps({}).deps), true);
assert.equal(
resolveDesiredOverlayInteractive(
makeDeps({ getCursorScreenPoint: () => ({ x: 200, y: 200 }) }).deps,
),
false,
);
});
test('resolveDesiredOverlayInteractive: renderer hint keeps it interactive off the rect', () => {
const { deps } = makeDeps({
getCursorScreenPoint: () => ({ x: 200, y: 200 }),
getRendererInteractiveHint: () => true,
});
assert.equal(resolveDesiredOverlayInteractive(deps), true);
});
test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without blocking between them', () => {
const measurement = {
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 940 },
interactiveRects: [
{ x: 700, y: 40, width: 520, height: 80 },
{ x: 760, y: 900, width: 400, height: 80 },
],
} as unknown as ReturnType<LinuxOverlayPointerInteractionDeps['getSubtitleMeasurement']>;
assert.equal(
resolveDesiredOverlayInteractive(
makeDeps({
getCursorScreenPoint: () => ({ x: 900, y: 300 }),
getSubtitleMeasurement: () => measurement,
}).deps,
),
false,
);
assert.equal(
resolveDesiredOverlayInteractive(
makeDeps({
getCursorScreenPoint: () => ({ x: 900, y: 1060 }),
getSubtitleMeasurement: () => measurement,
}).deps,
),
true,
);
assert.equal(
resolveDesiredOverlayInteractive(
makeDeps({
getCursorScreenPoint: () => ({ x: 900, y: 180 }),
getSubtitleMeasurement: () => measurement,
}).deps,
),
true,
);
});
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
const mapped = mapOverlayMeasurementForPointerInteraction({
layer: 'visible',
measuredAtMs: 1,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 940 },
interactiveRects: [
{ x: 700, y: 40, width: 520, height: 80 },
{ x: 760, y: 900, width: 400, height: 80 },
],
});
assert.deepEqual(mapped, {
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 940 },
interactiveRects: [
{ x: 700, y: 40, width: 520, height: 80 },
{ x: 760, y: 900, width: 400, height: 80 },
],
});
});
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover when another app is foreground', () => {
assert.equal(
shouldSuppressPointerInteractionForForegroundWindow({
hasForegroundSeparateWindow: false,
isTrackingMpvWindow: true,
isMpvWindowFocused: false,
isOverlayWindowFocused: false,
}),
true,
);
assert.equal(
shouldSuppressPointerInteractionForForegroundWindow({
hasForegroundSeparateWindow: false,
isTrackingMpvWindow: true,
isMpvWindowFocused: true,
isOverlayWindowFocused: false,
}),
false,
);
assert.equal(
shouldSuppressPointerInteractionForForegroundWindow({
hasForegroundSeparateWindow: false,
isTrackingMpvWindow: true,
isMpvWindowFocused: false,
isOverlayWindowFocused: true,
}),
false,
);
});
test('resolveForegroundSuppressionWithGrace ignores a transient startup focus blip', () => {
// Regression: right after playback starts the overlay can briefly become the X11 active
// window, so the tracker reports mpv unfocused. Suppressing immediately leaves subtitles
// inert for ~1s. The grace must hold interaction available until the loss is *stable*.
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
const base = {
hasForegroundSeparateWindow: false,
isTrackingMpvWindow: true,
isMpvWindowFocused: false,
isOverlayWindowFocused: false,
graceMs: 500,
state,
};
// Blip starts: not yet suppressed.
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
// Still within grace.
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_400 }), false);
// mpv regains focus before the grace elapses → reset, never suppressed.
assert.equal(
resolveForegroundSuppressionWithGrace({ ...base, isMpvWindowFocused: true, nowMs: 1_450 }),
false,
);
assert.equal(state.lossSinceMs, null);
});
test('resolveForegroundSuppressionWithGrace suppresses once foreground loss is stable', () => {
const state: ForegroundSuppressionGraceState = { lossSinceMs: null };
const base = {
hasForegroundSeparateWindow: false,
isTrackingMpvWindow: true,
isMpvWindowFocused: false,
isOverlayWindowFocused: false,
graceMs: 500,
state,
};
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_000 }), false);
// A real app stays foreground past the grace → suppress.
assert.equal(resolveForegroundSuppressionWithGrace({ ...base, nowMs: 1_500 }), true);
});
test('resolveForegroundSuppressionWithGrace defers to a separate window immediately', () => {
const state: ForegroundSuppressionGraceState = { lossSinceMs: 1_000 };
assert.equal(
resolveForegroundSuppressionWithGrace({
hasForegroundSeparateWindow: true,
isTrackingMpvWindow: true,
isMpvWindowFocused: true,
isOverlayWindowFocused: false,
nowMs: 2_000,
graceMs: 500,
state,
}),
true,
);
assert.equal(state.lossSinceMs, null);
});
test('shouldSuppressPointerInteractionForForegroundWindow suppresses hover for separate app windows', () => {
assert.equal(
shouldSuppressPointerInteractionForForegroundWindow({
hasForegroundSeparateWindow: true,
isTrackingMpvWindow: true,
isMpvWindowFocused: true,
isOverlayWindowFocused: false,
}),
true,
);
});
test('resolveDesiredOverlayInteractive: false when overlay hidden, null when suspended/no window', () => {
assert.equal(
resolveDesiredOverlayInteractive(makeDeps({ getVisibleOverlayVisible: () => false }).deps),
false,
);
assert.equal(
resolveDesiredOverlayInteractive(makeDeps({ shouldSuspend: () => true }).deps),
null,
);
assert.equal(
resolveDesiredOverlayInteractive(makeDeps({ getMainWindow: () => null }).deps),
null,
);
});
test('tick only writes interaction state on change', () => {
const calls: boolean[] = [];
const { deps, state } = makeDeps({
setInteractionActive: (active) => {
calls.push(active);
state.active = active;
},
});
tickLinuxOverlayPointerInteraction(deps); // off→on
tickLinuxOverlayPointerInteraction(deps); // no change
assert.deepEqual(calls, [true]);
});
test('tick does not flip state when suspended (returns null)', () => {
const calls: boolean[] = [];
const { deps } = makeDeps({
getInteractionActive: () => true,
shouldSuspend: () => true,
setInteractionActive: (active) => calls.push(active),
});
tickLinuxOverlayPointerInteraction(deps);
assert.deepEqual(calls, []);
});
test('tick clears active hover while a separate SubMiner window suppresses overlay interaction', () => {
const calls: boolean[] = [];
const { deps, state } = makeDeps({
getInteractionActive: () => true,
shouldSuppressInteraction: () => true,
setInteractionActive: (active) => {
calls.push(active);
state.active = active;
},
});
state.active = true;
tickLinuxOverlayPointerInteraction(deps);
assert.deepEqual(calls, [false]);
});
test('tick skips cursor-driven mouse-ignore toggles when Linux input shape owns hit rects', () => {
const calls: boolean[] = [];
const { deps } = makeDeps({
getInteractionActive: () => false,
shouldUseInputShape: () => true,
setInteractionActive: (active) => calls.push(active),
});
tickLinuxOverlayPointerInteraction(deps);
assert.deepEqual(calls, []);
});
test('applyLinuxOverlayInputShape shapes measured subtitle rects and enables mouse input', () => {
const calls: string[] = [];
const window = {
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => ({ ...BOUNDS, width: 3840, height: 2160 }),
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
calls.push(`shape:${JSON.stringify(rects)}`);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
};
assert.deepEqual(
applyLinuxOverlayInputShape({
getVisibleOverlayVisible: () => true,
getMainWindow: () => window,
getSubtitleMeasurement: () => MEASUREMENT,
getRendererInteractiveHint: () => false,
shouldSuspend: () => false,
shouldSuppressInteraction: () => false,
}),
{ handled: true, active: true },
);
assert.deepEqual(calls, [
'shape:[{"x":1594,"y":1794,"width":652,"height":172}]',
'ignore:false:plain',
]);
});
test('applyLinuxOverlayInputShape uses the full window while renderer reports off-rect interaction', () => {
const calls: string[] = [];
assert.deepEqual(
applyLinuxOverlayInputShape({
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
setShape: (rects: Array<{ x: number; y: number; width: number; height: number }>) => {
calls.push(`shape:${JSON.stringify(rects)}`);
},
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
}),
getSubtitleMeasurement: () => null,
getRendererInteractiveHint: () => true,
shouldSuspend: () => false,
}),
{ handled: true, active: true },
);
assert.deepEqual(calls, [
'shape:[{"x":0,"y":0,"width":1920,"height":1080}]',
'ignore:false:plain',
]);
});
test('applyLinuxOverlayInputShape falls back when setShape is unavailable', () => {
const calls: string[] = [];
assert.deepEqual(
applyLinuxOverlayInputShape({
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
setIgnoreMouseEvents: () => {
calls.push('ignore');
},
}),
getSubtitleMeasurement: () => MEASUREMENT,
getRendererInteractiveHint: () => false,
shouldSuspend: () => false,
}),
{ handled: false, active: false },
);
assert.deepEqual(calls, []);
});
test('applyLinuxOverlayPointerInteractionMousePassthrough toggles mouse input without full visibility refresh', () => {
const calls: string[] = [];
const window = {
isDestroyed: () => false,
isVisible: () => true,
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
calls.push(`ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
},
};
assert.equal(
applyLinuxOverlayPointerInteractionMousePassthrough({
active: true,
getVisibleOverlayVisible: () => true,
getMainWindow: () => window,
shouldSuspend: () => false,
shouldSuppressInteraction: () => false,
updateVisibleOverlayVisibility: () => {
calls.push('full-refresh');
},
}),
true,
);
assert.deepEqual(calls, ['ignore:false:plain']);
});
test('applyLinuxOverlayPointerInteractionMousePassthrough falls back when pointer interaction is suppressed', () => {
const calls: string[] = [];
assert.equal(
applyLinuxOverlayPointerInteractionMousePassthrough({
active: false,
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
setIgnoreMouseEvents: () => {
calls.push('mouse-ignore');
},
}),
shouldSuspend: () => false,
shouldSuppressInteraction: () => true,
updateVisibleOverlayVisibility: () => {
calls.push('full-refresh');
},
}),
false,
);
assert.deepEqual(calls, ['full-refresh']);
});
@@ -0,0 +1,347 @@
/*
Linux overlay pointer-interaction loop.
Electron cannot forward mouse-move events through a click-through window on Linux/X11
(the `forward` option of setIgnoreMouseEvents is unsupported there — electron/electron#16777).
The overlay's hover/lookup interaction relied on those forwarded events, so under XWayland
the click-through overlay never sees the cursor and stays inert.
This restores the Windows/macOS behavior with either a Linux input shape (preferred) or a
main-process cursor poll fallback. Input shapes keep only reported subtitle/sidebar rects
mouse-active so entering a subtitle does not have to flip BrowserWindow mouse-ignore state.
The cursor poll remains for runtimes where BrowserWindow.setShape is unavailable.
*/
import type { OverlayContentMeasurement } from '../../types';
export type PointerPoint = { x: number; y: number };
export type PointerRect = { x: number; y: number; width: number; height: number };
export type PointerViewport = { width: number; height: number };
export type OverlayContentMeasurementLike = {
viewport: PointerViewport;
contentRect: PointerRect | null;
interactiveRects?: PointerRect[] | null;
} | null;
type PointerInteractionWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
getBounds: () => PointerRect;
};
type PointerInteractionMousePassthroughWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
};
type PointerInteractionShapeWindow = PointerInteractionMousePassthroughWindow & {
getBounds: () => PointerRect;
setShape?: (rects: PointerRect[]) => void;
};
export type LinuxOverlayPointerInteractionDeps = {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionWindow | null;
getCursorScreenPoint: () => PointerPoint;
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
getRendererInteractiveHint: () => boolean;
/** True when a modal/stats overlay owns input — leave interaction state to that logic. */
shouldSuspend: () => boolean;
/** True when a separate app window should stay above the overlay. */
shouldSuppressInteraction?: () => boolean;
shouldUseInputShape?: () => boolean;
getInteractionActive: () => boolean;
setInteractionActive: (active: boolean) => void;
};
export const LINUX_OVERLAY_POINTER_POLL_INTERVAL_MS = 60;
// Padding (in window px) so the cursor doesn't have to land pixel-perfectly on the text.
const SUBTITLE_HIT_PADDING_PX = 6;
let pointerInteractionInterval: ReturnType<typeof setInterval> | null = null;
export function mapOverlayMeasurementForPointerInteraction(
measurement: OverlayContentMeasurement | null,
): OverlayContentMeasurementLike {
if (!measurement) return null;
return {
viewport: measurement.viewport,
contentRect: measurement.contentRect,
...(measurement.interactiveRects ? { interactiveRects: measurement.interactiveRects } : {}),
};
}
export function shouldSuppressPointerInteractionForForegroundWindow(options: {
hasForegroundSeparateWindow: boolean;
isTrackingMpvWindow: boolean;
isMpvWindowFocused: boolean;
isOverlayWindowFocused: boolean;
}): boolean {
if (options.hasForegroundSeparateWindow) return true;
if (!options.isTrackingMpvWindow) return false;
return !options.isMpvWindowFocused && !options.isOverlayWindowFocused;
}
/** Mutable timer state for {@link resolveForegroundSuppressionWithGrace}. */
export type ForegroundSuppressionGraceState = { lossSinceMs: number | null };
/**
* Suppress subtitle pointer interaction for a foreground window, but only once the foreground
* loss has been *stable* for `graceMs`. A separate SubMiner window defers immediately; a plain
* focus blip (e.g. the overlay briefly becoming the X11 active window at playback start) is
* ignored so subtitles don't go inert for a poll cycle while focus settles back onto mpv.
*/
export function resolveForegroundSuppressionWithGrace(options: {
hasForegroundSeparateWindow: boolean;
isTrackingMpvWindow: boolean;
isMpvWindowFocused: boolean;
isOverlayWindowFocused: boolean;
nowMs: number;
graceMs: number;
state: ForegroundSuppressionGraceState;
}): boolean {
if (options.hasForegroundSeparateWindow) {
options.state.lossSinceMs = null;
return true;
}
const rawSuppress = shouldSuppressPointerInteractionForForegroundWindow(options);
if (!rawSuppress) {
options.state.lossSinceMs = null;
return false;
}
if (options.state.lossSinceMs === null) {
options.state.lossSinceMs = options.nowMs;
}
return options.nowMs - options.state.lossSinceMs >= options.graceMs;
}
function isCursorOverRect(
cursor: PointerPoint,
bounds: PointerRect,
viewport: PointerViewport,
rect: PointerRect,
): boolean {
if (!(bounds.width > 0) || !(bounds.height > 0)) return false;
const scaleX = bounds.width / viewport.width;
const scaleY = bounds.height / viewport.height;
const left = bounds.x + rect.x * scaleX - SUBTITLE_HIT_PADDING_PX;
const top = bounds.y + rect.y * scaleY - SUBTITLE_HIT_PADDING_PX;
const right = left + rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2;
const bottom = top + rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2;
return cursor.x >= left && cursor.x <= right && cursor.y >= top && cursor.y <= bottom;
}
function measuredRectsForInput(measurement: OverlayContentMeasurementLike): PointerRect[] {
if (!measurement) return [];
return Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.length > 0
? measurement.interactiveRects
: measurement.contentRect
? [measurement.contentRect]
: [];
}
function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
const left = Math.max(0, Math.floor(rect.x));
const top = Math.max(0, Math.floor(rect.y));
const right = Math.min(Math.ceil(bounds.width), Math.ceil(rect.x + rect.width));
const bottom = Math.min(Math.ceil(bounds.height), Math.ceil(rect.y + rect.height));
if (right <= left || bottom <= top) return null;
return {
x: left,
y: top,
width: right - left,
height: bottom - top,
};
}
function mapMeasuredRectToWindowShape(
bounds: PointerRect,
viewport: PointerViewport,
rect: PointerRect,
): PointerRect | null {
if (!(bounds.width > 0) || !(bounds.height > 0)) return null;
if (!(viewport.width > 0) || !(viewport.height > 0)) return null;
const scaleX = bounds.width / viewport.width;
const scaleY = bounds.height / viewport.height;
return clampRectToWindow(
{
x: rect.x * scaleX - SUBTITLE_HIT_PADDING_PX,
y: rect.y * scaleY - SUBTITLE_HIT_PADDING_PX,
width: rect.width * scaleX + SUBTITLE_HIT_PADDING_PX * 2,
height: rect.height * scaleY + SUBTITLE_HIT_PADDING_PX * 2,
},
bounds,
);
}
function resolveInputShapeRects(options: {
bounds: PointerRect;
measurement: OverlayContentMeasurementLike;
rendererInteractiveHint: boolean;
}): PointerRect[] {
const { bounds } = options;
if (!(bounds.width > 0) || !(bounds.height > 0)) return [];
if (options.rendererInteractiveHint) {
return [
{
x: 0,
y: 0,
width: Math.ceil(bounds.width),
height: Math.ceil(bounds.height),
},
];
}
const measurement = options.measurement;
if (!measurement) return [];
return measuredRectsForInput(measurement)
.map((rect) => mapMeasuredRectToWindowShape(bounds, measurement.viewport, rect))
.filter((rect): rect is PointerRect => rect !== null);
}
/** Hit-test the global cursor against subtitle bar rects, mapping viewport px → screen px. */
export function isCursorOverSubtitle(
cursor: PointerPoint,
bounds: PointerRect,
measurement: OverlayContentMeasurementLike,
): boolean {
if (!measurement) return false;
const { viewport } = measurement;
if (!(viewport.width > 0) || !(viewport.height > 0)) return false;
const rects = measuredRectsForInput(measurement);
return rects.some((rect) => isCursorOverRect(cursor, bounds, viewport, rect));
}
/**
* Returns the desired interactive state, or null when the loop should not touch it
* (overlay hidden/destroyed or another surface owns input).
*/
export function resolveDesiredOverlayInteractive(
deps: LinuxOverlayPointerInteractionDeps,
): boolean | null {
if (!deps.getVisibleOverlayVisible()) return false;
if (deps.shouldSuspend()) return null;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return null;
}
if (deps.shouldSuppressInteraction?.()) return false;
if (deps.getRendererInteractiveHint()) return true;
return isCursorOverSubtitle(
deps.getCursorScreenPoint(),
mainWindow.getBounds(),
deps.getSubtitleMeasurement(),
);
}
export function tickLinuxOverlayPointerInteraction(deps: LinuxOverlayPointerInteractionDeps): void {
if (deps.shouldUseInputShape?.()) return;
const desired = resolveDesiredOverlayInteractive(deps);
if (desired === null) return;
if (deps.getInteractionActive() === desired) return;
deps.setInteractionActive(desired);
}
export function applyLinuxOverlayInputShape(deps: {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionShapeWindow | null;
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
getRendererInteractiveHint: () => boolean;
shouldSuspend: () => boolean;
shouldSuppressInteraction?: () => boolean;
}): { handled: boolean; active: boolean } {
const mainWindow = deps.getMainWindow();
if (!mainWindow || typeof mainWindow.setShape !== 'function') {
return { handled: false, active: false };
}
if (
!deps.getVisibleOverlayVisible() ||
deps.shouldSuspend() ||
mainWindow.isDestroyed() ||
deps.shouldSuppressInteraction?.()
) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
mainWindow.setShape([]);
return { handled: true, active: false };
}
const rects = resolveInputShapeRects({
bounds: mainWindow.getBounds(),
measurement: deps.getSubtitleMeasurement(),
rendererInteractiveHint: deps.getRendererInteractiveHint(),
});
if (rects.length === 0) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
mainWindow.setShape([]);
return { handled: true, active: false };
}
mainWindow.setShape(rects);
mainWindow.setIgnoreMouseEvents(false);
return { handled: true, active: true };
}
export function applyLinuxOverlayPointerInteractionMousePassthrough(deps: {
active: boolean;
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionMousePassthroughWindow | null;
shouldSuspend: () => boolean;
shouldSuppressInteraction?: () => boolean;
updateVisibleOverlayVisibility: () => void;
}): boolean {
if (!deps.getVisibleOverlayVisible() || deps.shouldSuspend()) {
deps.updateVisibleOverlayVisibility();
return false;
}
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
deps.updateVisibleOverlayVisibility();
return false;
}
if (deps.shouldSuppressInteraction?.()) {
deps.updateVisibleOverlayVisibility();
return false;
}
if (deps.active) {
mainWindow.setIgnoreMouseEvents(false);
} else {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
return true;
}
export function ensureLinuxOverlayPointerInteractionLoop(
deps: LinuxOverlayPointerInteractionDeps,
platform: NodeJS.Platform = process.platform,
): void {
if (pointerInteractionInterval !== null) return;
if (platform !== 'linux') return;
pointerInteractionInterval = setInterval(() => {
tickLinuxOverlayPointerInteraction(deps);
}, LINUX_OVERLAY_POINTER_POLL_INTERVAL_MS);
pointerInteractionInterval.unref?.();
}
export function stopLinuxOverlayPointerInteractionLoop(): void {
if (pointerInteractionInterval === null) return;
clearInterval(pointerInteractionInterval);
pointerInteractionInterval = null;
}
@@ -0,0 +1,54 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import {
buildFullWindowShapeRect,
restoreLinuxOverlayWindowShape,
} from './linux-overlay-window-shape';
test('buildFullWindowShapeRect maps current bounds to a full-window shape', () => {
assert.deepEqual(buildFullWindowShapeRect({ x: 100, y: 50, width: 1919.6, height: 1080.4 }), {
x: 0,
y: 0,
width: 1920,
height: 1080,
});
});
test('buildFullWindowShapeRect rejects invalid dimensions', () => {
assert.equal(buildFullWindowShapeRect({ x: 0, y: 0, width: 0, height: 1080 }), null);
assert.equal(buildFullWindowShapeRect({ x: 0, y: 0, width: 1920, height: Number.NaN }), null);
});
test('restoreLinuxOverlayWindowShape restores a full drawable shape', () => {
const calls: unknown[] = [];
assert.equal(
restoreLinuxOverlayWindowShape({
isDestroyed: () => false,
getBounds: () => ({ x: 760, y: 152, width: 1920, height: 1080 }),
setShape: (rects) => calls.push(rects),
}),
true,
);
assert.deepEqual(calls, [[{ x: 0, y: 0, width: 1920, height: 1080 }]]);
});
test('restoreLinuxOverlayWindowShape skips destroyed or unsupported windows', () => {
assert.equal(
restoreLinuxOverlayWindowShape({
isDestroyed: () => true,
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
setShape: () => {
throw new Error('should not shape destroyed windows');
},
}),
false,
);
assert.equal(
restoreLinuxOverlayWindowShape({
isDestroyed: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
}),
false,
);
});
@@ -0,0 +1,53 @@
export type LinuxOverlayShapeRect = {
x: number;
y: number;
width: number;
height: number;
};
export type LinuxOverlayShapeWindow = {
isDestroyed: () => boolean;
getBounds?: () => LinuxOverlayShapeRect;
setShape?: (rects: LinuxOverlayShapeRect[]) => void;
};
function toPositivePixel(value: number): number | null {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return Math.max(1, Math.round(value));
}
export function buildFullWindowShapeRect(
bounds: LinuxOverlayShapeRect,
): LinuxOverlayShapeRect | null {
const width = toPositivePixel(bounds.width);
const height = toPositivePixel(bounds.height);
if (width === null || height === null) {
return null;
}
return {
x: 0,
y: 0,
width,
height,
};
}
export function restoreLinuxOverlayWindowShape(window: LinuxOverlayShapeWindow | null): boolean {
if (!window || window.isDestroyed()) {
return false;
}
if (typeof window.setShape !== 'function' || typeof window.getBounds !== 'function') {
return false;
}
const rect = buildFullWindowShapeRect(window.getBounds());
if (!rect) {
return false;
}
window.setShape([rect]);
return true;
}
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
ensureLinuxOverlayZOrderKeepAliveLoop,
type LinuxOverlayZOrderKeepAliveDeps,
shouldRunLinuxOverlayZOrderKeepAlive,
stopLinuxOverlayZOrderKeepAliveLoop,
tickLinuxOverlayZOrderKeepAlive,
} from './linux-overlay-zorder-keepalive';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
try {
run();
} finally {
if (original) Object.defineProperty(process, 'platform', original);
}
}
function makeDeps(
overrides: Partial<LinuxOverlayZOrderKeepAliveDeps>,
calls: string[],
): LinuxOverlayZOrderKeepAliveDeps {
return {
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({ isDestroyed: () => false, isVisible: () => true }),
isTrackingMpvWindow: () => true,
isMpvWindowFocused: () => true,
isOverlayWindowFocused: () => false,
shouldSuppressReassert: () => false,
raiseMpvWindow: async () => {
calls.push('raise-mpv');
return true;
},
releaseOverlayLayerOrder: () => calls.push('release'),
enforceOverlayLayerOrder: () => calls.push('enforce'),
focusOverlayWindow: () => calls.push('focus-overlay'),
...overrides,
};
}
test('shouldRunLinuxOverlayZOrderKeepAlive runs on Linux except Hyprland/Sway', () => {
withPlatform('linux', () => {
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ XDG_CURRENT_DESKTOP: 'KDE' }), true);
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ HYPRLAND_INSTANCE_SIGNATURE: 'h' }), false);
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({ SWAYSOCK: '/tmp/s' }), false);
});
withPlatform('win32', () => {
assert.equal(shouldRunLinuxOverlayZOrderKeepAlive({}), false);
});
});
test('tick re-asserts overlay level when the overlay is shown and unobstructed', async () => {
const calls: string[] = [];
await tickLinuxOverlayZOrderKeepAlive(makeDeps({}, calls));
assert.deepEqual(calls, ['enforce']);
});
test('tick raises mpv behind a focused overlay when mpv is behind another app', async () => {
const calls: string[] = [];
await tickLinuxOverlayZOrderKeepAlive(
makeDeps(
{
isMpvWindowFocused: () => false,
isOverlayWindowFocused: () => true,
},
calls,
),
);
assert.deepEqual(calls, ['raise-mpv', 'enforce', 'focus-overlay']);
});
test('tick releases stale overlay topmost when another app is focused', async () => {
const calls: string[] = [];
await tickLinuxOverlayZOrderKeepAlive(
makeDeps(
{
isMpvWindowFocused: () => false,
isOverlayWindowFocused: () => false,
},
calls,
),
);
assert.deepEqual(calls, ['release']);
});
test('tick skips when overlay hidden, mpv untracked, suppressed, or window gone', async () => {
for (const override of [
{ getVisibleOverlayVisible: () => false },
{ isTrackingMpvWindow: () => false },
{ shouldSuppressReassert: () => true },
{ getMainWindow: () => null },
{ getMainWindow: () => ({ isDestroyed: () => true, isVisible: () => true }) },
{ getMainWindow: () => ({ isDestroyed: () => false, isVisible: () => false }) },
] satisfies Array<Partial<LinuxOverlayZOrderKeepAliveDeps>>) {
const calls: string[] = [];
await tickLinuxOverlayZOrderKeepAlive(makeDeps(override, calls));
assert.deepEqual(calls, []);
}
});
test('keep-alive loop skips overlapping ticks and resets after async completion', async () => {
const originalSetInterval = globalThis.setInterval;
const originalClearInterval = globalThis.clearInterval;
let intervalCallback: (() => void) | null = null;
let resolveRaise: (() => void) | null = null;
let raiseCalls = 0;
globalThis.setInterval = ((callback: () => void) => {
intervalCallback = callback;
return { unref: () => {} } as ReturnType<typeof setInterval>;
}) as typeof setInterval;
globalThis.clearInterval = (() => {}) as typeof clearInterval;
try {
withPlatform('linux', () => {
ensureLinuxOverlayZOrderKeepAliveLoop(
makeDeps(
{
isMpvWindowFocused: () => false,
isOverlayWindowFocused: () => true,
raiseMpvWindow: async () => {
raiseCalls += 1;
await new Promise<void>((resolve) => {
resolveRaise = resolve;
});
return true;
},
},
[],
),
{},
);
});
assert.ok(intervalCallback);
const tick = intervalCallback as () => void;
tick();
tick();
assert.equal(raiseCalls, 1);
assert.ok(resolveRaise);
const finishRaise = resolveRaise as () => void;
finishRaise();
await new Promise((resolve) => setTimeout(resolve, 0));
tick();
assert.equal(raiseCalls, 2);
} finally {
stopLinuxOverlayZOrderKeepAliveLoop();
globalThis.setInterval = originalSetInterval;
globalThis.clearInterval = originalClearInterval;
}
});
@@ -0,0 +1,102 @@
import { isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
/*
Linux overlay z-order keep-alive loop.
The visible overlay re-asserts its always-on-top level only when mpv's geometry changes
(the bounds-update path) or on a fullscreen toggle (the fullscreen refresh burst). When mpv
is raised above the overlay WITHOUT a geometry change — click-to-raise, focus change, or a
compositor restack on KDE/GNOME/other X11/XWayland window managers — nothing re-raises the
overlay and it stays buried. Windows guards against this with a foreground poll loop; this is
the Linux equivalent: a lightweight periodic re-assert while the overlay is shown and mpv
remains the foreground window. If another app is active, the overlay releases its global
keep-above level so that app can cover it.
Gated to X11/XWayland sessions (not Hyprland/Sway, which place the overlay natively and would
otherwise be spammed with hyprctl dispatches).
*/
type KeepAliveOverlayWindow = {
isDestroyed: () => boolean;
isVisible: () => boolean;
focus?: () => void;
};
export type LinuxOverlayZOrderKeepAliveDeps = {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => KeepAliveOverlayWindow | null;
isTrackingMpvWindow: () => boolean;
isMpvWindowFocused: () => boolean;
isOverlayWindowFocused: () => boolean;
/** True when a modal/stats overlay or active interaction owns the top — skip re-asserting. */
shouldSuppressReassert: () => boolean;
raiseMpvWindow: () => Promise<boolean>;
releaseOverlayLayerOrder: () => void;
enforceOverlayLayerOrder: () => void;
focusOverlayWindow?: () => void;
};
export const LINUX_OVERLAY_ZORDER_KEEPALIVE_INTERVAL_MS = 700;
let keepAliveInterval: ReturnType<typeof setInterval> | null = null;
let keepAliveTickInFlight = false;
export function shouldRunLinuxOverlayZOrderKeepAlive(
env: NodeJS.ProcessEnv = process.env,
): boolean {
return process.platform === 'linux' && !isSupportedWaylandCompositor(env);
}
export async function tickLinuxOverlayZOrderKeepAlive(
deps: LinuxOverlayZOrderKeepAliveDeps,
): Promise<void> {
if (!deps.getVisibleOverlayVisible()) return;
if (!deps.isTrackingMpvWindow()) return;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
const overlayFocused = deps.isOverlayWindowFocused();
const mpvFocused = deps.isMpvWindowFocused();
if (!mpvFocused && !overlayFocused) {
deps.releaseOverlayLayerOrder();
return;
}
if (deps.shouldSuppressReassert()) return;
if (overlayFocused && !mpvFocused) {
await deps.raiseMpvWindow();
}
deps.enforceOverlayLayerOrder();
if (overlayFocused && !mpvFocused) {
deps.focusOverlayWindow?.();
}
}
export function ensureLinuxOverlayZOrderKeepAliveLoop(
deps: LinuxOverlayZOrderKeepAliveDeps,
env: NodeJS.ProcessEnv = process.env,
): void {
if (keepAliveInterval !== null) return;
if (!shouldRunLinuxOverlayZOrderKeepAlive(env)) return;
keepAliveInterval = setInterval(() => {
if (keepAliveTickInFlight) return;
keepAliveTickInFlight = true;
void tickLinuxOverlayZOrderKeepAlive(deps)
.catch(() => {})
.finally(() => {
keepAliveTickInFlight = false;
});
}, LINUX_OVERLAY_ZORDER_KEEPALIVE_INTERVAL_MS);
keepAliveInterval.unref?.();
}
export function stopLinuxOverlayZOrderKeepAliveLoop(): void {
if (keepAliveInterval === null) return;
clearInterval(keepAliveInterval);
keepAliveInterval = null;
keepAliveTickInFlight = false;
}
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveLinuxVisibleOverlayWindowModeAction,
shouldExitFullscreenOverrideForTrackedGeometry,
} from './linux-visible-overlay-window-mode';
test('linux overlay mode sync records fullscreen without creating a hidden overlay', () => {
assert.deepEqual(
resolveLinuxVisibleOverlayWindowModeAction({
currentMode: 'managed',
fullscreen: true,
hasLiveWindow: false,
visibleOverlayVisible: false,
}),
{
nextMode: 'fullscreen-override',
shouldCreateWindow: false,
shouldDestroyCurrentWindow: false,
shouldRefreshVisibleOverlay: false,
createWindowTiming: 'none',
},
);
});
test('linux overlay mode sync destroys stale hidden window without replacing it', () => {
assert.deepEqual(
resolveLinuxVisibleOverlayWindowModeAction({
currentMode: 'managed',
fullscreen: true,
hasLiveWindow: true,
visibleOverlayVisible: false,
}),
{
nextMode: 'fullscreen-override',
shouldCreateWindow: false,
shouldDestroyCurrentWindow: true,
shouldRefreshVisibleOverlay: false,
createWindowTiming: 'none',
},
);
});
test('linux overlay mode sync replaces visible window when fullscreen mode changes', () => {
assert.deepEqual(
resolveLinuxVisibleOverlayWindowModeAction({
currentMode: 'managed',
fullscreen: true,
hasLiveWindow: true,
visibleOverlayVisible: true,
}),
{
nextMode: 'fullscreen-override',
shouldCreateWindow: true,
shouldDestroyCurrentWindow: true,
shouldRefreshVisibleOverlay: true,
createWindowTiming: 'after-current-destroyed',
},
);
});
test('linux overlay mode sync creates correct visible window when none exists', () => {
assert.deepEqual(
resolveLinuxVisibleOverlayWindowModeAction({
currentMode: 'fullscreen-override',
fullscreen: true,
hasLiveWindow: false,
visibleOverlayVisible: true,
}),
{
nextMode: 'fullscreen-override',
shouldCreateWindow: true,
shouldDestroyCurrentWindow: false,
shouldRefreshVisibleOverlay: true,
createWindowTiming: 'now',
},
);
});
test('linux overlay mode sync no-ops when live window already matches mode', () => {
assert.deepEqual(
resolveLinuxVisibleOverlayWindowModeAction({
currentMode: 'fullscreen-override',
fullscreen: true,
hasLiveWindow: true,
visibleOverlayVisible: true,
}),
{
nextMode: 'fullscreen-override',
shouldCreateWindow: false,
shouldDestroyCurrentWindow: false,
shouldRefreshVisibleOverlay: false,
createWindowTiming: 'none',
},
);
});
test('linux overlay mode exits fullscreen override when tracked geometry is windowed', () => {
assert.equal(
shouldExitFullscreenOverrideForTrackedGeometry({
currentMode: 'fullscreen-override',
trackedFullscreen: true,
geometry: { x: 420, y: 90, width: 1280, height: 720 },
displayBounds: { x: 0, y: 0, width: 2560, height: 1440 },
}),
true,
);
});
@@ -0,0 +1,90 @@
export type LinuxVisibleOverlayWindowMode = 'managed' | 'fullscreen-override';
type LinuxVisibleOverlayGeometry = {
x: number;
y: number;
width: number;
height: number;
};
export type LinuxVisibleOverlayWindowModeAction = {
nextMode: LinuxVisibleOverlayWindowMode;
shouldCreateWindow: boolean;
shouldDestroyCurrentWindow: boolean;
shouldRefreshVisibleOverlay: boolean;
createWindowTiming: 'none' | 'now' | 'after-current-destroyed';
};
export function resolveLinuxVisibleOverlayWindowModeAction(options: {
currentMode: LinuxVisibleOverlayWindowMode;
fullscreen: boolean;
hasLiveWindow: boolean;
visibleOverlayVisible: boolean;
}): LinuxVisibleOverlayWindowModeAction {
const nextMode: LinuxVisibleOverlayWindowMode = options.fullscreen
? 'fullscreen-override'
: 'managed';
const modeChanged = options.currentMode !== nextMode;
if (!options.visibleOverlayVisible) {
return {
nextMode,
shouldCreateWindow: false,
shouldDestroyCurrentWindow: options.hasLiveWindow && modeChanged,
shouldRefreshVisibleOverlay: false,
createWindowTiming: 'none',
};
}
if (options.hasLiveWindow && !modeChanged) {
return {
nextMode,
shouldCreateWindow: false,
shouldDestroyCurrentWindow: false,
shouldRefreshVisibleOverlay: false,
createWindowTiming: 'none',
};
}
return {
nextMode,
shouldCreateWindow: true,
shouldDestroyCurrentWindow: options.hasLiveWindow,
shouldRefreshVisibleOverlay: true,
createWindowTiming: options.hasLiveWindow ? 'after-current-destroyed' : 'now',
};
}
function geometryCoversDisplayBounds(
geometry: LinuxVisibleOverlayGeometry,
displayBounds: LinuxVisibleOverlayGeometry,
tolerancePx: number,
): boolean {
const geometryRight = geometry.x + geometry.width;
const geometryBottom = geometry.y + geometry.height;
const displayRight = displayBounds.x + displayBounds.width;
const displayBottom = displayBounds.y + displayBounds.height;
return (
geometry.x <= displayBounds.x + tolerancePx &&
geometry.y <= displayBounds.y + tolerancePx &&
geometryRight >= displayRight - tolerancePx &&
geometryBottom >= displayBottom - tolerancePx
);
}
export function shouldExitFullscreenOverrideForTrackedGeometry(options: {
currentMode: LinuxVisibleOverlayWindowMode;
trackedFullscreen: boolean;
geometry: LinuxVisibleOverlayGeometry;
displayBounds: LinuxVisibleOverlayGeometry;
tolerancePx?: number;
}): boolean {
if (options.currentMode !== 'fullscreen-override') return false;
if (!options.trackedFullscreen) return false;
return !geometryCoversDisplayBounds(
options.geometry,
options.displayBounds,
options.tolerancePx ?? 2,
);
}
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createLinuxX11CursorPointReader,
parseXdotoolMouseLocation,
} from './linux-x11-cursor-point';
test('parseXdotoolMouseLocation parses root cursor coordinates', () => {
assert.deepEqual(
parseXdotoolMouseLocation(`X=1700
Y=1050
SCREEN=0
WINDOW=44040194
`),
{ x: 1700, y: 1050 },
);
});
test('createLinuxX11CursorPointReader returns cached X11 cursor point over stale fallback', async () => {
let now = 1000;
const pendingCommand: { resolve?: (value: string) => void } = {};
const calls: Array<{ command: string; args: string[] }> = [];
const reader = createLinuxX11CursorPointReader({
env: { DISPLAY: ':1' },
platform: 'linux',
now: () => now,
runCommand: (command, args) => {
calls.push({ command, args });
return new Promise((resolve) => {
pendingCommand.resolve = resolve;
});
},
});
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 877, y: 718 });
assert.deepEqual(calls, [{ command: 'xdotool', args: ['getmouselocation', '--shell'] }]);
assert.ok(pendingCommand.resolve);
pendingCommand.resolve(`X=1700
Y=1050
SCREEN=0
WINDOW=44040194
`);
await new Promise((resolve) => setImmediate(resolve));
now += 60;
assert.deepEqual(reader.getCursorScreenPoint({ x: 877, y: 718 }), { x: 1700, y: 1050 });
});
test('createLinuxX11CursorPointReader does not spawn off X11 Linux', () => {
const calls: string[] = [];
const reader = createLinuxX11CursorPointReader({
env: {},
platform: 'linux',
runCommand: async (command) => {
calls.push(command);
return '';
},
});
assert.deepEqual(reader.getCursorScreenPoint({ x: 5, y: 6 }), { x: 5, y: 6 });
assert.deepEqual(calls, []);
});
test('createLinuxX11CursorPointReader does not spawn for supported native Wayland compositors', () => {
const calls: string[] = [];
const reader = createLinuxX11CursorPointReader({
env: {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
},
platform: 'linux',
runCommand: async (command) => {
calls.push(command);
return '';
},
});
assert.deepEqual(reader.getCursorScreenPoint({ x: 7, y: 8 }), { x: 7, y: 8 });
assert.deepEqual(calls, []);
});
@@ -0,0 +1,87 @@
import { execFile } from 'node:child_process';
import { getLinuxDesktopEnv, isSupportedWaylandCompositor } from '../../shared/mpv-x11-backend';
import type { PointerPoint } from './linux-overlay-pointer-interaction';
type CommandRunner = (command: string, args: string[]) => Promise<string>;
const XDOTOOL_CURSOR_ARGS = ['getmouselocation', '--shell'] as const;
const CURSOR_POINT_MAX_AGE_MS = 1000;
const COMMAND_FAILURE_RETRY_DELAY_MS = 1000;
function execFileUtf8(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf-8' }, (error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
});
});
}
export function parseXdotoolMouseLocation(raw: string): PointerPoint | null {
const xMatch = raw.match(/^X=(-?\d+)$/m);
const yMatch = raw.match(/^Y=(-?\d+)$/m);
if (!xMatch || !yMatch) return null;
const x = Number.parseInt(xMatch[1]!, 10);
const y = Number.parseInt(yMatch[1]!, 10);
if (!Number.isInteger(x) || !Number.isInteger(y)) return null;
return { x, y };
}
export function createLinuxX11CursorPointReader(options?: {
env?: NodeJS.ProcessEnv;
now?: () => number;
platform?: NodeJS.Platform;
runCommand?: CommandRunner;
}) {
const env = options?.env ?? process.env;
const now = options?.now ?? (() => Date.now());
const platform = options?.platform ?? process.platform;
const runCommand = options?.runCommand ?? execFileUtf8;
let latest: { point: PointerPoint; updatedAtMs: number } | null = null;
let inFlight = false;
let retryAfterMs = 0;
function isSupported(): boolean {
if (platform !== 'linux' || !env.DISPLAY?.trim()) return false;
if (getLinuxDesktopEnv(env).hasWayland && isSupportedWaylandCompositor(env)) return false;
return true;
}
function refresh(): void {
const nowMs = now();
if (!isSupported() || inFlight || nowMs < retryAfterMs) return;
inFlight = true;
void runCommand('xdotool', [...XDOTOOL_CURSOR_ARGS])
.then((raw) => {
const point = parseXdotoolMouseLocation(raw);
if (!point) {
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
return;
}
latest = { point, updatedAtMs: now() };
retryAfterMs = 0;
})
.catch(() => {
retryAfterMs = now() + COMMAND_FAILURE_RETRY_DELAY_MS;
})
.finally(() => {
inFlight = false;
});
}
return {
getCursorScreenPoint(fallback: PointerPoint): PointerPoint {
refresh();
if (latest && now() - latest.updatedAtMs <= CURSOR_POINT_MAX_AGE_MS) {
return latest.point;
}
return fallback;
},
refresh,
};
}
@@ -12,7 +12,7 @@ import {
createHandleMpvTimePosChangeHandler,
} from './mpv-main-event-actions';
test('subtitle change handler updates state, broadcasts, and forwards', () => {
test('subtitle change handler updates state and forwards uncached text without raw broadcast', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => calls.push(`set:${text}`),
@@ -23,7 +23,22 @@ test('subtitle change handler updates state, broadcasts, and forwards', () => {
});
handler({ text: 'line' });
assert.deepEqual(calls, ['set:line', 'broadcast:line', 'process:line', 'presence']);
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
});
test('subtitle change handler clears immediately for empty subtitle text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => calls.push(`set:${text}`),
getImmediateSubtitlePayload: () => null,
broadcastSubtitle: (payload) =>
calls.push(`broadcast:${payload.text}:${payload.tokens === null ? 'plain' : 'annotated'}`),
onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ text: '' });
assert.deepEqual(calls, ['set:', 'broadcast::plain', 'process:', 'presence']);
});
test('subtitle change handler broadcasts cached annotated payload immediately when available', () => {
+6 -4
View File
@@ -28,10 +28,12 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
deps.onSubtitleChange(text);
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
} else {
deps.broadcastSubtitle({
text,
tokens: null,
});
if (!text.trim()) {
deps.broadcastSubtitle({
text,
tokens: null,
});
}
deps.onSubtitleChange(text);
}
deps.refreshDiscordPresence();
@@ -28,6 +28,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
},
logSubtitleTimingError: () => calls.push('subtitle-error'),
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
refreshDiscordPresence: () => calls.push('presence-refresh'),
@@ -82,7 +83,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('set-sub:line'));
assert.ok(calls.includes('reset-sidebar-layout'));
assert.ok(calls.includes('broadcast-sub:line'));
assert.equal(calls.includes('broadcast-sub:line'), true);
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));
assert.ok(calls.includes('subtitle-track-list-change'));
+3 -1
View File
@@ -118,7 +118,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: (payload) => deps.emitImmediateSubtitle?.(payload),
emitImmediateSubtitle: deps.emitImmediateSubtitle
? (payload) => deps.emitImmediateSubtitle?.(payload)
: undefined,
broadcastSubtitle: (payload) => deps.broadcastSubtitle(payload),
onSubtitleChange: (text) => deps.onSubtitleChange(text),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
@@ -75,18 +75,66 @@ test('overlay modal input state restores main window focus on deactivation', ()
const calls: string[] = [];
const state = createOverlayModalInputState({
getModalWindow: () => modalWindow as never,
syncOverlayShortcutsForModal: () => {},
syncOverlayVisibilityForModal: () => {},
syncOverlayShortcutsForModal: (isActive) => {
calls.push(`shortcuts:${isActive}`);
},
syncOverlayVisibilityForModal: () => {
calls.push('visibility');
},
restoreMainWindowFocus: () => {
calls.push('restore-focus');
},
});
state.handleModalInputStateChange(true);
assert.deepEqual(calls, []);
calls.length = 0;
state.handleModalInputStateChange(false);
assert.deepEqual(calls, ['restore-focus']);
assert.deepEqual(calls, ['shortcuts:false', 'visibility', 'restore-focus', 'visibility']);
});
test('overlay modal input state schedules visibility settle burst after focus restore', () => {
const modalWindow = createModalWindow();
const calls: string[] = [];
const scheduled: Array<{ delayMs: number; callback: () => void }> = [];
const state = createOverlayModalInputState({
getModalWindow: () => modalWindow as never,
syncOverlayShortcutsForModal: () => {},
syncOverlayVisibilityForModal: () => {
calls.push('visibility');
},
restoreMainWindowFocus: () => {
calls.push('restore-focus');
},
schedulePostRestoreVisibilitySync: (callback, delayMs) => {
scheduled.push({ callback, delayMs });
return scheduled.length as never;
},
clearPostRestoreVisibilitySync: () => {},
});
state.handleModalInputStateChange(true);
calls.length = 0;
state.handleModalInputStateChange(false);
assert.deepEqual(
scheduled.map((entry) => entry.delayMs),
[50, 150, 300, 600, 1000],
);
for (const entry of scheduled) {
entry.callback();
}
assert.deepEqual(calls, [
'visibility',
'restore-focus',
'visibility',
'visibility',
'visibility',
'visibility',
'visibility',
'visibility',
]);
});
test('overlay modal input state is idempotent for unchanged state', () => {
@@ -1,5 +1,8 @@
import type { BrowserWindow } from 'electron';
type VisibilitySyncTimeout = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
const POST_RESTORE_VISIBILITY_SYNC_DELAYS_MS = [50, 150, 300, 600, 1000] as const;
function requestOverlayApplicationFocus(): void {
try {
const electron = require('electron') as {
@@ -25,16 +28,48 @@ export type OverlayModalInputStateDeps = {
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
restoreMainWindowFocus?: () => void;
schedulePostRestoreVisibilitySync?: (
callback: () => void,
delayMs: number,
) => VisibilitySyncTimeout;
clearPostRestoreVisibilitySync?: (timeout: VisibilitySyncTimeout) => void;
};
export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
let modalInputExclusive = false;
let postRestoreVisibilitySyncTimeouts: VisibilitySyncTimeout[] = [];
const schedulePostRestoreVisibilitySync =
deps.schedulePostRestoreVisibilitySync ?? globalThis.setTimeout;
const clearPostRestoreVisibilitySync =
deps.clearPostRestoreVisibilitySync ?? globalThis.clearTimeout;
const clearPostRestoreVisibilitySyncBurst = (): void => {
for (const timeout of postRestoreVisibilitySyncTimeouts) {
clearPostRestoreVisibilitySync(timeout);
}
postRestoreVisibilitySyncTimeouts = [];
};
const schedulePostRestoreVisibilitySyncBurst = (): void => {
clearPostRestoreVisibilitySyncBurst();
for (const delayMs of POST_RESTORE_VISIBILITY_SYNC_DELAYS_MS) {
const timeout = schedulePostRestoreVisibilitySync(() => {
postRestoreVisibilitySyncTimeouts = postRestoreVisibilitySyncTimeouts.filter(
(candidate) => candidate !== timeout,
);
deps.syncOverlayVisibilityForModal();
}, delayMs);
(timeout as { unref?: () => void }).unref?.();
postRestoreVisibilitySyncTimeouts.push(timeout);
}
};
const handleModalInputStateChange = (isActive: boolean): void => {
if (modalInputExclusive === isActive) {
return;
}
clearPostRestoreVisibilitySyncBurst();
modalInputExclusive = isActive;
if (isActive) {
const modalWindow = deps.getModalWindow();
@@ -54,6 +89,10 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
deps.syncOverlayVisibilityForModal();
if (!isActive) {
deps.restoreMainWindowFocus?.();
if (deps.restoreMainWindowFocus) {
deps.syncOverlayVisibilityForModal();
schedulePostRestoreVisibilitySyncBurst();
}
}
};
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
getModalActive: () => deps.getModalActive(),
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
getNonNativeInputRegionActive: () => deps.getNonNativeInputRegionActive?.() ?? false,
getSuspendVisibleOverlay: () => deps.getSuspendVisibleOverlay?.() ?? false,
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
getWindowTracker: () => deps.getWindowTracker(),
@@ -31,6 +32,8 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
isMacOSPlatform: () => deps.isMacOSPlatform(),
isWindowsPlatform: () => deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
hideNonNativeOverlayWhenTargetUnfocused: () =>
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
});
}
@@ -18,6 +18,7 @@ test('overlay window factory main deps builders return mapped handlers', () => {
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onVisibleWindowFocused: () => calls.push('visible-focus'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
});
@@ -27,12 +28,17 @@ test('overlay window factory main deps builders return mapped handlers', () => {
assert.equal(overlayDeps.isOverlayVisible('visible'), true);
assert.equal(overlayDeps.getYomitanSession(), yomitanSession);
overlayDeps.forwardTabToMpv();
overlayDeps.onVisibleWindowFocused?.();
const buildMainDeps = createBuildCreateMainWindowMainDepsHandler({
getMainWindow: () => null,
isWindowDestroyed: () => false,
createOverlayWindow: () => ({ id: 'visible' }),
setMainWindow: () => calls.push('set-main'),
});
const mainDeps = buildMainDeps();
assert.equal(mainDeps.getMainWindow(), null);
assert.equal(mainDeps.isWindowDestroyed({ id: 'visible' }), false);
mainDeps.setMainWindow(null);
const buildModalDeps = createBuildCreateModalWindowMainDepsHandler({
@@ -42,5 +48,5 @@ test('overlay window factory main deps builders return mapped handlers', () => {
const modalDeps = buildModalDeps();
modalDeps.setModalWindow(null);
assert.deepEqual(calls, ['forward-tab', 'set-main', 'set-modal']);
assert.deepEqual(calls, ['forward-tab', 'visible-focus', 'set-main', 'set-modal']);
});
@@ -11,9 +11,11 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
yomitanSession?: Session | null;
},
) => TWindow;
@@ -24,9 +26,11 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: 'visible' | 'modal') => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal') => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
getYomitanSession?: () => Session | null;
}) {
return () => ({
@@ -38,7 +42,9 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
@@ -46,10 +52,14 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
}
export function createBuildCreateMainWindowMainDepsHandler<TWindow>(deps: {
getMainWindow: () => TWindow | null;
isWindowDestroyed: (window: TWindow) => boolean;
createOverlayWindow: (kind: 'visible' | 'modal') => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return () => ({
getMainWindow: () => deps.getMainWindow(),
isWindowDestroyed: (window: TWindow) => deps.isWindowDestroyed(window),
createOverlayWindow: deps.createOverlayWindow,
setMainWindow: deps.setMainWindow,
});
@@ -18,9 +18,10 @@ test('create overlay window handler forwards options and kind', () => {
assert.equal(options.isOverlayVisible('modal'), false);
assert.equal(options.yomitanSession, yomitanSession);
options.forwardTabToMpv();
options.onVisibleWindowFocused?.();
options.onRuntimeOptionsChanged();
options.setOverlayDebugVisualizationEnabled(true);
options.onWindowClosed(kind);
options.onWindowClosed(kind, window);
return window;
},
isDev: true,
@@ -30,7 +31,9 @@ test('create overlay window handler forwards options and kind', () => {
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => calls.push('forward-tab'),
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
onVisibleWindowFocused: () => calls.push('visible-focus'),
onWindowClosed: (kind, closedWindow) =>
calls.push(`closed:${kind}:${(closedWindow as { id: number }).id}`),
getYomitanSession: () => yomitanSession,
});
@@ -38,27 +41,51 @@ test('create overlay window handler forwards options and kind', () => {
assert.deepEqual(calls, [
'kind:visible',
'forward-tab',
'visible-focus',
'runtime-options',
'debug:true',
'closed:visible',
'closed:visible:1',
]);
});
test('create main window handler stores visible window', () => {
const calls: string[] = [];
const visibleWindow = { id: 'visible' };
let mainWindow: typeof visibleWindow | null = null;
const createMainWindow = createCreateMainWindowHandler({
getMainWindow: () => mainWindow,
isWindowDestroyed: () => false,
createOverlayWindow: (kind) => {
calls.push(`create:${kind}`);
return visibleWindow;
},
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
setMainWindow: (window) => {
mainWindow = window;
calls.push(`set:${(window as { id: string }).id}`);
},
});
assert.equal(createMainWindow(), visibleWindow);
assert.deepEqual(calls, ['create:visible', 'set:visible']);
});
test('create main window handler reuses an existing live visible window', () => {
const calls: string[] = [];
const existingWindow = { id: 'existing' };
const createMainWindow = createCreateMainWindowHandler({
getMainWindow: () => existingWindow,
isWindowDestroyed: () => false,
createOverlayWindow: (kind) => {
calls.push(`create:${kind}`);
return { id: 'created' };
},
setMainWindow: (window) => calls.push(`set:${(window as { id: string }).id}`),
});
assert.equal(createMainWindow(), existingWindow);
assert.deepEqual(calls, []);
});
test('create modal window handler stores modal window', () => {
const calls: string[] = [];
const modalWindow = { id: 'modal' };
+15 -2
View File
@@ -13,9 +13,11 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
yomitanSession?: Session | null;
},
) => TWindow;
@@ -26,9 +28,11 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: (windowKind: OverlayWindowKind) => boolean;
tryHandleOverlayShortcutLocalFallback: (input: Electron.Input) => boolean;
forwardTabToMpv: () => void;
getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind) => void;
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
getYomitanSession?: () => Session | null;
}) {
return (kind: OverlayWindowKind): TWindow => {
@@ -40,7 +44,10 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
isOverlayVisible: deps.isOverlayVisible,
tryHandleOverlayShortcutLocalFallback: deps.tryHandleOverlayShortcutLocalFallback,
forwardTabToMpv: deps.forwardTabToMpv,
linuxX11FullscreenOverlay:
kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null,
@@ -49,10 +56,16 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
}
export function createCreateMainWindowHandler<TWindow>(deps: {
getMainWindow: () => TWindow | null;
isWindowDestroyed: (window: TWindow) => boolean;
createOverlayWindow: (kind: OverlayWindowKind) => TWindow;
setMainWindow: (window: TWindow | null) => void;
}) {
return (): TWindow => {
const existingWindow = deps.getMainWindow();
if (existingWindow && !deps.isWindowDestroyed(existingWindow)) {
return existingWindow;
}
const window = deps.createOverlayWindow('visible');
deps.setMainWindow(window);
return window;
@@ -10,8 +10,13 @@ test('overlay window layout main deps builders map callbacks', () => {
const calls: string[] = [];
const visible = createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
getCurrentOverlayWindowBounds: () => {
calls.push('visible-current');
return null;
},
setOverlayWindowBounds: () => calls.push('visible'),
})();
assert.equal(visible.getCurrentOverlayWindowBounds?.(), null);
visible.setOverlayWindowBounds({ x: 0, y: 0, width: 1, height: 1 });
const level = createBuildEnsureOverlayWindowLevelMainDepsHandler({
@@ -42,6 +47,7 @@ test('overlay window layout main deps builders map callbacks', () => {
order.ensureOverlayWindowLevel({});
assert.deepEqual(calls, [
'visible-current',
'visible',
'ensure-suppressed-check',
'ensure',
@@ -14,6 +14,9 @@ export function createBuildUpdateVisibleOverlayBoundsMainDepsHandler(
deps: UpdateVisibleOverlayBoundsMainDeps,
) {
return (): UpdateVisibleOverlayBoundsMainDeps => ({
getCurrentOverlayWindowBounds: () => deps.getCurrentOverlayWindowBounds?.() ?? null,
shouldRefreshUnchangedGeometry: (geometry) =>
deps.shouldRefreshUnchangedGeometry?.(geometry) ?? false,
setOverlayWindowBounds: (geometry) => deps.setOverlayWindowBounds(geometry),
afterSetOverlayWindowBounds: (geometry) => deps.afterSetOverlayWindowBounds?.(geometry),
});
@@ -4,6 +4,7 @@ import {
createEnforceOverlayLayerOrderHandler,
createEnsureOverlayWindowLevelHandler,
createUpdateVisibleOverlayBoundsHandler,
hasLiveOverlayWindowBoundsMismatch,
} from './overlay-window-layout';
test('visible bounds handler writes visible layer geometry', () => {
@@ -32,6 +33,72 @@ test('visible bounds handler runs follow-up callback after applying geometry', (
assert.deepEqual(calls, ['set-bounds', 'after-bounds']);
});
test('visible bounds handler skips unchanged geometry', () => {
const calls: string[] = [];
const geometry = { x: 0, y: 0, width: 100, height: 50 };
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
getCurrentOverlayWindowBounds: () => ({ ...geometry }),
setOverlayWindowBounds: () => calls.push('set-bounds'),
afterSetOverlayWindowBounds: () => calls.push('after-bounds'),
});
handleVisible(geometry);
assert.deepEqual(calls, []);
});
test('visible bounds handler can refresh unchanged geometry for mode reconciliation', () => {
const calls: string[] = [];
const geometry = { x: 0, y: 0, width: 100, height: 50 };
const handleVisible = createUpdateVisibleOverlayBoundsHandler({
getCurrentOverlayWindowBounds: () => ({ ...geometry }),
shouldRefreshUnchangedGeometry: (nextGeometry) => {
assert.deepEqual(nextGeometry, geometry);
calls.push('refresh-check');
return true;
},
setOverlayWindowBounds: () => calls.push('set-bounds'),
afterSetOverlayWindowBounds: () => calls.push('after-bounds'),
});
handleVisible(geometry);
assert.deepEqual(calls, ['refresh-check', 'set-bounds', 'after-bounds']);
});
test('live overlay bounds mismatch forces refresh after window manager restore drift', () => {
const geometry = { x: 100, y: 80, width: 1280, height: 720 };
assert.equal(
hasLiveOverlayWindowBoundsMismatch(
[
{
isDestroyed: () => false,
getBounds: () => ({ x: 96, y: 76, width: 1300, height: 740 }),
},
],
geometry,
),
true,
);
assert.equal(
hasLiveOverlayWindowBoundsMismatch(
[
{
isDestroyed: () => false,
getBounds: () => ({ ...geometry }),
},
{
isDestroyed: () => true,
getBounds: () => ({ x: 0, y: 0, width: 1, height: 1 }),
},
],
geometry,
),
false,
);
});
test('ensure overlay window level handler delegates to core', () => {
const calls: string[] = [];
const ensureLevel = createEnsureOverlayWindowLevelHandler({
+29
View File
@@ -1,10 +1,39 @@
import type { WindowGeometry } from '../../types';
type OverlayBoundsWindow = {
isDestroyed: () => boolean;
getBounds: () => 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;
}
export function hasLiveOverlayWindowBoundsMismatch(
windows: Array<OverlayBoundsWindow | null | undefined>,
geometry: WindowGeometry,
): boolean {
return windows.some((window) => {
if (!window || window.isDestroyed()) {
return false;
}
return !sameGeometry(window.getBounds(), geometry);
});
}
export function createUpdateVisibleOverlayBoundsHandler(deps: {
getCurrentOverlayWindowBounds?: () => WindowGeometry | null;
shouldRefreshUnchangedGeometry?: (geometry: WindowGeometry) => boolean;
setOverlayWindowBounds: (geometry: WindowGeometry) => void;
afterSetOverlayWindowBounds?: (geometry: WindowGeometry) => void;
}) {
return (geometry: WindowGeometry): void => {
if (
sameGeometry(deps.getCurrentOverlayWindowBounds?.(), geometry) &&
deps.shouldRefreshUnchangedGeometry?.(geometry) !== true
) {
return;
}
deps.setOverlayWindowBounds(geometry);
deps.afterSetOverlayWindowBounds?.(geometry);
};
@@ -27,6 +27,8 @@ test('overlay window runtime handlers compose create/main/modal handlers', () =>
onWindowClosed: (kind) => calls.push(`closed:${kind}`),
getYomitanSession: () => yomitanSession,
},
getMainWindow: () => mainWindow,
isWindowDestroyed: () => false,
setMainWindow: (window) => {
mainWindow = window;
},
@@ -15,6 +15,8 @@ type CreateOverlayWindowMainDeps<TWindow> = Parameters<
export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
createOverlayWindowDeps: CreateOverlayWindowMainDeps<TWindow>;
getMainWindow: () => TWindow | null;
isWindowDestroyed: (window: TWindow) => boolean;
setMainWindow: (window: TWindow | null) => void;
setModalWindow: (window: TWindow | null) => void;
}) {
@@ -23,6 +25,8 @@ export function createOverlayWindowRuntimeHandlers<TWindow>(deps: {
);
const createMainWindow = createCreateMainWindowHandler<TWindow>(
createBuildCreateMainWindowMainDepsHandler<TWindow>({
getMainWindow: () => deps.getMainWindow(),
isWindowDestroyed: (window) => deps.isWindowDestroyed(window),
createOverlayWindow: (kind) => createOverlayWindow(kind),
setMainWindow: (window) => deps.setMainWindow(window),
})(),
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveFreshPlaybackPaused } from './playback-paused-state';
test('resolveFreshPlaybackPaused prefers the live mpv pause property over cached state', async () => {
const paused = await resolveFreshPlaybackPaused({
getCachedPlaybackPaused: () => false,
getMpvClient: () => ({
connected: true,
requestProperty: async (name: string) => (name === 'pause' ? true : null),
}),
});
assert.equal(paused, true);
});
test('resolveFreshPlaybackPaused trusts cached paused state without probing mpv', async () => {
let requestCount = 0;
const paused = await resolveFreshPlaybackPaused({
getCachedPlaybackPaused: () => true,
getMpvClient: () => ({
connected: true,
requestProperty: async () => {
requestCount += 1;
return false;
},
}),
});
assert.equal(paused, true);
assert.equal(requestCount, 0);
});
test('resolveFreshPlaybackPaused normalizes mpv pause property strings and numbers', async () => {
const values: Array<[unknown, boolean]> = [
['yes', true],
['no', false],
['0', false],
[1, true],
[0, false],
];
for (const [value, expected] of values) {
const paused = await resolveFreshPlaybackPaused({
getCachedPlaybackPaused: () => null,
getMpvClient: () => ({
connected: true,
requestProperty: async () => value,
}),
});
assert.equal(paused, expected);
}
});
test('resolveFreshPlaybackPaused falls back to cached state when mpv is unavailable', async () => {
assert.equal(
await resolveFreshPlaybackPaused({
getCachedPlaybackPaused: () => true,
getMpvClient: () => null,
}),
true,
);
});
test('resolveFreshPlaybackPaused treats cached playing state as unknown when live state is unavailable', async () => {
assert.equal(
await resolveFreshPlaybackPaused({
getCachedPlaybackPaused: () => false,
getMpvClient: () => ({
connected: true,
requestProperty: async () => {
throw new Error('socket closed');
},
}),
}),
null,
);
});
+39
View File
@@ -0,0 +1,39 @@
type PlaybackPausedMpvClient = {
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
};
function coercePlaybackPaused(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'no' || normalized === 'false' || normalized === '0') return false;
if (normalized === 'yes' || normalized === 'true' || normalized === '1') return true;
}
return null;
}
export async function resolveFreshPlaybackPaused(deps: {
getCachedPlaybackPaused: () => boolean | null;
getMpvClient: () => PlaybackPausedMpvClient | null;
}): Promise<boolean | null> {
const cachedPaused = deps.getCachedPlaybackPaused();
if (cachedPaused === true) {
return true;
}
const client = deps.getMpvClient();
if (client?.connected === true && typeof client.requestProperty === 'function') {
try {
const livePaused = coercePlaybackPaused(await client.requestProperty('pause'));
if (livePaused !== null) {
return livePaused;
}
} catch {
// Avoid trusting a stale cached "playing" state for hover auto-pause.
}
}
return cachedPaused === false ? null : cachedPaused;
}
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldSuppressVisibleOverlayRaiseForSeparateWindow } from './settings-window-z-order';
import {
hasLiveSeparateWindow,
shouldSuppressVisibleOverlayRaiseForSeparateWindow,
} from './settings-window-z-order';
test('separate settings windows suppress visible overlay restacking', () => {
const mainWindow = { id: 'overlay', isDestroyed: () => false };
@@ -38,3 +41,20 @@ test('separate settings windows do not suppress unrelated or closed overlay work
false,
);
});
test('live separate window detection ignores hidden and destroyed windows', () => {
assert.equal(
hasLiveSeparateWindow([
{ isDestroyed: () => false, isVisible: () => false },
{ isDestroyed: () => true, isVisible: () => true },
]),
false,
);
assert.equal(
hasLiveSeparateWindow([
{ isDestroyed: () => false, isVisible: () => false },
{ isDestroyed: () => false, isVisible: () => true },
]),
true,
);
});
+9 -2
View File
@@ -1,9 +1,16 @@
type SeparateWindowLike = {
isDestroyed(): boolean;
isVisible?: () => boolean;
};
function hasLiveSeparateWindow(windows: Array<SeparateWindowLike | null | undefined>): boolean {
return windows.some((window) => Boolean(window && !window.isDestroyed()));
export function hasLiveSeparateWindow(
windows: Array<SeparateWindowLike | null | undefined>,
): boolean {
return windows.some(
(window) =>
Boolean(window && !window.isDestroyed()) &&
(typeof window?.isVisible !== 'function' || window.isVisible()),
);
}
export function shouldSuppressVisibleOverlayRaiseForSeparateWindow(options: {
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isVisibleOverlayAutoplayTargetReady } from './visible-overlay-autoplay-readiness';
import type { OverlayContentMeasurement } from '../../types';
const visibleMeasurement = (
measuredAtMs: number,
rect = { x: 100, y: 800, width: 500, height: 90 },
): OverlayContentMeasurement => ({
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: rect,
interactiveRects: [rect],
});
test('visible overlay autoplay target waits for a fresh interactive subtitle measurement', () => {
let measurement: OverlayContentMeasurement | null = null;
const deps = {
getVisibleOverlayVisible: () => true,
isOverlayWindowReady: () => true,
getLatestVisibleMeasurement: () => measurement,
};
const signal = {
mediaPath: '/media/video.mkv',
payload: { text: '字幕', tokens: null },
requestedAtMs: 1_000,
};
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
measurement = visibleMeasurement(999);
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
measurement = visibleMeasurement(1_000, { x: 100, y: 800, width: 0, height: 90 });
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), false);
measurement = visibleMeasurement(1_001);
assert.equal(isVisibleOverlayAutoplayTargetReady(deps, signal), true);
});
test('visible overlay autoplay target falls back when interactive rects have no area', () => {
const ready = isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => true,
isOverlayWindowReady: () => true,
getLatestVisibleMeasurement: () => ({
layer: 'visible',
measuredAtMs: 2_000,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 100, y: 800, width: 500, height: 90 },
interactiveRects: [{ x: 100, y: 800, width: 0, height: 90 }],
}),
},
{
mediaPath: '/media/video.mkv',
payload: { text: '字幕', tokens: null },
requestedAtMs: 1_000,
},
);
assert.equal(ready, true);
});
test('visible overlay autoplay target rejects synthetic warmup readiness', () => {
const ready = isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => true,
isOverlayWindowReady: () => true,
getLatestVisibleMeasurement: () => visibleMeasurement(2_000),
},
{
mediaPath: '/media/video.mkv',
payload: { text: '__warm__', tokens: null },
requestedAtMs: 1_000,
},
);
assert.equal(ready, false);
});
test('visible overlay autoplay target bypasses measurement when visible overlay is hidden', () => {
const ready = isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => false,
isOverlayWindowReady: () => false,
getLatestVisibleMeasurement: () => null,
},
{
mediaPath: '/media/video.mkv',
payload: { text: '__warm__', tokens: null },
requestedAtMs: 1_000,
},
);
assert.equal(ready, true);
});
@@ -0,0 +1,48 @@
import type { OverlayContentMeasurement, OverlayContentRect } from '../../types';
import type { AutoplayReadySignal } from './autoplay-ready-gate';
export type VisibleOverlayAutoplayReadinessDeps = {
getVisibleOverlayVisible: () => boolean;
isOverlayWindowReady: () => boolean;
getLatestVisibleMeasurement: () => OverlayContentMeasurement | null;
};
function hasArea(rect: OverlayContentRect): boolean {
return rect.width > 0 && rect.height > 0;
}
function hasMeasuredInteractiveContent(measurement: OverlayContentMeasurement): boolean {
const rects =
Array.isArray(measurement.interactiveRects) && measurement.interactiveRects.some(hasArea)
? measurement.interactiveRects
: measurement.contentRect
? [measurement.contentRect]
: [];
return rects.some(hasArea);
}
export function isVisibleOverlayAutoplayTargetReady(
deps: VisibleOverlayAutoplayReadinessDeps,
signal: AutoplayReadySignal,
): boolean {
if (!deps.getVisibleOverlayVisible()) {
return true;
}
const subtitleText = signal.payload.text.trim();
if (!subtitleText || subtitleText === '__warm__') {
return false;
}
if (!deps.isOverlayWindowReady()) {
return false;
}
const measurement = deps.getLatestVisibleMeasurement();
if (!measurement || measurement.measuredAtMs < signal.requestedAtMs) {
return false;
}
return hasMeasuredInteractiveContent(measurement);
}
@@ -26,8 +26,42 @@ test('reloadOverlayWindowsForYomitanContentScripts reloads only live overlay win
reload: () => calls.push('destroyed-webcontents'),
},
},
{
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
isLoading: () => true,
reload: () => calls.push('loading-webcontents'),
},
},
];
assert.equal(reloadOverlayWindowsForYomitanContentScripts(windows), 1);
assert.deepEqual(calls, ['live']);
});
test('reloadOverlayWindowsForYomitanContentScripts retries loading webContents after load', () => {
const calls: string[] = [];
let finishLoad: (() => void) | null = null;
const window = {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
isLoading: () => true,
once: (event: 'did-finish-load', listener: () => void) => {
assert.equal(event, 'did-finish-load');
finishLoad = listener;
},
reload: () => calls.push('reloaded-after-load'),
},
};
assert.equal(reloadOverlayWindowsForYomitanContentScripts([window]), 0);
assert.deepEqual(calls, []);
assert.ok(finishLoad);
const finish = finishLoad as () => void;
finish();
assert.deepEqual(calls, ['reloaded-after-load']);
});
@@ -1,5 +1,7 @@
type ReloadableWebContents = {
isDestroyed?: () => boolean;
isLoading?: () => boolean;
once?: (event: 'did-finish-load', listener: () => void) => void;
reload: () => void;
};
@@ -8,6 +10,19 @@ type ReloadableOverlayWindow = {
webContents?: ReloadableWebContents;
};
function reloadWebContentsForYomitanContentScripts(
webContents: ReloadableWebContents,
logWarn?: (message: string, error: unknown) => void,
): boolean {
try {
webContents.reload();
return true;
} catch (error) {
logWarn?.('Failed to reload overlay window after Yomitan extension load.', error);
return false;
}
}
export function reloadOverlayWindowsForYomitanContentScripts(
windows: ReloadableOverlayWindow[],
logWarn?: (message: string, error: unknown) => void,
@@ -23,12 +38,20 @@ export function reloadOverlayWindowsForYomitanContentScripts(
if (!webContents || webContents.isDestroyed?.()) {
continue;
}
if (webContents.isLoading?.()) {
webContents.once?.('did-finish-load', () => {
if (window.isDestroyed() || webContents.isDestroyed?.()) {
return;
}
if (reloadWebContentsForYomitanContentScripts(webContents, logWarn)) {
reloadCount += 1;
}
});
continue;
}
try {
webContents.reload();
if (reloadWebContentsForYomitanContentScripts(webContents, logWarn)) {
reloadCount += 1;
} catch (error) {
logWarn?.('Failed to reload overlay window after Yomitan extension load.', error);
}
}
+10
View File
@@ -242,6 +242,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleRaw),
getCurrentSubtitleAss: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSubtitleAss),
getSubtitleSidebarOpen: (): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarOpen),
getSubtitleSidebarSnapshot: () =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSidebarSnapshot),
getPlaybackPaused: (): Promise<boolean | null> =>
@@ -259,6 +261,10 @@ const electronAPI: ElectronAPI = {
ipcRenderer.send(IPC_CHANNELS.command.setIgnoreMouseEvents, ignore, options);
},
reportOverlayInteractive: (interactive: boolean) => {
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayInteractive, interactive);
},
openYomitanSettings: () => {
ipcRenderer.send(IPC_CHANNELS.command.openYomitanSettings);
},
@@ -350,6 +356,10 @@ const electronAPI: ElectronAPI = {
getCurrentSecondarySub: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getCurrentSecondarySub),
focusMainWindow: () => ipcRenderer.invoke(IPC_CHANNELS.request.focusMainWindow) as Promise<void>,
activatePlaybackWindowForOverlayInteraction: () =>
ipcRenderer.invoke(
IPC_CHANNELS.request.activatePlaybackWindowForOverlayInteraction,
) as Promise<boolean>,
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
onSubsyncManualOpen: onSubsyncManualOpenEvent,
+75
View File
@@ -103,12 +103,14 @@ function installKeyboardTestGlobals() {
const previousMutationObserver = (globalThis as { MutationObserver?: unknown }).MutationObserver;
const previousCustomEvent = (globalThis as { CustomEvent?: unknown }).CustomEvent;
const previousMouseEvent = (globalThis as { MouseEvent?: unknown }).MouseEvent;
const previousElement = (globalThis as { Element?: unknown }).Element;
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
const windowListeners = new Map<string, Array<(event: unknown) => void>>();
const commandEvents: CommandEventDetail[] = [];
const mpvCommands: Array<Array<string | number>> = [];
const sessionActions: Array<{ actionId: string; payload?: unknown }> = [];
const interactionActivations: string[] = [];
let sessionBindings: CompiledSessionBinding[] = [];
let getSessionBindingsImpl: () => Promise<CompiledSessionBinding[]> = async () => sessionBindings;
let playbackPausedResponse: boolean | null = false;
@@ -179,6 +181,14 @@ function installKeyboardTestGlobals() {
}
}
class TestElement {
tagName = 'DIV';
closest(_selector: string): unknown {
return null;
}
}
Object.defineProperty(globalThis, 'CustomEvent', {
configurable: true,
value: TestCustomEvent,
@@ -189,6 +199,11 @@ function installKeyboardTestGlobals() {
value: TestMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: TestElement,
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -242,6 +257,10 @@ function installKeyboardTestGlobals() {
focusMainWindowCalls += 1;
return Promise.resolve();
},
activatePlaybackWindowForOverlayInteraction: async () => {
interactionActivations.push('activate-playback-window');
return true;
},
notifyOverlayModalOpened: (modal: string) => {
openedModalNotifications.push(modal);
},
@@ -303,6 +322,18 @@ function installKeyboardTestGlobals() {
}
}
function dispatchDocumentMouseDown(event: { button: number; target?: unknown }): void {
const listeners = documentListeners.get('mousedown') ?? [];
const mouseEvent = {
button: event.button,
target: event.target ?? null,
preventDefault: () => {},
};
for (const listener of listeners) {
listener(mouseEvent);
}
}
function dispatchFocusInOnPopup(): void {
const listeners = documentListeners.get('focusin') ?? [];
const focusEvent = {
@@ -335,6 +366,10 @@ function installKeyboardTestGlobals() {
configurable: true,
value: previousMouseEvent,
});
Object.defineProperty(globalThis, 'Element', {
configurable: true,
value: previousElement,
});
}
const overlay = {
@@ -348,10 +383,12 @@ function installKeyboardTestGlobals() {
mpvCommands,
sessionActions,
overlay,
interactionActivations,
overlayFocusCalls,
focusMainWindowCalls: () => focusMainWindowCalls,
windowFocusCalls: () => windowFocusCalls,
dispatchKeydown,
dispatchDocumentMouseDown,
dispatchFocusInOnPopup,
dispatchWindowEvent,
setPopupVisible: (value: boolean) => {
@@ -369,6 +406,11 @@ function installKeyboardTestGlobals() {
setSessionBindings: (value: CompiledSessionBinding[]) => {
sessionBindings = value;
},
createInteractiveTarget: () => {
const target = new TestElement();
target.closest = (selector: string) => (selector.includes('.modal') ? target : null);
return target;
},
setGetSessionBindings: (value: () => Promise<CompiledSessionBinding[]>) => {
getSessionBindingsImpl = value;
},
@@ -565,6 +607,39 @@ test('mpv input forwarding waits for session bindings before resolving setup', a
}
});
test('right-clicking non-interactive overlay content raises playback window before toggling pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2 });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, ['activate-playback-window']);
assert.deepEqual(testGlobals.mpvCommands.slice(-1), [['cycle', 'pause']]);
} finally {
testGlobals.restore();
}
});
test('right-clicking interactive overlay controls does not raise playback window or toggle pause', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
const interactiveTarget = testGlobals.createInteractiveTarget();
try {
await handlers.setupMpvInputForwarding();
testGlobals.dispatchDocumentMouseDown({ button: 2, target: interactiveTarget });
await wait(0);
assert.deepEqual(testGlobals.interactionActivations, []);
assert.deepEqual(testGlobals.mpvCommands, []);
} finally {
testGlobals.restore();
}
});
test('mpv input forwarding retries a transient keyboard config IPC failure', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
let calls = 0;
+6 -1
View File
@@ -1218,7 +1218,12 @@ export function createKeyboardHandlers(
document.addEventListener('mousedown', (e: MouseEvent) => {
if (e.button === 2 && !isInteractiveTarget(e.target)) {
e.preventDefault();
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
void window.electronAPI
.activatePlaybackWindowForOverlayInteraction()
.catch(() => false)
.finally(() => {
window.electronAPI.sendMpvCommand(['cycle', 'pause']);
});
}
});
@@ -236,6 +236,7 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
const previousWindow = globals.window;
const previousDocument = globals.document;
const mpvCommands: Array<Array<string | number>> = [];
const modalNotifications: string[] = [];
const snapshot: SubtitleSidebarSnapshot = {
cues: [
@@ -280,6 +281,12 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
sendMpvCommand: (command: Array<string | number>) => {
mpvCommands.push(command);
},
notifyOverlayModalOpened: (modal: string) => {
modalNotifications.push(`open:${modal}`);
},
notifyOverlayModalClosed: (modal: string) => {
modalNotifications.push(`close:${modal}`);
},
} as unknown as ElectronAPI,
},
});
@@ -329,9 +336,13 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
},
state,
};
const visibilityChanges: boolean[] = [];
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
onVisibilityChanged: (visible) => {
visibilityChanges.push(visible);
},
});
await modal.openSubtitleSidebarModal();
@@ -345,9 +356,14 @@ test('subtitle sidebar modal opens from snapshot and clicking cue seeks playback
assert.equal(contentStyleValues.get('font-size'), '22px');
assert.equal(contentStyle.color, '#ffffff');
assert.equal(contentStyleValues.get('--subtitle-sidebar-timestamp-color'), '#aaaaaa');
assert.deepEqual(visibilityChanges, [true]);
modal.seekToCue(snapshot.cues[0]!);
assert.deepEqual(mpvCommands.at(-1), ['seek', 1.08, 'absolute+exact']);
modal.closeSubtitleSidebarModal();
assert.deepEqual(visibilityChanges, [true, false]);
assert.deepEqual(modalNotifications, ['open:subtitle-sidebar', 'close:subtitle-sidebar']);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
@@ -760,6 +776,104 @@ test('subtitle sidebar auto-open on startup only opens when enabled and configur
}
});
test('subtitle sidebar auto-open restores previously open sidebar after renderer replacement', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
const previousDocument = globals.document;
const snapshot: SubtitleSidebarSnapshot = {
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
currentSubtitle: {
text: 'first',
startTime: 1,
endTime: 2,
},
config: {
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',
pauseVideoOnHover: false,
autoScroll: true,
maxWidth: 420,
opacity: 0.92,
backgroundColor: 'rgba(54, 58, 79, 0.88)',
textColor: '#cad3f5',
fontFamily: '"Iosevka Aile", sans-serif',
fontSize: 17,
timestampColor: '#a5adcb',
activeLineColor: '#f5bde6',
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
},
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getSubtitleSidebarSnapshot: async () => snapshot,
sendMpvCommand: () => {},
} as unknown as ElectronAPI,
addEventListener: () => {},
removeEventListener: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createCueRow(),
body: {
classList: createClassList(),
},
documentElement: {
style: {
setProperty: () => {},
},
},
},
});
try {
const state = createRendererState();
const modalClassList = createClassList(['hidden']);
const cueList = createListStub();
const ctx = {
dom: {
overlay: { classList: createClassList() },
subtitleSidebarModal: {
classList: modalClassList,
setAttribute: () => {},
style: { setProperty: () => {} },
addEventListener: () => {},
},
subtitleSidebarContent: {
classList: createClassList(),
getBoundingClientRect: () => ({ width: 420 }),
},
subtitleSidebarClose: { addEventListener: () => {} },
subtitleSidebarStatus: { textContent: '' },
subtitleSidebarList: cueList,
},
state,
};
const modal = createSubtitleSidebarModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
shouldRestoreOpenOnStartup: async () => true,
});
await modal.autoOpenSubtitleSidebarOnStartup();
assert.equal(state.subtitleSidebarModalOpen, true);
assert.equal(modalClassList.contains('hidden'), false);
assert.equal(cueList.children.length, 1);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
}
});
test('subtitle sidebar refresh closes and clears state when config becomes disabled', async () => {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const previousWindow = globals.window;
+8 -1
View File
@@ -196,6 +196,8 @@ export function createSubtitleSidebarModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
onVisibilityChanged?: (visible: boolean) => void;
shouldRestoreOpenOnStartup?: () => Promise<boolean>;
},
) {
let snapshotPollInterval: ReturnType<typeof setTimeout> | null = null;
@@ -648,13 +650,16 @@ export function createSubtitleSidebarModal(
startSnapshotPolling();
syncEmbeddedSidebarLayout();
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalOpened?.('subtitle-sidebar');
options.onVisibilityChanged?.(true);
}
async function autoOpenSubtitleSidebarOnStartup(): Promise<void> {
const snapshot = await refreshSnapshot();
const shouldRestoreOpen = (await options.shouldRestoreOpenOnStartup?.()) === true;
if (
!snapshot.config.enabled ||
!snapshot.config.autoOpen ||
(!snapshot.config.autoOpen && !shouldRestoreOpen) ||
ctx.state.subtitleSidebarModalOpen
) {
return;
@@ -677,6 +682,8 @@ export function createSubtitleSidebarModal(
ctx.dom.overlay.classList.remove('interactive');
}
restoreEmbeddedSidebarPassthrough();
window.electronAPI.notifyOverlayModalClosed?.('subtitle-sidebar');
options.onVisibilityChanged?.(false);
}
async function toggleSubtitleSidebarModal(): Promise<void> {
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
function makeElement(textContent: string, rect: DOMRect): HTMLElement {
return {
textContent,
getBoundingClientRect: () => rect,
} as unknown as HTMLElement;
}
test('overlay measurement reports primary and secondary subtitle bars as separate interactive rects', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
dom: {
subtitleRoot: makeElement('primary', {
left: 810,
top: 910,
width: 300,
height: 48,
} as DOMRect),
subtitleContainer: makeElement('primary', {
left: 760,
top: 890,
width: 400,
height: 92,
} as DOMRect),
secondarySubRoot: makeElement('English', {
left: 850,
top: 50,
width: 220,
height: 34,
} as DOMRect),
secondarySubContainer: makeElement('English', {
left: 700,
top: 40,
width: 520,
height: 70,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 700, y: 40, width: 520, height: 942 },
interactiveRects: [
{ x: 760, y: 890, width: 400, height: 92 },
{ x: 700, y: 40, width: 520, height: 70 },
],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay measurement includes open subtitle sidebar bounds as an interactive rect', () => {
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const reports: unknown[] = [];
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
innerWidth: 1920,
innerHeight: 1080,
electronAPI: {
reportOverlayContentBounds: (payload: unknown) => {
reports.push(payload);
},
},
},
});
try {
const reporter = createOverlayContentMeasurementReporter({
platform: { overlayLayer: 'visible' },
state: { subtitleSidebarModalOpen: true },
dom: {
subtitleRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubRoot: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
secondarySubContainer: makeElement('', {
left: 0,
top: 0,
width: 0,
height: 0,
} as DOMRect),
subtitleSidebarContent: makeElement('sidebar', {
left: 1500,
top: 60,
width: 380,
height: 900,
} as DOMRect),
},
} as never);
reporter.emitNow();
const measuredAtMs = (reports[0] as { measuredAtMs?: unknown } | undefined)?.measuredAtMs;
if (typeof measuredAtMs !== 'number') {
assert.fail('Expected report timestamp.');
}
assert.deepEqual(reports, [
{
layer: 'visible',
measuredAtMs,
viewport: { width: 1920, height: 1080 },
contentRect: { x: 1500, y: 60, width: 380, height: 900 },
interactiveRects: [{ x: 1500, y: 60, width: 380, height: 900 }],
},
]);
} finally {
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
+28 -8
View File
@@ -47,25 +47,43 @@ function hasVisibleTextContent(element: HTMLElement): boolean {
return Boolean(element.textContent && element.textContent.trim().length > 0);
}
function collectContentRect(ctx: RendererContext): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
function hasArea(rect: OverlayContentRect): boolean {
return rect.width > 0 && rect.height > 0;
}
function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
const rects: OverlayContentRect[] = [];
const subtitleHasContent = hasVisibleTextContent(ctx.dom.subtitleRoot);
if (subtitleHasContent) {
const subtitleRect = toMeasuredRect(ctx.dom.subtitleRoot.getBoundingClientRect());
if (subtitleRect) {
combinedRect = subtitleRect;
const subtitleRect = toMeasuredRect(ctx.dom.subtitleContainer.getBoundingClientRect());
if (subtitleRect && hasArea(subtitleRect)) {
rects.push(subtitleRect);
}
}
const secondaryHasContent = hasVisibleTextContent(ctx.dom.secondarySubRoot);
if (secondaryHasContent) {
const secondaryRect = toMeasuredRect(ctx.dom.secondarySubContainer.getBoundingClientRect());
if (secondaryRect) {
combinedRect = combinedRect ? unionRects(combinedRect, secondaryRect) : secondaryRect;
if (secondaryRect && hasArea(secondaryRect)) {
rects.push(secondaryRect);
}
}
if (ctx.state?.subtitleSidebarModalOpen) {
const sidebarRect = toMeasuredRect(ctx.dom.subtitleSidebarContent.getBoundingClientRect());
if (sidebarRect && hasArea(sidebarRect)) {
rects.push(sidebarRect);
}
}
return rects;
}
function collectContentRect(rects: OverlayContentRect[]): OverlayContentRect | null {
let combinedRect: OverlayContentRect | null = null;
for (const rect of rects) {
combinedRect = combinedRect ? unionRects(combinedRect, rect) : rect;
}
if (!combinedRect) {
return null;
}
@@ -86,6 +104,7 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
return;
}
const interactiveRects = collectInteractiveRects(ctx);
const measurement: OverlayContentMeasurement = {
layer: ctx.platform.overlayLayer,
measuredAtMs: Date.now(),
@@ -94,7 +113,8 @@ export function createOverlayContentMeasurementReporter(ctx: RendererContext) {
height: window.innerHeight,
},
// Explicit null rect signals "no content yet", and main should use fallback bounds.
contentRect: collectContentRect(ctx),
contentRect: collectContentRect(interactiveRects),
interactiveRects,
};
window.electronAPI.reportOverlayContentBounds(measurement);
+134 -37
View File
@@ -15,17 +15,30 @@ function createClassList() {
};
}
function replaceGlobalProperty(key: 'window' | 'document', value: unknown): () => void {
const original = Object.getOwnPropertyDescriptor(globalThis, key);
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
return () => {
if (original) {
Object.defineProperty(globalThis, key, original);
return;
}
delete (globalThis as Record<string, unknown>)[key];
};
}
test('idle visible overlay starts click-through on platforms that toggle mouse ignore', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
@@ -58,21 +71,18 @@ test('idle visible overlay starts click-through on platforms that toggle mouse i
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [{ ignore: true, forward: true }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
restoreWindow();
}
});
test('youtube picker keeps overlay interactive even when subtitle hover is inactive', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
});
@@ -105,36 +115,32 @@ test('youtube picker keeps overlay interactive even when subtitle hover is inact
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow });
restoreWindow();
}
});
test('visible yomitan popup host keeps overlay interactive even when cached popup state is false', () => {
const classList = createClassList();
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
Object.assign(globalThis, {
window: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
},
document: {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
},
getComputedStyle: () => ({
visibility: 'visible',
display: 'block',
opacity: '1',
}),
});
const restoreDocument = replaceGlobalProperty('document', {
querySelectorAll: (selector: string) =>
selector ===
'[data-subminer-yomitan-popup-host="true"][data-subminer-yomitan-popup-visible="true"]'
? [{ getAttribute: () => 'true' }]
: [],
});
try {
@@ -165,6 +171,97 @@ test('visible yomitan popup host keeps overlay interactive even when cached popu
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
} finally {
Object.assign(globalThis, { window: originalWindow, document: originalDocument });
restoreDocument();
restoreWindow();
}
});
test('Linux subtitle hover keeps root passive and does not report whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: true,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: false,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), false);
assert.deepEqual(interactiveHints, [false]);
} finally {
restoreWindow();
}
});
test('Linux modal state reports whole-window interactive hint', () => {
const classList = createClassList();
const interactiveHints: boolean[] = [];
const restoreWindow = replaceGlobalProperty('window', {
electronAPI: {
reportOverlayInteractive: (interactive: boolean) => {
interactiveHints.push(interactive);
},
},
});
try {
syncOverlayMouseIgnoreState({
dom: {
overlay: { classList },
},
platform: {
isLinuxPlatform: true,
shouldToggleMouseIgnore: false,
},
state: {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
yomitanPopupVisible: false,
controllerSelectModalOpen: false,
controllerDebugModalOpen: false,
jimakuModalOpen: false,
youtubePickerModalOpen: false,
kikuModalOpen: false,
runtimeOptionsModalOpen: true,
subsyncModalOpen: false,
sessionHelpModalOpen: false,
subtitleSidebarModalOpen: false,
subtitleSidebarConfig: null,
},
} as never);
assert.equal(classList.contains('interactive'), true);
assert.deepEqual(interactiveHints, [true]);
} finally {
restoreWindow();
}
});
+13 -5
View File
@@ -26,18 +26,26 @@ function isYomitanPopupInteractionActive(state: RendererState): boolean {
}
export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
const shouldKeepWindowInteractive =
isYomitanPopupInteractionActive(ctx.state) || isBlockingOverlayModalOpen(ctx.state);
const shouldStayInteractive =
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
isYomitanPopupInteractionActive(ctx.state) ||
isBlockingOverlayModalOpen(ctx.state);
ctx.state.isOverSubtitle || ctx.state.isOverSubtitleSidebar || shouldKeepWindowInteractive;
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
? shouldKeepWindowInteractive
: shouldStayInteractive;
if (shouldStayInteractive) {
if (shouldMarkOverlayInteractive) {
ctx.dom.overlay.classList.add('interactive');
} else {
ctx.dom.overlay.classList.remove('interactive');
}
if (!ctx.platform?.shouldToggleMouseIgnore) {
// On Linux the main process owns window passthrough via a cursor poll (Electron can't
// forward mouse-move through a click-through window on X11). Report the interactive hint
// only for popups/modals that sit off measured hit rects; subtitles/sidebar use the poll.
if (ctx.platform?.isLinuxPlatform) {
window.electronAPI.reportOverlayInteractive?.(shouldKeepWindowInteractive);
}
return;
}
+83
View File
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
const rendererSource = fs.readFileSync(
path.join(process.cwd(), 'src/renderer/renderer.ts'),
'utf8',
);
function indexOfRequired(pattern: string): number {
const index = rendererSource.indexOf(pattern);
assert.notEqual(index, -1, `Expected renderer.ts to contain ${pattern}`);
return index;
}
test('renderer applies subtitle style and position before first subtitle paint', () => {
const styleIndex = indexOfRequired(
'const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();',
);
const positionIndex = indexOfRequired(
"await window.electronAPI.getSubtitlePosition(),\n 'startup',",
);
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
const currentSubtitleIndex = indexOfRequired(
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
);
assert.ok(styleIndex < listenerIndex);
assert.ok(positionIndex < listenerIndex);
assert.ok(styleIndex < currentSubtitleIndex);
assert.ok(positionIndex < currentSubtitleIndex);
});
test('renderer renders initial subtitle snapshot before subscribing to live subtitle updates', () => {
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
const currentSubtitleIndex = indexOfRequired(
'initialSubtitle = await window.electronAPI.getCurrentSubtitle();',
);
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
assert.ok(currentSubtitleIndex < initialRenderIndex);
assert.ok(initialRenderIndex < listenerIndex);
});
test('renderer reports subtitle bounds immediately after initial subtitle layout', () => {
const initialRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(initialSubtitle);');
const initialLayoutIndex = indexOfRequired(
'subtitleRenderer.renderSubtitle(initialSubtitle);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
);
const immediateMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
const listenerIndex = indexOfRequired('window.electronAPI.onSubtitle((data: SubtitleData) => {');
assert.equal(initialRenderIndex, initialLayoutIndex);
assert.ok(initialLayoutIndex < immediateMeasurementIndex);
assert.ok(immediateMeasurementIndex < listenerIndex);
});
test('renderer reports subtitle bounds immediately after live subtitle layout', () => {
const liveRenderIndex = indexOfRequired('subtitleRenderer.renderSubtitle(data);');
const liveLayoutIndex = indexOfRequired(
'subtitleRenderer.renderSubtitle(data);\n positioning.applyYPercent(positioning.getCurrentYPercent());',
);
const immediateMeasurementIndex = indexOfRequired(
'positioning.applyYPercent(positioning.getCurrentYPercent());\n measurementReporter.emitNow();',
);
const sidebarUpdateIndex = indexOfRequired('subtitleSidebarModal.handleSubtitleUpdated(data);');
assert.equal(liveRenderIndex, liveLayoutIndex);
assert.ok(liveLayoutIndex < immediateMeasurementIndex);
assert.ok(immediateMeasurementIndex < sidebarUpdateIndex);
});
test('renderer restores subtitle sidebar open state only on visible overlay layer', () => {
const sidebarRestoreIndex = indexOfRequired(
"ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen())",
);
const sidebarModalIndex = indexOfRequired('const subtitleSidebarModal = createSubtitleSidebarModal');
assert.ok(sidebarModalIndex < sidebarRestoreIndex);
});
+32 -23
View File
@@ -142,6 +142,11 @@ const sessionHelpModal = createSessionHelpModal(ctx, {
});
const subtitleSidebarModal = createSubtitleSidebarModal(ctx, {
modalStateReader: { isAnyModalOpen },
shouldRestoreOpenOnStartup: async () =>
ctx.platform.overlayLayer === 'visible' && (await window.electronAPI.getSubtitleSidebarOpen()),
onVisibilityChanged: () => {
measurementReporter.emitNow();
},
});
const kikuModal = createKikuModal(ctx, {
modalStateReader: { isAnyModalOpen },
@@ -596,15 +601,16 @@ async function init(): Promise<void> {
syncOverlayMouseIgnoreState(ctx);
}
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
await keyboardHandlers.setupMpvInputForwarding();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => {
runGuarded('subtitle-position:update', () => {
@@ -618,8 +624,6 @@ async function init(): Promise<void> {
});
});
await keyboardHandlers.setupMpvInputForwarding();
let initialSubtitle: SubtitleData | string = '';
try {
initialSubtitle = await window.electronAPI.getCurrentSubtitle();
@@ -629,7 +633,20 @@ async function init(): Promise<void> {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(initialSubtitle));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(initialSubtitle);
measurementReporter.schedule();
positioning.applyYPercent(positioning.getCurrentYPercent());
measurementReporter.emitNow();
window.electronAPI.onSubtitle((data: SubtitleData) => {
runGuarded('subtitle:update', () => {
lastSubtitlePreview = truncateForErrorLog(getSubtitleTextForPreview(data));
keyboardHandlers.handleSubtitleContentUpdated();
subtitleRenderer.renderSubtitle(data);
positioning.applyYPercent(positioning.getCurrentYPercent());
measurementReporter.emitNow();
subtitleSidebarModal.handleSubtitleUpdated(data);
measurementReporter.schedule();
});
});
window.electronAPI.onSecondarySub((text: string) => {
runGuarded('secondary-subtitle:update', () => {
@@ -713,18 +730,9 @@ async function init(): Promise<void> {
}
startControllerPolling();
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
await subtitleSidebarModal.refreshSubtitleSidebarSnapshot();
await subtitleSidebarModal.autoOpenSubtitleSidebarOnStartup();
positioning.applyStoredSubtitlePosition(
await window.electronAPI.getSubtitlePosition(),
'startup',
);
measurementReporter.schedule();
measurementReporter.emitNow();
}
@@ -775,7 +783,8 @@ function setupDragDropToMpvQueue(): void {
const droppedVideoPaths = collectDroppedVideoPaths(event.dataTransfer);
const droppedSubtitlePaths = collectDroppedSubtitlePaths(event.dataTransfer);
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, event.shiftKey);
const appendDroppedVideos = event.shiftKey;
const loadCommands = buildMpvLoadfileCommands(droppedVideoPaths, appendDroppedVideos);
const subtitleCommands = buildMpvSubtitleAddCommands(droppedSubtitlePaths);
for (const command of loadCommands) {
window.electronAPI.sendMpvCommand(command);
@@ -785,7 +794,7 @@ function setupDragDropToMpvQueue(): void {
}
const osdParts: string[] = [];
if (loadCommands.length > 0) {
const action = event.shiftKey ? 'Queued' : 'Loaded';
const action = appendDroppedVideos ? 'Queued' : 'Loaded';
osdParts.push(`${action} ${loadCommands.length} file${loadCommands.length === 1 ? '' : 's'}`);
}
if (subtitleCommands.length > 0) {
+5
View File
@@ -1218,6 +1218,11 @@ body.settings-modal-open #secondarySubContainer {
pointer-events: none !important;
}
body.settings-modal-open .subtitle-sidebar-modal {
display: none !important;
pointer-events: none !important;
}
.secondary-sub-hidden {
display: none !important;
}
+133
View File
@@ -57,6 +57,7 @@ class FakeElement {
dataset: Record<string, string> = {};
style = new FakeStyleDeclaration();
className = '';
replaceChildrenCalls = 0;
private ownTextContent = '';
constructor(public tagName: string) {}
@@ -97,6 +98,7 @@ class FakeElement {
}
replaceChildren(): void {
this.replaceChildrenCalls += 1;
this.childNodes = [];
this.ownTextContent = '';
}
@@ -347,6 +349,130 @@ test('renderSubtitle skips character image when name-match rendering is disabled
}
});
test('renderSubtitle skips identical primary subtitle DOM replacement', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({ text: '字幕', tokens: null });
renderer.renderSubtitle({ text: '字幕', tokens: null });
renderer.renderSubtitle({ text: '字幕2', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.equal(subtitleRoot.textContent, '字幕2');
} finally {
restoreDocument();
}
});
test('renderSubtitle keeps tokenized subtitle when stale plain payload repeats same text', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
});
renderer.renderSubtitle({ text: 'アクア', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 1);
assert.equal(collectWordNodes(subtitleRoot).length, 1);
assert.equal(subtitleRoot.textContent, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle accepts repeated plain payload after style invalidates tokenized render', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle({
text: 'アクア',
tokens: [createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' })],
});
renderer.applySubtitleStyle({ fontColor: '#fff' } as never);
renderer.renderSubtitle({ text: 'アクア', tokens: null });
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.equal(collectWordNodes(subtitleRoot).length, 0);
assert.equal(subtitleRoot.textContent, 'アクア');
} finally {
restoreDocument();
}
});
test('renderSubtitle re-renders identical text after style changes affect token output', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const ctx = {
state: {
...createRendererState(),
nameMatchEnabled: false,
},
dom: {
subtitleRoot,
subtitleContainer: new FakeElement('div'),
secondarySubRoot: new FakeElement('div'),
secondarySubContainer: new FakeElement('div'),
},
} as never;
const subtitle = {
text: 'アクア',
tokens: [
{
...createToken({ surface: 'アクア', headword: 'アクア', reading: 'あくあ' }),
isNameMatch: true,
} as MergedToken,
],
};
const renderer = createSubtitleRenderer(ctx);
renderer.renderSubtitle(subtitle);
renderer.applySubtitleStyle({ nameMatchEnabled: true } as never);
renderer.renderSubtitle(subtitle);
const [word] = collectWordNodes(subtitleRoot);
assert.equal(subtitleRoot.replaceChildrenCalls, 2);
assert.ok(word?.className.includes('word-name-match'));
} finally {
restoreDocument();
}
});
test('renderer content security policy allows data URL character images', () => {
const htmlPath = path.join(process.cwd(), 'src', 'renderer', 'index.html');
const htmlText = fs.readFileSync(htmlPath, 'utf-8');
@@ -1231,6 +1357,13 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
assert.match(secondaryHoverWindowsBlock, /top:\s*40px;/);
assert.match(secondaryHoverWindowsBlock, /padding-top:\s*0;/);
const sidebarSettingsModalBlock = extractClassBlock(
cssText,
'body.settings-modal-open .subtitle-sidebar-modal',
);
assert.match(sidebarSettingsModalBlock, /display:\s*none !important;/);
assert.match(sidebarSettingsModalBlock, /pointer-events:\s*none !important;/);
const subtitleSidebarListBlock = extractClassBlock(cssText, '.subtitle-sidebar-list');
assert.doesNotMatch(subtitleSidebarListBlock, /scroll-behavior:\s*smooth;/);
+48 -3
View File
@@ -653,9 +653,32 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void
}
export function createSubtitleRenderer(ctx: RendererContext) {
function renderSubtitle(data: SubtitleData | string): void {
ctx.dom.subtitleRoot.replaceChildren();
let lastPrimarySubtitleRenderKey: string | null = null;
let lastPrimarySubtitleNormalizedText: string | null = null;
let lastPrimarySubtitleRenderedTokenized = false;
function getPrimarySubtitleRenderKey(
text: string,
normalized: string,
tokens: MergedToken[] | null,
): string {
if (!shouldRenderTokenizedSubtitle(tokens?.length ?? 0) || !tokens) {
return JSON.stringify({
mode: 'plain',
text: normalized,
});
}
return JSON.stringify({
mode: 'tokens',
text,
tokens,
settings: getTokenRenderSettings(),
preserveSubtitleLineBreaks: ctx.state.preserveSubtitleLineBreaks,
});
}
function renderSubtitle(data: SubtitleData | string): void {
let text: string;
let tokens: MergedToken[] | null;
@@ -669,9 +692,30 @@ export function createSubtitleRenderer(ctx: RendererContext) {
return;
}
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
const hasRenderableTokens =
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
if (
lastPrimarySubtitleRenderKey !== null &&
!hasRenderableTokens &&
lastPrimarySubtitleRenderedTokenized &&
normalized === lastPrimarySubtitleNormalizedText
) {
return;
}
const renderKey = getPrimarySubtitleRenderKey(text, normalized, tokens);
if (renderKey === lastPrimarySubtitleRenderKey) {
return;
}
lastPrimarySubtitleRenderKey = renderKey;
lastPrimarySubtitleNormalizedText = normalized;
lastPrimarySubtitleRenderedTokenized = hasRenderableTokens;
ctx.dom.subtitleRoot.replaceChildren();
if (!text) return;
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
if (shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && tokens) {
renderWithTokens(
ctx.dom.subtitleRoot,
@@ -753,6 +797,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
function applySubtitleStyle(style: SubtitleRendererStyleConfig | null): void {
if (!style) return;
lastPrimarySubtitleRenderKey = null;
const styleDeclarations = style as Record<string, unknown>;
applyInlineStyleDeclarations(ctx.dom.subtitleRoot, styleDeclarations, CONTAINER_STYLE_KEYS);
+3
View File
@@ -39,6 +39,7 @@ export const IPC_CHANNELS = {
refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
reportOverlayContentBounds: 'overlay-content-bounds:report',
reportOverlayInteractive: 'overlay-interactive:report',
overlayModalOpened: 'overlay:modal-opened',
toggleStatsOverlay: 'stats:toggle-overlay',
markActiveVideoWatched: 'immersion:mark-active-video-watched',
@@ -50,6 +51,7 @@ export const IPC_CHANNELS = {
getCurrentSubtitleRaw: 'get-current-subtitle-raw',
getCurrentSubtitleAss: 'get-current-subtitle-ass',
getSubtitleSidebarSnapshot: 'get-subtitle-sidebar-snapshot',
getSubtitleSidebarOpen: 'get-subtitle-sidebar-open',
getPlaybackPaused: 'get-playback-paused',
getSubtitlePosition: 'get-subtitle-position',
getSubtitleStyle: 'get-subtitle-style',
@@ -64,6 +66,7 @@ export const IPC_CHANNELS = {
getCurrentSecondarySub: 'get-current-secondary-sub',
youtubePickerResolve: 'youtube:picker-resolve',
focusMainWindow: 'focus-main-window',
activatePlaybackWindowForOverlayInteraction: 'overlay:activate-playback-window',
runSubsyncManual: 'subsync:run-manual',
getAnkiConnectStatus: 'get-anki-connect-status',
getRuntimeOptions: 'runtime-options:get',
+107
View File
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
isSupportedWaylandCompositor,
shouldForceX11MpvBackend,
shouldForceX11WaylandSession,
} from './mpv-x11-backend';
function withPlatform(platform: NodeJS.Platform, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
try {
run();
} finally {
if (original) Object.defineProperty(process, 'platform', original);
}
}
const KDE_WAYLAND = {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma',
};
test('isSupportedWaylandCompositor detects Hyprland and Sway via env or xdg desktop', () => {
assert.equal(isSupportedWaylandCompositor({ HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }), true);
assert.equal(isSupportedWaylandCompositor({ SWAYSOCK: '/tmp/sway.sock' }), true);
assert.equal(isSupportedWaylandCompositor({ XDG_CURRENT_DESKTOP: 'Hyprland' }), true);
assert.equal(isSupportedWaylandCompositor({ XDG_SESSION_DESKTOP: 'sway' }), true);
assert.equal(isSupportedWaylandCompositor(KDE_WAYLAND), false);
});
test('shouldForceX11WaylandSession forces X11 for unsupported Wayland sessions only', () => {
withPlatform('linux', () => {
assert.equal(shouldForceX11WaylandSession(KDE_WAYLAND), true);
// GNOME Wayland (also unsupported) → forced.
assert.equal(
shouldForceX11WaylandSession({
DISPLAY: ':0',
WAYLAND_DISPLAY: 'wayland-0',
XDG_CURRENT_DESKTOP: 'GNOME',
}),
true,
);
// Hyprland keeps native Wayland.
assert.equal(
shouldForceX11WaylandSession({ ...KDE_WAYLAND, HYPRLAND_INSTANCE_SIGNATURE: 'hypr' }),
false,
);
// No X11 display to fall back to.
assert.equal(shouldForceX11WaylandSession({ WAYLAND_DISPLAY: 'wayland-0' }), false);
// Pure X11 session (no Wayland) → nothing to force.
assert.equal(shouldForceX11WaylandSession({ DISPLAY: ':0', XDG_SESSION_TYPE: 'x11' }), false);
});
});
test('shouldForceX11WaylandSession is false off Linux', () => {
withPlatform('darwin', () => {
assert.equal(shouldForceX11WaylandSession(KDE_WAYLAND), false);
});
withPlatform('win32', () => {
assert.equal(shouldForceX11WaylandSession(KDE_WAYLAND), false);
});
});
test('shouldForceX11MpvBackend honors explicit x11 and auto modes', () => {
withPlatform('linux', () => {
// Explicit x11 forces even without Wayland.
assert.equal(shouldForceX11MpvBackend('x11', { DISPLAY: ':0' }), true);
// Auto defers to the session check.
assert.equal(shouldForceX11MpvBackend('auto', KDE_WAYLAND), true);
assert.equal(
shouldForceX11MpvBackend('auto', { ...KDE_WAYLAND, SWAYSOCK: '/tmp/sway.sock' }),
false,
);
// No display at all.
assert.equal(shouldForceX11MpvBackend('x11', {}), false);
});
});
test('applyX11EnvOverrides strips Wayland hints and pins session type to x11', () => {
const env = {
DISPLAY: ':1',
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
HYPRLAND_INSTANCE_SIGNATURE: 'hypr',
SWAYSOCK: '/tmp/sway.sock',
};
const result = applyX11EnvOverrides(env);
assert.equal(result, env); // mutates in place
assert.equal(result.DISPLAY, ':1');
assert.equal(result.WAYLAND_DISPLAY, undefined);
assert.equal(result.HYPRLAND_INSTANCE_SIGNATURE, undefined);
assert.equal(result.SWAYSOCK, undefined);
assert.equal(result.XDG_SESSION_TYPE, 'x11');
});
test('MPV_X11_BACKEND_ARGS pins the GPU stack to X11', () => {
assert.deepEqual(
[...MPV_X11_BACKEND_ARGS],
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
);
});
+93
View File
@@ -0,0 +1,93 @@
/*
Shared XWayland/X11 backend forcing for mpv and the Electron app.
On Wayland sessions the SubMiner overlay can only be reliably kept above mpv when
BOTH processes run under XWayland: the Wayland protocol forbids clients from
controlling window stacking, so Electron's `setAlwaysOnTop`/`moveTop` become
no-ops under a native Wayland surface. Hyprland and Sway are the exception they
are supported natively via compositor-specific window placement so all forcing
here is gated to "Linux + Wayland session + NOT Hyprland/Sway".
This module is shared between the `launcher/` bundle and the Electron `src/` build
so the gate and the mpv backend args stay in one place.
*/
/** mpv args that pin the GPU/windowing stack to X11/XWayland (libGL via EGL on X11). */
export const MPV_X11_BACKEND_ARGS = [
'--vo=gpu',
'--gpu-api=opengl',
'--gpu-context=x11egl,x11',
] as const;
export type LinuxDesktopEnv = {
xdgCurrentDesktop: string;
xdgSessionDesktop: string;
hasWayland: boolean;
};
export function getLinuxDesktopEnv(env: NodeJS.ProcessEnv = process.env): LinuxDesktopEnv {
const xdgCurrentDesktop = (env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (env.XDG_SESSION_TYPE || '').toLowerCase();
return {
xdgCurrentDesktop,
xdgSessionDesktop,
hasWayland: Boolean(env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland',
};
}
/**
* Compositors that SubMiner supports natively on Wayland (no XWayland forcing).
* Detected via their socket env vars or the XDG desktop identifiers.
*/
export function isSupportedWaylandCompositor(env: NodeJS.ProcessEnv = process.env): boolean {
const desktop = getLinuxDesktopEnv(env);
return (
Boolean(env.HYPRLAND_INSTANCE_SIGNATURE || env.SWAYSOCK) ||
desktop.xdgCurrentDesktop.includes('hyprland') ||
desktop.xdgCurrentDesktop.includes('sway') ||
desktop.xdgSessionDesktop.includes('hyprland') ||
desktop.xdgSessionDesktop.includes('sway')
);
}
/**
* Should this Linux session be pushed onto XWayland/X11? True for a Wayland session
* that is not one of the natively-supported compositors and has an X11 display
* available for the fallback. This is the "auto" decision shared by the Electron app
* and SubMiner-managed mpv launches.
*/
export function shouldForceX11WaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
if (process.platform !== 'linux') return false;
if (!env.DISPLAY?.trim()) return false;
if (!getLinuxDesktopEnv(env).hasWayland) return false;
return !isSupportedWaylandCompositor(env);
}
/**
* Launcher-facing decision that also honors an explicit `--backend` choice:
* - `x11` forces the X11 stack whenever an X11 display exists,
* - `auto` defers to {@link shouldForceX11WaylandSession}.
*/
export function shouldForceX11MpvBackend(
backend: string,
env: NodeJS.ProcessEnv = process.env,
): boolean {
if (process.platform !== 'linux' || !env.DISPLAY?.trim()) {
return false;
}
if (backend === 'x11') return true;
return backend === 'auto' && shouldForceX11WaylandSession(env);
}
/**
* Strip Wayland/compositor hints and pin the session type to X11 on the given env
* object (mutates in place and returns it) so a child mpv process picks XWayland.
*/
export function applyX11EnvOverrides(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
delete env.WAYLAND_DISPLAY;
delete env.HYPRLAND_INSTANCE_SIGNATURE;
delete env.SWAYSOCK;
env.XDG_SESSION_TYPE = 'x11';
return env;
}
+4
View File
@@ -329,6 +329,7 @@ export interface OverlayContentMeasurement {
height: number;
};
contentRect: OverlayContentRect | null;
interactiveRects?: OverlayContentRect[];
}
export interface MecabStatus {
@@ -410,9 +411,11 @@ export interface ElectronAPI {
getCurrentSubtitleRaw: () => Promise<string>;
getCurrentSubtitleAss: () => Promise<string>;
getSubtitleSidebarSnapshot: () => Promise<SubtitleSidebarSnapshot>;
getSubtitleSidebarOpen: () => Promise<boolean>;
getPlaybackPaused: () => Promise<boolean | null>;
onSubtitleAss: (callback: (assText: string) => void) => void;
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
reportOverlayInteractive: (interactive: boolean) => void;
openYomitanSettings: () => void;
recordYomitanLookup: (context?: SubtitleMiningContext | null) => void;
getSubtitlePosition: () => Promise<SubtitlePosition | null>;
@@ -449,6 +452,7 @@ export interface ElectronAPI {
getSecondarySubMode: () => Promise<SecondarySubMode>;
getCurrentSecondarySub: () => Promise<string>;
focusMainWindow: () => Promise<void>;
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;

Some files were not shown because too many files have changed in this diff Show More