mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-25 12:55:18 -07:00
Fix macOS overlay foreground handling and character-dictionary cache reuse (#68)
This commit is contained in:
@@ -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,6 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
|
||||
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
|
||||
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
type: fixed
|
||||
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.
|
||||
|
||||
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
|
||||
{
|
||||
"anilist": {
|
||||
"enabled": true,
|
||||
"accessToken": ""
|
||||
}
|
||||
"accessToken": "",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
||||
The update flow:
|
||||
|
||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
||||
|
||||
## Update Queue and Retry
|
||||
|
||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||
|
||||
| Parameter | Value |
|
||||
| --- | --- |
|
||||
| Initial backoff | 30 seconds |
|
||||
| Maximum backoff | 6 hours |
|
||||
| Maximum attempts | 8 |
|
||||
| Queue capacity | 500 items |
|
||||
| Parameter | Value |
|
||||
| ---------------- | ---------- |
|
||||
| Initial backoff | 30 seconds |
|
||||
| Maximum backoff | 6 hours |
|
||||
| Maximum attempts | 8 |
|
||||
| Queue capacity | 500 items |
|
||||
|
||||
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
|
||||
|
||||
@@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
||||
"collapsibleSections": {
|
||||
"description": false,
|
||||
"characterInformation": false,
|
||||
"voicedBy": false
|
||||
}
|
||||
}
|
||||
}
|
||||
"voicedBy": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||
|
||||
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||
| Command | Description |
|
||||
| ----------------------- | ------------------------------------------------------------- |
|
||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
|
||||
- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
|
||||
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
|
||||
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
|
||||
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
|
||||
|
||||
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
|
||||
```
|
||||
|
||||
::: 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
|
||||
@@ -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).
|
||||
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
|
||||
{
|
||||
|
||||
@@ -1,36 +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:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds.
|
||||
|
||||
**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH.
|
||||
|
||||
### Fixed
|
||||
|
||||
**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected.
|
||||
|
||||
**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing.
|
||||
|
||||
**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher.
|
||||
|
||||
**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version.
|
||||
|
||||
**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal.
|
||||
|
||||
**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running.
|
||||
|
||||
**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation.
|
||||
|
||||
## 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.
|
||||
//
|
||||
// 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
|
||||
@@ -25,9 +25,16 @@ private struct WindowState {
|
||||
let focused: Bool
|
||||
}
|
||||
|
||||
private struct FrontmostApplicationState {
|
||||
let pid: pid_t
|
||||
let isMpv: Bool
|
||||
}
|
||||
|
||||
private enum WindowLookupResult {
|
||||
case visible(WindowState)
|
||||
case minimized
|
||||
case active
|
||||
case inactive
|
||||
}
|
||||
|
||||
private let targetMpvSocketPath: String? = {
|
||||
@@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? {
|
||||
return geometry
|
||||
}
|
||||
|
||||
private func frontmostApplicationPid() -> pid_t? {
|
||||
NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||
private func frontmostApplicationState() -> FrontmostApplicationState? {
|
||||
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? {
|
||||
@@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
return normalizedMpvName(name)
|
||||
}
|
||||
|
||||
let frontmostPid = frontmostApplicationPid()
|
||||
let frontmost = frontmostApplicationState()
|
||||
var foundMinimizedTargetWindow = false
|
||||
|
||||
for app in runningApps {
|
||||
@@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? {
|
||||
return .visible(
|
||||
WindowState(
|
||||
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.
|
||||
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
|
||||
let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? []
|
||||
let frontmostPid = frontmostApplicationPid()
|
||||
let frontmost = frontmostApplicationState()
|
||||
|
||||
for window in windowList {
|
||||
guard let ownerName = window[kCGWindowOwnerName as String] as? String,
|
||||
@@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? {
|
||||
|
||||
return WindowState(
|
||||
geometry: geometry,
|
||||
focused: frontmostPid == ownerPid
|
||||
focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = {
|
||||
if let cgWindow = windowStateFromCoreGraphics() {
|
||||
return .visible(cgWindow)
|
||||
}
|
||||
let frontmost = frontmostApplicationState()
|
||||
if isFrontmostTargetMpv(frontmost) {
|
||||
return .active
|
||||
}
|
||||
if frontmost != nil {
|
||||
return .inactive
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
@@ -285,6 +332,10 @@ if let result = lookupResult {
|
||||
)
|
||||
case .minimized:
|
||||
print("minimized")
|
||||
case .active:
|
||||
print("active")
|
||||
case .inactive:
|
||||
print("inactive")
|
||||
}
|
||||
} else {
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => {
|
||||
const loggerState = createLogger();
|
||||
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||
|
||||
queue.enqueue('k1', 'Demo', 1);
|
||||
queue.enqueue('k1', 'Demo', 1, 2);
|
||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||
assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 });
|
||||
assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1');
|
||||
assert.deepEqual(
|
||||
{
|
||||
key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key,
|
||||
season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season,
|
||||
},
|
||||
{ key: 'k1', season: 2 },
|
||||
);
|
||||
|
||||
queue.markSuccess('k1');
|
||||
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||
|
||||
@@ -9,6 +9,7 @@ const MAX_ITEMS = 500;
|
||||
export interface AnilistQueuedUpdate {
|
||||
key: string;
|
||||
title: string;
|
||||
season?: number | null;
|
||||
episode: number;
|
||||
createdAt: number;
|
||||
attemptCount: number;
|
||||
@@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot {
|
||||
}
|
||||
|
||||
export interface AnilistUpdateQueue {
|
||||
enqueue: (key: string, title: string, episode: number) => void;
|
||||
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||
markSuccess: (key: string) => void;
|
||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||
@@ -106,7 +107,7 @@ export function createAnilistUpdateQueue(
|
||||
load();
|
||||
|
||||
return {
|
||||
enqueue(key: string, title: string, episode: number): void {
|
||||
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||
const existing = pending.find((item) => item.key === key);
|
||||
if (existing) {
|
||||
return;
|
||||
@@ -117,6 +118,7 @@ export function createAnilistUpdateQueue(
|
||||
pending.push({
|
||||
key,
|
||||
title,
|
||||
season,
|
||||
episode,
|
||||
createdAt: Date.now(),
|
||||
attemptCount: 0,
|
||||
|
||||
@@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 33, mediaListEntry: null },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const searchTerms: string[] = [];
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
call += 1;
|
||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
||||
if (call === 1) {
|
||||
searchTerms.push(String(body.variables?.search));
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [
|
||||
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
|
||||
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
assert.equal(body.variables?.mediaId, 202);
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 202, mediaListEntry: null },
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
||||
season: 2,
|
||||
});
|
||||
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||
assert.equal(call, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Page: {
|
||||
media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||
},
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.retryable, false);
|
||||
assert.match(result.message, /marked repeating on AniList/i);
|
||||
assert.equal(call, 2);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress returns error when search fails', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
@@ -18,10 +18,12 @@ export interface AnilistMediaGuess {
|
||||
export interface AnilistPostWatchUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'error';
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
export interface AnilistPostWatchUpdateOptions {
|
||||
rateLimiter?: AnilistRateLimiter;
|
||||
season?: number | null;
|
||||
}
|
||||
|
||||
interface AnilistGraphQlError {
|
||||
@@ -156,6 +158,28 @@ function normalizeTitle(text: string): string {
|
||||
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function titleMentionsSeason(title: string, season: number): boolean {
|
||||
const normalized = normalizeTitle(title);
|
||||
return (
|
||||
normalized.includes(`season ${season}`) ||
|
||||
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
|
||||
normalized.includes(`s${season}`)
|
||||
);
|
||||
}
|
||||
|
||||
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return [];
|
||||
const candidates =
|
||||
typeof season === 'number' &&
|
||||
Number.isInteger(season) &&
|
||||
season > 1 &&
|
||||
!titleMentionsSeason(trimmed, season)
|
||||
? [`${trimmed} Season ${season}`, trimmed]
|
||||
: [trimmed];
|
||||
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
|
||||
}
|
||||
|
||||
async function anilistGraphQl<T>(
|
||||
accessToken: string,
|
||||
query: string,
|
||||
@@ -226,6 +250,15 @@ function pickBestSearchResult(
|
||||
return { id: selected.id, title: selectedTitle };
|
||||
}
|
||||
|
||||
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||
return status === 'CURRENT' || status === 'PLANNING';
|
||||
}
|
||||
|
||||
function formatListStatus(status: string | null | undefined): string {
|
||||
if (!status) return 'not in your AniList Planning or Watching list';
|
||||
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
|
||||
}
|
||||
|
||||
export async function guessAnilistMediaInfo(
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
@@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress(
|
||||
episode: number,
|
||||
options: AnilistPostWatchUpdateOptions = {},
|
||||
): Promise<AnilistPostWatchUpdateResult> {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
`
|
||||
query ($search: String!) {
|
||||
Page(perPage: 5) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
|
||||
let searchError: string | null = null;
|
||||
let pickTitle = title;
|
||||
const searchCandidates = buildSearchCandidates(title, options.season);
|
||||
for (const search of searchCandidates) {
|
||||
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||
accessToken,
|
||||
`
|
||||
query ($search: String!) {
|
||||
Page(perPage: 5) {
|
||||
media(search: $search, type: ANIME) {
|
||||
id
|
||||
episodes
|
||||
title {
|
||||
romaji
|
||||
english
|
||||
native
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ search: title },
|
||||
options,
|
||||
);
|
||||
const searchError = firstErrorMessage(searchResponse);
|
||||
`,
|
||||
{ search },
|
||||
options,
|
||||
);
|
||||
searchError = firstErrorMessage(searchResponse);
|
||||
if (searchError) {
|
||||
break;
|
||||
}
|
||||
media = searchResponse.data?.Page?.media ?? [];
|
||||
if (media.length > 0) {
|
||||
pickTitle = search;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchError) {
|
||||
return {
|
||||
status: 'error',
|
||||
@@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress(
|
||||
};
|
||||
}
|
||||
|
||||
const media = searchResponse.data?.Page?.media ?? [];
|
||||
const picked = pickBestSearchResult(title, episode, media);
|
||||
const picked = pickBestSearchResult(pickTitle, episode, media);
|
||||
if (!picked) {
|
||||
return { status: 'error', message: 'AniList search returned no matches.' };
|
||||
}
|
||||
@@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress(
|
||||
};
|
||||
}
|
||||
|
||||
const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0;
|
||||
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
|
||||
if (!entry || !isUpdateableListStatus(entry.status)) {
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentProgress = entry.progress ?? 0;
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
|
||||
@@ -218,6 +218,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
getMainWindow: () => null,
|
||||
getVisibleOverlayVisibility: () => false,
|
||||
onOverlayModalClosed: () => {},
|
||||
onOverlayMouseInteractionChanged: (active) => {
|
||||
calls.push(`overlay-interaction:${active}`);
|
||||
},
|
||||
openYomitanSettings: () => {},
|
||||
quitApp: () => {},
|
||||
toggleVisibleOverlay: () => {},
|
||||
@@ -281,6 +284,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' });
|
||||
deps.clearAnilistToken();
|
||||
deps.openAnilistSetup();
|
||||
deps.onOverlayMouseInteractionChanged?.(true, null);
|
||||
assert.deepEqual(deps.getAnilistQueueStatus(), {
|
||||
pending: 1,
|
||||
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.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface IpcServiceDeps {
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayMouseInteractionChanged?: (
|
||||
active: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleDevTools: () => void;
|
||||
@@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions {
|
||||
modal: OverlayHostedModal,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
onOverlayMouseInteractionChanged?: (
|
||||
active: boolean,
|
||||
senderWindow: ElectronBrowserWindow | null,
|
||||
) => void;
|
||||
openYomitanSettings: () => void;
|
||||
quitApp: () => void;
|
||||
toggleVisibleOverlay: () => void;
|
||||
@@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
return {
|
||||
onOverlayModalClosed: options.onOverlayModalClosed,
|
||||
onOverlayModalOpened: options.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
|
||||
openYomitanSettings: options.openYomitanSettings,
|
||||
quitApp: options.quitApp,
|
||||
toggleDevTools: () => {
|
||||
@@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ function createMainWindowRecorder() {
|
||||
setAlwaysOnTop: (flag: boolean) => {
|
||||
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 }) => {
|
||||
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'));
|
||||
});
|
||||
|
||||
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 tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
@@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority
|
||||
forceMousePassthrough: true,
|
||||
} 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('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('enforce-order'));
|
||||
});
|
||||
@@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal
|
||||
} as never);
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re
|
||||
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 tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
@@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', (
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
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('mouse-ignore:true:forward'));
|
||||
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('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
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'));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
@@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible
|
||||
} as never);
|
||||
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine
|
||||
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 tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
@@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss',
|
||||
},
|
||||
} 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('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
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('hide'));
|
||||
assert.ok(!calls.includes('loading-osd'));
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void {
|
||||
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 {
|
||||
const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window);
|
||||
if (!pendingTimeout) {
|
||||
@@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
visibleOverlayVisible: boolean;
|
||||
modalActive?: boolean;
|
||||
forceMousePassthrough?: boolean;
|
||||
overlayInteractionActive?: boolean;
|
||||
mainWindow: BrowserWindow | null;
|
||||
windowTracker: BaseWindowTracker | null;
|
||||
lastKnownWindowsForegroundProcessName?: string | null;
|
||||
@@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
|
||||
const mainWindow = args.mainWindow;
|
||||
const overlayInteractionActive = args.overlayInteractionActive === true;
|
||||
|
||||
if (args.modalActive) {
|
||||
if (args.isWindowsPlatform) {
|
||||
@@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
const isVisibleOverlayFocused =
|
||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||
overlayInteractionActive ||
|
||||
(typeof mainWindow.isFocused === 'function' && mainWindow.isFocused());
|
||||
const windowTracker = args.windowTracker;
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function';
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||
const hasTransientMacOSTrackerLoss =
|
||||
args.isMacOSPlatform &&
|
||||
canReportMacOSTargetMinimized &&
|
||||
!!windowTracker &&
|
||||
!windowTracker.isTracking() &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
trackedMacOSTargetFocused !== false &&
|
||||
mainWindow.isVisible();
|
||||
const isTrackedMacOSTargetFocused =
|
||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||
? true
|
||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
||||
: (trackedMacOSTargetFocused ?? true);
|
||||
const shouldReleaseMacOSOverlayLevel =
|
||||
args.isMacOSPlatform &&
|
||||
!!args.windowTracker &&
|
||||
@@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!isVisibleOverlayFocused &&
|
||||
!isTrackedMacOSTargetFocused;
|
||||
// Renderer hover tracking temporarily disables this for subtitle and popup interaction.
|
||||
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform;
|
||||
const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldDefaultToPassthrough =
|
||||
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
||||
const windowsForegroundProcessName =
|
||||
@@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
|
||||
if (shouldReleaseMacOSOverlayLevel) {
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
if (wasVisible) {
|
||||
mainWindow.hide();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldBindTrackedWindowsOverlay) {
|
||||
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
||||
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
||||
// without any manual z-order management.
|
||||
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
|
||||
} else if (!forceMousePassthrough || args.isMacOSPlatform) {
|
||||
args.ensureOverlayWindowLevel(mainWindow);
|
||||
} else {
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
if (!wasVisible) {
|
||||
const hasWebContents =
|
||||
@@ -179,16 +203,20 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||
// callback will trigger another visibility update when the renderer
|
||||
// has painted its first frame.
|
||||
} else if (args.isWindowsPlatform && shouldIgnoreMouseEvents) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
mainWindow.showInactive();
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
scheduleWindowsOverlayReveal(
|
||||
mainWindow,
|
||||
shouldBindTrackedWindowsOverlay
|
||||
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||
: undefined,
|
||||
);
|
||||
if (args.isWindowsPlatform) {
|
||||
scheduleWindowsOverlayReveal(
|
||||
mainWindow,
|
||||
shouldBindTrackedWindowsOverlay
|
||||
? (window) => args.syncWindowsOverlayToMpvZOrder?.(window)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
@@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.syncWindowsOverlayToMpvZOrder?.(mainWindow);
|
||||
}
|
||||
|
||||
if (
|
||||
args.isMacOSPlatform &&
|
||||
overlayInteractionActive &&
|
||||
!forceMousePassthrough &&
|
||||
typeof mainWindow.isFocused === 'function' &&
|
||||
!mainWindow.isFocused()
|
||||
) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
@@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return !shouldReleaseMacOSOverlayLevel;
|
||||
};
|
||||
|
||||
const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean =>
|
||||
shouldEnforceLayerOrder &&
|
||||
!args.isWindowsPlatform &&
|
||||
(!args.forceMousePassthrough || args.isMacOSPlatform === true);
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) {
|
||||
return;
|
||||
@@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||
args.enforceOverlayLayerOrder();
|
||||
}
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||
const hasActiveMacOSTargetSignal =
|
||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||
const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive;
|
||||
const canReportMacOSTargetMinimized =
|
||||
args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function';
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
@@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
(args.isMacOSPlatform &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
(hasRetainedTrackedGeometry ||
|
||||
(mainWindow.isVisible() && hasActiveMacOSOverlaySignal) ||
|
||||
(mainWindow.isVisible() && hasActiveMacOSTargetSignal) ||
|
||||
(canReportMacOSTargetMinimized && mainWindow.isVisible()))) ||
|
||||
(args.isWindowsPlatform &&
|
||||
@@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) {
|
||||
args.enforceOverlayLayerOrder();
|
||||
}
|
||||
args.syncOverlayShortcuts();
|
||||
|
||||
@@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: {
|
||||
isOverlayVisible: (kind: OverlayWindowKind) => boolean;
|
||||
ensureOverlayWindowLevel: () => void;
|
||||
moveWindowTop: () => void;
|
||||
onWindowsVisibleOverlayBlur?: () => void;
|
||||
onVisibleOverlayBlur?: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}): boolean {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === 'win32' && options.kind === 'visible') {
|
||||
options.onWindowsVisibleOverlayBlur?.();
|
||||
options.onVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
if (platform === 'darwin' && options.kind === 'visible') {
|
||||
options.onVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onWindowsVisibleOverlayBlur: () => {
|
||||
calls.push('windows-visible-blur');
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
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', () => {
|
||||
@@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo
|
||||
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 handled = handleOverlayWindowBlurred({
|
||||
@@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onWindowsVisibleOverlayBlur: () => {
|
||||
calls.push('windows-visible-blur');
|
||||
onVisibleOverlayBlur: () => {
|
||||
calls.push('visible-blur');
|
||||
},
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(calls, ['visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||
|
||||
@@ -180,7 +180,7 @@ export function createOverlayWindow(
|
||||
moveWindowTop: () => {
|
||||
window.moveTop();
|
||||
},
|
||||
onWindowsVisibleOverlayBlur:
|
||||
onVisibleOverlayBlur:
|
||||
kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
Partial<Pick<BrowserWindow, 'showInactive'>>;
|
||||
|
||||
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
||||
return (
|
||||
@@ -104,6 +106,23 @@ export function promoteStatsWindowLevel(
|
||||
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): {
|
||||
query: Record<string, string>;
|
||||
} {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
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']);
|
||||
});
|
||||
|
||||
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 {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
shouldHideStatsWindowForInput,
|
||||
@@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
const bounds = options.resolveBounds();
|
||||
let placementBounds = syncStatsWindowBounds(window, bounds);
|
||||
promoteStatsWindowLevel(window);
|
||||
window.show();
|
||||
presentStatsWindow(window);
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
if (
|
||||
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
||||
) {
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
}
|
||||
window.focus();
|
||||
options.onVisibilityChanged?.(true);
|
||||
promoteStatsWindowLevel(window);
|
||||
}
|
||||
|
||||
+48
-17
@@ -2069,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
getModalActive: () => overlayModalInputState.getModalInputExclusive(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => appState.statsOverlayVisible,
|
||||
getOverlayInteractionActive: () => visibleOverlayInteractionActive,
|
||||
getWindowTracker: () => appState.windowTracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||
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_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
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 windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
let windowsVisibleOverlayZOrderSyncQueued = false;
|
||||
let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let visibleOverlayInteractionActive = false;
|
||||
|
||||
function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) {
|
||||
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
windowsVisibleOverlayBlurRefreshTimeouts = [];
|
||||
visibleOverlayBlurRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
@@ -2329,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void {
|
||||
}
|
||||
|
||||
function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
clearWindowsVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
if (process.platform === 'win32') {
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
}
|
||||
clearVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter(
|
||||
visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}, delayMs);
|
||||
windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||
visibleOverlayBlurRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3043,6 +3047,7 @@ const {
|
||||
resetAnilistMediaTracking,
|
||||
getAnilistMediaGuessRuntimeState,
|
||||
setAnilistMediaGuessRuntimeState,
|
||||
recordAnilistMediaDuration,
|
||||
resetAnilistMediaGuessState,
|
||||
maybeProbeAnilistDuration,
|
||||
ensureAnilistMediaGuess,
|
||||
@@ -3146,6 +3151,13 @@ const {
|
||||
);
|
||||
},
|
||||
},
|
||||
recordMediaDurationMainDeps: {
|
||||
getCurrentMediaKey: () => getCurrentAnilistMediaKey(),
|
||||
getState: () => getAnilistMediaGuessRuntimeState(),
|
||||
setState: (state) => {
|
||||
setAnilistMediaGuessRuntimeState(state);
|
||||
},
|
||||
},
|
||||
resetMediaGuessStateMainDeps: {
|
||||
setMediaGuess: (value) => {
|
||||
anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState(
|
||||
@@ -3197,9 +3209,10 @@ const {
|
||||
);
|
||||
},
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
season,
|
||||
}),
|
||||
markSuccess: (key) => {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
@@ -3230,13 +3243,13 @@ const {
|
||||
resetAnilistMediaTracking(mediaKey);
|
||||
},
|
||||
getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options),
|
||||
ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key, title, episode) => {
|
||||
anilistUpdateQueue.enqueue(key, title, episode);
|
||||
enqueueRetry: (key, title, episode, season) => {
|
||||
anilistUpdateQueue.enqueue(key, title, episode, season);
|
||||
},
|
||||
markRetryFailure: (key, message) => {
|
||||
anilistUpdateQueue.markFailure(key, message);
|
||||
@@ -3245,9 +3258,10 @@ const {
|
||||
anilistUpdateQueue.markSuccess(key);
|
||||
},
|
||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode) =>
|
||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||
rateLimiter: anilistRateLimiter,
|
||||
season,
|
||||
}),
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
rememberAnilistAttemptedUpdate(key);
|
||||
@@ -3984,7 +3998,10 @@ const {
|
||||
reportJellyfinRemoteStopped: () => {
|
||||
void reportJellyfinRemoteStopped();
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options),
|
||||
recordAnilistMediaDuration: (durationSec) => {
|
||||
recordAnilistMediaDuration(durationSec);
|
||||
},
|
||||
logSubtitleTimingError: (message, error) => logger.error(message, error),
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
broadcastToOverlayWindows(channel, payload);
|
||||
@@ -5126,6 +5143,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
onOverlayModalOpened: (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),
|
||||
openYomitanSettings: () => openYomitanSettings(),
|
||||
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 originalFetch = globalThis.fetch;
|
||||
let searchQueryCount = 0;
|
||||
@@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data',
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
assert.equal(first.fromCache, false);
|
||||
assert.equal(second.fromCache, true);
|
||||
assert.equal(searchQueryCount, 2);
|
||||
assert.equal(searchQueryCount, 1);
|
||||
assert.equal(characterQueryCount, 1);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')),
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
getMergedZipPath,
|
||||
getSnapshotPath,
|
||||
normalizeMergedMediaIds,
|
||||
readCachedMediaResolution,
|
||||
readCachedSnapshots,
|
||||
readSnapshot,
|
||||
writeCachedMediaResolution,
|
||||
writeSnapshot,
|
||||
} from './character-dictionary-runtime/cache';
|
||||
import {
|
||||
@@ -41,6 +44,7 @@ import type {
|
||||
CharacterDictionaryManualSelectionResult,
|
||||
CharacterDictionaryManualSelectionSnapshot,
|
||||
CharacterDictionaryRuntimeDeps,
|
||||
CharacterDictionarySnapshot,
|
||||
CharacterDictionarySnapshotImage,
|
||||
CharacterDictionarySnapshotProgress,
|
||||
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 (
|
||||
targetPath?: string,
|
||||
beforeRequest?: () => Promise<void>,
|
||||
@@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
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);
|
||||
writeCachedMediaResolution(outputDir, {
|
||||
seriesKey,
|
||||
mediaId: resolved.id,
|
||||
mediaTitle: resolved.title,
|
||||
});
|
||||
deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
@@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string {
|
||||
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 {
|
||||
try {
|
||||
const raw = fs.readFileSync(snapshotPath, 'utf8');
|
||||
|
||||
@@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility'];
|
||||
onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed'];
|
||||
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
|
||||
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
|
||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||
@@ -229,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getVisibleOverlayVisibility: params.getVisibleOverlayVisibility,
|
||||
onOverlayModalClosed: params.onOverlayModalClosed,
|
||||
onOverlayModalOpened: params.onOverlayModalOpened,
|
||||
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
|
||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||
openYomitanSettings: params.openYomitanSettings,
|
||||
quitApp: params.quitApp,
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getModalActive: () => boolean;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
getForceMousePassthrough: () => boolean;
|
||||
getOverlayInteractionActive?: () => boolean;
|
||||
getWindowTracker: () => BaseWindowTracker | null;
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
getWindowsOverlayProcessName?: () => string | null;
|
||||
@@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
visibleOverlayVisible,
|
||||
modalActive: deps.getModalActive(),
|
||||
forceMousePassthrough,
|
||||
overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false,
|
||||
mainWindow,
|
||||
windowTracker,
|
||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||
|
||||
@@ -30,6 +30,36 @@ test('maybeProbeAnilistDuration updates state with probed duration', async () =>
|
||||
assert.equal(state.mediaDurationSec, 321);
|
||||
});
|
||||
|
||||
test('maybeProbeAnilistDuration force option bypasses retry interval', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
mediaDurationSec: null,
|
||||
mediaGuess: null,
|
||||
mediaGuessPromise: null,
|
||||
lastDurationProbeAtMs: 1900,
|
||||
};
|
||||
let requestCount = 0;
|
||||
const probe = createMaybeProbeAnilistDurationHandler({
|
||||
getState: () => state,
|
||||
setState: (next) => {
|
||||
state = next;
|
||||
},
|
||||
durationRetryIntervalMs: 1000,
|
||||
now: () => 2000,
|
||||
requestMpvDuration: async () => {
|
||||
requestCount += 1;
|
||||
return 321;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
const duration = await probe('/tmp/video.mkv', { force: true });
|
||||
|
||||
assert.equal(duration, 321);
|
||||
assert.equal(requestCount, 1);
|
||||
assert.equal(state.mediaDurationSec, 321);
|
||||
});
|
||||
|
||||
test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
||||
let state: AnilistMediaGuessRuntimeState = {
|
||||
mediaKey: '/tmp/video.mkv',
|
||||
|
||||
@@ -14,6 +14,10 @@ type GuessAnilistMediaInfo = (
|
||||
mediaTitle: string | null,
|
||||
) => Promise<AnilistMediaGuess | null>;
|
||||
|
||||
type AnilistDurationProbeOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
getState: () => AnilistMediaGuessRuntimeState;
|
||||
setState: (state: AnilistMediaGuessRuntimeState) => void;
|
||||
@@ -22,7 +26,10 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
requestMpvDuration: () => Promise<unknown>;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return async (mediaKey: string): Promise<number | null> => {
|
||||
return async (
|
||||
mediaKey: string,
|
||||
options: AnilistDurationProbeOptions = {},
|
||||
): Promise<number | null> => {
|
||||
const state = deps.getState();
|
||||
if (state.mediaKey !== mediaKey) {
|
||||
return null;
|
||||
@@ -34,7 +41,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
||||
return state.mediaDurationSec;
|
||||
}
|
||||
const now = deps.now();
|
||||
if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
||||
if (!options.force && now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
createBuildGetCurrentAnilistMediaKeyMainDepsHandler,
|
||||
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||
createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler,
|
||||
@@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => {
|
||||
deps.setMediaGuessPromise(null);
|
||||
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 {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createRecordAnilistMediaDurationHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
createSetAnilistMediaGuessRuntimeStateHandler,
|
||||
@@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||
type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters<
|
||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||
>[0];
|
||||
type RecordAnilistMediaDurationMainDeps = Parameters<
|
||||
typeof createRecordAnilistMediaDurationHandler
|
||||
>[0];
|
||||
type ResetAnilistMediaGuessStateMainDeps = Parameters<
|
||||
typeof createResetAnilistMediaGuessStateHandler
|
||||
>[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(
|
||||
deps: ResetAnilistMediaGuessStateMainDeps,
|
||||
) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import {
|
||||
createGetAnilistMediaGuessRuntimeStateHandler,
|
||||
createGetCurrentAnilistMediaKeyHandler,
|
||||
createRecordAnilistMediaDurationHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
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.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: {
|
||||
setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void;
|
||||
setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void;
|
||||
|
||||
@@ -13,7 +13,10 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: () => calls.push('error'),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `ok:${season}`,
|
||||
}),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
@@ -26,9 +29,9 @@ test('process next anilist retry update main deps builder maps callbacks', async
|
||||
deps.setLastAttemptAt(1);
|
||||
deps.setLastError('x');
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||
status: 'updated',
|
||||
message: 'ok',
|
||||
message: 'ok:2',
|
||||
});
|
||||
deps.markSuccess('k');
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
@@ -58,16 +61,22 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
getTrackedMediaKey: () => 'media',
|
||||
resetTrackedMedia: () => calls.push('reset'),
|
||||
getWatchedSeconds: () => 100,
|
||||
maybeProbeAnilistDuration: async () => 120,
|
||||
maybeProbeAnilistDuration: async (_mediaKey, options) => {
|
||||
calls.push(`probe:${options?.force === true}`);
|
||||
return 120;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`),
|
||||
markRetryFailure: () => calls.push('retry-fail'),
|
||||
markRetrySuccess: () => calls.push('retry-ok'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `done:${season}`,
|
||||
}),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: () => calls.push('osd'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
@@ -84,7 +93,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
assert.equal(deps.getTrackedMediaKey(), 'media');
|
||||
deps.resetTrackedMedia('media');
|
||||
assert.equal(deps.getWatchedSeconds(), 100);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media'), 120);
|
||||
assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120);
|
||||
assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), {
|
||||
title: 'x',
|
||||
season: null,
|
||||
@@ -93,13 +102,13 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
assert.equal(deps.hasAttemptedUpdateKey('k'), false);
|
||||
assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' });
|
||||
assert.equal(await deps.refreshAnilistClientSecretState(), 'token');
|
||||
deps.enqueueRetry('k', 't', 1);
|
||||
deps.enqueueRetry('k', 't', 1, 2);
|
||||
deps.markRetryFailure('k', 'bad');
|
||||
deps.markRetrySuccess('k');
|
||||
deps.refreshRetryQueueState();
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), {
|
||||
assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), {
|
||||
status: 'updated',
|
||||
message: 'done',
|
||||
message: 'done:2',
|
||||
});
|
||||
deps.rememberAttemptedUpdateKey('k');
|
||||
deps.showMpvOsd('ok');
|
||||
@@ -110,7 +119,8 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy
|
||||
assert.deepEqual(calls, [
|
||||
'in-flight',
|
||||
'reset',
|
||||
'enqueue',
|
||||
'probe:true',
|
||||
'enqueue:2',
|
||||
'retry-fail',
|
||||
'retry-ok',
|
||||
'refresh',
|
||||
|
||||
@@ -19,8 +19,12 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
||||
setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value),
|
||||
setLastError: (value: string | null) => deps.setLastError(value),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||
markSuccess: (key: string) => deps.markSuccess(key),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
||||
@@ -42,18 +46,23 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
||||
getTrackedMediaKey: () => deps.getTrackedMediaKey(),
|
||||
resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey),
|
||||
getWatchedSeconds: () => deps.getWatchedSeconds(),
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
||||
maybeProbeAnilistDuration: (mediaKey: string, options) =>
|
||||
deps.maybeProbeAnilistDuration(mediaKey, options),
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
||||
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
||||
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||
enqueueRetry: (key: string, title: string, episode: number) =>
|
||||
deps.enqueueRetry(key, title, episode),
|
||||
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
|
||||
deps.enqueueRetry(key, title, episode, season),
|
||||
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
||||
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||
updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) =>
|
||||
deps.updateAnilistPostWatchProgress(accessToken, title, episode),
|
||||
updateAnilistPostWatchProgress: (
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
|
||||
@@ -20,12 +20,15 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
|
||||
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }),
|
||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
setLastAttemptAt: () => calls.push('attempt'),
|
||||
setLastError: (value) => calls.push(`error:${value ?? 'null'}`),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }),
|
||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({
|
||||
status: 'updated',
|
||||
message: `updated ok:${season}`,
|
||||
}),
|
||||
markSuccess: () => calls.push('success'),
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
markFailure: () => calls.push('failure'),
|
||||
@@ -34,7 +37,7 @@ test('createProcessNextAnilistRetryUpdateHandler handles successful retry', asyn
|
||||
});
|
||||
|
||||
const result = await handler();
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok' });
|
||||
assert.deepEqual(result, { ok: true, message: 'updated ok:2' });
|
||||
assert.ok(calls.includes('success'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
});
|
||||
@@ -93,7 +96,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
@@ -121,6 +124,106 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<string>();
|
||||
let updateCalls = 0;
|
||||
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: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => 1000,
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }),
|
||||
hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
|
||||
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 () => {
|
||||
updateCalls += 1;
|
||||
return {
|
||||
status: 'error',
|
||||
retryable: false,
|
||||
message:
|
||||
'AniList update not possible: Show is not in your AniList Planning or Watching list.',
|
||||
};
|
||||
},
|
||||
rememberAttemptedUpdateKey: (key) => {
|
||||
attemptedKeys.add(key);
|
||||
calls.push(`remember:${key}`);
|
||||
},
|
||||
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();
|
||||
await handler();
|
||||
|
||||
assert.equal(updateCalls, 1);
|
||||
assert.equal(calls.includes('enqueue'), false);
|
||||
assert.equal(calls.includes('mark-failure'), false);
|
||||
assert.ok(calls.some((call) => call.startsWith('remember:')));
|
||||
assert.ok(calls.includes('refresh'));
|
||||
assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible')));
|
||||
assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible')));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => {
|
||||
const calls: string[] = [];
|
||||
let durationProbeOptions: unknown = null;
|
||||
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 (_mediaKey, options) => {
|
||||
durationProbeOptions = options;
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, 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 (_accessToken, _title, _episode, season) => {
|
||||
calls.push(`update:${season}`);
|
||||
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.deepEqual(durationProbeOptions, { force: true });
|
||||
assert.ok(calls.includes('update:2'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
||||
const calls: string[] = [];
|
||||
let inFlight = false;
|
||||
|
||||
@@ -2,22 +2,30 @@ import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type AnilistGuess = {
|
||||
title: string;
|
||||
season: number | null;
|
||||
episode: number | null;
|
||||
};
|
||||
|
||||
type AnilistUpdateResult = {
|
||||
status: 'updated' | 'skipped' | 'error';
|
||||
message: string;
|
||||
retryable?: boolean;
|
||||
};
|
||||
|
||||
type RetryQueueItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
season?: number | null;
|
||||
episode: number;
|
||||
};
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
force?: boolean;
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
type AnilistDurationProbeOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
||||
@@ -49,6 +57,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
markSuccess: (key: string) => void;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
@@ -74,6 +83,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
||||
accessToken,
|
||||
queued.title,
|
||||
queued.episode,
|
||||
queued.season ?? null,
|
||||
);
|
||||
if (result.status === 'updated' || result.status === 'skipped') {
|
||||
deps.markSuccess(queued.key);
|
||||
@@ -101,12 +111,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
getTrackedMediaKey: () => string | null;
|
||||
resetTrackedMedia: (mediaKey: string | null) => void;
|
||||
getWatchedSeconds: () => number;
|
||||
maybeProbeAnilistDuration: (mediaKey: string) => Promise<number | null>;
|
||||
maybeProbeAnilistDuration: (
|
||||
mediaKey: string,
|
||||
options?: AnilistDurationProbeOptions,
|
||||
) => Promise<number | null>;
|
||||
ensureAnilistMediaGuess: (mediaKey: string) => Promise<AnilistGuess | null>;
|
||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||
enqueueRetry: (key: string, title: string, episode: number) => void;
|
||||
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||
markRetryFailure: (key: string, message: string) => void;
|
||||
markRetrySuccess: (key: string) => void;
|
||||
refreshRetryQueueState: () => void;
|
||||
@@ -114,6 +127,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
accessToken: string,
|
||||
title: string,
|
||||
episode: number,
|
||||
season?: number | null,
|
||||
) => Promise<AnilistUpdateResult>;
|
||||
rememberAttemptedUpdateKey: (key: string) => void;
|
||||
showMpvOsd: (message: string) => void;
|
||||
@@ -146,7 +160,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
|
||||
let watchedSeconds = 0;
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -155,7 +172,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
deps.setInFlight(true);
|
||||
try {
|
||||
if (!force) {
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey, {
|
||||
force:
|
||||
typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds),
|
||||
});
|
||||
if (!duration || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +201,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd('AniList: access token not configured');
|
||||
@@ -192,6 +212,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
accessToken,
|
||||
guess.title,
|
||||
guess.episode,
|
||||
guess.season,
|
||||
);
|
||||
if (result.status === 'updated') {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
@@ -209,7 +230,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode);
|
||||
if (result.retryable === false) {
|
||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(result.message);
|
||||
deps.logWarn(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||
deps.markRetryFailure(attemptKey, result.message);
|
||||
deps.refreshRetryQueueState();
|
||||
deps.showMpvOsd(`AniList: ${result.message}`);
|
||||
|
||||
@@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call
|
||||
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: {
|
||||
setMediaGuess: (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.getAnilistMediaGuessRuntimeState, 'function');
|
||||
assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function');
|
||||
assert.equal(typeof composed.recordAnilistMediaDuration, 'function');
|
||||
assert.equal(typeof composed.resetAnilistMediaGuessState, 'function');
|
||||
assert.equal(typeof composed.maybeProbeAnilistDuration, '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);
|
||||
|
||||
composed.recordAnilistMediaDuration(180);
|
||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180);
|
||||
|
||||
composed.resetAnilistMediaGuessState();
|
||||
assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createBuildMaybeProbeAnilistDurationMainDepsHandler,
|
||||
createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler,
|
||||
createBuildProcessNextAnilistRetryUpdateMainDepsHandler,
|
||||
createBuildRecordAnilistMediaDurationMainDepsHandler,
|
||||
createBuildRefreshAnilistClientSecretStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler,
|
||||
createBuildResetAnilistMediaTrackingMainDepsHandler,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
createMaybeProbeAnilistDurationHandler,
|
||||
createMaybeRunAnilistPostWatchUpdateHandler,
|
||||
createProcessNextAnilistRetryUpdateHandler,
|
||||
createRecordAnilistMediaDurationHandler,
|
||||
createRefreshAnilistClientSecretStateHandler,
|
||||
createResetAnilistMediaGuessStateHandler,
|
||||
createResetAnilistMediaTrackingHandler,
|
||||
@@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{
|
||||
setMediaGuessRuntimeStateMainDeps: Parameters<
|
||||
typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler
|
||||
>[0];
|
||||
recordMediaDurationMainDeps: Parameters<
|
||||
typeof createBuildRecordAnilistMediaDurationMainDepsHandler
|
||||
>[0];
|
||||
resetMediaGuessStateMainDeps: Parameters<
|
||||
typeof createBuildResetAnilistMediaGuessStateMainDepsHandler
|
||||
>[0];
|
||||
@@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{
|
||||
setAnilistMediaGuessRuntimeState: ReturnType<
|
||||
typeof createSetAnilistMediaGuessRuntimeStateHandler
|
||||
>;
|
||||
recordAnilistMediaDuration: ReturnType<typeof createRecordAnilistMediaDurationHandler>;
|
||||
resetAnilistMediaGuessState: ReturnType<typeof createResetAnilistMediaGuessStateHandler>;
|
||||
maybeProbeAnilistDuration: ReturnType<typeof createMaybeProbeAnilistDurationHandler>;
|
||||
ensureAnilistMediaGuess: ReturnType<typeof createEnsureAnilistMediaGuessHandler>;
|
||||
@@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers(
|
||||
options.setMediaGuessRuntimeStateMainDeps,
|
||||
)(),
|
||||
);
|
||||
const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler(
|
||||
createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(),
|
||||
);
|
||||
const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler(
|
||||
createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(),
|
||||
);
|
||||
@@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers(
|
||||
resetAnilistMediaTracking,
|
||||
getAnilistMediaGuessRuntimeState,
|
||||
setAnilistMediaGuessRuntimeState,
|
||||
recordAnilistMediaDuration,
|
||||
resetAnilistMediaGuessState,
|
||||
maybeProbeAnilistDuration,
|
||||
ensureAnilistMediaGuess,
|
||||
|
||||
@@ -97,20 +97,38 @@ test('mpv connection handler keeps overlay-initialized non-youtube sessions aliv
|
||||
assert.deepEqual(calls, ['presence-refresh', 'report-stop']);
|
||||
});
|
||||
|
||||
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
||||
test('mpv subtitle timing handler skips blank subtitle recording but still checks AniList time', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleTimingHandler({
|
||||
recordImmersionSubtitleLine: () => calls.push('immersion'),
|
||||
hasSubtitleTimingTracker: () => true,
|
||||
recordSubtitleTiming: () => calls.push('timing'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||
calls.push(`post-watch:${options?.watchedSeconds}`);
|
||||
},
|
||||
logError: () => calls.push('error'),
|
||||
});
|
||||
|
||||
handler({ text: ' ', start: 1, end: 2 });
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(calls, ['post-watch:2']);
|
||||
});
|
||||
|
||||
test('mpv subtitle timing handler runs AniList without timing tracker and passes subtitle time', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleTimingHandler({
|
||||
recordImmersionSubtitleLine: (text, start, end) =>
|
||||
calls.push(`immersion:${text}:${start}:${end}`),
|
||||
hasSubtitleTimingTracker: () => false,
|
||||
recordSubtitleTiming: () => calls.push('timing'),
|
||||
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||
calls.push(`post-watch:${options?.watchedSeconds}`);
|
||||
},
|
||||
logError: () => calls.push('error'),
|
||||
});
|
||||
|
||||
handler({ text: 'line', start: 899, end: 901 });
|
||||
|
||||
assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']);
|
||||
});
|
||||
|
||||
test('mpv event bindings register all expected events', () => {
|
||||
|
||||
@@ -19,6 +19,10 @@ type MpvEventClient = {
|
||||
on: <K extends MpvBindingEventName>(event: K, handler: (payload: any) => void) => void;
|
||||
};
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
@@ -57,15 +61,22 @@ export function createHandleMpvSubtitleTimingHandler(deps: {
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||
hasSubtitleTimingTracker: () => boolean;
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
logError: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
return ({ text, start, end }: { text: string; start: number; end: number }): void => {
|
||||
if (!text.trim()) return;
|
||||
deps.recordImmersionSubtitleLine(text, start, end);
|
||||
if (!deps.hasSubtitleTimingTracker()) return;
|
||||
deps.recordSubtitleTiming(text, start, end);
|
||||
void deps.maybeRunAnilistPostWatchUpdate().catch((error) => {
|
||||
const watchedSeconds = Math.max(
|
||||
Number.isFinite(start) ? start : 0,
|
||||
Number.isFinite(end) ? end : 0,
|
||||
);
|
||||
const options = watchedSeconds > 0 ? { watchedSeconds } : undefined;
|
||||
if (text.trim()) {
|
||||
deps.recordImmersionSubtitleLine(text, start, end);
|
||||
if (deps.hasSubtitleTimingTracker()) {
|
||||
deps.recordSubtitleTiming(text, start, end);
|
||||
}
|
||||
}
|
||||
void deps.maybeRunAnilistPostWatchUpdate(options).catch((error) => {
|
||||
deps.logError('AniList post-watch update failed unexpectedly', error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
const calls: string[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
@@ -105,7 +109,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
|
||||
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
@@ -113,7 +117,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.refreshDiscordPresence();
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||
});
|
||||
deps.onTimePosUpdate?.(time);
|
||||
|
||||
@@ -23,8 +23,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`),
|
||||
hasSubtitleTimingTracker: () => false,
|
||||
recordSubtitleTiming: () => calls.push('record-timing'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
maybeRunAnilistPostWatchUpdate: async (options) => {
|
||||
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
|
||||
},
|
||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||
@@ -74,6 +74,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '' });
|
||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
|
||||
handlers.get('time-pos-change')?.({ time: 2.5 });
|
||||
handlers.get('pause-change')?.({ paused: true });
|
||||
|
||||
@@ -87,6 +88,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('reset-guess-state'));
|
||||
assert.ok(calls.includes('notify-title:Episode 1'));
|
||||
assert.ok(calls.includes('post-watch:901'));
|
||||
assert.ok(calls.includes('progress:normal'));
|
||||
assert.ok(calls.includes('progress:force'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
|
||||
type MpvEventClient = Parameters<ReturnType<typeof createBindMpvClientEventHandlers>>[0];
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
@@ -34,7 +38,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => void;
|
||||
hasSubtitleTimingTracker: () => boolean;
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
|
||||
setCurrentSubText: (text: string) => void;
|
||||
@@ -103,7 +107,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
deps.recordImmersionSubtitleLine(text, start, end),
|
||||
hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(),
|
||||
recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
});
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
@@ -149,7 +153,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
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}`),
|
||||
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
|
||||
recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`),
|
||||
recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`),
|
||||
recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
@@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('anilist-post-watch');
|
||||
},
|
||||
recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`),
|
||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||
broadcastToOverlayWindows: (channel, 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.notifyImmersionTitleUpdate('title');
|
||||
deps.recordPlaybackPosition(10);
|
||||
deps.recordMediaDuration(1234);
|
||||
deps.reportJellyfinRemoteProgress(true);
|
||||
deps.onFullscreenChange?.(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('restore-mpv-sub'));
|
||||
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', () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { MergedToken, SubtitleData } from '../../types';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: {
|
||||
@@ -42,7 +46,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
quitApp: () => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
maybeRunAnilistPostWatchUpdate: () => Promise<void>;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
@@ -126,7 +131,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
deps.logSubtitleTimingError(message, error),
|
||||
setCurrentSubText: (text: string) => {
|
||||
@@ -179,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordMediaDuration: (durationSec: number) => {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
||||
deps.recordAnilistMediaDuration?.(durationSec);
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
|
||||
@@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
||||
getModalActive: () => true,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
getForceMousePassthrough: () => true,
|
||||
getOverlayInteractionActive: () => true,
|
||||
getWindowTracker: () => tracker,
|
||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||
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.getVisibleOverlayVisible(), true);
|
||||
assert.equal(deps.getForceMousePassthrough(), true);
|
||||
assert.equal(deps.getOverlayInteractionActive?.(), true);
|
||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
||||
getModalActive: () => deps.getModalActive(),
|
||||
getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(),
|
||||
getForceMousePassthrough: () => deps.getForceMousePassthrough(),
|
||||
getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false,
|
||||
getWindowTracker: () => deps.getWindowTracker(),
|
||||
getLastKnownWindowsForegroundProcessName: () =>
|
||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||
|
||||
@@ -18,8 +18,12 @@ type UpdaterLogger = {
|
||||
|
||||
test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => {
|
||||
const logged: string[] = [];
|
||||
const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = {
|
||||
const updater: ElectronAutoUpdaterLike & {
|
||||
autoInstallOnAppQuit: boolean;
|
||||
logger?: UpdaterLogger | null;
|
||||
} = {
|
||||
autoDownload: true,
|
||||
autoInstallOnAppQuit: true,
|
||||
allowPrerelease: true,
|
||||
allowDowngrade: true,
|
||||
logger: null,
|
||||
@@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo
|
||||
configureAutoUpdater(updater, (message) => logged.push(message));
|
||||
|
||||
assert.equal(updater.autoDownload, false);
|
||||
assert.equal(updater.autoInstallOnAppQuit, false);
|
||||
assert.equal(updater.allowPrerelease, false);
|
||||
assert.equal(updater.allowDowngrade, false);
|
||||
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.']);
|
||||
});
|
||||
|
||||
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({
|
||||
platform: 'darwin',
|
||||
isPackaged: true,
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
readCodeSignature: () =>
|
||||
['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'),
|
||||
log: (message) => logged.push(message),
|
||||
readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)',
|
||||
});
|
||||
|
||||
assert.equal(supported, true);
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
test('linux native updater is unsupported even for writable direct AppImage installs', async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike {
|
||||
|
||||
export interface ElectronAutoUpdaterLike {
|
||||
autoDownload: boolean;
|
||||
autoInstallOnAppQuit?: boolean;
|
||||
allowPrerelease: boolean;
|
||||
allowDowngrade: boolean;
|
||||
logger?: ElectronUpdaterLoggerLike | null;
|
||||
@@ -120,6 +121,8 @@ export function configureAutoUpdater(
|
||||
channel: UpdateChannel = 'stable',
|
||||
): ElectronAutoUpdaterLike {
|
||||
updater.autoDownload = false;
|
||||
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
|
||||
updater.autoInstallOnAppQuit = false;
|
||||
updater.allowPrerelease = channel === 'prerelease';
|
||||
updater.allowDowngrade = false;
|
||||
updater.logger = {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
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 { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker';
|
||||
import {
|
||||
isCompiledMacOSHelperCurrent,
|
||||
MacOSWindowTracker,
|
||||
parseMacOSHelperOutput,
|
||||
} from './macos-tracker';
|
||||
|
||||
test('parseMacOSHelperOutput parses minimized state', () => {
|
||||
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 () => {
|
||||
let callIndex = 0;
|
||||
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 () => {
|
||||
let callIndex = 0;
|
||||
const outputs = [
|
||||
{ stdout: '10,20,1280,720,1', stderr: '' },
|
||||
{ stdout: '10,20,1280,720,0', 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();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
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));
|
||||
assert.equal(tracker.isTracking(), false);
|
||||
assert.equal(tracker.getGeometry(), null);
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
});
|
||||
|
||||
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 now = 1_000;
|
||||
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: '' },
|
||||
@@ -156,6 +469,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () =>
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(tracker.isTracking(), true);
|
||||
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||
|
||||
now += 250;
|
||||
(tracker as unknown as { pollGeometry: () => void }).pollGeometry();
|
||||
|
||||
@@ -25,6 +25,8 @@ import { createLogger } from '../logger';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const log = createLogger('tracker').child('macos');
|
||||
const MACOS_FAST_POLL_INTERVAL_MS = 250;
|
||||
const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
type MacOSTrackerRunnerResult = {
|
||||
stdout: string;
|
||||
@@ -42,6 +44,10 @@ type MacOSTrackerDeps = {
|
||||
trackingLossGraceMs?: number;
|
||||
minimizedTrackingLossGraceMs?: number;
|
||||
now?: () => number;
|
||||
fastPollIntervalMs?: number;
|
||||
stablePollIntervalMs?: number;
|
||||
setPollTimeout?: typeof setTimeout;
|
||||
clearPollTimeout?: typeof clearTimeout;
|
||||
};
|
||||
|
||||
export type MacOSHelperWindowState =
|
||||
@@ -49,11 +55,29 @@ export type MacOSHelperWindowState =
|
||||
geometry: WindowGeometry;
|
||||
focused: boolean;
|
||||
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;
|
||||
focused: false;
|
||||
minimized: true;
|
||||
active?: false;
|
||||
inactive?: false;
|
||||
};
|
||||
|
||||
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 {
|
||||
const trimmed = result.trim();
|
||||
if (trimmed === 'minimized') {
|
||||
@@ -99,6 +142,20 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
||||
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') {
|
||||
return null;
|
||||
}
|
||||
@@ -138,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
||||
}
|
||||
|
||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private pollInFlight = false;
|
||||
private started = false;
|
||||
private helperPath: string | null = null;
|
||||
private helperType: 'binary' | 'swift' | null = null;
|
||||
private lastExecErrorFingerprint: string | null = null;
|
||||
@@ -154,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
private readonly trackingLossGraceMs: number;
|
||||
private readonly minimizedTrackingLossGraceMs: 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 trackingLossStartedAtMs: number | null = null;
|
||||
private targetWindowMinimized = false;
|
||||
@@ -169,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
Math.floor(deps.minimizedTrackingLossGraceMs ?? 500),
|
||||
);
|
||||
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;
|
||||
if (resolvedHelper) {
|
||||
this.helperPath = resolvedHelper.helperPath;
|
||||
@@ -216,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
return true;
|
||||
}
|
||||
|
||||
private detectHelper(): void {
|
||||
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
|
||||
|
||||
// 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;
|
||||
private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean {
|
||||
if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
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.
|
||||
const resourcesPath = process.resourcesPath;
|
||||
@@ -235,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
}
|
||||
|
||||
// Dist binary path (development / unpacked installs).
|
||||
const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos');
|
||||
if (this.tryUseHelper(distBinaryPath, 'binary')) {
|
||||
// Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist.
|
||||
const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -269,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
|
||||
if (this.started) {
|
||||
return;
|
||||
}
|
||||
this.started = true;
|
||||
this.pollGeometry();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
this.started = false;
|
||||
this.clearScheduledPoll();
|
||||
}
|
||||
|
||||
override isTargetWindowMinimized(): boolean {
|
||||
@@ -303,7 +388,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
return this.now() - this.trackingLossStartedAtMs > graceMs;
|
||||
}
|
||||
|
||||
private shouldPreserveFocusedTargetOnMiss(): boolean {
|
||||
return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null;
|
||||
}
|
||||
|
||||
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;
|
||||
if (this.shouldDropTracking(graceMs)) {
|
||||
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 {
|
||||
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
||||
return;
|
||||
@@ -327,10 +459,22 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
this.registerTrackingMiss(this.minimizedTrackingLossGraceMs);
|
||||
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.targetWindowMinimized = false;
|
||||
this.updateFocus(parsed.focused);
|
||||
this.updateGeometry(parsed.geometry);
|
||||
this.updateGeometry(parsed.geometry, parsed.focused);
|
||||
this.updateTargetWindowFocused(parsed.focused);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -352,6 +496,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
||||
})
|
||||
.finally(() => {
|
||||
this.pollInFlight = false;
|
||||
this.scheduleNextPoll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user