mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f044877c83
|
|||
|
fe201a2d2f
|
|||
|
215e0f804b
|
|||
|
a36e628512
|
|||
|
b6272b229e
|
|||
|
3a7d650a70
|
|||
|
89723e2ccb
|
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anilist
|
||||||
|
|
||||||
|
- Used fresh mpv time-position and duration events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
|
||||||
|
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
|
||||||
|
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
|
||||||
|
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
|
||||||
|
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
|
||||||
|
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
|
||||||
|
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: updates
|
area: updates
|
||||||
|
|
||||||
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
|
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
|
|||||||
```
|
```
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
|
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
|
|||||||
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
||||||
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
||||||
|
|
||||||
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
|
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.15.0-beta.2",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Auto-Update:** Tray and `subminer -u` command-line update checks for new SubMiner releases, with app and launcher update prompts, checksum verification, configurable update notifications, and an opt-in prerelease channel for beta and RC builds.
|
|
||||||
- **First-Run Setup:** Guided setup flow to install Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim is installed so you can type `subminer` in any terminal without adding the main executable to PATH.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **macOS Overlay:** Transient mpv window appearances no longer incorrectly hide the subtitle overlay; minimizing mpv still hides it as expected. mpv controls are also now clickable before hovering a subtitle bar.
|
|
||||||
- **Subtitle Sync Modal:** Opening the subtitle sync panel on macOS no longer flashes and dismisses on the first attempt, and no longer leaves stale modal state after syncing.
|
|
||||||
- **Updater Stability:** Linux tray and background update checks now use GitHub release metadata instead of the native Electron updater, preventing crashes. Unsafe native updater paths are avoided on all platforms.
|
|
||||||
- **Linux Launcher Update:** `subminer -u` on Linux now performs release updates directly from the launcher without requiring the tray app to be running. When already on the latest version it reports up to date without downloading assets. Support asset updates are limited to the Linux rofi theme.
|
|
||||||
- **Linux Launcher Install:** First-run launcher installs on Linux now use a valid Bun shebang so the installed launcher executes correctly.
|
|
||||||
- **macOS Setup:** First-run setup now correctly recognizes launchers already installed via Homebrew or user PATH directories, and manual installs avoid writing to Homebrew-managed locations.
|
|
||||||
- **Update Dialog:** macOS update dialogs are brought to the front when `subminer --update` is run from the command line.
|
|
||||||
- **Setup Flow:** `subminer app --setup` now correctly opens the setup window when SubMiner is already running in the background. The standalone setup process also quits after first-run completes, returning the terminal prompt instead of leaving the app open.
|
|
||||||
- **Build:** One-shot `make clean build install` flows now correctly pick up the AppImage produced by the current build rather than a stale previous one.
|
|
||||||
- **Tray Settings:** Closing Yomitan settings launched from the tray no longer quits the tray app, and loading settings no longer blocks other tray actions. A close button is shown within the Yomitan settings page on Hyprland where native window controls are unavailable. The embedded Yomitan popup preview is disabled in the tray settings window to prevent renderer hangs. Extension refreshes are now serialized to prevent startup race conditions, and session help modals can close correctly without mpv running.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
// It works with both bundled and unbundled mpv installations.
|
// It works with both bundled and unbundled mpv installations.
|
||||||
//
|
//
|
||||||
// Usage: swift get-mpv-window-macos.swift
|
// Usage: swift get-mpv-window-macos.swift
|
||||||
// Output: "x,y,width,height" or "not-found"
|
// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found"
|
||||||
//
|
//
|
||||||
|
|
||||||
import Cocoa
|
import Cocoa
|
||||||
@@ -25,9 +25,16 @@ private struct WindowState {
|
|||||||
let focused: Bool
|
let focused: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct FrontmostApplicationState {
|
||||||
|
let pid: pid_t
|
||||||
|
let isMpv: Bool
|
||||||
|
}
|
||||||
|
|
||||||
private enum WindowLookupResult {
|
private enum WindowLookupResult {
|
||||||
case visible(WindowState)
|
case visible(WindowState)
|
||||||
case minimized
|
case minimized
|
||||||
|
case active
|
||||||
|
case inactive
|
||||||
}
|
}
|
||||||
|
|
||||||
private let targetMpvSocketPath: String? = {
|
private let targetMpvSocketPath: String? = {
|
||||||
@@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
|
|||||||
return geometry
|
return geometry
|
||||||
}
|
}
|
||||||
|
|
||||||
private func frontmostApplicationPid() -> pid_t? {
|
private func frontmostApplicationState() -> FrontmostApplicationState? {
|
||||||
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
guard let app = NSWorkspace.shared.frontmostApplication else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return FrontmostApplicationState(
|
||||||
|
pid: app.processIdentifier,
|
||||||
|
isMpv: app.localizedName.map(normalizedMpvName) ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool {
|
||||||
|
guard let frontmost = frontmost else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if frontmost.pid == ownerPid {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return frontmost.isMpv && windowHasTargetSocket(ownerPid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool {
|
||||||
|
guard let frontmost = frontmost, frontmost.isMpv else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if windowHasTargetSocket(frontmost.pid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// When macOS says mpv is frontmost but geometry APIs miss, keep the
|
||||||
|
// overlay stable even if ps cannot expose the socket argument.
|
||||||
|
return targetMpvSocketPath != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||||
@@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
|||||||
return normalizedMpvName(name)
|
return normalizedMpvName(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
let frontmostPid = frontmostApplicationPid()
|
let frontmost = frontmostApplicationState()
|
||||||
var foundMinimizedTargetWindow = false
|
var foundMinimizedTargetWindow = false
|
||||||
|
|
||||||
for app in runningApps {
|
for app in runningApps {
|
||||||
@@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
|||||||
return .visible(
|
return .visible(
|
||||||
WindowState(
|
WindowState(
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
focused: frontmostPid == windowPid
|
focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -217,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
|||||||
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
|
// Use on-screen layer-0 windows to avoid off-screen helpers/shadows.
|
||||||
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
||||||
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||||
let frontmostPid = frontmostApplicationPid()
|
let frontmost = frontmostApplicationState()
|
||||||
|
|
||||||
for window in windowList {
|
for window in windowList {
|
||||||
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
|
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
|
||||||
@@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
|||||||
|
|
||||||
return WindowState(
|
return WindowState(
|
||||||
geometry: geometry,
|
geometry: geometry,
|
||||||
focused: frontmostPid == ownerPid
|
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = {
|
|||||||
if let cgWindow = windowStateFromCoreGraphics() {
|
if let cgWindow = windowStateFromCoreGraphics() {
|
||||||
return .visible(cgWindow)
|
return .visible(cgWindow)
|
||||||
}
|
}
|
||||||
|
let frontmost = frontmostApplicationState()
|
||||||
|
if isFrontmostTargetMpv(frontmost) {
|
||||||
|
return .active
|
||||||
|
}
|
||||||
|
if frontmost != nil {
|
||||||
|
return .inactive
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -285,6 +332,10 @@ if let result = lookupResult {
|
|||||||
)
|
)
|
||||||
case .minimized:
|
case .minimized:
|
||||||
print("minimized")
|
print("minimized")
|
||||||
|
case .active:
|
||||||
|
print("active")
|
||||||
|
case .inactive:
|
||||||
|
print("inactive")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("not-found")
|
print("not-found")
|
||||||
|
|||||||
@@ -31,3 +31,64 @@ test('minimized Accessibility windows are validated by PID and socket before rep
|
|||||||
'target socket must be validated before accepting a minimized window',
|
'target socket must be validated before accepting a minimized window',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('focused mpv window follows the frontmost mpv app signal', () => {
|
||||||
|
const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow');
|
||||||
|
assert.notEqual(focusHelperIndex, -1);
|
||||||
|
|
||||||
|
const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1);
|
||||||
|
const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
focusHelperBody.includes('frontmost.pid == ownerPid'),
|
||||||
|
'matching frontmost PID should mark the mpv window focused',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'),
|
||||||
|
'frontmost mpv app should mark the target mpv window focused even when PIDs differ',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'),
|
||||||
|
'Accessibility path should use the shared focused mpv helper',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'),
|
||||||
|
'CoreGraphics path should use the shared focused mpv helper',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontmost mpv app emits active state when geometry lookup misses', () => {
|
||||||
|
assert.ok(
|
||||||
|
/case\s+\.active:/.test(source),
|
||||||
|
'helper should expose an active state without window geometry',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('if windowHasTargetSocket(frontmost.pid)'),
|
||||||
|
'active state should still accept a matching target socket when available',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('return targetMpvSocketPath != nil'),
|
||||||
|
'active state should preserve frontmost mpv even if command-line socket detection fails',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('return .active'),
|
||||||
|
'lookup should preserve active mpv state after geometry lookup misses',
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => {
|
||||||
|
assert.ok(
|
||||||
|
/case\s+\.inactive:/.test(source),
|
||||||
|
'helper should expose an inactive state without window geometry',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
source.includes('if frontmost != nil'),
|
||||||
|
'helper should distinguish a known non-mpv frontmost app from an unknown miss',
|
||||||
|
);
|
||||||
|
assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive');
|
||||||
|
assert.ok(
|
||||||
|
source.includes('print("inactive")'),
|
||||||
|
'inactive state should be printed for the tracker',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getVisibleOverlayVisibility: () => false,
|
getVisibleOverlayVisibility: () => false,
|
||||||
onOverlayModalClosed: () => {},
|
onOverlayModalClosed: () => {},
|
||||||
|
onOverlayMouseInteractionChanged: (active) => {
|
||||||
|
calls.push(`overlay-interaction:${active}`);
|
||||||
|
},
|
||||||
openYomitanSettings: () => {},
|
openYomitanSettings: () => {},
|
||||||
quitApp: () => {},
|
quitApp: () => {},
|
||||||
toggleVisibleOverlay: () => {},
|
toggleVisibleOverlay: () => {},
|
||||||
@@ -281,6 +284,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||||
deps.clearAnilistToken();
|
deps.clearAnilistToken();
|
||||||
deps.openAnilistSetup();
|
deps.openAnilistSetup();
|
||||||
|
deps.onOverlayMouseInteractionChanged?.(true, null);
|
||||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
||||||
pending: 1,
|
pending: 1,
|
||||||
ready: 0,
|
ready: 0,
|
||||||
@@ -298,10 +302,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
|||||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
||||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
||||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
assert.deepEqual(calls, [
|
||||||
|
'clearAnilistToken',
|
||||||
|
'openAnilistSetup',
|
||||||
|
'overlay-interaction:true',
|
||||||
|
'retryAnilistQueueNow',
|
||||||
|
]);
|
||||||
assert.equal(deps.getPlaybackPaused(), true);
|
assert.equal(deps.getPlaybackPaused(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => {
|
||||||
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
registerIpcHandlers(
|
||||||
|
createRegisterIpcDeps({
|
||||||
|
onOverlayMouseInteractionChanged: (active) => {
|
||||||
|
calls.push(`overlay-interaction:${active}`);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
registrar,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents);
|
||||||
|
assert.equal(typeof handler, 'function');
|
||||||
|
|
||||||
|
handler?.({}, true, { forward: true });
|
||||||
|
handler?.({}, false, {});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']);
|
||||||
|
});
|
||||||
|
|
||||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
|
|||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
onOverlayMouseInteractionChanged?: (
|
||||||
|
active: boolean,
|
||||||
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleDevTools: () => void;
|
toggleDevTools: () => void;
|
||||||
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
modal: OverlayHostedModal,
|
modal: OverlayHostedModal,
|
||||||
senderWindow: ElectronBrowserWindow | null,
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
) => void;
|
) => void;
|
||||||
|
onOverlayMouseInteractionChanged?: (
|
||||||
|
active: boolean,
|
||||||
|
senderWindow: ElectronBrowserWindow | null,
|
||||||
|
) => void;
|
||||||
openYomitanSettings: () => void;
|
openYomitanSettings: () => void;
|
||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
toggleVisibleOverlay: () => void;
|
toggleVisibleOverlay: () => void;
|
||||||
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
return {
|
return {
|
||||||
onOverlayModalClosed: options.onOverlayModalClosed,
|
onOverlayModalClosed: options.onOverlayModalClosed,
|
||||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||||
|
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||||
openYomitanSettings: options.openYomitanSettings,
|
openYomitanSettings: options.openYomitanSettings,
|
||||||
quitApp: options.quitApp,
|
quitApp: options.quitApp,
|
||||||
toggleDevTools: () => {
|
toggleDevTools: () => {
|
||||||
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
|
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
|
|||||||
setAlwaysOnTop: (flag: boolean) => {
|
setAlwaysOnTop: (flag: boolean) => {
|
||||||
calls.push(`always-on-top:${flag}`);
|
calls.push(`always-on-top:${flag}`);
|
||||||
},
|
},
|
||||||
|
setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => {
|
||||||
|
calls.push(
|
||||||
|
`all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||||
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`);
|
||||||
},
|
},
|
||||||
@@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo
|
|||||||
assert.ok(calls.includes('sync-windows-z-order'));
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => {
|
test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
updateVisibleOverlayVisibility({
|
||||||
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
|
|||||||
forceMousePassthrough: true,
|
forceMousePassthrough: true,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => {
|
||||||
|
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: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
forceMousePassthrough: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('always-on-top:false'));
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(!calls.includes('enforce-order'));
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
});
|
});
|
||||||
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
|
|||||||
assert.deepEqual(osdMessages, []);
|
assert.deepEqual(osdMessages, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
|
test('macOS tracked overlay hides when mpv loses foreground', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => true,
|
isTracking: () => true,
|
||||||
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
|||||||
isTargetWindowFocused: () => false,
|
isTargetWindowFocused: () => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
updateVisibleOverlayVisibility({
|
updateVisibleOverlayVisibility({
|
||||||
visibleOverlayVisible: true,
|
visibleOverlayVisible: true,
|
||||||
mainWindow: window as never,
|
mainWindow: window as never,
|
||||||
@@ -1046,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
|||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('always-on-top:false'));
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(!calls.includes('enforce-order'));
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
||||||
|
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,
|
||||||
|
overlayInteractionActive: 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: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('update-bounds'));
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('hide'));
|
assert.ok(!calls.includes('hide'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('macOS lets an active overlay receive mouse input instead of forcing passthrough', () => {
|
||||||
|
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,
|
||||||
|
overlayInteractionActive: 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: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(!calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS focuses an active overlay so lookup trigger keys reach it', () => {
|
||||||
|
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,
|
||||||
|
overlayInteractionActive: 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: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('focus'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS tracked overlay passively reappears when mpv regains foreground', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
let targetFocused = false;
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => targetFocused,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
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: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
run();
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
targetFocused = true;
|
||||||
|
run();
|
||||||
|
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(!calls.includes('focus'));
|
||||||
|
});
|
||||||
|
|
||||||
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
|
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const osdMessages: string[] = [];
|
const osdMessages: string[] = [];
|
||||||
@@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show-inactive'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
assert.ok(!calls.includes('focus'));
|
assert.ok(!calls.includes('focus'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
|||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('macOS preserves visible overlay level during non-minimized tracker loss', () => {
|
test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
isTracking: () => false,
|
isTracking: () => false,
|
||||||
@@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
|
|||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(calls.includes('all-workspaces:false:plain'));
|
||||||
|
assert.ok(calls.includes('hide'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
|
assert.ok(!calls.includes('enforce-order'));
|
||||||
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps a focused overlay visible during tracker loss', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => false,
|
||||||
|
getGeometry: () => null,
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(true);
|
||||||
|
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: true,
|
||||||
|
showOverlayLoadingOsd: () => {
|
||||||
|
calls.push('loading-osd');
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('sync-layer'));
|
assert.ok(calls.includes('sync-layer'));
|
||||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
assert.ok(calls.includes('ensure-level'));
|
assert.ok(calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('enforce-order'));
|
assert.ok(calls.includes('enforce-order'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('hide'));
|
assert.ok(!calls.includes('hide'));
|
||||||
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => {
|
||||||
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => false,
|
||||||
|
getGeometry: () => null,
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
setFocused(false);
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
overlayInteractionActive: 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: true,
|
||||||
|
showOverlayLoadingOsd: () => {
|
||||||
|
calls.push('loading-osd');
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
assert.ok(!calls.includes('always-on-top:false'));
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
assert.ok(!calls.includes('loading-osd'));
|
assert.ok(!calls.includes('loading-osd'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
|||||||
opacityCapableWindow.setOpacity?.(opacity);
|
opacityCapableWindow.setOpacity?.(opacity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releaseOverlayWindowLevel(window: BrowserWindow): void {
|
||||||
|
window.setAlwaysOnTop(false);
|
||||||
|
const allWorkspacesWindow = window as BrowserWindow & {
|
||||||
|
setVisibleOnAllWorkspaces?: (
|
||||||
|
visible: boolean,
|
||||||
|
options?: { visibleOnFullScreen?: boolean },
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false });
|
||||||
|
}
|
||||||
|
|
||||||
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
|
function clearPendingWindowsOverlayReveal(window: BrowserWindow): void {
|
||||||
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
||||||
if (!pendingTimeout) {
|
if (!pendingTimeout) {
|
||||||
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
visibleOverlayVisible: boolean;
|
visibleOverlayVisible: boolean;
|
||||||
modalActive?: boolean;
|
modalActive?: boolean;
|
||||||
forceMousePassthrough?: boolean;
|
forceMousePassthrough?: boolean;
|
||||||
|
overlayInteractionActive?: boolean;
|
||||||
mainWindow: BrowserWindow | null;
|
mainWindow: BrowserWindow | null;
|
||||||
windowTracker: BaseWindowTracker | null;
|
windowTracker: BaseWindowTracker | null;
|
||||||
lastKnownWindowsForegroundProcessName?: string | null;
|
lastKnownWindowsForegroundProcessName?: string | null;
|
||||||
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainWindow = args.mainWindow;
|
const mainWindow = args.mainWindow;
|
||||||
|
const overlayInteractionActive = args.overlayInteractionActive === true;
|
||||||
|
|
||||||
if (args.modalActive) {
|
if (args.modalActive) {
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
@@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||||
const wasVisible = mainWindow.isVisible();
|
const wasVisible = mainWindow.isVisible();
|
||||||
const isVisibleOverlayFocused =
|
const isVisibleOverlayFocused =
|
||||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
overlayInteractionActive ||
|
||||||
|
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
|
||||||
const windowTracker = args.windowTracker;
|
const windowTracker = args.windowTracker;
|
||||||
const canReportMacOSTargetMinimized =
|
const canReportMacOSTargetMinimized =
|
||||||
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||||
const isTrackedMacOSTargetMinimized =
|
const isTrackedMacOSTargetMinimized =
|
||||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||||
|
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||||
const hasTransientMacOSTrackerLoss =
|
const hasTransientMacOSTrackerLoss =
|
||||||
args.isMacOSPlatform &&
|
args.isMacOSPlatform &&
|
||||||
canReportMacOSTargetMinimized &&
|
canReportMacOSTargetMinimized &&
|
||||||
!!windowTracker &&
|
!!windowTracker &&
|
||||||
!windowTracker.isTracking() &&
|
!windowTracker.isTracking() &&
|
||||||
!isTrackedMacOSTargetMinimized &&
|
!isTrackedMacOSTargetMinimized &&
|
||||||
|
trackedMacOSTargetFocused !== false &&
|
||||||
mainWindow.isVisible();
|
mainWindow.isVisible();
|
||||||
const isTrackedMacOSTargetFocused =
|
const isTrackedMacOSTargetFocused =
|
||||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||||
? true
|
? true
|
||||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
: (trackedMacOSTargetFocused ?? true);
|
||||||
const shouldReleaseMacOSOverlayLevel =
|
const shouldReleaseMacOSOverlayLevel =
|
||||||
args.isMacOSPlatform &&
|
args.isMacOSPlatform &&
|
||||||
!!args.windowTracker &&
|
!!args.windowTracker &&
|
||||||
@@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
!isVisibleOverlayFocused &&
|
!isVisibleOverlayFocused &&
|
||||||
!isTrackedMacOSTargetFocused;
|
!isTrackedMacOSTargetFocused;
|
||||||
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
||||||
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
|
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
|
||||||
const shouldDefaultToPassthrough =
|
const shouldDefaultToPassthrough =
|
||||||
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
||||||
const windowsForegroundProcessName =
|
const windowsForegroundProcessName =
|
||||||
@@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldReleaseMacOSOverlayLevel) {
|
||||||
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
|
if (wasVisible) {
|
||||||
|
mainWindow.hide();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldBindTrackedWindowsOverlay) {
|
if (shouldBindTrackedWindowsOverlay) {
|
||||||
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
||||||
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
||||||
// without any manual z-order management.
|
// without any manual z-order management.
|
||||||
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
|
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
|
||||||
args.ensureOverlayWindowLevel(mainWindow);
|
args.ensureOverlayWindowLevel(mainWindow);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setAlwaysOnTop(false);
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
}
|
}
|
||||||
if (!wasVisible) {
|
if (!wasVisible) {
|
||||||
const hasWebContents =
|
const hasWebContents =
|
||||||
@@ -179,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||||
// callback will trigger another visibility update when the renderer
|
// callback will trigger another visibility update when the renderer
|
||||||
// has painted its first frame.
|
// has painted its first frame.
|
||||||
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
|
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
if (args.isWindowsPlatform) {
|
||||||
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
|
}
|
||||||
mainWindow.showInactive();
|
mainWindow.showInactive();
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
scheduleWindowsOverlayReveal(
|
if (args.isWindowsPlatform) {
|
||||||
mainWindow,
|
scheduleWindowsOverlayReveal(
|
||||||
shouldBindTrackedWindowsOverlay
|
mainWindow,
|
||||||
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
shouldBindTrackedWindowsOverlay
|
||||||
: undefined,
|
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||||
);
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
@@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
args.isMacOSPlatform &&
|
||||||
|
overlayInteractionActive &&
|
||||||
|
!forceMousePassthrough &&
|
||||||
|
typeof mainWindow.isFocused === 'function' &&
|
||||||
|
!mainWindow.isFocused()
|
||||||
|
) {
|
||||||
|
mainWindow.focus();
|
||||||
|
}
|
||||||
|
|
||||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||||
mainWindow.focus();
|
mainWindow.focus();
|
||||||
}
|
}
|
||||||
@@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
return !shouldReleaseMacOSOverlayLevel;
|
return !shouldReleaseMacOSOverlayLevel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
|
||||||
|
shouldEnforceLayerOrder &&
|
||||||
|
!args.isWindowsPlatform &&
|
||||||
|
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||||
|
|
||||||
const maybeShowOverlayLoadingOsd = (): void => {
|
const maybeShowOverlayLoadingOsd = (): void => {
|
||||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||||
return;
|
return;
|
||||||
@@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||||
args.enforceOverlayLayerOrder();
|
args.enforceOverlayLayerOrder();
|
||||||
}
|
}
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
@@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||||
const hasActiveMacOSTargetSignal =
|
const hasActiveMacOSTargetSignal =
|
||||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||||
|
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
|
||||||
const canReportMacOSTargetMinimized =
|
const canReportMacOSTargetMinimized =
|
||||||
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
||||||
const isTrackedMacOSTargetMinimized =
|
const isTrackedMacOSTargetMinimized =
|
||||||
@@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
(args.isMacOSPlatform &&
|
(args.isMacOSPlatform &&
|
||||||
!isTrackedMacOSTargetMinimized &&
|
!isTrackedMacOSTargetMinimized &&
|
||||||
(hasRetainedTrackedGeometry ||
|
(hasRetainedTrackedGeometry ||
|
||||||
|
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
|
||||||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
||||||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
||||||
(args.isWindowsPlatform &&
|
(args.isWindowsPlatform &&
|
||||||
@@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
args.syncPrimaryOverlayWindowLayer('visible');
|
||||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||||
args.enforceOverlayLayerOrder();
|
args.enforceOverlayLayerOrder();
|
||||||
}
|
}
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
|
|||||||
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
|
|||||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||||
ensureOverlayWindowLevel: () => void;
|
ensureOverlayWindowLevel: () => void;
|
||||||
moveWindowTop: () => void;
|
moveWindowTop: () => void;
|
||||||
onWindowsVisibleOverlayBlur?: () => void;
|
onVisibleOverlayBlur?: () => void;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (platform === 'win32' && options.kind === 'visible') {
|
if (platform === 'win32' && options.kind === 'visible') {
|
||||||
options.onWindowsVisibleOverlayBlur?.();
|
options.onVisibleOverlayBlur?.();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (platform === 'darwin' && options.kind === 'visible') {
|
if (platform === 'darwin' && options.kind === 'visible') {
|
||||||
|
options.onVisibleOverlayBlur?.();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-top');
|
calls.push('move-top');
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur: () => {
|
onVisibleOverlayBlur: () => {
|
||||||
calls.push('windows-visible-blur');
|
calls.push('visible-blur');
|
||||||
},
|
},
|
||||||
platform: 'win32',
|
platform: 'win32',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
assert.deepEqual(calls, ['windows-visible-blur']);
|
assert.deepEqual(calls, ['visible-blur']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
||||||
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
|
test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
const handled = handleOverlayWindowBlurred({
|
const handled = handleOverlayWindowBlurred({
|
||||||
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
calls.push('move-top');
|
calls.push('move-top');
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur: () => {
|
onVisibleOverlayBlur: () => {
|
||||||
calls.push('windows-visible-blur');
|
calls.push('visible-blur');
|
||||||
},
|
},
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(handled, false);
|
assert.equal(handled, false);
|
||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, ['visible-blur']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export function createOverlayWindow(
|
|||||||
moveWindowTop: () => {
|
moveWindowTop: () => {
|
||||||
window.moveTop();
|
window.moveTop();
|
||||||
},
|
},
|
||||||
onWindowsVisibleOverlayBlur:
|
onVisibleOverlayBlur:
|
||||||
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
|||||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||||
|
|
||||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||||
|
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||||
|
Partial<Pick<BrowserWindow, 'showInactive'>>;
|
||||||
|
|
||||||
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
|
|||||||
window.moveTop();
|
window.moveTop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function presentStatsWindow(
|
||||||
|
window: StatsWindowPresentationController,
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): void {
|
||||||
|
if (platform === 'darwin') {
|
||||||
|
if (window.showInactive) {
|
||||||
|
window.showInactive();
|
||||||
|
} else {
|
||||||
|
window.show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
window.focus();
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): {
|
||||||
query: Record<string, string>;
|
query: Record<string, string>;
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowLoadFileOptions,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
|
presentStatsWindow,
|
||||||
promoteStatsWindowLevel,
|
promoteStatsWindowLevel,
|
||||||
resolveStatsWindowOuterBoundsForContent,
|
resolveStatsWindowOuterBoundsForContent,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
@@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () =
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
|
assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
presentStatsWindow(
|
||||||
|
{
|
||||||
|
show: () => {
|
||||||
|
calls.push('show');
|
||||||
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
calls.push('show-inactive');
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
calls.push('focus');
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'darwin',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['show-inactive']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('presentStatsWindow shows and focuses on non-macOS platforms', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
presentStatsWindow(
|
||||||
|
{
|
||||||
|
show: () => {
|
||||||
|
calls.push('show');
|
||||||
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
calls.push('show-inactive');
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
calls.push('focus');
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
'linux',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['show', 'focus']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
|||||||
import {
|
import {
|
||||||
buildStatsWindowLoadFileOptions,
|
buildStatsWindowLoadFileOptions,
|
||||||
buildStatsWindowOptions,
|
buildStatsWindowOptions,
|
||||||
|
presentStatsWindow,
|
||||||
promoteStatsWindowLevel,
|
promoteStatsWindowLevel,
|
||||||
resolveStatsWindowOuterBoundsForContent,
|
resolveStatsWindowOuterBoundsForContent,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
|||||||
const bounds = options.resolveBounds();
|
const bounds = options.resolveBounds();
|
||||||
let placementBounds = syncStatsWindowBounds(window, bounds);
|
let placementBounds = syncStatsWindowBounds(window, bounds);
|
||||||
promoteStatsWindowLevel(window);
|
promoteStatsWindowLevel(window);
|
||||||
window.show();
|
presentStatsWindow(window);
|
||||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||||
if (
|
if (
|
||||||
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
||||||
) {
|
) {
|
||||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||||
}
|
}
|
||||||
window.focus();
|
|
||||||
options.onVisibilityChanged?.(true);
|
options.onVisibilityChanged?.(true);
|
||||||
promoteStatsWindowLevel(window);
|
promoteStatsWindowLevel(window);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-12
@@ -2069,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
||||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||||
|
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||||
getWindowTracker: () => appState.windowTracker,
|
getWindowTracker: () => appState.windowTracker,
|
||||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||||
@@ -2112,23 +2113,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
})(),
|
})(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||||
let windowsVisibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
|
let visibleOverlayInteractionActive = false;
|
||||||
|
|
||||||
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||||
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
windowsVisibleOverlayBlurRefreshTimeouts = [];
|
visibleOverlayBlurRefreshTimeouts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||||
@@ -2329,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
if (process.platform === 'win32') {
|
||||||
clearWindowsVisibleOverlayBlurRefreshTimeouts();
|
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
}
|
||||||
|
clearVisibleOverlayBlurRefreshTimeouts();
|
||||||
|
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||||
const refreshTimeout = setTimeout(() => {
|
const refreshTimeout = setTimeout(() => {
|
||||||
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
|
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||||
(timeout) => timeout !== refreshTimeout,
|
(timeout) => timeout !== refreshTimeout,
|
||||||
);
|
);
|
||||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3043,6 +3047,7 @@ const {
|
|||||||
resetAnilistMediaTracking,
|
resetAnilistMediaTracking,
|
||||||
getAnilistMediaGuessRuntimeState,
|
getAnilistMediaGuessRuntimeState,
|
||||||
setAnilistMediaGuessRuntimeState,
|
setAnilistMediaGuessRuntimeState,
|
||||||
|
recordAnilistMediaDuration,
|
||||||
resetAnilistMediaGuessState,
|
resetAnilistMediaGuessState,
|
||||||
maybeProbeAnilistDuration,
|
maybeProbeAnilistDuration,
|
||||||
ensureAnilistMediaGuess,
|
ensureAnilistMediaGuess,
|
||||||
@@ -3146,6 +3151,13 @@ const {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recordMediaDurationMainDeps: {
|
||||||
|
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
|
||||||
|
getState: () => getAnilistMediaGuessRuntimeState(),
|
||||||
|
setState: (state) => {
|
||||||
|
setAnilistMediaGuessRuntimeState(state);
|
||||||
|
},
|
||||||
|
},
|
||||||
resetMediaGuessStateMainDeps: {
|
resetMediaGuessStateMainDeps: {
|
||||||
setMediaGuess: (value) => {
|
setMediaGuess: (value) => {
|
||||||
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
|
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
|
||||||
@@ -3984,7 +3996,10 @@ const {
|
|||||||
reportJellyfinRemoteStopped: () => {
|
reportJellyfinRemoteStopped: () => {
|
||||||
void reportJellyfinRemoteStopped();
|
void reportJellyfinRemoteStopped();
|
||||||
},
|
},
|
||||||
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||||
|
recordAnilistMediaDuration: (durationSec) => {
|
||||||
|
recordAnilistMediaDuration(durationSec);
|
||||||
|
},
|
||||||
logSubtitleTimingError: (message, error) => logger.error(message, error),
|
logSubtitleTimingError: (message, error) => logger.error(message, error),
|
||||||
broadcastToOverlayWindows: (channel, payload) => {
|
broadcastToOverlayWindows: (channel, payload) => {
|
||||||
broadcastToOverlayWindows(channel, payload);
|
broadcastToOverlayWindows(channel, payload);
|
||||||
@@ -4697,6 +4712,8 @@ function getUpdateService() {
|
|||||||
showUpdateAvailableDialog: (version) =>
|
showUpdateAvailableDialog: (version) =>
|
||||||
updateDialogPresenter.showUpdateAvailableDialog(version),
|
updateDialogPresenter.showUpdateAvailableDialog(version),
|
||||||
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
showUpdateFailedDialog: (message) => updateDialogPresenter.showUpdateFailedDialog(message),
|
||||||
|
showManualUpdateRequiredDialog: (version) =>
|
||||||
|
updateDialogPresenter.showManualUpdateRequiredDialog(version),
|
||||||
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
downloadAppUpdate: () => appUpdater.downloadUpdate(),
|
||||||
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
showRestartDialog: () => updateDialogPresenter.showRestartDialog(),
|
||||||
quitAndInstall: () => appUpdater.quitAndInstall(),
|
quitAndInstall: () => appUpdater.quitAndInstall(),
|
||||||
@@ -5124,6 +5141,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
onOverlayModalOpened: (modal) => {
|
onOverlayModalOpened: (modal) => {
|
||||||
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
overlayModalRuntime.notifyOverlayModalOpened(modal);
|
||||||
},
|
},
|
||||||
|
onOverlayMouseInteractionChanged: (active, senderWindow) => {
|
||||||
|
const mainWindow = overlayManager.getMainWindow();
|
||||||
|
if (!mainWindow || senderWindow !== mainWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visibleOverlayInteractionActive === active) {
|
||||||
|
if (active && process.platform === 'darwin' && !mainWindow.isFocused()) {
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visibleOverlayInteractionActive = active;
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
},
|
||||||
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
|
||||||
openYomitanSettings: () => openYomitanSettings(),
|
openYomitanSettings: () => openYomitanSettings(),
|
||||||
quitApp: () => requestAppQuit(),
|
quitApp: () => requestAppQuit(),
|
||||||
|
|||||||
@@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
|
test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let searchQueryCount = 0;
|
let searchQueryCount = 0;
|
||||||
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
|
|||||||
});
|
});
|
||||||
|
|
||||||
const first = await runtime.getOrCreateCurrentSnapshot();
|
const first = await runtime.getOrCreateCurrentSnapshot();
|
||||||
|
assert.equal(searchQueryCount, 1);
|
||||||
|
assert.equal(characterQueryCount, 1);
|
||||||
|
|
||||||
|
fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
const second = await runtime.getOrCreateCurrentSnapshot();
|
const second = await runtime.getOrCreateCurrentSnapshot();
|
||||||
|
|
||||||
assert.equal(first.fromCache, false);
|
assert.equal(first.fromCache, false);
|
||||||
assert.equal(second.fromCache, true);
|
assert.equal(second.fromCache, true);
|
||||||
assert.equal(searchQueryCount, 2);
|
assert.equal(searchQueryCount, 1);
|
||||||
assert.equal(characterQueryCount, 1);
|
assert.equal(characterQueryCount, 1);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
|
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
getMergedZipPath,
|
getMergedZipPath,
|
||||||
getSnapshotPath,
|
getSnapshotPath,
|
||||||
normalizeMergedMediaIds,
|
normalizeMergedMediaIds,
|
||||||
|
readCachedMediaResolution,
|
||||||
|
readCachedSnapshots,
|
||||||
readSnapshot,
|
readSnapshot,
|
||||||
|
writeCachedMediaResolution,
|
||||||
writeSnapshot,
|
writeSnapshot,
|
||||||
} from './character-dictionary-runtime/cache';
|
} from './character-dictionary-runtime/cache';
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +44,7 @@ import type {
|
|||||||
CharacterDictionaryManualSelectionResult,
|
CharacterDictionaryManualSelectionResult,
|
||||||
CharacterDictionaryManualSelectionSnapshot,
|
CharacterDictionaryManualSelectionSnapshot,
|
||||||
CharacterDictionaryRuntimeDeps,
|
CharacterDictionaryRuntimeDeps,
|
||||||
|
CharacterDictionarySnapshot,
|
||||||
CharacterDictionarySnapshotImage,
|
CharacterDictionarySnapshotImage,
|
||||||
CharacterDictionarySnapshotProgress,
|
CharacterDictionarySnapshotProgress,
|
||||||
CharacterDictionarySnapshotProgressCallbacks,
|
CharacterDictionarySnapshotProgressCallbacks,
|
||||||
@@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findCachedSnapshotForSeriesKey = (
|
||||||
|
seriesKey: string,
|
||||||
|
): CharacterDictionarySnapshot | null => {
|
||||||
|
return (
|
||||||
|
readCachedSnapshots(outputDir).find((snapshot) => {
|
||||||
|
const snapshotSeriesKey = buildCharacterDictionarySeriesKey({
|
||||||
|
mediaPath: null,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
guess: {
|
||||||
|
title: snapshot.mediaTitle,
|
||||||
|
season: null,
|
||||||
|
episode: null,
|
||||||
|
source: 'fallback',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return snapshotSeriesKey === seriesKey;
|
||||||
|
}) ?? null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const resolveCurrentMedia = async (
|
const resolveCurrentMedia = async (
|
||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
beforeRequest?: () => Promise<void>,
|
beforeRequest?: () => Promise<void>,
|
||||||
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
staleMediaIds: override.staleMediaIds,
|
staleMediaIds: override.staleMediaIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cachedResolution = readCachedMediaResolution(outputDir, seriesKey);
|
||||||
|
if (cachedResolution) {
|
||||||
|
const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId));
|
||||||
|
if (cachedSnapshot) {
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: cachedSnapshot.mediaId,
|
||||||
|
title: cachedSnapshot.mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey);
|
||||||
|
if (cachedSnapshot) {
|
||||||
|
writeCachedMediaResolution(outputDir, {
|
||||||
|
seriesKey,
|
||||||
|
mediaId: cachedSnapshot.mediaId,
|
||||||
|
mediaTitle: cachedSnapshot.mediaTitle,
|
||||||
|
});
|
||||||
|
deps.logInfo?.(
|
||||||
|
`[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: cachedSnapshot.mediaId,
|
||||||
|
title: cachedSnapshot.mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||||
|
writeCachedMediaResolution(outputDir, {
|
||||||
|
seriesKey,
|
||||||
|
mediaId: resolved.id,
|
||||||
|
mediaTitle: resolved.title,
|
||||||
|
});
|
||||||
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
||||||
return resolved;
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
|
|||||||
return path.join(outputDir, 'merged.zip');
|
return path.join(outputDir, 'merged.zip');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaResolutionCacheEntry = {
|
||||||
|
seriesKey: string;
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaResolutionCacheFile = {
|
||||||
|
entries?: MediaResolutionCacheEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMediaResolutionCachePath(outputDir: string): string {
|
||||||
|
return path.join(outputDir, 'anilist-resolution-cache.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null {
|
||||||
|
if (!value || typeof value !== 'object') return null;
|
||||||
|
const raw = value as Partial<MediaResolutionCacheEntry>;
|
||||||
|
const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : '';
|
||||||
|
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||||
|
if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null;
|
||||||
|
const mediaId = Math.floor(raw.mediaId);
|
||||||
|
if (!seriesKey || mediaId <= 0 || !mediaTitle) return null;
|
||||||
|
return {
|
||||||
|
seriesKey,
|
||||||
|
mediaId,
|
||||||
|
mediaTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'),
|
||||||
|
) as MediaResolutionCacheFile;
|
||||||
|
if (!Array.isArray(parsed.entries)) return [];
|
||||||
|
const byKey = new Map<string, MediaResolutionCacheEntry>();
|
||||||
|
for (const value of parsed.entries) {
|
||||||
|
const normalized = normalizeMediaResolutionEntry(value);
|
||||||
|
if (normalized) byKey.set(normalized.seriesKey, normalized);
|
||||||
|
}
|
||||||
|
return [...byKey.values()];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeMediaResolutionEntries(
|
||||||
|
outputDir: string,
|
||||||
|
entries: MediaResolutionCacheEntry[],
|
||||||
|
): void {
|
||||||
|
ensureDir(outputDir);
|
||||||
|
fs.writeFileSync(
|
||||||
|
getMediaResolutionCachePath(outputDir),
|
||||||
|
JSON.stringify({ entries }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedMediaResolution(
|
||||||
|
outputDir: string,
|
||||||
|
seriesKey: string,
|
||||||
|
): MediaResolutionCacheEntry | null {
|
||||||
|
const normalizedKey = seriesKey.trim();
|
||||||
|
if (!normalizedKey) return null;
|
||||||
|
return (
|
||||||
|
readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeCachedMediaResolution(
|
||||||
|
outputDir: string,
|
||||||
|
entry: MediaResolutionCacheEntry,
|
||||||
|
): void {
|
||||||
|
const normalized = normalizeMediaResolutionEntry(entry);
|
||||||
|
if (!normalized) return;
|
||||||
|
const remaining = readMediaResolutionEntries(outputDir).filter(
|
||||||
|
(existing) => existing.seriesKey !== normalized.seriesKey,
|
||||||
|
);
|
||||||
|
writeMediaResolutionEntries(outputDir, [...remaining, normalized]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] {
|
||||||
|
let entries: fs.Dirent[] = [];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name))
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name))
|
||||||
|
.map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name)))
|
||||||
|
.filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null);
|
||||||
|
}
|
||||||
|
|
||||||
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
|
export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null {
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||||
|
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||||
@@ -229,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||||
|
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||||
openYomitanSettings: params.openYomitanSettings,
|
openYomitanSettings: params.openYomitanSettings,
|
||||||
quitApp: params.quitApp,
|
quitApp: params.quitApp,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
|||||||
getModalActive: () => boolean;
|
getModalActive: () => boolean;
|
||||||
getVisibleOverlayVisible: () => boolean;
|
getVisibleOverlayVisible: () => boolean;
|
||||||
getForceMousePassthrough: () => boolean;
|
getForceMousePassthrough: () => boolean;
|
||||||
|
getOverlayInteractionActive?: () => boolean;
|
||||||
getWindowTracker: () => BaseWindowTracker | null;
|
getWindowTracker: () => BaseWindowTracker | null;
|
||||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||||
getWindowsOverlayProcessName?: () => string | null;
|
getWindowsOverlayProcessName?: () => string | null;
|
||||||
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
visibleOverlayVisible,
|
visibleOverlayVisible,
|
||||||
modalActive: deps.getModalActive(),
|
modalActive: deps.getModalActive(),
|
||||||
forceMousePassthrough,
|
forceMousePassthrough,
|
||||||
|
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||||
mainWindow,
|
mainWindow,
|
||||||
windowTracker,
|
windowTracker,
|
||||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||||
|
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||||
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
|
|||||||
deps.setMediaGuessPromise(null);
|
deps.setMediaGuessPromise(null);
|
||||||
assert.deepEqual(calls, ['guess', 'promise']);
|
assert.deepEqual(calls, ['guess', 'promise']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('record anilist media duration main deps builder maps callbacks', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const state = {
|
||||||
|
mediaKey: '/tmp/video.mkv',
|
||||||
|
mediaDurationSec: null,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
};
|
||||||
|
const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({
|
||||||
|
getCurrentMediaKey: () => {
|
||||||
|
calls.push('key');
|
||||||
|
return '/tmp/video.mkv';
|
||||||
|
},
|
||||||
|
getState: () => {
|
||||||
|
calls.push('get');
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
setState: () => {
|
||||||
|
calls.push('set');
|
||||||
|
},
|
||||||
|
})();
|
||||||
|
|
||||||
|
assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv');
|
||||||
|
deps.getState();
|
||||||
|
deps.setState(state);
|
||||||
|
assert.deepEqual(calls, ['key', 'get', 'set']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||||
createGetCurrentAnilistMediaKeyHandler,
|
createGetCurrentAnilistMediaKeyHandler,
|
||||||
|
createRecordAnilistMediaDurationHandler,
|
||||||
createResetAnilistMediaGuessStateHandler,
|
createResetAnilistMediaGuessStateHandler,
|
||||||
createResetAnilistMediaTrackingHandler,
|
createResetAnilistMediaTrackingHandler,
|
||||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||||
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
|||||||
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||||
>[0];
|
>[0];
|
||||||
|
type RecordAnilistMediaDurationMainDeps = Parameters<
|
||||||
|
typeof createRecordAnilistMediaDurationHandler
|
||||||
|
>[0];
|
||||||
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
||||||
typeof createResetAnilistMediaGuessStateHandler
|
typeof createResetAnilistMediaGuessStateHandler
|
||||||
>[0];
|
>[0];
|
||||||
@@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createBuildRecordAnilistMediaDurationMainDepsHandler(
|
||||||
|
deps: RecordAnilistMediaDurationMainDeps,
|
||||||
|
) {
|
||||||
|
return (): RecordAnilistMediaDurationMainDeps => ({
|
||||||
|
getCurrentMediaKey: () => deps.getCurrentMediaKey(),
|
||||||
|
getState: () => deps.getState(),
|
||||||
|
setState: (state) => deps.setState(state),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
|
export function createBuildResetAnilistMediaGuessStateMainDepsHandler(
|
||||||
deps: ResetAnilistMediaGuessStateMainDeps,
|
deps: ResetAnilistMediaGuessStateMainDeps,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||||
createGetCurrentAnilistMediaKeyHandler,
|
createGetCurrentAnilistMediaKeyHandler,
|
||||||
|
createRecordAnilistMediaDurationHandler,
|
||||||
createResetAnilistMediaGuessStateHandler,
|
createResetAnilistMediaGuessStateHandler,
|
||||||
createResetAnilistMediaTrackingHandler,
|
createResetAnilistMediaTrackingHandler,
|
||||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||||
@@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () =>
|
|||||||
assert.equal(state.mediaDurationSec, 240);
|
assert.equal(state.mediaDurationSec, 240);
|
||||||
assert.equal(state.lastDurationProbeAtMs, 321);
|
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('record anilist media duration stores observed mpv duration for current media', () => {
|
||||||
|
const existingPromise = Promise.resolve(null);
|
||||||
|
let state = {
|
||||||
|
mediaKey: '/tmp/video.mkv' as string | null,
|
||||||
|
mediaDurationSec: null as number | null,
|
||||||
|
mediaGuess: { title: 'guess' } as { title: string } | null,
|
||||||
|
mediaGuessPromise: existingPromise as Promise<unknown> | null,
|
||||||
|
lastDurationProbeAtMs: 321,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordDuration = createRecordAnilistMediaDurationHandler({
|
||||||
|
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||||
|
getState: () => state as never,
|
||||||
|
setState: (nextState) => {
|
||||||
|
state = nextState as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
recordDuration(1440);
|
||||||
|
|
||||||
|
assert.equal(state.mediaDurationSec, 1440);
|
||||||
|
assert.deepEqual(state.mediaGuess, { title: 'guess' });
|
||||||
|
assert.equal(state.mediaGuessPromise, existingPromise);
|
||||||
|
assert.equal(state.lastDurationProbeAtMs, 321);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('record anilist media duration resets stale media state when media key changes', () => {
|
||||||
|
let state = {
|
||||||
|
mediaKey: '/tmp/old.mkv' as string | null,
|
||||||
|
mediaDurationSec: 120 as number | null,
|
||||||
|
mediaGuess: { title: 'old' } as { title: string } | null,
|
||||||
|
mediaGuessPromise: Promise.resolve(null) as Promise<unknown> | null,
|
||||||
|
lastDurationProbeAtMs: 321,
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordDuration = createRecordAnilistMediaDurationHandler({
|
||||||
|
getCurrentMediaKey: () => '/tmp/new.mkv',
|
||||||
|
getState: () => state as never,
|
||||||
|
setState: (nextState) => {
|
||||||
|
state = nextState as never;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
recordDuration(1440);
|
||||||
|
|
||||||
|
assert.deepEqual(state, {
|
||||||
|
mediaKey: '/tmp/new.mkv',
|
||||||
|
mediaDurationSec: 1440,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRecordAnilistMediaDurationHandler(deps: {
|
||||||
|
getCurrentMediaKey: () => string | null;
|
||||||
|
getState: () => AnilistMediaGuessRuntimeState;
|
||||||
|
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||||
|
}) {
|
||||||
|
return (durationSec: number): void => {
|
||||||
|
if (!Number.isFinite(durationSec) || durationSec <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mediaKey = deps.getCurrentMediaKey();
|
||||||
|
if (!mediaKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = deps.getState();
|
||||||
|
if (state.mediaKey === mediaKey) {
|
||||||
|
deps.setState({
|
||||||
|
...state,
|
||||||
|
mediaDurationSec: durationSec,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.setState({
|
||||||
|
mediaKey,
|
||||||
|
mediaDurationSec: durationSec,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createResetAnilistMediaGuessStateHandler(deps: {
|
export function createResetAnilistMediaGuessStateHandler(deps: {
|
||||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||||
|
|||||||
@@ -121,6 +121,46 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
|||||||
assert.ok(calls.includes('osd:updated ok'));
|
assert.ok(calls.includes('osd:updated ok'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||||
|
getInFlight: () => false,
|
||||||
|
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||||
|
getResolvedConfig: () => ({}),
|
||||||
|
isAnilistTrackingEnabled: () => true,
|
||||||
|
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||||
|
hasMpvClient: () => true,
|
||||||
|
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||||
|
resetTrackedMedia: () => {},
|
||||||
|
getWatchedSeconds: () => 0,
|
||||||
|
maybeProbeAnilistDuration: async () => 1000,
|
||||||
|
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 8 }),
|
||||||
|
hasAttemptedUpdateKey: () => false,
|
||||||
|
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||||
|
refreshAnilistClientSecretState: async () => 'token',
|
||||||
|
enqueueRetry: () => calls.push('enqueue'),
|
||||||
|
markRetryFailure: () => calls.push('mark-failure'),
|
||||||
|
markRetrySuccess: () => calls.push('mark-success'),
|
||||||
|
refreshRetryQueueState: () => calls.push('refresh'),
|
||||||
|
updateAnilistPostWatchProgress: async () => {
|
||||||
|
calls.push('update');
|
||||||
|
return { status: 'updated', message: 'updated ok' };
|
||||||
|
},
|
||||||
|
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||||
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
minWatchSeconds: 600,
|
||||||
|
minWatchRatio: 0.85,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler({ watchedSeconds: 850 });
|
||||||
|
|
||||||
|
assert.ok(calls.includes('update'));
|
||||||
|
assert.ok(calls.includes('remember'));
|
||||||
|
assert.ok(calls.includes('osd:updated ok'));
|
||||||
|
});
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let inFlight = false;
|
let inFlight = false;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type RetryQueueItem = {
|
|||||||
|
|
||||||
type AnilistPostWatchRunOptions = {
|
type AnilistPostWatchRunOptions = {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
|
watchedSeconds?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
||||||
@@ -146,7 +147,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
|
|
||||||
let watchedSeconds = 0;
|
let watchedSeconds = 0;
|
||||||
if (!force) {
|
if (!force) {
|
||||||
watchedSeconds = deps.getWatchedSeconds();
|
watchedSeconds =
|
||||||
|
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds)
|
||||||
|
? options.watchedSeconds
|
||||||
|
: deps.getWatchedSeconds();
|
||||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
|||||||
lastDurationProbeAtMsState = value;
|
lastDurationProbeAtMsState = value;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
recordMediaDurationMainDeps: {
|
||||||
|
getCurrentMediaKey: () => 'media-key',
|
||||||
|
getState: () => ({
|
||||||
|
mediaKey: mediaKeyState,
|
||||||
|
mediaDurationSec: mediaDurationSecState,
|
||||||
|
mediaGuess: mediaGuessState,
|
||||||
|
mediaGuessPromise: mediaGuessPromiseState,
|
||||||
|
lastDurationProbeAtMs: lastDurationProbeAtMsState,
|
||||||
|
}),
|
||||||
|
setState: (state) => {
|
||||||
|
mediaKeyState = state.mediaKey;
|
||||||
|
mediaDurationSecState = state.mediaDurationSec;
|
||||||
|
mediaGuessState = state.mediaGuess;
|
||||||
|
mediaGuessPromiseState = state.mediaGuessPromise;
|
||||||
|
lastDurationProbeAtMsState = state.lastDurationProbeAtMs;
|
||||||
|
},
|
||||||
|
},
|
||||||
resetMediaGuessStateMainDeps: {
|
resetMediaGuessStateMainDeps: {
|
||||||
setMediaGuess: (value) => {
|
setMediaGuess: (value) => {
|
||||||
mediaGuessState = value;
|
mediaGuessState = value;
|
||||||
@@ -192,6 +209,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
|||||||
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
|
assert.equal(typeof composed.resetAnilistMediaTracking, 'function');
|
||||||
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
|
assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function');
|
||||||
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
|
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
|
||||||
|
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
|
||||||
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
|
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
|
||||||
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
|
assert.equal(typeof composed.maybeProbeAnilistDuration, 'function');
|
||||||
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
|
assert.equal(typeof composed.ensureAnilistMediaGuess, 'function');
|
||||||
@@ -216,6 +234,9 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
|||||||
});
|
});
|
||||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
|
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90);
|
||||||
|
|
||||||
|
composed.recordAnilistMediaDuration(180);
|
||||||
|
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
|
||||||
|
|
||||||
composed.resetAnilistMediaGuessState();
|
composed.resetAnilistMediaGuessState();
|
||||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
|
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
||||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
||||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
||||||
|
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||||
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
|
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
|
||||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
createMaybeProbeAnilistDurationHandler,
|
createMaybeProbeAnilistDurationHandler,
|
||||||
createMaybeRunAnilistPostWatchUpdateHandler,
|
createMaybeRunAnilistPostWatchUpdateHandler,
|
||||||
createProcessNextAnilistRetryUpdateHandler,
|
createProcessNextAnilistRetryUpdateHandler,
|
||||||
|
createRecordAnilistMediaDurationHandler,
|
||||||
createRefreshAnilistClientSecretStateHandler,
|
createRefreshAnilistClientSecretStateHandler,
|
||||||
createResetAnilistMediaGuessStateHandler,
|
createResetAnilistMediaGuessStateHandler,
|
||||||
createResetAnilistMediaTrackingHandler,
|
createResetAnilistMediaTrackingHandler,
|
||||||
@@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{
|
|||||||
setMediaGuessRuntimeStateMainDeps: Parameters<
|
setMediaGuessRuntimeStateMainDeps: Parameters<
|
||||||
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
|
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
|
||||||
>[0];
|
>[0];
|
||||||
|
recordMediaDurationMainDeps: Parameters<
|
||||||
|
typeof createBuildRecordAnilistMediaDurationMainDepsHandler
|
||||||
|
>[0];
|
||||||
resetMediaGuessStateMainDeps: Parameters<
|
resetMediaGuessStateMainDeps: Parameters<
|
||||||
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
|
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
|
||||||
>[0];
|
>[0];
|
||||||
@@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{
|
|||||||
setAnilistMediaGuessRuntimeState: ReturnType<
|
setAnilistMediaGuessRuntimeState: ReturnType<
|
||||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||||
>;
|
>;
|
||||||
|
recordAnilistMediaDuration: ReturnType<typeof createRecordAnilistMediaDurationHandler>;
|
||||||
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
||||||
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
||||||
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
||||||
@@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers(
|
|||||||
options.setMediaGuessRuntimeStateMainDeps,
|
options.setMediaGuessRuntimeStateMainDeps,
|
||||||
)(),
|
)(),
|
||||||
);
|
);
|
||||||
|
const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler(
|
||||||
|
createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(),
|
||||||
|
);
|
||||||
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
|
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
|
||||||
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
|
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
|
||||||
);
|
);
|
||||||
@@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers(
|
|||||||
resetAnilistMediaTracking,
|
resetAnilistMediaTracking,
|
||||||
getAnilistMediaGuessRuntimeState,
|
getAnilistMediaGuessRuntimeState,
|
||||||
setAnilistMediaGuessRuntimeState,
|
setAnilistMediaGuessRuntimeState,
|
||||||
|
recordAnilistMediaDuration,
|
||||||
resetAnilistMediaGuessState,
|
resetAnilistMediaGuessState,
|
||||||
maybeProbeAnilistDuration,
|
maybeProbeAnilistDuration,
|
||||||
ensureAnilistMediaGuess,
|
ensureAnilistMediaGuess,
|
||||||
|
|||||||
@@ -223,6 +223,23 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||||
|
const watchedSeconds: unknown[] = [];
|
||||||
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
recordPlaybackPosition: () => {},
|
||||||
|
reportJellyfinRemoteProgress: () => {},
|
||||||
|
refreshDiscordPresence: () => {},
|
||||||
|
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||||
|
watchedSeconds.push(options?.watchedSeconds);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
timeHandler({ time: 850 });
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(watchedSeconds, [850]);
|
||||||
|
});
|
||||||
|
|
||||||
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
|
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
@@ -105,7 +109,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
recordPlaybackPosition: (time: number) => void;
|
recordPlaybackPosition: (time: number) => void;
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logError?: (message: string, error: unknown) => void;
|
logError?: (message: string, error: unknown) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -113,7 +117,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
deps.recordPlaybackPosition(time);
|
deps.recordPlaybackPosition(time);
|
||||||
deps.reportJellyfinRemoteProgress(false);
|
deps.reportJellyfinRemoteProgress(false);
|
||||||
deps.refreshDiscordPresence();
|
deps.refreshDiscordPresence();
|
||||||
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
|
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||||
});
|
});
|
||||||
deps.onTimePosUpdate?.(time);
|
deps.onTimePosUpdate?.(time);
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
|
|
||||||
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
|
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
@@ -34,7 +38,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||||
hasSubtitleTimingTracker: () => boolean;
|
hasSubtitleTimingTracker: () => boolean;
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||||
|
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
@@ -149,7 +153,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
|
||||||
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
||||||
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
|
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
|
||||||
|
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
|
||||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||||
},
|
},
|
||||||
subtitleTimingTracker: {
|
subtitleTimingTracker: {
|
||||||
@@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
maybeRunAnilistPostWatchUpdate: async () => {
|
maybeRunAnilistPostWatchUpdate: async () => {
|
||||||
calls.push('anilist-post-watch');
|
calls.push('anilist-post-watch');
|
||||||
},
|
},
|
||||||
|
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
|
||||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||||
broadcastToOverlayWindows: (channel, payload) =>
|
broadcastToOverlayWindows: (channel, payload) =>
|
||||||
calls.push(`broadcast:${channel}:${String(payload)}`),
|
calls.push(`broadcast:${channel}:${String(payload)}`),
|
||||||
@@ -95,6 +97,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
deps.resetAnilistMediaGuessState();
|
deps.resetAnilistMediaGuessState();
|
||||||
deps.notifyImmersionTitleUpdate('title');
|
deps.notifyImmersionTitleUpdate('title');
|
||||||
deps.recordPlaybackPosition(10);
|
deps.recordPlaybackPosition(10);
|
||||||
|
deps.recordMediaDuration(1234);
|
||||||
deps.reportJellyfinRemoteProgress(true);
|
deps.reportJellyfinRemoteProgress(true);
|
||||||
deps.onFullscreenChange?.(true);
|
deps.onFullscreenChange?.(true);
|
||||||
deps.recordPauseState(true);
|
deps.recordPauseState(true);
|
||||||
@@ -118,6 +121,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
|||||||
assert.ok(calls.includes('presence-refresh'));
|
assert.ok(calls.includes('presence-refresh'));
|
||||||
assert.ok(calls.includes('restore-mpv-sub'));
|
assert.ok(calls.includes('restore-mpv-sub'));
|
||||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||||
|
assert.ok(calls.includes('immersion-duration:1234'));
|
||||||
|
assert.ok(calls.includes('anilist-duration:1234'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
|
test('mpv main event main deps wire subtitle callbacks without suppression gate', () => {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { MergedToken, SubtitleData } from '../../types';
|
import type { MergedToken, SubtitleData } from '../../types';
|
||||||
|
|
||||||
|
type AnilistPostWatchRunOptions = {
|
||||||
|
watchedSeconds?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||||
appState: {
|
appState: {
|
||||||
initialArgs?: {
|
initialArgs?: {
|
||||||
@@ -42,7 +46,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
quitApp: () => void;
|
quitApp: () => void;
|
||||||
reportJellyfinRemoteStopped: () => void;
|
reportJellyfinRemoteStopped: () => void;
|
||||||
syncOverlayMpvSubtitleSuppression: () => void;
|
syncOverlayMpvSubtitleSuppression: () => void;
|
||||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
|
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
@@ -126,7 +131,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||||
|
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||||
deps.logSubtitleTimingError(message, error),
|
deps.logSubtitleTimingError(message, error),
|
||||||
setCurrentSubText: (text: string) => {
|
setCurrentSubText: (text: string) => {
|
||||||
@@ -179,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
recordMediaDuration: (durationSec: number) => {
|
recordMediaDuration: (durationSec: number) => {
|
||||||
deps.ensureImmersionTrackerInitialized();
|
deps.ensureImmersionTrackerInitialized();
|
||||||
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
||||||
|
deps.recordAnilistMediaDuration?.(durationSec);
|
||||||
},
|
},
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
getModalActive: () => true,
|
getModalActive: () => true,
|
||||||
getVisibleOverlayVisible: () => true,
|
getVisibleOverlayVisible: () => true,
|
||||||
getForceMousePassthrough: () => true,
|
getForceMousePassthrough: () => true,
|
||||||
|
getOverlayInteractionActive: () => true,
|
||||||
getWindowTracker: () => tracker,
|
getWindowTracker: () => tracker,
|
||||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||||
getWindowsOverlayProcessName: () => 'subminer',
|
getWindowsOverlayProcessName: () => 'subminer',
|
||||||
@@ -40,6 +41,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
assert.equal(deps.getModalActive(), true);
|
assert.equal(deps.getModalActive(), true);
|
||||||
assert.equal(deps.getVisibleOverlayVisible(), true);
|
assert.equal(deps.getVisibleOverlayVisible(), true);
|
||||||
assert.equal(deps.getForceMousePassthrough(), true);
|
assert.equal(deps.getForceMousePassthrough(), true);
|
||||||
|
assert.equal(deps.getOverlayInteractionActive?.(), true);
|
||||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
|||||||
getModalActive: () => deps.getModalActive(),
|
getModalActive: () => deps.getModalActive(),
|
||||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||||
|
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
|
||||||
getWindowTracker: () => deps.getWindowTracker(),
|
getWindowTracker: () => deps.getWindowTracker(),
|
||||||
getLastKnownWindowsForegroundProcessName: () =>
|
getLastKnownWindowsForegroundProcessName: () =>
|
||||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ type UpdaterLogger = {
|
|||||||
|
|
||||||
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
||||||
const logged: string[] = [];
|
const logged: string[] = [];
|
||||||
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
|
const updater: ElectronAutoUpdaterLike & {
|
||||||
|
autoInstallOnAppQuit: boolean;
|
||||||
|
logger?: UpdaterLogger | null;
|
||||||
|
} = {
|
||||||
autoDownload: true,
|
autoDownload: true,
|
||||||
|
autoInstallOnAppQuit: true,
|
||||||
allowPrerelease: true,
|
allowPrerelease: true,
|
||||||
allowDowngrade: true,
|
allowDowngrade: true,
|
||||||
logger: null,
|
logger: null,
|
||||||
@@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo
|
|||||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||||
|
|
||||||
assert.equal(updater.autoDownload, false);
|
assert.equal(updater.autoDownload, false);
|
||||||
|
assert.equal(updater.autoInstallOnAppQuit, false);
|
||||||
assert.equal(updater.allowPrerelease, false);
|
assert.equal(updater.allowPrerelease, false);
|
||||||
assert.equal(updater.allowDowngrade, false);
|
assert.equal(updater.allowDowngrade, false);
|
||||||
assert.ok(updater.logger);
|
assert.ok(updater.logger);
|
||||||
@@ -180,16 +185,18 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async ()
|
|||||||
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mac native updater is supported for Developer ID signed app bundles', async () => {
|
test('mac native updater supports Developer ID signed packaged app bundles', async () => {
|
||||||
|
const logged: string[] = [];
|
||||||
const supported = await isNativeUpdaterSupported({
|
const supported = await isNativeUpdaterSupported({
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
isPackaged: true,
|
isPackaged: true,
|
||||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||||
readCodeSignature: () =>
|
log: (message) => logged.push(message),
|
||||||
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
|
readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(supported, true);
|
assert.equal(supported, true);
|
||||||
|
assert.deepEqual(logged, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike {
|
|||||||
|
|
||||||
export interface ElectronAutoUpdaterLike {
|
export interface ElectronAutoUpdaterLike {
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
|
autoInstallOnAppQuit?: boolean;
|
||||||
allowPrerelease: boolean;
|
allowPrerelease: boolean;
|
||||||
allowDowngrade: boolean;
|
allowDowngrade: boolean;
|
||||||
logger?: ElectronUpdaterLoggerLike | null;
|
logger?: ElectronUpdaterLoggerLike | null;
|
||||||
@@ -120,6 +121,8 @@ export function configureAutoUpdater(
|
|||||||
channel: UpdateChannel = 'stable',
|
channel: UpdateChannel = 'stable',
|
||||||
): ElectronAutoUpdaterLike {
|
): ElectronAutoUpdaterLike {
|
||||||
updater.autoDownload = false;
|
updater.autoDownload = false;
|
||||||
|
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
|
||||||
|
updater.autoInstallOnAppQuit = false;
|
||||||
updater.allowPrerelease = channel === 'prerelease';
|
updater.allowPrerelease = channel === 'prerelease';
|
||||||
updater.allowDowngrade = false;
|
updater.allowDowngrade = false;
|
||||||
updater.logger = {
|
updater.logger = {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createUpdateDialogPresenter, type ShowMessageBox } from './update-dialogs';
|
import {
|
||||||
|
createUpdateDialogPresenter,
|
||||||
|
showManualUpdateRequiredDialog,
|
||||||
|
type ShowMessageBox,
|
||||||
|
} from './update-dialogs';
|
||||||
|
|
||||||
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
test('update dialog presenter focuses app before showing macOS dialogs', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
@@ -35,3 +39,26 @@ test('update dialog presenter does not focus app before showing non-macOS dialog
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
assert.deepEqual(calls, ['dialog:SubMiner is up to date (v0.14.0)']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('manual update required dialog explains that automatic install is unavailable', async () => {
|
||||||
|
let shown:
|
||||||
|
| {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
buttons?: string[];
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const showMessageBox: ShowMessageBox = async (options) => {
|
||||||
|
shown = options;
|
||||||
|
return { response: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
await showManualUpdateRequiredDialog(showMessageBox, '0.15.0-beta.1');
|
||||||
|
|
||||||
|
assert.equal(shown?.type, 'warning');
|
||||||
|
assert.equal(shown?.message, 'Manual install required');
|
||||||
|
assert.match(shown?.detail ?? '', /SubMiner v0\.15\.0-beta\.1 is available/);
|
||||||
|
assert.match(shown?.detail ?? '', /cannot install app updates automatically/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
|||||||
showUpdateAvailableDialog(showFocusedMessageBox, version),
|
showUpdateAvailableDialog(showFocusedMessageBox, version),
|
||||||
showUpdateFailedDialog: (message: string) =>
|
showUpdateFailedDialog: (message: string) =>
|
||||||
showUpdateFailedDialog(showFocusedMessageBox, message),
|
showUpdateFailedDialog(showFocusedMessageBox, message),
|
||||||
|
showManualUpdateRequiredDialog: (version: string) =>
|
||||||
|
showManualUpdateRequiredDialog(showFocusedMessageBox, version),
|
||||||
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
|
showRestartDialog: () => showRestartDialog(showFocusedMessageBox),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,19 @@ export async function showRestartDialog(showMessageBox: ShowMessageBox): Promise
|
|||||||
return result.response === 0 ? 'restart' : 'later';
|
return result.response === 0 ? 'restart' : 'later';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function showManualUpdateRequiredDialog(
|
||||||
|
showMessageBox: ShowMessageBox,
|
||||||
|
version: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await showMessageBox({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'SubMiner Updates',
|
||||||
|
message: 'Manual install required',
|
||||||
|
detail: `SubMiner v${version} is available, but this build cannot install app updates automatically. Download and install the latest release, then reopen SubMiner.`,
|
||||||
|
buttons: ['Close'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function showUpdateFailedDialog(
|
export async function showUpdateFailedDialog(
|
||||||
showMessageBox: ShowMessageBox,
|
showMessageBox: ShowMessageBox,
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ function createDeps(overrides: Partial<UpdateServiceDeps> = {}) {
|
|||||||
showUpdateFailedDialog: async (message) => {
|
showUpdateFailedDialog: async (message) => {
|
||||||
calls.push(`failed:${message}`);
|
calls.push(`failed:${message}`);
|
||||||
},
|
},
|
||||||
|
showManualUpdateRequiredDialog: async (version) => {
|
||||||
|
calls.push(`manual-install:${version}`);
|
||||||
|
},
|
||||||
downloadAppUpdate: async () => {
|
downloadAppUpdate: async () => {
|
||||||
calls.push('download');
|
calls.push('download');
|
||||||
},
|
},
|
||||||
@@ -115,7 +118,44 @@ test('manual update check reports available when no update asset was applied', a
|
|||||||
const result = await service.checkForUpdates({ source: 'manual' });
|
const result = await service.checkForUpdates({ source: 'manual' });
|
||||||
|
|
||||||
assert.equal(result.status, 'update-available');
|
assert.equal(result.status, 'update-available');
|
||||||
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable']);
|
assert.deepEqual(calls, ['available-dialog:0.15.0', 'launcher:stable', 'manual-install:0.15.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual update check does not prompt restart when only launcher updates', async () => {
|
||||||
|
const { deps, calls } = createDeps({
|
||||||
|
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
|
||||||
|
fetchLatestStableRelease: async () => ({
|
||||||
|
tag_name: 'v0.15.0',
|
||||||
|
prerelease: false,
|
||||||
|
draft: false,
|
||||||
|
assets: [],
|
||||||
|
}),
|
||||||
|
showUpdateAvailableDialog: async (version) => {
|
||||||
|
calls.push(`available-dialog:${version}`);
|
||||||
|
return 'update';
|
||||||
|
},
|
||||||
|
updateLauncher: async (_launcherPath, channel) => {
|
||||||
|
calls.push(`launcher:${channel}`);
|
||||||
|
return { status: 'updated' };
|
||||||
|
},
|
||||||
|
showRestartDialog: async () => {
|
||||||
|
calls.push('restart-dialog');
|
||||||
|
return 'restart';
|
||||||
|
},
|
||||||
|
quitAndInstall: () => {
|
||||||
|
calls.push('quit-install');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const service = createUpdateService(deps);
|
||||||
|
|
||||||
|
const result = await service.checkForUpdates({ source: 'manual' });
|
||||||
|
|
||||||
|
assert.equal(result.status, 'update-available');
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'available-dialog:0.15.0',
|
||||||
|
'launcher:stable',
|
||||||
|
'manual-install:0.15.0',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('automatic update check skips inside configured interval', async () => {
|
test('automatic update check skips inside configured interval', async () => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface UpdateServiceDeps {
|
|||||||
showNoUpdateDialog: (version: string) => Promise<void>;
|
showNoUpdateDialog: (version: string) => Promise<void>;
|
||||||
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
showUpdateAvailableDialog: (version: string) => Promise<'update' | 'close'>;
|
||||||
showUpdateFailedDialog: (message: string) => Promise<void>;
|
showUpdateFailedDialog: (message: string) => Promise<void>;
|
||||||
|
showManualUpdateRequiredDialog: (version: string) => Promise<void>;
|
||||||
downloadAppUpdate: () => Promise<void>;
|
downloadAppUpdate: () => Promise<void>;
|
||||||
showRestartDialog: () => Promise<'restart' | 'later'>;
|
showRestartDialog: () => Promise<'restart' | 'later'>;
|
||||||
quitAndInstall: () => void | Promise<void>;
|
quitAndInstall: () => void | Promise<void>;
|
||||||
@@ -158,8 +159,9 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
|||||||
return { status: 'update-available', version: latest.version };
|
return { status: 'update-available', version: latest.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;
|
||||||
let appUpdateApplied = false;
|
let appUpdateApplied = false;
|
||||||
if (appUpdate.available && appUpdate.canUpdate !== false) {
|
if (canInstallAppUpdate) {
|
||||||
await deps.downloadAppUpdate();
|
await deps.downloadAppUpdate();
|
||||||
appUpdateApplied = true;
|
appUpdateApplied = true;
|
||||||
}
|
}
|
||||||
@@ -168,8 +170,8 @@ export function createUpdateService(deps: UpdateServiceDeps) {
|
|||||||
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
deps.log(`Launcher update requires manual command: ${launcherResult.command}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const launcherUpdateApplied = launcherResult.status === 'updated';
|
if (!appUpdateApplied) {
|
||||||
if (!appUpdateApplied && !launcherUpdateApplied) {
|
await deps.showManualUpdateRequiredDialog(latest.version);
|
||||||
return { status: 'update-available', version: latest.version };
|
return { status: 'update-available', version: latest.version };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker';
|
import {
|
||||||
|
isCompiledMacOSHelperCurrent,
|
||||||
|
MacOSWindowTracker,
|
||||||
|
parseMacOSHelperOutput,
|
||||||
|
} from './macos-tracker';
|
||||||
|
|
||||||
test('parseMacOSHelperOutput parses minimized state', () => {
|
test('parseMacOSHelperOutput parses minimized state', () => {
|
||||||
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
|
assert.deepEqual(parseMacOSHelperOutput('minimized'), {
|
||||||
@@ -10,6 +17,99 @@ test('parseMacOSHelperOutput parses minimized state', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseMacOSHelperOutput parses active focused state without geometry', () => {
|
||||||
|
assert.deepEqual(parseMacOSHelperOutput('active'), {
|
||||||
|
geometry: null,
|
||||||
|
focused: true,
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseMacOSHelperOutput parses inactive state without geometry', () => {
|
||||||
|
assert.deepEqual(parseMacOSHelperOutput('inactive'), {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
inactive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-'));
|
||||||
|
try {
|
||||||
|
const binaryPath = join(tempDir, 'get-mpv-window-macos');
|
||||||
|
const sourcePath = join(tempDir, 'get-mpv-window-macos.swift');
|
||||||
|
writeFileSync(binaryPath, 'binary');
|
||||||
|
writeFileSync(sourcePath, 'source');
|
||||||
|
|
||||||
|
const older = new Date('2026-01-01T00:00:00Z');
|
||||||
|
const newer = new Date('2026-01-01T00:00:05Z');
|
||||||
|
utimesSync(binaryPath, older, older);
|
||||||
|
utimesSync(sourcePath, newer, newer);
|
||||||
|
|
||||||
|
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false);
|
||||||
|
|
||||||
|
utimesSync(binaryPath, newer, newer);
|
||||||
|
utimesSync(sourcePath, older, older);
|
||||||
|
|
||||||
|
assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true);
|
||||||
|
} finally {
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker slows polling while focused target is stable', async () => {
|
||||||
|
const scheduledDelays: number[] = [];
|
||||||
|
let callIndex = 0;
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper',
|
||||||
|
helperType: 'binary',
|
||||||
|
}),
|
||||||
|
runHelper: async () => {
|
||||||
|
callIndex += 1;
|
||||||
|
return { stdout: '10,20,1280,720,1', stderr: '' };
|
||||||
|
},
|
||||||
|
fastPollIntervalMs: 250,
|
||||||
|
stablePollIntervalMs: 1_000,
|
||||||
|
setPollTimeout: ((_callback: () => void, delayMs: number) => {
|
||||||
|
scheduledDelays.push(delayMs);
|
||||||
|
return {} as ReturnType<typeof setTimeout>;
|
||||||
|
}) as never,
|
||||||
|
clearPollTimeout: (() => {}) as never,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
tracker.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
tracker.stop();
|
||||||
|
|
||||||
|
assert.equal(callIndex, 1);
|
||||||
|
assert.deepEqual(scheduledDelays, [1_000]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker keeps fast polling while target is not focused', async () => {
|
||||||
|
const scheduledDelays: number[] = [];
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper',
|
||||||
|
helperType: 'binary',
|
||||||
|
}),
|
||||||
|
runHelper: async () => ({ stdout: '10,20,1280,720,0', stderr: '' }),
|
||||||
|
fastPollIntervalMs: 250,
|
||||||
|
stablePollIntervalMs: 1_000,
|
||||||
|
setPollTimeout: ((_callback: () => void, delayMs: number) => {
|
||||||
|
scheduledDelays.push(delayMs);
|
||||||
|
return {} as ReturnType<typeof setTimeout>;
|
||||||
|
}) as never,
|
||||||
|
clearPollTimeout: (() => {}) as never,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
tracker.start();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
tracker.stop();
|
||||||
|
|
||||||
|
assert.deepEqual(scheduledDelays, [250]);
|
||||||
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
@@ -55,10 +155,221 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker preserves target focus on helper not-found while retaining geometry', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'not-found', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
trackingLossGraceMs: 1_500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(focusChanges, [true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker keeps focused fullscreen target through active helper misses after grace', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'active', stderr: '' },
|
||||||
|
{ stdout: 'active', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
now: () => now,
|
||||||
|
trackingLossGraceMs: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker drops previously focused target after repeated not-found misses exceed grace', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'not-found', stderr: '' },
|
||||||
|
{ stdout: 'not-found', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
now: () => now,
|
||||||
|
trackingLossGraceMs: 500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.deepEqual(focusChanges, [true]);
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), false);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker drops previously focused target after repeated helper execution failures exceed grace', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
let now = 1_000;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => {
|
||||||
|
callIndex += 1;
|
||||||
|
if (callIndex === 1) {
|
||||||
|
return { stdout: '10,20,1280,720,1', stderr: '' };
|
||||||
|
}
|
||||||
|
throw Object.assign(new Error('helper timed out'), { stderr: 'timeout' });
|
||||||
|
},
|
||||||
|
now: () => now,
|
||||||
|
trackingLossGraceMs: 500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
now += 1_000;
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), false);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker marks target unfocused on explicit inactive helper signal', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
const focusChanges: boolean[] = [];
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||||
|
{ stdout: 'inactive', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
trackingLossGraceMs: 1_500,
|
||||||
|
});
|
||||||
|
tracker.onWindowFocusChange = (focused) => {
|
||||||
|
focusChanges.push(focused);
|
||||||
|
};
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
{ stdout: '10,20,1280,720,0', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
];
|
];
|
||||||
@@ -75,6 +386,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
|
|||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), true);
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -84,6 +396,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), false);
|
assert.equal(tracker.isTracking(), false);
|
||||||
assert.equal(tracker.getGeometry(), null);
|
assert.equal(tracker.getGeometry(), null);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
|
test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => {
|
||||||
@@ -137,7 +450,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
|||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
{ stdout: '10,20,1280,720,0', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
{ stdout: 'not-found', stderr: '' },
|
{ stdout: 'not-found', stderr: '' },
|
||||||
@@ -156,6 +469,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
|||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(tracker.isTracking(), true);
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
now += 250;
|
now += 250;
|
||||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { createLogger } from '../logger';
|
|||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
|
|
||||||
const log = createLogger('tracker').child('macos');
|
const log = createLogger('tracker').child('macos');
|
||||||
|
const MACOS_FAST_POLL_INTERVAL_MS = 250;
|
||||||
|
const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000;
|
||||||
|
|
||||||
type MacOSTrackerRunnerResult = {
|
type MacOSTrackerRunnerResult = {
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -42,6 +44,10 @@ type MacOSTrackerDeps = {
|
|||||||
trackingLossGraceMs?: number;
|
trackingLossGraceMs?: number;
|
||||||
minimizedTrackingLossGraceMs?: number;
|
minimizedTrackingLossGraceMs?: number;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
fastPollIntervalMs?: number;
|
||||||
|
stablePollIntervalMs?: number;
|
||||||
|
setPollTimeout?: typeof setTimeout;
|
||||||
|
clearPollTimeout?: typeof clearTimeout;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MacOSHelperWindowState =
|
export type MacOSHelperWindowState =
|
||||||
@@ -49,11 +55,29 @@ export type MacOSHelperWindowState =
|
|||||||
geometry: WindowGeometry;
|
geometry: WindowGeometry;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
minimized?: false;
|
minimized?: false;
|
||||||
|
active?: false;
|
||||||
|
inactive?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
geometry: null;
|
||||||
|
focused: true;
|
||||||
|
active: true;
|
||||||
|
minimized?: false;
|
||||||
|
inactive?: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
geometry: null;
|
||||||
|
focused: false;
|
||||||
|
inactive: true;
|
||||||
|
active?: false;
|
||||||
|
minimized?: false;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
geometry: null;
|
geometry: null;
|
||||||
focused: false;
|
focused: false;
|
||||||
minimized: true;
|
minimized: true;
|
||||||
|
active?: false;
|
||||||
|
inactive?: false;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runHelperWithExecFile(
|
function runHelperWithExecFile(
|
||||||
@@ -90,6 +114,25 @@ function runHelperWithExecFile(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCompiledMacOSHelperCurrent(
|
||||||
|
binaryPath: string,
|
||||||
|
sourcePath: string,
|
||||||
|
helperFs: Pick<typeof fs, 'existsSync' | 'statSync'> = fs,
|
||||||
|
): boolean {
|
||||||
|
if (!helperFs.existsSync(binaryPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!helperFs.existsSync(sourcePath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return helperFs.statSync(binaryPath).mtimeMs >= helperFs.statSync(sourcePath).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null {
|
||||||
const trimmed = result.trim();
|
const trimmed = result.trim();
|
||||||
if (trimmed === 'minimized') {
|
if (trimmed === 'minimized') {
|
||||||
@@ -99,6 +142,20 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
minimized: true,
|
minimized: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (trimmed === 'active') {
|
||||||
|
return {
|
||||||
|
geometry: null,
|
||||||
|
focused: true,
|
||||||
|
active: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (trimmed === 'inactive') {
|
||||||
|
return {
|
||||||
|
geometry: null,
|
||||||
|
focused: false,
|
||||||
|
inactive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
if (!trimmed || trimmed === 'not-found') {
|
if (!trimmed || trimmed === 'not-found') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -138,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private pollInFlight = false;
|
private pollInFlight = false;
|
||||||
|
private started = false;
|
||||||
private helperPath: string | null = null;
|
private helperPath: string | null = null;
|
||||||
private helperType: 'binary' | 'swift' | null = null;
|
private helperType: 'binary' | 'swift' | null = null;
|
||||||
private lastExecErrorFingerprint: string | null = null;
|
private lastExecErrorFingerprint: string | null = null;
|
||||||
@@ -154,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
private readonly trackingLossGraceMs: number;
|
private readonly trackingLossGraceMs: number;
|
||||||
private readonly minimizedTrackingLossGraceMs: number;
|
private readonly minimizedTrackingLossGraceMs: number;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
|
private readonly fastPollIntervalMs: number;
|
||||||
|
private readonly stablePollIntervalMs: number;
|
||||||
|
private readonly setPollTimeout: typeof setTimeout;
|
||||||
|
private readonly clearPollTimeout: typeof clearTimeout;
|
||||||
private consecutiveMisses = 0;
|
private consecutiveMisses = 0;
|
||||||
private trackingLossStartedAtMs: number | null = null;
|
private trackingLossStartedAtMs: number | null = null;
|
||||||
private targetWindowMinimized = false;
|
private targetWindowMinimized = false;
|
||||||
@@ -169,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
||||||
);
|
);
|
||||||
this.now = deps.now ?? (() => Date.now());
|
this.now = deps.now ?? (() => Date.now());
|
||||||
|
this.fastPollIntervalMs = Math.max(
|
||||||
|
50,
|
||||||
|
Math.floor(deps.fastPollIntervalMs ?? MACOS_FAST_POLL_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
this.stablePollIntervalMs = Math.max(
|
||||||
|
this.fastPollIntervalMs,
|
||||||
|
Math.floor(deps.stablePollIntervalMs ?? MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS),
|
||||||
|
);
|
||||||
|
this.setPollTimeout = deps.setPollTimeout ?? setTimeout;
|
||||||
|
this.clearPollTimeout = deps.clearPollTimeout ?? clearTimeout;
|
||||||
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
const resolvedHelper = deps.resolveHelper?.() ?? null;
|
||||||
if (resolvedHelper) {
|
if (resolvedHelper) {
|
||||||
this.helperPath = resolvedHelper.helperPath;
|
this.helperPath = resolvedHelper.helperPath;
|
||||||
@@ -216,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private detectHelper(): void {
|
private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean {
|
||||||
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
|
if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) {
|
||||||
|
return false;
|
||||||
// Fall back to Swift helper first when filtering by socket path to avoid
|
|
||||||
// stale prebuilt binaries that don't support the new socket filter argument.
|
|
||||||
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
|
|
||||||
if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return this.tryUseHelper(candidatePath, 'binary');
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectHelper(): void {
|
||||||
|
const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift');
|
||||||
|
|
||||||
// Prefer resources path (outside asar) in packaged apps.
|
// Prefer resources path (outside asar) in packaged apps.
|
||||||
const resourcesPath = process.resourcesPath;
|
const resourcesPath = process.resourcesPath;
|
||||||
@@ -235,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dist binary path (development / unpacked installs).
|
// Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist.
|
||||||
const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos');
|
const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos');
|
||||||
if (this.tryUseHelper(distBinaryPath, 'binary')) {
|
if (this.tryUseCompiledHelper(bundledBinaryPath, swiftPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source-tree/manual helper build path.
|
||||||
|
const sourceTreeBinaryPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'scripts',
|
||||||
|
'get-mpv-window-macos',
|
||||||
|
);
|
||||||
|
if (this.tryUseCompiledHelper(sourceTreeBinaryPath, swiftPath)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
if (this.started) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.started = true;
|
||||||
this.pollGeometry();
|
this.pollGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (this.pollInterval) {
|
this.started = false;
|
||||||
clearInterval(this.pollInterval);
|
this.clearScheduledPoll();
|
||||||
this.pollInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override isTargetWindowMinimized(): boolean {
|
override isTargetWindowMinimized(): boolean {
|
||||||
@@ -303,7 +388,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldPreserveFocusedTargetOnMiss(): boolean {
|
||||||
|
return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void {
|
||||||
|
if (this.shouldPreserveFocusedTargetOnMiss()) {
|
||||||
|
if (this.trackingLossStartedAtMs === null) {
|
||||||
|
this.trackingLossStartedAtMs = this.now();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.now() - this.trackingLossStartedAtMs <= graceMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.consecutiveMisses += 1;
|
this.consecutiveMisses += 1;
|
||||||
if (this.shouldDropTracking(graceMs)) {
|
if (this.shouldDropTracking(graceMs)) {
|
||||||
this.updateGeometry(null);
|
this.updateGeometry(null);
|
||||||
@@ -311,6 +410,39 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveNextPollIntervalMs(): number {
|
||||||
|
if (
|
||||||
|
this.isTracking() &&
|
||||||
|
this.isTargetWindowFocused() &&
|
||||||
|
!this.targetWindowMinimized &&
|
||||||
|
this.getGeometry() !== null
|
||||||
|
) {
|
||||||
|
return this.stablePollIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fastPollIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearScheduledPoll(): void {
|
||||||
|
if (!this.pollTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearPollTimeout(this.pollTimeout);
|
||||||
|
this.pollTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleNextPoll(): void {
|
||||||
|
if (!this.started || this.pollTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pollTimeout = this.setPollTimeout(() => {
|
||||||
|
this.pollTimeout = null;
|
||||||
|
this.pollGeometry();
|
||||||
|
}, this.resolveNextPollIntervalMs());
|
||||||
|
}
|
||||||
|
|
||||||
private pollGeometry(): void {
|
private pollGeometry(): void {
|
||||||
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
||||||
return;
|
return;
|
||||||
@@ -327,10 +459,22 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parsed.active) {
|
||||||
|
this.resetTrackingLossState();
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
|
this.updateTargetWindowFocused(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parsed.inactive) {
|
||||||
|
this.targetWindowMinimized = false;
|
||||||
|
this.updateTargetWindowFocused(false);
|
||||||
|
this.registerTrackingMiss();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.resetTrackingLossState();
|
this.resetTrackingLossState();
|
||||||
this.targetWindowMinimized = false;
|
this.targetWindowMinimized = false;
|
||||||
this.updateFocus(parsed.focused);
|
this.updateGeometry(parsed.geometry, parsed.focused);
|
||||||
this.updateGeometry(parsed.geometry);
|
this.updateTargetWindowFocused(parsed.focused);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +496,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.pollInFlight = false;
|
this.pollInFlight = false;
|
||||||
|
this.scheduleNextPoll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user