mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ebf6a45fe
|
||
|
|
fc7fde30d5
|
||
|
|
f335b26fe3
|
||
|
|
00b1b79bf4 | ||
|
|
e11a5fea0d
|
||
|
|
f73fe179d0
|
||
|
|
2938e7a32a | ||
|
|
82f6b4705a |
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: docs
|
||||
|
||||
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
|
||||
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
|
||||
@@ -1,5 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Dedicated overlay modals are prewarmed and reused on macOS and Windows so shortcuts open them promptly on the first press. On macOS, these modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
|
||||
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
|
||||
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
@@ -26,6 +27,9 @@ function optionalEnv(value: string | undefined): string | undefined {
|
||||
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
|
||||
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||
// The tracked `docs-site/` checkout, which stays a git working tree even when
|
||||
// `docsSourceDir` points at an untracked release snapshot. Used for git lookups only.
|
||||
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
|
||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
||||
@@ -82,15 +86,18 @@ function pageToRoute(page: string): string | null {
|
||||
return route ? `/${route}` : '/';
|
||||
}
|
||||
|
||||
// Only the root channel is indexable. `main` and every /v/<version>/ archive are
|
||||
// near-verbatim copies of it, so they own their URL via a self-referential canonical
|
||||
// and are excluded from the index instead of being consolidated onto root. Uniform
|
||||
// self-canonical plus noindex avoids mixing noindex with a cross-page canonical,
|
||||
// which Google treats as a conflicting signal.
|
||||
const isIndexableChannel = channel === 'stable-root';
|
||||
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
const route = pageToRoute(page);
|
||||
if (!route) return null;
|
||||
|
||||
if (channel === 'main') {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
||||
if (!isIndexableChannel) {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
@@ -106,7 +113,9 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
||||
const href = pageToCanonicalHref(page);
|
||||
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
|
||||
if (channel === 'main') {
|
||||
// Crawlable so links still pass through, but out of the index: ~30 archived copies
|
||||
// of every page otherwise soak up the crawl budget the current docs need.
|
||||
if (!isIndexableChannel) {
|
||||
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
}
|
||||
|
||||
@@ -287,6 +296,39 @@ const versionItems = [
|
||||
})),
|
||||
];
|
||||
|
||||
function sitemapUrlToPage(url: string): string {
|
||||
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
|
||||
return route ? `${route}.md` : 'index.md';
|
||||
}
|
||||
|
||||
// VitePress derives <lastmod> by running `git log` inside its source dir. Production
|
||||
// builds point that at an untracked snapshot of the release tag, so the lookup comes
|
||||
// back empty and the sitemap ships with no dates at all. Resolve it from the tracked
|
||||
// checkout at the ref being built instead.
|
||||
function lastModifiedFor(url: string): string | undefined {
|
||||
const ref = docsVersion && docsVersion !== 'main' ? docsVersion : 'HEAD';
|
||||
const result = spawnSync('git', ['log', '-1', '--format=%cI', ref, '--', sitemapUrlToPage(url)], {
|
||||
cwd: repoDocsDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
return (result.status === 0 && result.stdout.trim()) || undefined;
|
||||
}
|
||||
|
||||
// Only the root channel publishes a sitemap. Archived and `main` builds would emit
|
||||
// their own copies listing the same canonical URLs, which just advertises the
|
||||
// duplicate trees we are trying to keep out of the index.
|
||||
const sitemap: UserConfig['sitemap'] = isIndexableChannel
|
||||
? {
|
||||
hostname: DOCS_HOSTNAME,
|
||||
transformItems(items) {
|
||||
return items
|
||||
.filter((item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`)
|
||||
.map((item) => ({ ...item, lastmod: item.lastmod ?? lastModifiedFor(item.url) }));
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
@@ -419,14 +461,7 @@ const config: UserConfig = {
|
||||
appearance: 'dark',
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
sitemap: {
|
||||
hostname: DOCS_HOSTNAME,
|
||||
transformItems(items) {
|
||||
return items.filter(
|
||||
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
|
||||
);
|
||||
},
|
||||
},
|
||||
sitemap,
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**', 'README.md'],
|
||||
|
||||
+3
-1
@@ -38,8 +38,10 @@ bun run docs:dev
|
||||
The public docs root is stable-only:
|
||||
|
||||
- `/` serves the latest stable release docs.
|
||||
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
|
||||
- `/main/` serves development docs from `main`.
|
||||
- `/v/<version>/` serves stable release archives.
|
||||
- Prerelease tags do not update the docs site.
|
||||
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
|
||||
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
|
||||
|
||||
+49
-21
@@ -56,34 +56,43 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
||||
]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
expect(mainDocsConfig.sitemap).toBeUndefined();
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
});
|
||||
|
||||
test('latest stable archive canonical points to root equivalent', async () => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
|
||||
test.each([
|
||||
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
|
||||
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
|
||||
])(
|
||||
'%s archive keeps a self-referential canonical and stays out of the index',
|
||||
async (_label, version, base, expectedCanonical) => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = base;
|
||||
process.env.SUBMINER_DOCS_VERSION = version;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
try {
|
||||
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
|
||||
|
||||
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
const head = await archiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
|
||||
expect(head).toContainEqual([
|
||||
'link',
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
|
||||
]);
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
});
|
||||
expect(head).toContainEqual(['link', { rel: 'canonical', href: expectedCanonical }]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
// A sitemap here would advertise the archive tree we just excluded.
|
||||
expect(archiveConfig.sitemap).toBeUndefined();
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('stable archive theme links stay on the selected version', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
@@ -433,3 +442,22 @@ test('docs sitemap excludes duplicate README page from indexable URLs', async ()
|
||||
|
||||
expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']);
|
||||
});
|
||||
|
||||
test('docs sitemap dates every URL from the tracked checkout', async () => {
|
||||
const previousRepoDir = process.env.SUBMINER_DOCS_REPO_DIR;
|
||||
// Production builds render from an untracked snapshot, so the date has to come from
|
||||
// the real checkout rather than VitePress's own srcDir git lookup.
|
||||
process.env.SUBMINER_DOCS_REPO_DIR = docsSiteDir;
|
||||
try {
|
||||
const { default: sitemapConfig } = await import('./.vitepress/config?sitemap-lastmod');
|
||||
|
||||
const items = await sitemapConfig.sitemap?.transformItems?.([{ url: '' }, { url: 'usage' }]);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
for (const item of items ?? []) {
|
||||
expect(item.lastmod).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
}
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_REPO_DIR = previousRepoDir;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Subtitle Overlay Priming
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-04
|
||||
Last verified: 2026-08-17
|
||||
Owner: Kyle Yasuda
|
||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||
|
||||
@@ -77,6 +77,25 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||
or character images, but cue visibility does not wait for that work.
|
||||
|
||||
## Secondary Subtitle Flow
|
||||
|
||||
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
|
||||
still appear without waiting for file resolution.
|
||||
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
|
||||
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
|
||||
source resolver used by primary subtitle prefetching.
|
||||
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
|
||||
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
|
||||
`secondary-sub-delay`.
|
||||
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
||||
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
||||
text when a readable source is available.
|
||||
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
||||
track-list changes refresh without discarding an unchanged source. Observed
|
||||
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
||||
extraction, or parsing fails, the controller returns to live mpv text and the renderer's
|
||||
conservative short stack heuristic remains the final display fallback.
|
||||
|
||||
## Emitted State
|
||||
|
||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||
@@ -84,8 +103,8 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||
when text and cue timing match.
|
||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||
- Secondary priming reads mpv `secondary-sub-text` and routes it through the secondary track
|
||||
controller. A parsed active cue replaces the live text when the selected source is readable.
|
||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||
written.
|
||||
|
||||
@@ -129,7 +148,11 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
|
||||
overlay window remained mapped above mpv.
|
||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping.
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through
|
||||
must go through `applyOverlayClickThrough()` (`src/core/services/overlay-click-through.ts`),
|
||||
which omits `forward: true` there: Electron implements forwarding with a global low-level mouse
|
||||
hook that lags mouse input system-wide whenever the main thread stalls; the Windows cursor poll
|
||||
handles overlay wake-up instead.
|
||||
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
|
||||
interaction state is already inactive. The next cursor-poll tick must still reapply
|
||||
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.3",
|
||||
"version": "0.19.4-beta.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -7,6 +7,8 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
|
||||
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
|
||||
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
|
||||
local OVERLAY_LOADING_OSD_DEADLINE_SECONDS = 30
|
||||
local OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE = "Overlay did not become ready; check SubMiner logs"
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
||||
@@ -265,10 +267,19 @@ function M.create(ctx)
|
||||
state.overlay_loading_osd_timer = nil
|
||||
end
|
||||
|
||||
local function clear_overlay_loading_osd_deadline()
|
||||
local timeout = state.overlay_loading_osd_deadline
|
||||
if timeout and timeout.kill then
|
||||
timeout:kill()
|
||||
end
|
||||
state.overlay_loading_osd_deadline = nil
|
||||
end
|
||||
|
||||
local function stop_overlay_loading_osd()
|
||||
state.overlay_loading_osd_active = false
|
||||
state.overlay_loading_osd_frame = 1
|
||||
clear_overlay_loading_osd_timer()
|
||||
clear_overlay_loading_osd_deadline()
|
||||
end
|
||||
|
||||
local function start_overlay_loading_osd()
|
||||
@@ -291,6 +302,21 @@ function M.create(ctx)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if type(mp.add_timeout) == "function" then
|
||||
state.overlay_loading_osd_deadline = mp.add_timeout(OVERLAY_LOADING_OSD_DEADLINE_SECONDS, function()
|
||||
if not state.overlay_loading_osd_active then
|
||||
return
|
||||
end
|
||||
state.overlay_loading_osd_deadline = nil
|
||||
stop_overlay_loading_osd()
|
||||
subminer_log(
|
||||
"warn",
|
||||
"process",
|
||||
"Overlay loading deadline expired before the app reported content ready"
|
||||
)
|
||||
show_osd(OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE, { force = true })
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local function disarm_auto_play_ready_gate(options)
|
||||
|
||||
@@ -26,6 +26,7 @@ function M.new()
|
||||
auto_play_ready_initial_pause_ownership_consumed = false,
|
||||
overlay_loading_osd_active = false,
|
||||
overlay_loading_osd_timer = nil,
|
||||
overlay_loading_osd_deadline = nil,
|
||||
overlay_loading_osd_frame = 1,
|
||||
pending_visible_overlay_hide_timer = nil,
|
||||
pending_visible_overlay_hide_generation = 0,
|
||||
|
||||
+38
-66
@@ -1,80 +1,52 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.19.0 -->
|
||||
<!-- prerelease-base-version: 0.19.4 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Sync Stats & History**
|
||||
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
|
||||
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
|
||||
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
|
||||
|
||||
- **TsukiHime Subtitle Downloads**
|
||||
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
|
||||
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
|
||||
|
||||
- **Post-Playback History Menu**
|
||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
||||
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
|
||||
|
||||
- **Known-Word Highlighting by Anki Maturity**
|
||||
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
|
||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
||||
|
||||
- **Stats Library Entry Deletion**
|
||||
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
|
||||
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
|
||||
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clipboard-Video Shortcut**
|
||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
|
||||
- **Library Merge and Move**
|
||||
- Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card.
|
||||
- Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair.
|
||||
- Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation.
|
||||
|
||||
### Fixed
|
||||
- **Anki Audio Generation on Network Drives**
|
||||
- Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams.
|
||||
- Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one.
|
||||
- **Duplicate Subtitle Line Stats**
|
||||
- Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected.
|
||||
- Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched.
|
||||
- **Overlay Modals on macOS and Windows**
|
||||
- Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space.
|
||||
- Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions.
|
||||
- **Wayland File Drag-and-Drop**
|
||||
- Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv.
|
||||
- **Windows Mouse Lag**
|
||||
- Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking.
|
||||
- **Mining Clip Accuracy**
|
||||
- Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match.
|
||||
- **Linux Notifications**
|
||||
- Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change.
|
||||
- **Stats Delete Performance**
|
||||
- Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes.
|
||||
- Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history.
|
||||
- Session deletes on large libraries dropped from minutes to milliseconds.
|
||||
|
||||
- **Word Highlighting Accuracy**
|
||||
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
||||
|
||||
- **AniList Season Resolution**
|
||||
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
|
||||
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
|
||||
|
||||
- **Startup Playback Pausing Too Early**
|
||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
||||
|
||||
- **Linux AppImage Crash Notification on Quit**
|
||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
|
||||
|
||||
- **AnkiConnect Proxy Port Conflict**
|
||||
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
||||
|
||||
- **Stats & Settings Reliability**
|
||||
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
|
||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||
|
||||
- **Stats Library Cover After Relink**
|
||||
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
|
||||
|
||||
- **Rofi Menu Prompt Spacing**
|
||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
||||
### Docs
|
||||
- **Feature Demos Page**
|
||||
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
||||
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -35,6 +35,17 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
|
||||
const maxCloudflareFiles = 20_000;
|
||||
const maxCloudflareFileBytes = 25 * 1024 * 1024;
|
||||
|
||||
// Cloudflare Pages header rules for the whole deployment. Mirrors the `noindex,follow`
|
||||
// meta tag the non-root channels emit, so the duplicate trees stay out of the index
|
||||
// even for responses a crawler takes without parsing the HTML.
|
||||
const deployHeaders = `# Generated by scripts/build-versioned-docs.ts. Do not edit by hand.
|
||||
/main/*
|
||||
X-Robots-Tag: noindex, follow
|
||||
|
||||
/v/*
|
||||
X-Robots-Tag: noindex, follow
|
||||
`;
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -173,6 +184,7 @@ function buildDocs(options: {
|
||||
SUBMINER_DOCS_BASE: options.base,
|
||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||
SUBMINER_DOCS_REPO_DIR: currentDocsSite,
|
||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
||||
@@ -378,6 +390,7 @@ function main() {
|
||||
});
|
||||
|
||||
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
|
||||
assertCloudflarePagesLimits(aggregateOutDir);
|
||||
const prunedArchives = pruneArchiveCacheGenerations({
|
||||
cacheRoot: archiveCacheRoot,
|
||||
|
||||
@@ -130,7 +130,9 @@ local function run_plugin_scenario(config)
|
||||
|
||||
function mp.add_timeout(seconds, callback)
|
||||
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
||||
local delay = tonumber(seconds) or 0
|
||||
local timeout = {
|
||||
seconds = delay,
|
||||
killed = false,
|
||||
callback = callback,
|
||||
}
|
||||
@@ -138,7 +140,6 @@ local function run_plugin_scenario(config)
|
||||
self.killed = true
|
||||
end
|
||||
|
||||
local delay = tonumber(seconds) or 0
|
||||
if callback and delay < 5 and not config.defer_timeouts then
|
||||
callback()
|
||||
end
|
||||
@@ -514,6 +515,15 @@ local function has_timeout(timeouts, target)
|
||||
return false
|
||||
end
|
||||
|
||||
local function find_timeout_handle(recorded, target)
|
||||
for _, timeout in ipairs(recorded.timeout_handles) do
|
||||
if math.abs(timeout.seconds - target) < 0.0001 then
|
||||
return timeout
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function env_has(call, target)
|
||||
local env = (call and call.env) or {}
|
||||
for _, value in ipairs(env) do
|
||||
@@ -1636,6 +1646,8 @@ do
|
||||
#recorded.periodic_timers == 1,
|
||||
"auto-start visible overlay should refresh the early overlay loading OSD"
|
||||
)
|
||||
local overlay_loading_deadline = find_timeout_handle(recorded, 30)
|
||||
assert_true(overlay_loading_deadline ~= nil, "overlay loading OSD should have a bounded deadline")
|
||||
local overlay_loading_timer = recorded.periodic_timers[1]
|
||||
recorded.periodic_timers[1].callback()
|
||||
assert_true(
|
||||
@@ -1670,6 +1682,46 @@ do
|
||||
recorded.periodic_timers[1].killed == true,
|
||||
"overlay loading ready should stop the early overlay loading OSD refresher"
|
||||
)
|
||||
assert_true(
|
||||
overlay_loading_deadline.killed == true,
|
||||
"overlay loading ready should cancel the bounded loading deadline"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
defer_timeouts = true,
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
osd_messages = false,
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for overlay loading deadline scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
local overlay_loading_deadline = find_timeout_handle(recorded, 30)
|
||||
assert_true(overlay_loading_deadline ~= nil, "overlay loading deadline should be scheduled")
|
||||
overlay_loading_deadline.callback()
|
||||
assert_true(
|
||||
recorded.periodic_timers[1].killed == true,
|
||||
"overlay loading deadline should stop the loading spinner"
|
||||
)
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay did not become ready; check SubMiner logs"),
|
||||
"overlay loading deadline should replace the spinner with actionable feedback"
|
||||
)
|
||||
assert_true(
|
||||
has_log_containing(recorded.logs, "Overlay loading deadline expired"),
|
||||
"overlay loading deadline should leave a diagnostic log entry"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
parseSubsyncManualRunRequest,
|
||||
parseYoutubePickerResolveRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
@@ -442,7 +443,13 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
const senderWindow =
|
||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
// Route forwarding requests through the platform-aware helper so Windows never
|
||||
// installs Electron's global mouse hook (see overlay-click-through.ts).
|
||||
if (ignore && parsedOptions?.forward) {
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
} else {
|
||||
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||
}
|
||||
}
|
||||
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||
},
|
||||
|
||||
@@ -125,9 +125,40 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
|
||||
test('mineSentenceCard uses normalized secondary subtitle state instead of raw mpv text', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
let requestedRawSecondaryText = false;
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: 'Your\nmosaic',
|
||||
requestProperty: async () => {
|
||||
requestedRawSecondaryText = true;
|
||||
return 'Your\nYour\nYour\nYour\nmosaic';
|
||||
},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.equal(requestedRawSecondaryText, false);
|
||||
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'Your\nmosaic' }]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard omits normalized secondary text that matches the primary subtitle', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const requestedProperties: string[] = [];
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
@@ -145,43 +176,6 @@ test('mineSentenceCard refreshes secondary subtitle text before creating card',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: '日本語字幕',
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return name === 'secondary-sub-text' ? 'English subtitle' : null;
|
||||
},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(requestedProperties, ['secondary-sub-text']);
|
||||
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: 'stale cached subtitle',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'secondary-sub-text') {
|
||||
return '';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
@@ -129,19 +129,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function getCurrentSecondarySubTextForSentenceCard(
|
||||
mpvClient: MpvClientLike,
|
||||
): Promise<string | undefined> {
|
||||
const primaryText = mpvClient.currentSubText;
|
||||
if (mpvClient.requestProperty) {
|
||||
try {
|
||||
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
|
||||
return normalizeSecondarySubText(latestSecondaryText, primaryText);
|
||||
} catch {
|
||||
// Fall back to the cached secondary subtitle below.
|
||||
}
|
||||
}
|
||||
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText);
|
||||
function getCurrentSecondarySubTextForSentenceCard(mpvClient: MpvClientLike): string | undefined {
|
||||
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, mpvClient.currentSubText);
|
||||
}
|
||||
|
||||
export async function updateLastCardFromClipboard(deps: {
|
||||
@@ -190,7 +179,7 @@ export async function mineSentenceCard(deps: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient);
|
||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient);
|
||||
return await anki.createSentenceCard(
|
||||
mpvClient.currentSubText,
|
||||
mpvClient.currentSubStart,
|
||||
|
||||
@@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'secondary-sub-visibility',
|
||||
'sub-visibility',
|
||||
'sid',
|
||||
'secondary-sid',
|
||||
'secondary-sub-delay',
|
||||
'track-list',
|
||||
];
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
emitSubtitleTiming: (payload) => state.events.push(payload),
|
||||
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
|
||||
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||
emitSecondarySubtitleTrackChange: (payload) => state.events.push(payload),
|
||||
emitSecondarySubtitleDelayChange: (payload) => state.events.push(payload),
|
||||
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
|
||||
getCurrentSubText: () => state.subText,
|
||||
setCurrentSubText: (text) => {
|
||||
@@ -158,12 +160,42 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: '4' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sub-delay', data: '0.5' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
|
||||
assert.deepEqual(state.events, [
|
||||
{ sid: 3 },
|
||||
{ sid: 4 },
|
||||
{ delay: 0.5 },
|
||||
{ trackList: [{ type: 'sub', id: 3 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '4.5' }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: '4.5' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: 4.5 }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: 4.5 },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
emitSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
emitSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
emitSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
|
||||
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
getCurrentSubText: () => string;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
@@ -281,7 +283,25 @@ export async function dispatchMpvProtocolMessage(
|
||||
: typeof msg.data === 'string'
|
||||
? Number(msg.data)
|
||||
: null;
|
||||
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
|
||||
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
|
||||
} else if (msg.name === 'secondary-sid') {
|
||||
const sid =
|
||||
typeof msg.data === 'number'
|
||||
? msg.data
|
||||
: typeof msg.data === 'string'
|
||||
? Number(msg.data)
|
||||
: null;
|
||||
deps.emitSecondarySubtitleTrackChange({
|
||||
sid: sid !== null && Number.isInteger(sid) ? sid : null,
|
||||
});
|
||||
} else if (msg.name === 'secondary-sub-delay') {
|
||||
const delay =
|
||||
typeof msg.data === 'number'
|
||||
? msg.data
|
||||
: typeof msg.data === 'string'
|
||||
? Number(msg.data)
|
||||
: 0;
|
||||
deps.emitSecondarySubtitleDelayChange({ delay: Number.isFinite(delay) ? delay : 0 });
|
||||
} else if (msg.name === 'track-list') {
|
||||
deps.emitSubtitleTrackListChange({
|
||||
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
|
||||
|
||||
@@ -38,7 +38,15 @@ class ManualCloseSocket extends FakeSocket {
|
||||
}
|
||||
}
|
||||
|
||||
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
class HangingSocket extends FakeSocket {
|
||||
override connect(path: string): void {
|
||||
this.connectedPaths.push(path);
|
||||
// Never emits 'connect', 'error', or 'close' on its own: models a named
|
||||
// pipe dial that stalls indefinitely.
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
||||
@@ -232,6 +240,75 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('MpvSocketTransport aborts a hung connect after the timeout and allows a fresh dial', async () => {
|
||||
const events: string[] = [];
|
||||
const errors: Error[] = [];
|
||||
const sockets: HangingSocket[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
connectTimeoutMs: 5,
|
||||
onConnect: () => {
|
||||
events.push('connect');
|
||||
},
|
||||
onData: () => {},
|
||||
onError: (error) => {
|
||||
events.push('error');
|
||||
errors.push(error);
|
||||
},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
socketFactory: () => {
|
||||
const socket = new HangingSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as net.Socket;
|
||||
},
|
||||
});
|
||||
|
||||
transport.connect();
|
||||
assert.equal(transport.isConnecting, true);
|
||||
|
||||
await wait(20);
|
||||
|
||||
assert.deepEqual(events, ['error', 'close']);
|
||||
assert.match(errors[0]!.message, /connect timed out/);
|
||||
assert.equal(sockets[0]!.destroyed, true);
|
||||
assert.equal(transport.isConnecting, false);
|
||||
assert.equal(transport.isConnected, false);
|
||||
|
||||
transport.connect();
|
||||
assert.equal(transport.isConnecting, true);
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
|
||||
|
||||
transport.shutdown();
|
||||
});
|
||||
|
||||
test('MpvSocketTransport does not fire the connect timeout after a successful connect', async () => {
|
||||
const events: string[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
connectTimeoutMs: 5,
|
||||
onConnect: () => {
|
||||
events.push('connect');
|
||||
},
|
||||
onData: () => {},
|
||||
onError: () => {
|
||||
events.push('error');
|
||||
},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
socketFactory: () => new FakeSocket() as unknown as net.Socket,
|
||||
});
|
||||
|
||||
transport.connect();
|
||||
await wait(20);
|
||||
|
||||
assert.deepEqual(events, ['connect']);
|
||||
assert.equal(transport.isConnected, true);
|
||||
});
|
||||
|
||||
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
|
||||
const events: string[] = [];
|
||||
const sockets: ManualCloseSocket[] = [];
|
||||
|
||||
@@ -62,6 +62,8 @@ interface MpvSocketTransportEvents {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MPV_CONNECT_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface MpvSocketTransportOptions {
|
||||
socketPath: string;
|
||||
onConnect: () => void;
|
||||
@@ -69,13 +71,16 @@ export interface MpvSocketTransportOptions {
|
||||
onError: (error: Error) => void;
|
||||
onClose: () => void;
|
||||
socketFactory?: () => net.Socket;
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class MpvSocketTransport {
|
||||
private socketPath: string;
|
||||
private readonly callbacks: MpvSocketTransportEvents;
|
||||
private readonly socketFactory: () => net.Socket;
|
||||
private readonly connectTimeoutMs: number;
|
||||
private socketRef: net.Socket | null = null;
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
public socket: net.Socket | null = null;
|
||||
public connected = false;
|
||||
public connecting = false;
|
||||
@@ -83,6 +88,7 @@ export class MpvSocketTransport {
|
||||
constructor(options: MpvSocketTransportOptions) {
|
||||
this.socketPath = options.socketPath;
|
||||
this.socketFactory = options.socketFactory ?? (() => new net.Socket());
|
||||
this.connectTimeoutMs = options.connectTimeoutMs ?? MPV_CONNECT_TIMEOUT_MS;
|
||||
this.callbacks = {
|
||||
onConnect: options.onConnect,
|
||||
onData: options.onData,
|
||||
@@ -91,6 +97,31 @@ export class MpvSocketTransport {
|
||||
};
|
||||
}
|
||||
|
||||
private clearConnectTimeout(): void {
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A named-pipe/socket dial that neither connects nor errors would otherwise
|
||||
// latch `connecting` forever and silently block every future connect().
|
||||
private armConnectTimeout(socket: net.Socket): void {
|
||||
this.clearConnectTimeout();
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null;
|
||||
if (this.socketRef !== socket || this.connected) return;
|
||||
this.connecting = false;
|
||||
this.callbacks.onError(
|
||||
new Error(`MPV IPC connect timed out after ${this.connectTimeoutMs}ms: ${this.socketPath}`),
|
||||
);
|
||||
// Destroying the socket emits 'close', which drives the normal
|
||||
// disconnect path (including reconnect scheduling) upstream.
|
||||
socket.destroy();
|
||||
}, this.connectTimeoutMs);
|
||||
this.connectTimer.unref?.();
|
||||
}
|
||||
|
||||
setSocketPath(socketPath: string): void {
|
||||
this.socketPath = socketPath;
|
||||
}
|
||||
@@ -111,6 +142,7 @@ export class MpvSocketTransport {
|
||||
|
||||
socket.on('connect', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.clearConnectTimeout();
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.callbacks.onConnect();
|
||||
@@ -123,6 +155,7 @@ export class MpvSocketTransport {
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.clearConnectTimeout();
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onError(error);
|
||||
@@ -130,12 +163,14 @@ export class MpvSocketTransport {
|
||||
|
||||
socket.on('close', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.clearConnectTimeout();
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onClose();
|
||||
});
|
||||
|
||||
socket.connect(this.socketPath);
|
||||
this.armConnectTimeout(socket);
|
||||
}
|
||||
|
||||
send(payload: MpvSocketMessagePayload): boolean {
|
||||
@@ -149,6 +184,7 @@ export class MpvSocketTransport {
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.clearConnectTimeout();
|
||||
const socket = this.socketRef;
|
||||
this.socketRef = null;
|
||||
this.socket = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
MpvIpcClient,
|
||||
MpvIpcClientDeps,
|
||||
@@ -23,6 +24,18 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
|
||||
};
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Timed out waiting for MPV retry connection');
|
||||
}
|
||||
await wait(10);
|
||||
}
|
||||
}
|
||||
|
||||
function captureWarnLogs(run: () => void): string[] {
|
||||
const originalWarn = console.warn;
|
||||
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
@@ -756,3 +769,117 @@ test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playi
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, true);
|
||||
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
|
||||
});
|
||||
|
||||
class HangingTestSocket extends EventEmitter {
|
||||
public connectedPaths: string[] = [];
|
||||
public destroyed = false;
|
||||
|
||||
connect(path: string): void {
|
||||
this.connectedPaths.push(path);
|
||||
// Never resolves: models a stalled named-pipe dial.
|
||||
}
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RetryTestSocket extends EventEmitter {
|
||||
public connectedPaths: string[] = [];
|
||||
public destroyed = false;
|
||||
|
||||
constructor(private readonly shouldConnect: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(path: string): void {
|
||||
this.connectedPaths.push(path);
|
||||
if (this.shouldConnect) {
|
||||
setTimeout(() => this.emit('connect'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
test('MpvIpcClient automatically retries the same socket path after a connect timeout', async () => {
|
||||
const sockets: RetryTestSocket[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
connectTimeoutMs: 5,
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
setReconnectTimer: (timer) => {
|
||||
reconnectTimer = timer;
|
||||
},
|
||||
socketFactory: () => {
|
||||
const socket = new RetryTestSocket(sockets.length > 0);
|
||||
sockets.push(socket);
|
||||
return socket as unknown as import('node:net').Socket;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.SUBMINER_LOG_LEVEL = 'error';
|
||||
try {
|
||||
client.connect();
|
||||
await waitFor(() => client.connected);
|
||||
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[0]!.destroyed, true);
|
||||
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv.sock');
|
||||
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
|
||||
assert.equal(client.connected, true);
|
||||
} finally {
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env.SUBMINER_LOG_LEVEL;
|
||||
} else {
|
||||
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
(client as any).transport.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test('MpvIpcClient.setSocketPath aborts an in-flight connect so the next dial targets the new path', () => {
|
||||
const sockets: HangingTestSocket[] = [];
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv-old.sock',
|
||||
makeDeps({
|
||||
socketFactory: () => {
|
||||
const socket = new HangingTestSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as import('node:net').Socket;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
client.connect();
|
||||
assert.equal(sockets.length, 1);
|
||||
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv-old.sock');
|
||||
assert.equal((client as any).connecting, true);
|
||||
|
||||
client.setSocketPath('/tmp/mpv-new.sock');
|
||||
assert.equal((client as any).connecting, false);
|
||||
assert.equal(sockets[0]!.destroyed, true);
|
||||
|
||||
client.connect();
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv-new.sock');
|
||||
|
||||
(client as any).transport.shutdown();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
splitMpvMessagesFromBuffer,
|
||||
} from './mpv-protocol';
|
||||
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
|
||||
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
|
||||
import {
|
||||
scheduleMpvReconnect,
|
||||
MpvSocketTransport,
|
||||
MpvSocketTransportOptions,
|
||||
} from './mpv-transport';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:mpv');
|
||||
@@ -110,6 +114,8 @@ export interface MpvIpcClientProtocolDeps {
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
socketFactory?: MpvSocketTransportOptions['socketFactory'];
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||
@@ -125,6 +131,8 @@ export interface MpvIpcClientEventMap {
|
||||
'fullscreen-change': { fullscreen: boolean };
|
||||
'secondary-subtitle-change': { text: string };
|
||||
'subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-delay-change': { delay: number };
|
||||
'subtitle-track-list-change': { trackList: unknown[] | null };
|
||||
'media-path-change': { path: string };
|
||||
'media-title-change': { title: string | null };
|
||||
@@ -188,6 +196,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
|
||||
this.transport = new MpvSocketTransport({
|
||||
socketPath,
|
||||
socketFactory: deps.socketFactory,
|
||||
connectTimeoutMs: deps.connectTimeoutMs,
|
||||
onConnect: () => {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
@@ -289,6 +299,14 @@ export class MpvIpcClient implements MpvClient {
|
||||
previousSocketPath: this.socketPath,
|
||||
socketPath,
|
||||
});
|
||||
if (this.connecting && !this.connected) {
|
||||
// Abort the in-flight dial to the old path; otherwise the connecting
|
||||
// latch turns every later connect() into a no-op while we hang on a
|
||||
// stale socket.
|
||||
logger.debug('Aborting in-flight MPV IPC connect for socket path change.');
|
||||
this.transport.shutdown();
|
||||
this.connecting = false;
|
||||
}
|
||||
}
|
||||
this.socketPath = socketPath;
|
||||
this.transport.setSocketPath(socketPath);
|
||||
@@ -422,6 +440,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitSubtitleTrackChange: (payload) => {
|
||||
this.emit('subtitle-track-change', payload);
|
||||
},
|
||||
emitSecondarySubtitleTrackChange: (payload) => {
|
||||
this.emit('secondary-subtitle-track-change', payload);
|
||||
},
|
||||
emitSecondarySubtitleDelayChange: (payload) => {
|
||||
this.emit('secondary-subtitle-delay-change', payload);
|
||||
},
|
||||
emitSubtitleTrackListChange: (payload) => {
|
||||
this.emit('subtitle-track-list-change', payload);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
|
||||
test('applyOverlayClickThrough requests forwarding only off Windows', () => {
|
||||
const calls: Array<{ ignore: boolean; forward: boolean }> = [];
|
||||
const window = {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
|
||||
calls.push({ ignore, forward: options?.forward === true });
|
||||
},
|
||||
};
|
||||
|
||||
applyOverlayClickThrough(window, true);
|
||||
applyOverlayClickThrough(window, false);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ ignore: true, forward: false },
|
||||
{ ignore: true, forward: true },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
type ClickThroughWindow = {
|
||||
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Puts an overlay window into click-through mode. Forwarded mouse-move ({ forward: true }) is
|
||||
* what lets renderer hover tracking wake a click-through overlay, but on Windows Electron
|
||||
* implements it with a global WH_MOUSE_LL hook whose callback runs on the main-process message
|
||||
* loop, so any main-thread stall delays mouse input system-wide (electron/electron#10183).
|
||||
* Windows instead wakes the overlay via the main-process cursor poll
|
||||
* (tickWindowsOverlayPointerInteraction), so no forwarding is requested there. macOS still
|
||||
* needs forwarding for renderer hover tracking; Linux ignores the flag entirely
|
||||
* (electron/electron#16777).
|
||||
*
|
||||
* Pass isWindowsPlatform when the caller already carries a platform flag (tests simulate
|
||||
* platforms through it); otherwise the real process.platform decides.
|
||||
*/
|
||||
export function applyOverlayClickThrough(
|
||||
window: ClickThroughWindow,
|
||||
isWindowsPlatform?: boolean,
|
||||
): void {
|
||||
if (isWindowsPlatform ?? process.platform === 'win32') {
|
||||
window.setIgnoreMouseEvents(true);
|
||||
} else {
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
@@ -848,7 +848,7 @@ test('Windows visible overlay stays click-through and binds to mpv while tracked
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('opacity:0'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
@@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => {
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
@@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => {
|
||||
forceMousePassthrough: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
@@ -1339,7 +1339,7 @@ test('tracked Windows overlay rebinds without hiding when tracker focus changes'
|
||||
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
@@ -1489,7 +1489,7 @@ test('tracked Windows overlay reshows click-through even if focus state is stale
|
||||
isWindowsPlatform: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
@@ -1532,7 +1532,7 @@ test('tracked Windows overlay binds above mpv even when tracker focus lags', ()
|
||||
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
});
|
||||
@@ -2193,7 +2193,7 @@ test('Windows preserves visible overlay and rebinds to mpv while tracker transie
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('move-top'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:plain'));
|
||||
assert.ok(calls.includes('sync-windows-z-order'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { BaseWindowTracker } from '../../window-trackers';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
|
||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||
@@ -117,7 +118,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
clearPendingWindowsOverlayReveal(mainWindow);
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -215,7 +216,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||
if (shouldIgnoreMouseEvents) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
}
|
||||
@@ -263,7 +264,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (hasNonNativeInputRegion) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
}
|
||||
if (args.isWindowsPlatform) {
|
||||
scheduleWindowsOverlayReveal(
|
||||
@@ -424,7 +425,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||
releaseOverlayWindowLevel(mainWindow);
|
||||
mainWindow.hide();
|
||||
args.syncOverlayShortcuts();
|
||||
|
||||
+37
-2
@@ -331,6 +331,7 @@ import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from './core/services/youtube/generate';
|
||||
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
|
||||
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
@@ -526,6 +527,7 @@ import {
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from './main/runtime/subtitle-prefetch-runtime';
|
||||
import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track';
|
||||
import {
|
||||
createCreateAnilistSetupWindowHandler,
|
||||
createCreateConfigSettingsWindowHandler,
|
||||
@@ -1942,7 +1944,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
emitSecondarySubtitle: (text) => {
|
||||
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
secondarySubtitleTrackController.handleLiveText(text);
|
||||
},
|
||||
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
|
||||
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
|
||||
@@ -2000,6 +2002,24 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
const secondarySubtitleTrackController = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos,
|
||||
resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
loadSubtitleSourceText,
|
||||
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
|
||||
setCurrentSecondaryText: (text) => {
|
||||
if (appState.mpvClient) {
|
||||
appState.mpvClient.currentSecondarySubText = text;
|
||||
}
|
||||
},
|
||||
broadcastSecondaryText: (text) => {
|
||||
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
},
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
@@ -4382,6 +4402,7 @@ const {
|
||||
onMpvConnected: () => {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
flushQueuedMpvOsdNotifications();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -4400,6 +4421,9 @@ const {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
overlayManager.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
onSecondarySubtitleChange: (text) => {
|
||||
secondarySubtitleTrackController.handleLiveText(text);
|
||||
},
|
||||
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||
emitImmediateSubtitle: (payload) => {
|
||||
emitSubtitlePayload(payload);
|
||||
@@ -4433,6 +4457,7 @@ const {
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
);
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
secondarySubtitleTrackController.reset();
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||
const frequencyOptions = {
|
||||
@@ -4467,6 +4492,7 @@ const {
|
||||
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
secondarySubtitleTrackController.scheduleRefresh();
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
// Retry after a short delay because MPV can populate track-list after path.
|
||||
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
|
||||
@@ -4521,6 +4547,7 @@ const {
|
||||
subtitlePrefetchService.onSeek(time);
|
||||
}
|
||||
lastObservedTimePos = time;
|
||||
secondarySubtitleTrackController.handleTimePos(time);
|
||||
},
|
||||
onFullscreenChange: (fullscreen) => {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
@@ -4548,6 +4575,13 @@ const {
|
||||
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
onSecondarySubtitleTrackChange: () => {
|
||||
secondarySubtitleTrackController.handleTrackChange();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
},
|
||||
onSecondarySubtitleDelayChange: (delay) => {
|
||||
secondarySubtitleTrackController.handleDelayChange(delay);
|
||||
},
|
||||
onSubtitleTrackListChange: (trackList) => {
|
||||
const diagnostics = buildSubtitleTrackDiagnostics(
|
||||
lastObservedPrimarySubtitleTrackId,
|
||||
@@ -4561,6 +4595,7 @@ const {
|
||||
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
|
||||
}
|
||||
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
@@ -5469,7 +5504,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
senderWindow === modalWindow &&
|
||||
!senderWindow.isDestroyed()
|
||||
) {
|
||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
senderWindow.hide();
|
||||
}
|
||||
handleOverlayModalClosedHandler(modal);
|
||||
|
||||
@@ -1010,44 +1010,85 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
});
|
||||
|
||||
for (const platform of ['darwin', 'win32'] as const) {
|
||||
test(`modal reopen reuses the warm window and shows it immediately on ${platform}`, () => {
|
||||
const modalWindow = createMockWindow();
|
||||
let createCalls = 0;
|
||||
test('modal reopen reuses the warm window and shows it immediately on macOS', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
let createCalls = 0;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
return modalWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
return modalWindow as never;
|
||||
},
|
||||
{ platform },
|
||||
);
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(modalWindow.isDestroyed(), false);
|
||||
assert.equal(modalWindow.isVisible(), false);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(createCalls, 0);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(modalWindow.getShowCount(), 2);
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
}
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(modalWindow.isDestroyed(), false);
|
||||
assert.equal(modalWindow.isVisible(), false);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(createCalls, 0);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(modalWindow.getShowCount(), 2);
|
||||
});
|
||||
|
||||
test('modal reopen on Windows uses a fresh prewarmed interactive window', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const replacementWindow = createMockWindow();
|
||||
let currentModal = firstWindow;
|
||||
let createCalls = 0;
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
currentModal = replacementWindow;
|
||||
return replacementWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'win32' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
assert.equal(currentModal, replacementWindow);
|
||||
assert.equal(replacementWindow.isVisible(), false);
|
||||
assert.equal(createCalls, 1);
|
||||
|
||||
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(replacementWindow.isVisible(), true);
|
||||
assert.equal(replacementWindow.ignoreMouseEvents, false);
|
||||
assert.deepEqual(replacementWindow.sent, [['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen on the warm window notifies state change for each lifecycle', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron';
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import type { WindowGeometry } from '../types';
|
||||
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
||||
import { applyOverlayClickThrough } from '../core/services/overlay-click-through';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
@@ -87,7 +88,8 @@ export function createOverlayModalRuntimeService(
|
||||
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
||||
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const keepModalWindowWarm = platform === 'darwin' || platform === 'win32';
|
||||
const shouldPrimeModalWindow = platform === 'darwin' || platform === 'win32';
|
||||
const reuseModalWindowAfterClose = platform === 'darwin';
|
||||
const focusApplication = options.focusApplication ?? requestOverlayApplicationFocus;
|
||||
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||
@@ -181,7 +183,7 @@ export function createOverlayModalRuntimeService(
|
||||
};
|
||||
|
||||
const primeModalWindow = (): boolean => {
|
||||
if (!keepModalWindowWarm) {
|
||||
if (!shouldPrimeModalWindow) {
|
||||
return false;
|
||||
}
|
||||
const modalWindow = resolveModalWindow();
|
||||
@@ -298,7 +300,7 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
elevateModalWindow(window);
|
||||
if (options.passThroughMouseEvents) {
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(window, platform === 'win32');
|
||||
} else {
|
||||
window.setIgnoreMouseEvents(false);
|
||||
}
|
||||
@@ -359,7 +361,7 @@ export function createOverlayModalRuntimeService(
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
return;
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, platform === 'win32');
|
||||
mainWindowMousePassthroughForcedByModal = true;
|
||||
return;
|
||||
}
|
||||
@@ -515,13 +517,19 @@ export function createOverlayModalRuntimeService(
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
if (keepModalWindowWarm) {
|
||||
modalWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
if (reuseModalWindowAfterClose) {
|
||||
applyOverlayClickThrough(modalWindow, false);
|
||||
modalWindow.hide();
|
||||
markModalWindowPrimed(modalWindow);
|
||||
} else {
|
||||
modalWindow.destroy();
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
// Reusing a transparent click-through BrowserWindow can leave later modal sessions
|
||||
// non-interactive on Windows. Recycle the renderer after every close, then warm its
|
||||
// replacement so the next shortcut still opens promptly.
|
||||
if (platform === 'win32') {
|
||||
primeModalWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
|
||||
@@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTrackChange: () => {},
|
||||
onSecondarySubtitleTrackChange: () => {},
|
||||
onSecondarySubtitleDelayChange: () => {},
|
||||
onSubtitleTrackListChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
@@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'secondary-subtitle-track-change',
|
||||
'secondary-subtitle-delay-change',
|
||||
'subtitle-track-list-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
|
||||
@@ -4,6 +4,8 @@ type MpvBindingEventName =
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-track-change'
|
||||
| 'secondary-subtitle-track-change'
|
||||
| 'secondary-subtitle-delay-change'
|
||||
| 'subtitle-track-list-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
@@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
|
||||
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string | null }) => void;
|
||||
@@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
|
||||
mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange);
|
||||
mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange);
|
||||
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
|
||||
@@ -37,6 +37,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
|
||||
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
|
||||
onSecondarySubtitleDelayChange: (delay) =>
|
||||
calls.push(`secondary-subtitle-delay-change:${delay}`),
|
||||
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
@@ -73,6 +76,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
handlers.get('connection-change')?.({ connected: true });
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
|
||||
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
|
||||
handlers.get('media-path-change')?.({ path: '' });
|
||||
@@ -86,6 +91,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.equal(calls.includes('broadcast-sub:line'), true);
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('secondary-subtitle-track-change'));
|
||||
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
|
||||
|
||||
@@ -54,6 +54,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
@@ -189,6 +191,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
|
||||
onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid),
|
||||
onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay),
|
||||
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
|
||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||
onMediaPathChange: handleMpvMediaPathChange,
|
||||
|
||||
@@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
calls.push(`broadcast:${channel}:${String(payload)}`),
|
||||
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
|
||||
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
|
||||
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
@@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.setCurrentSubAssText('ass');
|
||||
deps.broadcastSubtitleAss('ass');
|
||||
deps.broadcastSecondarySubtitle('sec');
|
||||
deps.onSecondarySubtitleTrackChange?.(4);
|
||||
deps.onSecondarySubtitleDelayChange?.(0.5);
|
||||
deps.updateCurrentMediaPath('/tmp/video');
|
||||
deps.restoreMpvSubVisibility();
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
@@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
||||
assert.ok(calls.includes('anilist-post-watch'));
|
||||
assert.ok(calls.includes('timing:y:secondary'));
|
||||
assert.ok(calls.includes('secondary:sec'));
|
||||
assert.ok(calls.includes('secondary-track:4'));
|
||||
assert.ok(calls.includes('secondary-delay:0.5'));
|
||||
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
|
||||
assert.ok(calls.includes('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
|
||||
@@ -53,11 +53,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
onSecondarySubtitleChange?: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
logSubtitleProcessingDebug?: (message: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
@@ -173,6 +176,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
immersionLineDedupGate.reset();
|
||||
deps.onSubtitleTrackChange?.(sid);
|
||||
},
|
||||
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
|
||||
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
|
||||
: undefined,
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||
: undefined,
|
||||
@@ -182,8 +191,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
broadcastSubtitleAss: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) => {
|
||||
if (deps.onSecondarySubtitleChange) {
|
||||
deps.onSecondarySubtitleChange(text);
|
||||
return;
|
||||
}
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
},
|
||||
updateCurrentMediaPath: (path: string) => {
|
||||
immersionLineDedupGate.reset();
|
||||
deps.updateCurrentMediaPath(path);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import {
|
||||
createSecondarySubtitleTrackController,
|
||||
findActiveSubtitleText,
|
||||
} from './secondary-subtitle-track';
|
||||
|
||||
test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
|
||||
assert.equal(
|
||||
findActiveSubtitleText(
|
||||
[
|
||||
{ startTime: 1, endTime: 3, text: 'Your' },
|
||||
{ startTime: 1, endTime: 3, text: 'Your' },
|
||||
{ startTime: 1, endTime: 3, text: 'mosaic' },
|
||||
],
|
||||
2,
|
||||
),
|
||||
'Your\nmosaic',
|
||||
);
|
||||
});
|
||||
|
||||
test('secondary track controller parses the selected ASS file before publishing', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = [];
|
||||
const ass = `[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async (input) => {
|
||||
resolverInputs.push(input);
|
||||
return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' };
|
||||
},
|
||||
loadSubtitleSourceText: async () => ass,
|
||||
parseSubtitleCues,
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic');
|
||||
|
||||
assert.equal(resolverInputs[0]?.allowSelectedFallback, false);
|
||||
assert.equal(currentText, 'Your\nmosaic');
|
||||
assert.deepEqual(broadcasts, ['Your\nmosaic']);
|
||||
});
|
||||
|
||||
test('secondary track controller follows parsed cue timing and subtitle delay', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let time = 2.25;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0.5;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => time,
|
||||
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 2, endTime: 3, text: 'second' },
|
||||
],
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleDelayChange(0);
|
||||
time = 3.25;
|
||||
controller.handleTimePos(time);
|
||||
|
||||
assert.deepEqual(broadcasts, ['first', 'second', '']);
|
||||
});
|
||||
|
||||
test('secondary track controller clears old parsed text immediately on a track change', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }),
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }],
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleTrackChange();
|
||||
controller.handleLiveText('new live text');
|
||||
|
||||
assert.equal(currentText, 'new live text');
|
||||
assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']);
|
||||
});
|
||||
|
||||
test('secondary track controller falls back to live mpv text without a readable source', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 'no';
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => null,
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [],
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
controller.handleLiveText('live fallback');
|
||||
await controller.refresh();
|
||||
|
||||
assert.equal(currentText, 'live fallback');
|
||||
assert.deepEqual(broadcasts, ['live fallback']);
|
||||
});
|
||||
|
||||
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
|
||||
let resolveCalls = 0;
|
||||
let parseCalls = 0;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }];
|
||||
}
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
resolveCalls += 1;
|
||||
return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' };
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => {
|
||||
parseCalls += 1;
|
||||
return [{ startTime: 1, endTime: 3, text: 'parsed' }];
|
||||
},
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: () => {},
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
await controller.refresh();
|
||||
|
||||
assert.equal(resolveCalls, 1);
|
||||
assert.equal(parseCalls, 1);
|
||||
});
|
||||
|
||||
test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let notifyResolveStarted: (() => void) | undefined;
|
||||
let releaseResolve: (() => void) | undefined;
|
||||
let cleanupCalls = 0;
|
||||
let parseCalls = 0;
|
||||
const resolveStarted = new Promise<void>((resolve) => {
|
||||
notifyResolveStarted = resolve;
|
||||
});
|
||||
const resolveGate = new Promise<void>((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
notifyResolveStarted?.();
|
||||
await resolveGate;
|
||||
return {
|
||||
path: '/subs/secondary.ass',
|
||||
sourceKey: 'secondary',
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => {
|
||||
parseCalls += 1;
|
||||
return [{ startTime: 1, endTime: 3, text: 'stale' }];
|
||||
},
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
const refresh = controller.refresh();
|
||||
await resolveStarted;
|
||||
controller.reset();
|
||||
releaseResolve?.();
|
||||
await refresh;
|
||||
|
||||
assert.deepEqual(broadcasts, ['']);
|
||||
assert.equal(parseCalls, 0);
|
||||
assert.equal(cleanupCalls, 1);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { SubtitleCue } from '../../types/subtitle';
|
||||
|
||||
type SecondarySubtitleMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type ResolvedSubtitleSource = {
|
||||
path: string;
|
||||
sourceKey: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SecondarySubtitleSourceInput = {
|
||||
currentExternalFilenameRaw: unknown;
|
||||
currentTrackRaw: unknown;
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
allowSelectedFallback?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_REFRESH_DELAY_MS = 500;
|
||||
|
||||
function finiteNumber(value: unknown, fallback = 0): number {
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function trackId(value: unknown): number | null {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
const number = typeof value === 'number' ? value : Number(value.trim());
|
||||
return Number.isInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
function buildSelectedTrackIdentity(
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
videoPath: string,
|
||||
): string | null {
|
||||
if (!Array.isArray(trackListRaw)) return null;
|
||||
const sid = trackId(sidRaw);
|
||||
if (sid === null) return null;
|
||||
|
||||
const selectedTrack = trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const track = entry as Record<string, unknown>;
|
||||
return track.type === 'sub' && trackId(track.id) === sid;
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (!selectedTrack) return null;
|
||||
|
||||
return JSON.stringify([
|
||||
videoPath,
|
||||
sid,
|
||||
selectedTrack.external === true,
|
||||
selectedTrack['external-filename'] ?? null,
|
||||
trackId(selectedTrack['ff-index']),
|
||||
]);
|
||||
}
|
||||
|
||||
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
|
||||
if (!Number.isFinite(timeSeconds)) return '';
|
||||
|
||||
const seen = new Set<string>();
|
||||
const activeText: string[] = [];
|
||||
for (const cue of cues) {
|
||||
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
|
||||
const text = cue.text.trim();
|
||||
if (!text || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
activeText.push(text);
|
||||
}
|
||||
return activeText.join('\n');
|
||||
}
|
||||
|
||||
export function createSecondarySubtitleTrackController(deps: {
|
||||
getMpvClient: () => SecondarySubtitleMpvClient | null;
|
||||
getCurrentTimePos: () => number;
|
||||
resolveSubtitleSource: (
|
||||
input: SecondarySubtitleSourceInput,
|
||||
) => Promise<ResolvedSubtitleSource | null>;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
setCurrentSecondaryText: (text: string) => void;
|
||||
broadcastSecondaryText: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
let parsedCues: SubtitleCue[] | null = null;
|
||||
let parsedSourceKey: string | null = null;
|
||||
let parsedTrackIdentity: string | null = null;
|
||||
let secondaryDelaySeconds = 0;
|
||||
let lastLiveText = '';
|
||||
let lastBroadcastText: string | null = null;
|
||||
let refreshGeneration = 0;
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const publish = (text: string): void => {
|
||||
deps.setCurrentSecondaryText(text);
|
||||
if (text === lastBroadcastText) return;
|
||||
lastBroadcastText = text;
|
||||
deps.broadcastSecondaryText(text);
|
||||
};
|
||||
|
||||
const resolveAtTime = (timeSeconds: number): string => {
|
||||
if (!parsedCues) return lastLiveText;
|
||||
return findActiveSubtitleText(parsedCues, timeSeconds - secondaryDelaySeconds);
|
||||
};
|
||||
|
||||
const useLiveFallback = (): void => {
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
publish(lastLiveText);
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const generation = ++refreshGeneration;
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedSource: ResolvedSubtitleSource | null = null;
|
||||
try {
|
||||
const [secondarySid, trackList, videoPathRaw, secondaryDelayRaw] = await Promise.all([
|
||||
client.requestProperty('secondary-sid').catch(() => null),
|
||||
client.requestProperty('track-list').catch(() => null),
|
||||
client.requestProperty('path').catch(() => null),
|
||||
client.requestProperty('secondary-sub-delay').catch(() => 0),
|
||||
]);
|
||||
if (generation !== refreshGeneration) return;
|
||||
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
|
||||
if (!videoPath || secondarySid === null || secondarySid === 'no') {
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
secondaryDelaySeconds = finiteNumber(secondaryDelayRaw);
|
||||
const selectedTrackIdentity = buildSelectedTrackIdentity(trackList, secondarySid, videoPath);
|
||||
if (selectedTrackIdentity && selectedTrackIdentity === parsedTrackIdentity && parsedCues) {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedSource = await deps.resolveSubtitleSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: trackList,
|
||||
sidRaw: secondarySid,
|
||||
videoPath,
|
||||
allowSelectedFallback: false,
|
||||
});
|
||||
if (generation !== refreshGeneration) return;
|
||||
if (!resolvedSource) {
|
||||
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
|
||||
parsedTrackIdentity = selectedTrackIdentity;
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await deps.loadSubtitleSourceText(resolvedSource.path);
|
||||
const cues = deps.parseSubtitleCues(content, resolvedSource.path);
|
||||
if (generation !== refreshGeneration) return;
|
||||
if (cues.length === 0) {
|
||||
deps.logDebug?.('[secondary-subtitle-track] selected source contained no parsed cues');
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
parsedCues = cues;
|
||||
parsedSourceKey = resolvedSource.sourceKey;
|
||||
parsedTrackIdentity = selectedTrackIdentity;
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
} catch (error) {
|
||||
if (generation !== refreshGeneration) return;
|
||||
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
|
||||
useLiveFallback();
|
||||
} finally {
|
||||
await resolvedSource?.cleanup?.().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = (delayMs = DEFAULT_REFRESH_DELAY_MS): void => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
void refresh();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const clearSelectedTrack = (): void => {
|
||||
refreshGeneration += 1;
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
secondaryDelaySeconds = 0;
|
||||
lastLiveText = '';
|
||||
publish('');
|
||||
};
|
||||
|
||||
return {
|
||||
refresh,
|
||||
scheduleRefresh,
|
||||
handleLiveText(text: string): void {
|
||||
lastLiveText = text;
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
},
|
||||
handleTimePos(timeSeconds: number): void {
|
||||
if (!parsedCues) return;
|
||||
publish(resolveAtTime(timeSeconds));
|
||||
},
|
||||
handleTrackChange(): void {
|
||||
clearSelectedTrack();
|
||||
},
|
||||
handleDelayChange(delaySeconds: number): void {
|
||||
secondaryDelaySeconds = finiteNumber(delaySeconds);
|
||||
if (parsedCues) {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
}
|
||||
},
|
||||
reset: clearSelectedTrack,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||
|
||||
type StatsOverlayVisibilityWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
@@ -8,7 +10,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v
|
||||
if (!window || window.isDestroyed() || !window.isVisible()) {
|
||||
return;
|
||||
}
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(window);
|
||||
}
|
||||
|
||||
export function createStatsOverlayVisibilityChangeHandler(deps: {
|
||||
|
||||
@@ -248,3 +248,31 @@ test('subtitle source resolver logs debug when no active subtitle track is selec
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
|
||||
});
|
||||
|
||||
test('subtitle source resolver does not fall back to the primary selected track for secondary', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
throw new Error('should not extract the primary track');
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/subs/primary.ass',
|
||||
},
|
||||
],
|
||||
sidRaw: null,
|
||||
videoPath: '/media/video.mkv',
|
||||
allowSelectedFallback: false,
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ function getActiveSubtitleTrack(
|
||||
currentTrackRaw: unknown,
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
allowSelectedFallback: boolean,
|
||||
): MpvSubtitleTrackLike | null {
|
||||
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
|
||||
const track = currentTrackRaw as MpvSubtitleTrackLike;
|
||||
@@ -68,6 +69,10 @@ function getActiveSubtitleTrack(
|
||||
return bySid;
|
||||
}
|
||||
|
||||
if (!allowSelectedFallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
@@ -94,6 +99,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
allowSelectedFallback?: boolean;
|
||||
}): Promise<ActiveSubtitleSidebarSource | null> => {
|
||||
const currentExternalFilename =
|
||||
typeof input.currentExternalFilenameRaw === 'string'
|
||||
@@ -103,7 +109,12 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
|
||||
}
|
||||
|
||||
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
||||
const track = getActiveSubtitleTrack(
|
||||
input.currentTrackRaw,
|
||||
input.trackListRaw,
|
||||
input.sidRaw,
|
||||
input.allowSelectedFallback !== false,
|
||||
);
|
||||
if (!track) {
|
||||
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
|
||||
return null;
|
||||
|
||||
@@ -31,6 +31,20 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command
|
||||
return { spawn, children, commands };
|
||||
}
|
||||
|
||||
async function waitForResult<T>(promise: Promise<T>, timeoutMs = 3000): Promise<T> {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error('Timed out waiting for result.')), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout !== null) clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
|
||||
const { spawn, children, commands } = makeSpawn();
|
||||
const events: SyncProgressEvent[] = [];
|
||||
@@ -96,7 +110,9 @@ test('runSyncLauncher settles after exit when close never arrives', async () =>
|
||||
// so `close` never fires.
|
||||
child.emit('exit', 1, null);
|
||||
|
||||
const result = await handle.done;
|
||||
// Keep the isolated Bun test process alive while the production drain timer
|
||||
// remains unref'ed, and fail instead of hanging if the result never settles.
|
||||
const result = await waitForResult(handle.done);
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? '', /remote refused/);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function runSyncLauncher(options: {
|
||||
spawn?: SyncLauncherSpawn;
|
||||
timeoutMs?: number;
|
||||
}): SyncLauncherRunHandle {
|
||||
const spawn =
|
||||
const spawn: SyncLauncherSpawn =
|
||||
options.spawn ??
|
||||
((command, args) => {
|
||||
// The child must boot as a full Electron app (its entry handles
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type BrowserWindow, screen } from 'electron';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||
@@ -603,7 +604,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
||||
if (active) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1434,6 +1434,18 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => {
|
||||
const dialogue = ['Wait', 'Wait', 'Wait'];
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => {
|
||||
const dialogue = ['Wait', 'Go!', 'No!', 'Run!'];
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => {
|
||||
const dialogue = ' I never expected this. \\N\\N But here we are. ';
|
||||
|
||||
|
||||
+118
-46
@@ -1,4 +1,4 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import koffi from 'koffi';
|
||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||
|
||||
@@ -173,43 +173,125 @@ function getProcessNameByPid(pid: number): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
const processCommandLineCache = new Map<number, string>();
|
||||
// Short-lived cache so the 250ms poll doesn't re-query every top-level window's process
|
||||
// on each pass. The TTL bounds staleness from PID reuse.
|
||||
const PROCESS_NAME_CACHE_TTL_MS = 5_000;
|
||||
const PROCESS_NAME_CACHE_PRUNE_THRESHOLD = 512;
|
||||
const processNameCache = new Map<number, { name: string | null; expiresAtMs: number }>();
|
||||
|
||||
function getCachedProcessNameByPid(pid: number): string | null {
|
||||
const nowMs = Date.now();
|
||||
const cached = processNameCache.get(pid);
|
||||
if (cached && cached.expiresAtMs > nowMs) {
|
||||
return cached.name;
|
||||
}
|
||||
const name = getProcessNameByPid(pid);
|
||||
processNameCache.set(pid, { name, expiresAtMs: nowMs + PROCESS_NAME_CACHE_TTL_MS });
|
||||
return name;
|
||||
}
|
||||
|
||||
function pruneExpiredProcessNames(nowMs: number): void {
|
||||
if (processNameCache.size <= PROCESS_NAME_CACHE_PRUNE_THRESHOLD) return;
|
||||
for (const [pid, entry] of processNameCache) {
|
||||
if (entry.expiresAtMs <= nowMs) {
|
||||
processNameCache.delete(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ProcessCommandLineCacheEntry =
|
||||
| { state: 'resolved'; commandLine: string; expiresAtMs: number; refreshInFlight: boolean }
|
||||
| { state: 'pending' }
|
||||
| { state: 'failed'; retryAtMs: number; backoffMs: number };
|
||||
|
||||
const COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS = 2_000;
|
||||
const COMMAND_LINE_RETRY_MAX_BACKOFF_MS = 30_000;
|
||||
// A process command line never changes, so a resolved entry only has to expire to survive
|
||||
// Windows PID reuse (a dead mpv's PID handed to a new instance, whose stale socket path would
|
||||
// otherwise match the wrong window forever). Longer than the process-name TTL because each
|
||||
// refresh costs a PowerShell spawn, and the cached value keeps being served while the refresh
|
||||
// runs, so expiry never interrupts window matching.
|
||||
const COMMAND_LINE_CACHE_TTL_MS = 60_000;
|
||||
const processCommandLineCache = new Map<number, ProcessCommandLineCacheEntry>();
|
||||
|
||||
function queryProcessCommandLine(
|
||||
pid: number,
|
||||
onResult: (commandLine: string | null) => void,
|
||||
): void {
|
||||
execFile(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 1500,
|
||||
},
|
||||
(error, stdout) => {
|
||||
const output = error ? '' : stdout.trim();
|
||||
onResult(output.length > 0 ? output : null);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Resolves a process command line via a background PowerShell lookup. Returns null until the
|
||||
// first lookup completes; the caller's next poll picks up the cached result. Failures are
|
||||
// negative-cached with exponential backoff: the synchronous version of this lookup could
|
||||
// block the main thread for its full 1.5s timeout on every 250ms poll, which (combined with
|
||||
// the forward:true mouse hook) stalled mouse input system-wide.
|
||||
function getProcessCommandLineByPid(pid: number): string | null {
|
||||
if (processCommandLineCache.has(pid)) {
|
||||
return processCommandLineCache.get(pid) ?? null;
|
||||
const entry = processCommandLineCache.get(pid);
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (entry?.state === 'resolved') {
|
||||
if (nowMs >= entry.expiresAtMs && !entry.refreshInFlight) {
|
||||
entry.refreshInFlight = true;
|
||||
queryProcessCommandLine(pid, (commandLine) => {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'resolved',
|
||||
// A failed refresh is usually a transient query error rather than a dead process
|
||||
// (a gone process owns no window, so it is never looked up again). Keep the last
|
||||
// known command line and re-check after the next TTL.
|
||||
commandLine: commandLine ?? entry.commandLine,
|
||||
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
|
||||
refreshInFlight: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
return entry.commandLine;
|
||||
}
|
||||
|
||||
let commandLine: string | null = null;
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-Command',
|
||||
`$process = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; if ($process -and $process.CommandLine) { [Console]::Out.Write($process.CommandLine) }`,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1500,
|
||||
},
|
||||
).trim();
|
||||
commandLine = output.length > 0 ? output : null;
|
||||
} catch {
|
||||
commandLine = null;
|
||||
}
|
||||
if (entry?.state === 'pending') return null;
|
||||
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null;
|
||||
|
||||
if (commandLine !== null) {
|
||||
processCommandLineCache.set(pid, commandLine);
|
||||
} else {
|
||||
processCommandLineCache.delete(pid);
|
||||
}
|
||||
return commandLine;
|
||||
const nextBackoffMs =
|
||||
entry?.state === 'failed'
|
||||
? Math.min(entry.backoffMs * 2, COMMAND_LINE_RETRY_MAX_BACKOFF_MS)
|
||||
: COMMAND_LINE_RETRY_INITIAL_BACKOFF_MS;
|
||||
processCommandLineCache.set(pid, { state: 'pending' });
|
||||
queryProcessCommandLine(pid, (commandLine) => {
|
||||
if (commandLine !== null) {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'resolved',
|
||||
commandLine,
|
||||
expiresAtMs: Date.now() + COMMAND_LINE_CACHE_TTL_MS,
|
||||
refreshInFlight: false,
|
||||
});
|
||||
} else {
|
||||
processCommandLineCache.set(pid, {
|
||||
state: 'failed',
|
||||
retryAtMs: Date.now() + nextBackoffMs,
|
||||
backoffMs: nextBackoffMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||
@@ -217,8 +299,7 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
const matches: MpvWindowMatch[] = [];
|
||||
let hasMinimized = false;
|
||||
let hasFocused = false;
|
||||
const processNameCache = new Map<number, string | null>();
|
||||
const processCommandLineLookupCache = new Map<number, string | null>();
|
||||
pruneExpiredProcessNames(Date.now());
|
||||
|
||||
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
||||
if (!IsWindowVisible(hwnd)) return true;
|
||||
@@ -228,21 +309,12 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
||||
const pidValue = pid[0]!;
|
||||
if (pidValue === 0) return true;
|
||||
|
||||
let processName = processNameCache.get(pidValue);
|
||||
if (processName === undefined) {
|
||||
processName = getProcessNameByPid(pidValue);
|
||||
processNameCache.set(pidValue, processName);
|
||||
}
|
||||
|
||||
const processName = getCachedProcessNameByPid(pidValue);
|
||||
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
||||
|
||||
let commandLine: string | null = null;
|
||||
if (targetSocketPath) {
|
||||
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
|
||||
if (!processCommandLineLookupCache.has(pidValue)) {
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
processCommandLineLookupCache.set(pidValue, commandLine);
|
||||
}
|
||||
commandLine = getProcessCommandLineByPid(pidValue);
|
||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user