mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5af519dc6c
|
||
|
|
7046a4451f
|
||
|
|
39286c2c55
|
||
|
|
00b1b79bf4 | ||
|
|
e11a5fea0d
|
||
|
|
f73fe179d0
|
||
|
|
2938e7a32a | ||
|
|
82f6b4705a | ||
|
|
a02c33dac4 |
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it.
|
||||
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -138,6 +138,8 @@ Karaoke openings and animated signs are authored as one subtitle event per anima
|
||||
|
||||
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
|
||||
|
||||
- When a typeset ASS file stores a clean lyric or sign in a timed authoring comment, or in full-line events surrounding generated fragments, the matching complete line is recorded once. The repeated glyph or clip-animation frames are not recorded. Dialogue spoken while such an animation is on screen records as itself, without the fragment lines beside it.
|
||||
- When karaoke styling redraws the same complete lyric across consecutive color or highlight phases, those phases are combined into one line with their full timing. Repeated ordinary dialogue remains separate.
|
||||
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
|
||||
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record.
|
||||
|
||||
|
||||
+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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ When SubMiner parses the active subtitle source into a cue list, the sidebar bec
|
||||
- Clicking any cue seeks mpv to that timestamp.
|
||||
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
|
||||
|
||||
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
|
||||
|
||||
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
|
||||
|
||||
## Layout Modes
|
||||
|
||||
@@ -70,18 +70,25 @@ interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // plain text, decoded from the source format
|
||||
source?: 'canonical-ass'; // recovered authored text for generated ASS animation
|
||||
animationStartTime?: number; // full generated-frame envelope; entrance/exit frames
|
||||
animationEndTime?: number; // run past the authored timing, live matching uses this
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
|
||||
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas.
|
||||
|
||||
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||
|
||||
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||
|
||||
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
|
||||
|
||||
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored.
|
||||
|
||||
#### Prefetch Service Lifecycle
|
||||
|
||||
1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure.
|
||||
|
||||
@@ -129,7 +129,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",
|
||||
|
||||
@@ -106,8 +106,11 @@ function M.create(ctx)
|
||||
|
||||
local function get_subtitle_ass_property()
|
||||
local ass_text = mp.get_property("sub-text/ass")
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
return ass_text
|
||||
if ass_text ~= nil then
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
return ass_text
|
||||
end
|
||||
return nil
|
||||
end
|
||||
ass_text = mp.get_property("sub-text-ass")
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -232,7 +232,7 @@ function M.create(ctx)
|
||||
elseif action_id == "triggerFieldGrouping" then
|
||||
return { "--trigger-field-grouping" }
|
||||
elseif action_id == "triggerSubsync" then
|
||||
return { "--trigger-subsync" }
|
||||
return { "--session-action", '{"actionId":"triggerSubsync"}' }
|
||||
elseif action_id == "mineSentence" then
|
||||
return { "--mine-sentence" }
|
||||
elseif action_id == "mineSentenceMultiple" then
|
||||
@@ -251,7 +251,7 @@ function M.create(ctx)
|
||||
elseif action_id == "markWatched" then
|
||||
return { "--mark-watched" }
|
||||
elseif action_id == "openRuntimeOptions" then
|
||||
return { "--open-runtime-options" }
|
||||
return { "--session-action", '{"actionId":"openRuntimeOptions"}' }
|
||||
elseif action_id == "openJimaku" then
|
||||
return { "--open-jimaku" }
|
||||
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
||||
@@ -259,7 +259,7 @@ function M.create(ctx)
|
||||
elseif action_id == "openYoutubePicker" then
|
||||
return { "--open-youtube-picker" }
|
||||
elseif action_id == "openSessionHelp" then
|
||||
return { "--open-session-help" }
|
||||
return { "--session-action", '{"actionId":"openSessionHelp"}' }
|
||||
elseif action_id == "openCharacterDictionaryManager" then
|
||||
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
|
||||
elseif action_id == "openControllerSelect" then
|
||||
|
||||
@@ -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,
|
||||
|
||||
+13
-1
@@ -4,6 +4,7 @@ function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local input = ctx.input
|
||||
local process = ctx.process
|
||||
local state = ctx.state
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
|
||||
@@ -93,7 +94,18 @@ function M.create(ctx)
|
||||
if not ensure_binary_for_menu() then
|
||||
return
|
||||
end
|
||||
process.run_control_command_async("open-session-help")
|
||||
process.run_binary_command_async({
|
||||
state.binary_path,
|
||||
"--session-action",
|
||||
'{"actionId":"openSessionHelp"}',
|
||||
}, function(ok, result, error)
|
||||
if ok then
|
||||
return
|
||||
end
|
||||
local reason = error or (result and result.stderr) or "unknown error"
|
||||
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
|
||||
show_osd("Session action failed")
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
@@ -21,17 +18,6 @@ interface SeedLine {
|
||||
createdMs?: number;
|
||||
}
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-duplicate-line-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/** One episode, two sessions of it, and one word occurrence per seeded line. */
|
||||
function seed(db: DatabaseSync, lines: SeedLine[]): void {
|
||||
db.exec(`
|
||||
@@ -82,12 +68,16 @@ function seed(db: DatabaseSync, lines: SeedLine[]): void {
|
||||
`);
|
||||
}
|
||||
|
||||
function createDb(lines: SeedLine[]): { db: DatabaseSync; dbPath: string } {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
/**
|
||||
* These tests exercise the cleanup SQL, not durability. A fresh on-disk database per
|
||||
* test pays a schema-creation fsync that is cheap on a local NVMe but slow enough on CI
|
||||
* runners to blow the 5s per-test timeout, so the database stays in memory.
|
||||
*/
|
||||
function createDb(lines: SeedLine[]): { db: DatabaseSync } {
|
||||
const db = new Database(':memory:');
|
||||
ensureSchema(db);
|
||||
seed(db, lines);
|
||||
return { db, dbPath };
|
||||
return { db };
|
||||
}
|
||||
|
||||
/** A typeset line mpv reported once per animation frame. */
|
||||
@@ -119,7 +109,7 @@ function wordFrequency(db: DatabaseSync): number {
|
||||
}
|
||||
|
||||
test('a karaoke burst collapses to one line and gives back its word counts', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
const { db } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 40, 40),
|
||||
{ session: 1, text: 'おはよう', startMs: 20_000, endMs: 22_000 },
|
||||
]);
|
||||
@@ -148,7 +138,6 @@ test('a karaoke burst collapses to one line and gives back its word counts', ()
|
||||
assert.equal(summary.samples[0]!.videoTitle, 'Ep 1');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,7 +149,7 @@ test('ordinary repeated dialogue survives', () => {
|
||||
startMs: 5_000 + index * 800,
|
||||
endMs: 5_000 + (index + 1) * 800,
|
||||
}));
|
||||
const { db, dbPath } = createDb(lines);
|
||||
const { db } = createDb(lines);
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -171,14 +160,13 @@ test('ordinary repeated dialogue survives', () => {
|
||||
assert.equal(wordFrequency(db), 6);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a long run of quarter-second frames is still a burst', () => {
|
||||
// Between the timing-only bound (0.1s) and the animation-frame bound (0.3s): heavier
|
||||
// typesetting lands here, and the run length is what makes it conclusive.
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -189,12 +177,11 @@ test('a long run of quarter-second frames is still a burst', () => {
|
||||
assert.equal(wordFrequency(db), 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a qualifying short-frame burst may end with one long hold frame', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
const { db } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 8, 40),
|
||||
{ session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 },
|
||||
]);
|
||||
@@ -208,12 +195,11 @@ test('a qualifying short-frame burst may end with one long hold frame', () => {
|
||||
assert.equal(wordFrequency(db), 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a long event before the final frame prevents burst cleanup', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
const { db } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 5, 40),
|
||||
{ session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 },
|
||||
{ session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 },
|
||||
@@ -226,12 +212,11 @@ test('a long event before the final frame prevents burst cleanup', () => {
|
||||
assert.equal(countLines(db), 7);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a run of frames longer than the animation bound survives', () => {
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -240,7 +225,6 @@ test('a run of frames longer than the animation bound survives', () => {
|
||||
assert.equal(countLines(db), 6);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -248,7 +232,7 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
|
||||
// The streaming gate records the first four frames of a burst before the run is long
|
||||
// enough to recognise. Four contiguous identical events under the strict timing-only
|
||||
// bound are that residue, and no real dialogue.
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -259,14 +243,13 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
|
||||
assert.equal(wordFrequency(db), 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a four-frame run above the strict frame bound survives', () => {
|
||||
// Long enough per event to be plausible dialogue; only a five-event run may use the
|
||||
// looser animation-frame bound.
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -275,14 +258,13 @@ test('a four-frame run above the strict frame bound survives', () => {
|
||||
assert.equal(countLines(db), 4);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('an explicit minRunLength raises the bar', () => {
|
||||
// Five quarter-second frames qualify under the defaults; a cautious run asking for six
|
||||
// leaves them alone. Above the strict bound, so the residue rule stays out of it.
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
|
||||
|
||||
try {
|
||||
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
|
||||
@@ -293,12 +275,11 @@ test('an explicit minRunLength raises the bar', () => {
|
||||
assert.equal(countLines(db), 5);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('an explicit maxFrameSeconds tightens the frame bound', () => {
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: 0.2 });
|
||||
@@ -307,13 +288,12 @@ test('an explicit maxFrameSeconds tightens the frame bound', () => {
|
||||
assert.equal(countLines(db), 6);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-finite maxFrameSeconds falls back to the default bound', () => {
|
||||
// Six normal-beat lines: Infinity must not turn every event into a "short frame".
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: Infinity });
|
||||
@@ -322,12 +302,11 @@ test('a non-finite maxFrameSeconds falls back to the default bound', () => {
|
||||
assert.equal(countLines(db), 6);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('sampleLimit zero removes bursts but reports no samples', () => {
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db, { sampleLimit: 0 });
|
||||
@@ -337,12 +316,11 @@ test('sampleLimit zero removes bursts but reports no samples', () => {
|
||||
assert.equal(countLines(db), 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a short run below every threshold survives', () => {
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -351,7 +329,6 @@ test('a short run below every threshold survives', () => {
|
||||
assert.equal(countLines(db), 3);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -361,7 +338,7 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
|
||||
const kanji = karaokeFrames(1, '飛び上がる', 10_000, 20, 60);
|
||||
const romaji = karaokeFrames(1, 'tobiagaru', 10_001, 20, 60);
|
||||
const interleaved = [...kanji, ...romaji].sort((a, b) => a.startMs - b.startMs);
|
||||
const { db, dbPath } = createDb(interleaved);
|
||||
const { db } = createDb(interleaved);
|
||||
|
||||
try {
|
||||
const summary = cleanupDuplicateSubtitleLines(db);
|
||||
@@ -372,12 +349,11 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
|
||||
assert.equal(wordFrequency(db), 2);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('the same line in a rewatch session is never merged into the first watch', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
const { db } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
|
||||
...karaokeFrames(2, '飛び上がる', 10_000, 6, 40),
|
||||
]);
|
||||
@@ -392,12 +368,11 @@ test('the same line in a rewatch session is never merged into the first watch',
|
||||
assert.equal(wordFrequency(db), 2);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a gap between runs splits them', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
const { db } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
|
||||
...karaokeFrames(1, '飛び上がる', 60_000, 6, 40),
|
||||
]);
|
||||
@@ -409,12 +384,11 @@ test('a gap between runs splits them', () => {
|
||||
assert.equal(countLines(db), 2);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a dry run reports what an apply would do and writes nothing', () => {
|
||||
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
|
||||
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
|
||||
|
||||
try {
|
||||
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
|
||||
@@ -430,14 +404,13 @@ test('a dry run reports what an apply would do and writes nothing', () => {
|
||||
assert.equal(countLines(db), 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('the lookback window leaves older bursts alone', () => {
|
||||
const recentMs = BASE_MS;
|
||||
const oldMs = BASE_MS - 40 * DAY_MS;
|
||||
const { db, dbPath } = createDb([
|
||||
const { db } = createDb([
|
||||
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40).map((line) => ({
|
||||
...line,
|
||||
createdMs: oldMs,
|
||||
@@ -462,6 +435,5 @@ test('the lookback window leaves older bursts alone', () => {
|
||||
} finally {
|
||||
globalThis.__subminerTestNowMs = undefined;
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,6 +125,49 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard prefers a canonical primary subtitle snapshot', async () => {
|
||||
const created: Array<{
|
||||
sentence: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
secondarySub?: string;
|
||||
}> = [];
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
|
||||
created.push({ sentence, startTime, endTime, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: '今今今手手手',
|
||||
currentSubStart: 11.4,
|
||||
currentSubEnd: 11.8,
|
||||
currentSecondarySubText: 'English subtitle',
|
||||
},
|
||||
primarySubtitle: {
|
||||
text: '今 手にある物差しでは',
|
||||
startTime: 11.13,
|
||||
endTime: 13.83,
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(created, [
|
||||
{
|
||||
sentence: '今 手にある物差しでは',
|
||||
startTime: 11.13,
|
||||
endTime: 13.83,
|
||||
secondarySub: 'English subtitle',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const requestedProperties: string[] = [];
|
||||
|
||||
@@ -131,8 +131,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
|
||||
|
||||
async function getCurrentSecondarySubTextForSentenceCard(
|
||||
mpvClient: MpvClientLike,
|
||||
primaryText: string,
|
||||
): Promise<string | undefined> {
|
||||
const primaryText = mpvClient.currentSubText;
|
||||
if (mpvClient.requestProperty) {
|
||||
try {
|
||||
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
|
||||
@@ -175,6 +175,7 @@ export async function markLastCardAsAudioCard(deps: {
|
||||
export async function mineSentenceCard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
mpvClient: MpvClientLike | null;
|
||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}): Promise<boolean> {
|
||||
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
|
||||
@@ -185,16 +186,17 @@ export async function mineSentenceCard(deps: {
|
||||
deps.showMpvOsd('MPV not connected');
|
||||
return false;
|
||||
}
|
||||
if (!mpvClient.currentSubText) {
|
||||
const primaryText = deps.primarySubtitle?.text ?? mpvClient.currentSubText;
|
||||
if (!primaryText) {
|
||||
deps.showMpvOsd('No current subtitle');
|
||||
return false;
|
||||
}
|
||||
|
||||
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient);
|
||||
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||
return await anki.createSentenceCard(
|
||||
mpvClient.currentSubText,
|
||||
mpvClient.currentSubStart,
|
||||
mpvClient.currentSubEnd,
|
||||
primaryText,
|
||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
||||
secondarySubText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'sub-scale-by-window',
|
||||
'osd-height',
|
||||
'osd-dimensions',
|
||||
'sub-text-ass',
|
||||
'sub-text/ass',
|
||||
'sub-border-size',
|
||||
'sub-shadow-offset',
|
||||
'sub-ass-override',
|
||||
@@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
||||
request_id: MPV_REQUEST_ID_SUBTEXT,
|
||||
},
|
||||
{
|
||||
command: ['get_property', 'sub-text-ass'],
|
||||
command: ['get_property', 'sub-text/ass'],
|
||||
request_id: MPV_REQUEST_ID_SUBTEXT_ASS,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -129,6 +129,28 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits ASS subtitle text from the current mpv property', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-text/ass', data: '{\\b1}字幕' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits ASS subtitle text from the legacy mpv property', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-text-ass', data: '{\\b1}字幕' },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ text: '{\\b1}字幕' }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||
|
||||
@@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
isOverlayVisible: overlayVisible,
|
||||
});
|
||||
deps.setCurrentSubText(nextSubText);
|
||||
} else if (msg.name === 'sub-text-ass') {
|
||||
} else if (msg.name === 'sub-text/ass' || msg.name === 'sub-text-ass') {
|
||||
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
|
||||
} else if (msg.name === 'sub-start') {
|
||||
deps.setCurrentSubStart((msg.data as number) || 0);
|
||||
|
||||
@@ -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;
|
||||
@@ -505,6 +518,17 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
(command as { command: unknown[] }).command[1] === 1 &&
|
||||
(command as { command: unknown[] }).command[2] === 'sub-text',
|
||||
);
|
||||
const hasAssSubtitleSubscription = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'observe_property' &&
|
||||
(command as { command: unknown[] }).command[2] === 'sub-text/ass',
|
||||
);
|
||||
const hasDeprecatedAssSubtitleProperty = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command.includes('sub-text-ass'),
|
||||
);
|
||||
const hasPathRequest = commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command: unknown[] }).command) &&
|
||||
@@ -514,6 +538,8 @@ test('MpvIpcClient reconnect replays property subscriptions and initial state re
|
||||
|
||||
assert.equal(hasSecondaryVisibilityReset, true);
|
||||
assert.equal(hasTrackSubscription, true);
|
||||
assert.equal(hasAssSubtitleSubscription, true);
|
||||
assert.equal(hasDeprecatedAssSubtitleProperty, false);
|
||||
assert.equal(hasPathRequest, true);
|
||||
});
|
||||
|
||||
@@ -743,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 {}
|
||||
@@ -188,6 +194,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 +297,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -15,6 +15,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||
});
|
||||
|
||||
test('macOS modal overlay uses a fullscreen auxiliary panel without changing the passive overlay', () => {
|
||||
const visibleOptions = buildOverlayWindowOptions('visible', {
|
||||
isDev: false,
|
||||
platform: 'darwin',
|
||||
yomitanSession: null,
|
||||
});
|
||||
const modalOptions = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
platform: 'darwin',
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(visibleOptions.type, undefined);
|
||||
assert.equal(modalOptions.type, 'panel');
|
||||
});
|
||||
|
||||
test('non-macOS modal overlay remains a regular window', () => {
|
||||
const options = buildOverlayWindowOptions('modal', {
|
||||
isDev: false,
|
||||
platform: 'linux',
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||
export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded';
|
||||
|
||||
@@ -12,15 +12,17 @@ export function buildOverlayWindowOptions(
|
||||
options: {
|
||||
isDev: boolean;
|
||||
linuxX11FullscreenOverlay?: boolean;
|
||||
platform?: NodeJS.Platform;
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindowConstructorOptions {
|
||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
||||
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
|
||||
const platform = options.platform ?? process.platform;
|
||||
const showNativeDebugFrame = platform === 'win32' && options.isDev;
|
||||
const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible';
|
||||
const isLinuxFullscreenOverlay =
|
||||
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
|
||||
const shouldStartAlwaysOnTop =
|
||||
!(process.platform === 'win32' && kind === 'visible') &&
|
||||
!(platform === 'win32' && kind === 'visible') &&
|
||||
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
|
||||
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
|
||||
|
||||
@@ -41,7 +43,10 @@ export function buildOverlayWindowOptions(
|
||||
hasShadow: false,
|
||||
focusable: !isLinuxFullscreenOverlay,
|
||||
acceptFirstMouse: true,
|
||||
...(process.platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
// A macOS panel is a fullscreen auxiliary window, so modal surfaces stay on the
|
||||
// active mpv Space instead of opening on SubMiner's last regular desktop.
|
||||
...(platform === 'darwin' && kind === 'modal' ? { type: 'panel' as const } : {}),
|
||||
...(platform === 'win32' ? { thickFrame: showNativeDebugFrame } : {}),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
|
||||
@@ -16,7 +16,10 @@ import {
|
||||
} from './hyprland-window-placement';
|
||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from './overlay-window-flags';
|
||||
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
@@ -133,6 +136,9 @@ export function createOverlayWindow(
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||
] = false;
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||
] = false;
|
||||
|
||||
if (!(process.platform === 'win32' && kind === 'visible')) {
|
||||
options.ensureOverlayWindowLevel(window);
|
||||
@@ -144,11 +150,20 @@ export function createOverlayWindow(
|
||||
});
|
||||
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||
] = true;
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
options.onRuntimeOptionsChanged();
|
||||
options.onWindowDidFinishLoad?.();
|
||||
});
|
||||
|
||||
window.webContents.on('did-start-loading', () => {
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||
] = false;
|
||||
});
|
||||
|
||||
window.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
|
||||
@@ -57,7 +57,9 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey:
|
||||
export function buildStatsWindowOptions(options: {
|
||||
preloadPath: string;
|
||||
bounds?: WindowGeometry | null;
|
||||
platform?: NodeJS.Platform;
|
||||
}): BrowserWindowConstructorOptions {
|
||||
const platform = options.platform ?? process.platform;
|
||||
return {
|
||||
title: STATS_WINDOW_TITLE,
|
||||
x: options.bounds?.x,
|
||||
@@ -73,6 +75,9 @@ export function buildStatsWindowOptions(options: {
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
fullscreenable: false,
|
||||
// Panels join fullscreen Spaces on macOS without moving the user back to the
|
||||
// desktop where SubMiner last owned a regular application window.
|
||||
...(platform === 'darwin' ? { type: 'panel' as const } : {}),
|
||||
backgroundColor: '#24273a',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
@@ -84,6 +89,12 @@ export function buildStatsWindowOptions(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldPresentStatsWindowAfterLoad(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
return platform === 'darwin';
|
||||
}
|
||||
|
||||
export function resolveStatsWindowOuterBoundsForContent(
|
||||
window: StatsWindowBoundsController,
|
||||
target: WindowGeometry,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
shouldPresentStatsWindowAfterLoad,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
|
||||
@@ -40,6 +41,30 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
|
||||
assert.equal(options.webPreferences?.sandbox, true);
|
||||
});
|
||||
|
||||
test('buildStatsWindowOptions uses a fullscreen auxiliary panel on macOS', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(options.type, 'panel');
|
||||
});
|
||||
|
||||
test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
||||
const options = buildStatsWindowOptions({
|
||||
preloadPath: '/tmp/preload-stats.js',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
});
|
||||
|
||||
test('stats panels present after document load on macOS', () => {
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('darwin'), true);
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('linux'), false);
|
||||
assert.equal(shouldPresentStatsWindowAfterLoad('win32'), false);
|
||||
});
|
||||
|
||||
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
||||
assert.equal(
|
||||
shouldHideStatsWindowForInput(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
scheduleStatsWindowPostShowReconciles,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
shouldPresentStatsWindowAfterLoad,
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
||||
@@ -209,10 +210,15 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
options.onVisibilityChanged?.(false);
|
||||
}
|
||||
});
|
||||
statsWindow.once('ready-to-show', () => {
|
||||
const showInitialStatsWindow = () => {
|
||||
if (!statsWindow) return;
|
||||
showStatsWindow(statsWindow, options);
|
||||
});
|
||||
};
|
||||
if (shouldPresentStatsWindowAfterLoad()) {
|
||||
statsWindow.webContents.once('did-finish-load', showInitialStatsWindow);
|
||||
} else {
|
||||
statsWindow.once('ready-to-show', showInitialStatsWindow);
|
||||
}
|
||||
|
||||
statsWindow.on('blur', () => {
|
||||
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
|
||||
|
||||
@@ -27,17 +27,188 @@ function cueKey(cue: SubtitleCue): string {
|
||||
|
||||
/**
|
||||
* Identical text over an identical span is redundant however it was authored -- most
|
||||
* often a layered ASS event stacking a shadow copy under the visible one.
|
||||
* often a layered ASS event stacking a shadow copy under the visible one. When one of
|
||||
* the duplicates is a recovered canonical cue, that copy survives: dropping it would
|
||||
* strip the `source` marker and animation envelope the live overlay substitutes on.
|
||||
*/
|
||||
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const seen = new Set<string>();
|
||||
return cues.filter((cue) => {
|
||||
const survivorByKey = new Map<string, AnnotatedSubtitleCue>();
|
||||
const keysInOrder: string[] = [];
|
||||
for (const cue of cues) {
|
||||
const key = cueKey(cue);
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
const existing = survivorByKey.get(key);
|
||||
if (!existing) {
|
||||
survivorByKey.set(key, cue);
|
||||
keysInOrder.push(key);
|
||||
} else if (!existing.source && cue.source) {
|
||||
survivorByKey.set(key, cue);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
return keysInOrder.map((key) => survivorByKey.get(key)!);
|
||||
}
|
||||
|
||||
const SPATIAL_ASS_OVERRIDE_COMMANDS = new Set([
|
||||
'a',
|
||||
'an',
|
||||
'clip',
|
||||
'iclip',
|
||||
'move',
|
||||
'org',
|
||||
'pbo',
|
||||
'pos',
|
||||
'q',
|
||||
]);
|
||||
|
||||
interface RepeatedPhaseRun {
|
||||
cues: AnnotatedSubtitleCue[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
// A changing override signature alone is weak: two ordinary repeats restyled with
|
||||
// different colors look identical to a phase pair. Real phase redraws carry a styling
|
||||
// stack over a full lyric line, and they exist to move a color/highlight boundary
|
||||
// *within* the line -- so every event also has an override block after visible text
|
||||
// began. An ordinary restyled repeat carries only a leading block and stays separate.
|
||||
const MIN_PHASE_EVIDENCE_OVERRIDES = 2;
|
||||
const MIN_PHASE_TEXT_LENGTH = 4;
|
||||
|
||||
function hasMidLineOverrideBlock(rawText: string): boolean {
|
||||
let sawVisibleText = false;
|
||||
for (let i = 0; i < rawText.length; i += 1) {
|
||||
if (rawText[i] === '{') {
|
||||
const close = rawText.indexOf('}', i);
|
||||
if (close === -1) {
|
||||
// Unclosed brace renders as literal text; nothing after it is markup.
|
||||
return false;
|
||||
}
|
||||
if (sawVisibleText) {
|
||||
return true;
|
||||
}
|
||||
i = close;
|
||||
} else if (!/\s/.test(rawText[i]!)) {
|
||||
sawVisibleText = true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function assStyleKey(cue: AnnotatedSubtitleCue): string {
|
||||
return `${cue.style}\0${cue.name}\0${cue.layer}`;
|
||||
}
|
||||
|
||||
function spatialOverrideSignature(cue: AnnotatedSubtitleCue): string {
|
||||
return cue.overrides
|
||||
.filter((command) => SPATIAL_ASS_OVERRIDE_COMMANDS.has(command.name.toLowerCase()))
|
||||
.map((command) => `${command.name.toLowerCase()}(${command.args})`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function hasStableSpatialOverrides(run: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
const firstSignature = spatialOverrideSignature(run[0]!);
|
||||
return run.every((cue) => spatialOverrideSignature(cue) === firstSignature);
|
||||
}
|
||||
|
||||
function hasDirectPhaseEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
|
||||
// Phases redraw one authored line in place. Whatever the animation evidence, a run
|
||||
// whose spatial placement changes is separate authored occurrences -- two flush
|
||||
// same-text `\move` signs at different coordinates must never merge.
|
||||
if (!hasStableSpatialOverrides(run)) {
|
||||
return false;
|
||||
}
|
||||
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||
return true;
|
||||
}
|
||||
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [first] = run;
|
||||
return (
|
||||
first!.text.replace(/\s+/gu, '').length >= MIN_PHASE_TEXT_LENGTH &&
|
||||
run.every(
|
||||
(cue) =>
|
||||
cue.overrides.length >= MIN_PHASE_EVIDENCE_OVERRIDES &&
|
||||
hasMidLineOverrideBlock(cue.rawText),
|
||||
) &&
|
||||
run.some((cue) => cue.overrideSignature !== first!.overrideSignature)
|
||||
);
|
||||
}
|
||||
|
||||
function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRun[] {
|
||||
const runs: RepeatedPhaseRun[] = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < cues.length) {
|
||||
const first = cues[start]!;
|
||||
const styleKey = assStyleKey(first);
|
||||
let end = start;
|
||||
|
||||
while (end + 1 < cues.length) {
|
||||
const current = cues[end]!;
|
||||
const next = cues[end + 1]!;
|
||||
const isFlush =
|
||||
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
||||
if (
|
||||
first.source === 'canonical-ass' ||
|
||||
next.source === 'canonical-ass' ||
|
||||
next.text !== first.text ||
|
||||
assStyleKey(next) !== styleKey ||
|
||||
!isFlush
|
||||
) {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
|
||||
if (end > start) {
|
||||
const indices = Array.from({ length: end - start + 1 }, (_, offset) => start + offset);
|
||||
runs.push({
|
||||
cues: indices.map((index) => cues[index]!),
|
||||
indices,
|
||||
});
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some karaoke scripts redraw one complete lyric for each color/highlight phase. These
|
||||
* events last far longer than animation frames, but are still one sidebar/history line.
|
||||
* The events must prove themselves through direct animation metadata or changing
|
||||
* non-spatial overrides. Plain repeated dialogue and separately positioned signs stay
|
||||
* intact.
|
||||
*/
|
||||
function collapseAnimatedStylePhases(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
const runs = collectRepeatedPhaseRuns(cues);
|
||||
if (runs.length === 0) {
|
||||
return cues;
|
||||
}
|
||||
|
||||
const dropped = new Set<number>();
|
||||
const extendedEnd = new Map<number, number>();
|
||||
for (const run of runs) {
|
||||
if (!hasDirectPhaseEvidence(run.cues)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [firstIndex, ...remainingIndices] = run.indices;
|
||||
for (const index of remainingIndices) {
|
||||
dropped.add(index);
|
||||
}
|
||||
extendedEnd.set(firstIndex!, Math.max(...run.cues.map((cue) => cue.endTime)));
|
||||
}
|
||||
|
||||
if (dropped.size === 0) {
|
||||
return cues;
|
||||
}
|
||||
return cues.flatMap((cue, index) => {
|
||||
if (dropped.has(index)) {
|
||||
return [];
|
||||
}
|
||||
const endTime = extendedEnd.get(index);
|
||||
return endTime !== undefined ? [{ ...cue, endTime }] : [cue];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,5 +347,8 @@ export function mergeDuplicateCues(
|
||||
cues: AnnotatedSubtitleCue[],
|
||||
format: SubtitleSourceFormat,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
|
||||
const exactDeduplicated = collapseExactDuplicates(cues);
|
||||
const phaseDeduplicated =
|
||||
format === 'ass' ? collapseAnimatedStylePhases(exactDeduplicated) : exactDeduplicated;
|
||||
return collapseAnimationBursts(phaseDeduplicated, format);
|
||||
}
|
||||
|
||||
@@ -327,6 +327,122 @@ test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', ()
|
||||
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses long full-line color phases', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:03:49.75,0:03:51.21,OPJP,,0,0,0,,{\\blur0.6\\c&H312D38&\\4c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
|
||||
'Dialogue: 1,0:03:51.21,0:03:52.25,OPJP,,0,0,0,,{\\blur0.6\\4c&H312D38&\\c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 229.75,
|
||||
endTime: 232.25,
|
||||
text: 'ちゃんと目を合わせてよ',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps ordinary repeated dialogue separate', () => {
|
||||
// A single restyle tag on a repeated line is how ordinary dialogue gets decorated;
|
||||
// it is not phase evidence, whatever the line length.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:02.00,OPJP,,0,0,0,,{\\c&H111111&}歌詞',
|
||||
'Dialogue: 1,0:00:02.00,0:00:03.00,OPJP,,0,0,0,,{\\c&H222222&}歌詞',
|
||||
'Dialogue: 1,0:00:04.00,0:00:05.00,OPJP,,0,0,0,,{\\c&H333333&}別の歌詞',
|
||||
'Dialogue: 1,0:00:05.00,0:00:06.00,OPJP,,0,0,0,,{\\c&H444444&}別の歌詞',
|
||||
'Dialogue: 8,0:00:07.00,0:00:08.00,Text - JP,,0,0,0,,えっ?',
|
||||
'Dialogue: 8,0:00:08.00,0:00:09.00,Text - JP,,0,0,0,,えっ?',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: '歌詞' },
|
||||
{ startTime: 2, endTime: 3, text: '歌詞' },
|
||||
{ startTime: 4, endTime: 5, text: '別の歌詞' },
|
||||
{ startTime: 5, endTime: 6, text: '別の歌詞' },
|
||||
{ startTime: 7, endTime: 8, text: 'えっ?' },
|
||||
{ startTime: 8, endTime: 9, text: 'えっ?' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps separately positioned temporal signs separate', () => {
|
||||
// Two flush signs with the same text but different \move paths are separate authored
|
||||
// occurrences, not phases of one redraw: temporal evidence alone must not merge them.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Sign,,0,0,0,,{\\move(100,100,200,100)}立入禁止',
|
||||
'Dialogue: 0,0:00:02.00,0:00:03.00,Sign,,0,0,0,,{\\move(500,400,600,400)}立入禁止',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: '立入禁止' },
|
||||
{ startTime: 2, endTime: 3, text: '立入禁止' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps richly styled ordinary repeats separate', () => {
|
||||
// Blur plus a changing color is still an ordinary restyle. Phase redraws are
|
||||
// recognized by the color/highlight boundary moving *within* the line, which these
|
||||
// leading-block-only events do not have.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}待ってよ',
|
||||
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}待ってよ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: '待ってよ' },
|
||||
{ startTime: 2, endTime: 3, text: '待ってよ' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps canonical metadata when an identical plain cue exists', () => {
|
||||
// A plain dialogue line can share exact timing and text with a recovered canonical
|
||||
// cue from another style. The canonical copy must win the exact-duplicate collapse,
|
||||
// or the live overlay loses the marker it substitutes on.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:05.00,0:00:08.00,Plain,,0,0,0,,ライン',
|
||||
'Comment: 0,0:00:05.00,0:00:08.00,OP,,0,0,0,,ライン',
|
||||
'Dialogue: 0,0:00:05.00,0:00:05.04,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}ライン',
|
||||
'Dialogue: 0,0:00:05.04,0:00:05.08,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}ライン',
|
||||
'Dialogue: 0,0:00:05.08,0:00:08.00,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}ライン',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 5,
|
||||
endTime: 8,
|
||||
text: 'ライン',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 5,
|
||||
animationEndTime: 8,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps short styled repeats separate even with richer styling', () => {
|
||||
// Two ordinary えっ lines restyled with different colors are two utterances, not two
|
||||
// phases of one lyric: short text never satisfies the changing-override evidence path.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}えっ',
|
||||
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}えっ',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{ startTime: 1, endTime: 2, text: 'えっ' },
|
||||
{ startTime: 2, endTime: 3, text: 'えっ' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
|
||||
// Several characters greeting in turn: distinct utterances that happen to abut.
|
||||
const content = [
|
||||
@@ -357,6 +473,194 @@ test('parseSubtitleCues collapses exact duplicate cues even without effect tags'
|
||||
assert.equal(cues.length, 1);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues replaces generated glyph animation with its timed canonical comment', () => {
|
||||
// Aegisub automation commonly keeps the authored lyric as a Comment and emits
|
||||
// multiple moving Dialogue layers for every glyph. This mirrors the MyGO ED script:
|
||||
// three entrance copies followed by three exit copies for each character.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.20,0:00:03.80,ED_JP,,0,0,0,,{\\fad(480,480)}今 手にある',
|
||||
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(10,20,100,200)\\t(0,600,\\fscx100)}今',
|
||||
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(30,40,100,200)\\t(0,600,\\fscx100)}今',
|
||||
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(50,60,100,200)\\t(0,600,\\fscx100)}今',
|
||||
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,20,30)\\t(2000,2600,\\blur20)}今',
|
||||
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,40,50)\\t(2000,2600,\\blur20)}今',
|
||||
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,60,70)\\t(2000,2600,\\blur20)}今',
|
||||
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(10,20,140,200)\\t(0,600,\\fscx100)}手',
|
||||
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(30,40,140,200)\\t(0,600,\\fscx100)}手',
|
||||
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(50,60,140,200)\\t(0,600,\\fscx100)}手',
|
||||
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,20,30)\\t(2000,2600,\\blur20)}手',
|
||||
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,40,50)\\t(2000,2600,\\blur20)}手',
|
||||
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,60,70)\\t(2000,2600,\\blur20)}手',
|
||||
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(10,20,180,200)\\t(0,600,\\fscx100)}にある',
|
||||
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(30,40,180,200)\\t(0,600,\\fscx100)}にある',
|
||||
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(50,60,180,200)\\t(0,600,\\fscx100)}にある',
|
||||
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,20,30)\\t(2000,2600,\\blur20)}にある',
|
||||
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,40,50)\\t(2000,2600,\\blur20)}にある',
|
||||
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,60,70)\\t(2000,2600,\\blur20)}にある',
|
||||
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial_JP,,0,0,0,,普通の会話',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
// Entrance frames start before and exit frames end after the authored timing.
|
||||
animationStartTime: 0.8,
|
||||
animationEndTime: 4.32,
|
||||
},
|
||||
{ startTime: 6, endTime: 8, text: '普通の会話' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues recovers a full Dialogue line surrounding generated fragments', () => {
|
||||
// Some scripts do not retain the authored line as a Comment. Instead, brief entrance
|
||||
// and exit events contain the complete line around a long run of generated syllables.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:01.15,ED Romaji,,0,0,0,fx,{\\move(100,40,60,40)}toki yo ugokidase',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(0,300,\\c&HFFFFFF&)}to',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(300,500,\\c&HFFFFFF&)}ki',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(500,700,\\c&HFFFFFF&)}yo',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(700,900,\\c&HFFFFFF&)}u',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(900,1100,\\c&HFFFFFF&)}go',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1100,1300,\\c&HFFFFFF&)}ki',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1300,1500,\\c&HFFFFFF&)}da',
|
||||
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1500,1800,\\c&HFFFFFF&)}se',
|
||||
'Dialogue: 1,0:00:03.00,0:00:03.15,ED Romaji,,0,0,0,fx,{\\move(60,40,20,40)}toki yo ugokidase',
|
||||
'Dialogue: 0,0:00:06.00,0:00:08.00,Default,,0,0,0,,Ordinary dialogue',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3.15,
|
||||
text: 'toki yo ugokidase',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 1,
|
||||
animationEndTime: 3.15,
|
||||
},
|
||||
{ startTime: 6, endTime: 8, text: 'Ordinary dialogue' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
|
||||
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
|
||||
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
|
||||
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.equal(
|
||||
cues.some((cue) => cue.source === 'canonical-ass'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,編集メモ',
|
||||
'Comment: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,通常の字幕',
|
||||
'Dialogue: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{ startTime: 1, endTime: 3, text: '通常の字幕' },
|
||||
{ startTime: 4, endTime: 6, text: '別案の字幕' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseAssCues returns recovered canonical cues in chronological order', () => {
|
||||
// Recovery appends recovered cues after surviving dialogue; the bare parseAssCues
|
||||
// export must still come back time-ordered.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial,,0,0,0,,あとのセリフ',
|
||||
'Comment: 0,0:00:01.20,0:00:03.80,OP,,0,0,0,,雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.20,0:00:01.24,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.24,0:00:01.28,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.28,0:00:03.80,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}雨が上がっても',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(
|
||||
parseAssCues(content).map((cue) => cue.startTime),
|
||||
[1.2, 6],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues withdraws a recovery whose owner is claimed by a later candidate', () => {
|
||||
// The exit boundary event appears first in the file and recovers a canonical cue from
|
||||
// its own small cluster. The entrance candidate then proves that exit event was a
|
||||
// generated frame of the full animation; the earlier recovery is a duplicate of the
|
||||
// same authored line and must not survive alongside it.
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 1,0:00:14.00,0:00:14.20,ED,,0,0,0,,{\\move(100,200,20,30)}ABCDEFGH',
|
||||
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(0,300,\\c&HFFFFFF&)}ABC',
|
||||
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(300,600,\\c&HFFFFFF&)}DEF',
|
||||
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(600,900,\\c&HFFFFFF&)}GH',
|
||||
'Dialogue: 0,0:00:10.00,0:00:10.20,ED,,0,0,0,,{\\move(10,20,100,200)}ABCDEFGH',
|
||||
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(0,300,\\fscx100)}ABC',
|
||||
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(300,600,\\fscx100)}DEF',
|
||||
'Dialogue: 0,0:00:10.00,0:00:13.40,ED,,0,0,0,,{\\t(600,900,\\fscx100)}GH',
|
||||
].join('\n');
|
||||
|
||||
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||
{
|
||||
startTime: 10,
|
||||
endTime: 14.2,
|
||||
text: 'ABCDEFGH',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 10,
|
||||
animationEndTime: 14.2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues recovers canonical comments from generated clip frames', () => {
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Comment: 0,0:00:01.00,0:00:03.00,OP_JP,,0,0,0,,雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.00,0:00:01.04,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 1 1)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.04,0:00:01.08,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 2 2)}雨が上がっても',
|
||||
'Dialogue: 0,0:00:01.08,0:00:03.00,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 3 3)}雨が上がっても',
|
||||
].join('\n');
|
||||
|
||||
const cues = parseSubtitleCues(content, 'test.ass');
|
||||
|
||||
assert.deepEqual(cues, [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: '雨が上がっても',
|
||||
source: 'canonical-ass',
|
||||
animationStartTime: 1,
|
||||
animationEndTime: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
|
||||
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
|
||||
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
|
||||
|
||||
@@ -6,12 +6,21 @@ import {
|
||||
type AssEffectKind,
|
||||
type AssOverrideCommand,
|
||||
} from './ass-text';
|
||||
import { mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
|
||||
export interface SubtitleCue {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
/** A complete authored line recovered from matching generated ASS animation events. */
|
||||
source?: 'canonical-ass';
|
||||
/**
|
||||
* Full span of the generated animation events a canonical cue replaced. Entrance and
|
||||
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
|
||||
* matching must use this envelope while display and history keep the authored timing.
|
||||
*/
|
||||
animationStartTime?: number;
|
||||
animationEndTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,7 +28,8 @@ export interface SubtitleCue {
|
||||
* Deduplication needs the authoring context -- which style the line belongs to, which
|
||||
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
||||
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
||||
* outside the parser, so the public API stays `{startTime, endTime, text}`.
|
||||
* outside the parser, so the public API exposes only timing, text, and the optional
|
||||
* canonical-source marker used by live subtitle consumers.
|
||||
*/
|
||||
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||
/** Text exactly as authored, override blocks and all. */
|
||||
@@ -70,7 +80,11 @@ function sanitizeSubtitleCueText(text: string): string {
|
||||
}
|
||||
|
||||
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||
return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
|
||||
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
|
||||
source
|
||||
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
|
||||
: { startTime, endTime, text },
|
||||
);
|
||||
}
|
||||
|
||||
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
|
||||
@@ -138,7 +152,13 @@ export function parseSrtCues(content: string): SubtitleCue[] {
|
||||
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
|
||||
const ASS_FORMAT_PREFIX = 'Format:';
|
||||
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
|
||||
const ASS_COMMENT_PREFIX = 'Comment:';
|
||||
const ASS_NAME_FIELD_ALIASES = ['name', 'actor'];
|
||||
const CANONICAL_MATCH_MARGIN_SECONDS = 1;
|
||||
const MIN_CANONICAL_ANIMATION_EVENTS = 3;
|
||||
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
||||
// not enough evidence that the fragment represents an authored line boundary.
|
||||
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
||||
|
||||
function parseAssTimestamp(raw: string): number | null {
|
||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||
@@ -166,10 +186,333 @@ function findFieldIndex(formatFields: string[], aliases: string[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
interface ParsedAssEvents {
|
||||
dialogue: AnnotatedSubtitleCue[];
|
||||
comments: AnnotatedSubtitleCue[];
|
||||
}
|
||||
|
||||
// Every candidate line re-reads the compacted text of each event in its window, so on
|
||||
// fragment-heavy scripts the same event compacts thousands of times without this cache.
|
||||
const compactMatchTextCache = new WeakMap<AnnotatedSubtitleCue, string>();
|
||||
|
||||
function compactAssMatchText(text: string): string {
|
||||
return text.replace(/\s+/gu, '');
|
||||
}
|
||||
|
||||
function compactCueMatchText(cue: AnnotatedSubtitleCue): string {
|
||||
let compact = compactMatchTextCache.get(cue);
|
||||
if (compact === undefined) {
|
||||
compact = compactAssMatchText(cue.text);
|
||||
compactMatchTextCache.set(cue, compact);
|
||||
}
|
||||
return compact;
|
||||
}
|
||||
|
||||
function assEventGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||
return `${cue.style}\0${cue.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed lookup over one style/name group. Every candidate line queries its time
|
||||
* neighborhood, and fragment-heavy scripts put thousands of candidates in one group, so
|
||||
* a linear rescan per candidate is quadratic in practice. Events are sorted by start
|
||||
* once; `prefixMaxEnd` lets the backward walk stop as soon as no earlier event can still
|
||||
* reach the window.
|
||||
*/
|
||||
interface AssEventGroupIndex {
|
||||
byStart: AnnotatedSubtitleCue[];
|
||||
prefixMaxEnd: number[];
|
||||
}
|
||||
|
||||
function buildAssEventGroupIndex(events: readonly AnnotatedSubtitleCue[]): AssEventGroupIndex {
|
||||
const byStart = [...events].sort((a, b) => a.startTime - b.startTime || a.order - b.order);
|
||||
const prefixMaxEnd: number[] = [];
|
||||
let maxEnd = -Infinity;
|
||||
for (const event of byStart) {
|
||||
maxEnd = Math.max(maxEnd, event.endTime);
|
||||
prefixMaxEnd.push(maxEnd);
|
||||
}
|
||||
return { byStart, prefixMaxEnd };
|
||||
}
|
||||
|
||||
/** Group events overlapping `[startTime, endTime]`, returned in source order. */
|
||||
function eventsOverlappingWindow(
|
||||
index: AssEventGroupIndex,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const { byStart, prefixMaxEnd } = index;
|
||||
let low = 0;
|
||||
let high = byStart.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (byStart[mid]!.startTime <= endTime) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
const matches: AnnotatedSubtitleCue[] = [];
|
||||
for (let i = low - 1; i >= 0 && prefixMaxEnd[i]! >= startTime; i -= 1) {
|
||||
if (byStart[i]!.endTime >= startTime) {
|
||||
matches.push(byStart[i]!);
|
||||
}
|
||||
}
|
||||
return matches.sort((a, b) => a.order - b.order);
|
||||
}
|
||||
|
||||
interface FragmentGroup {
|
||||
text: string;
|
||||
events: AnnotatedSubtitleCue[];
|
||||
}
|
||||
|
||||
function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
|
||||
const anchors = new Set<string>();
|
||||
for (const command of event.overrides) {
|
||||
const name = command.name.toLowerCase();
|
||||
const args = command.args.split(',').map((value) => value.trim());
|
||||
if (name === 'pos' && args.length >= 2) {
|
||||
anchors.add(`pos:${args[0]},${args[1]}`);
|
||||
} else if (name === 'move' && args.length >= 4) {
|
||||
anchors.add(`move:${args[0]},${args[1]}`);
|
||||
anchors.add(`move:${args[2]},${args[3]}`);
|
||||
}
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function isRepeatedFragmentCopy(
|
||||
previous: AnnotatedSubtitleCue,
|
||||
current: AnnotatedSubtitleCue,
|
||||
): boolean {
|
||||
const previousAnchors = fragmentPlacementAnchors(previous);
|
||||
if ([...fragmentPlacementAnchors(current)].some((anchor) => previousAnchors.has(anchor))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
previous.startTime === current.startTime &&
|
||||
previous.endTime === current.endTime &&
|
||||
previous.overrideSignature === current.overrideSignature
|
||||
);
|
||||
}
|
||||
|
||||
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
||||
const groups: FragmentGroup[] = [];
|
||||
for (const event of events) {
|
||||
const text = compactCueMatchText(event);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const previous = groups.at(-1);
|
||||
if (
|
||||
previous?.text === text &&
|
||||
previous.events.some((previousEvent) => isRepeatedFragmentCopy(previousEvent, event))
|
||||
) {
|
||||
previous.events.push(event);
|
||||
} else {
|
||||
groups.push({ text, events: [event] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function findCanonicalFragmentEvents(
|
||||
events: readonly AnnotatedSubtitleCue[],
|
||||
canonicalText: string,
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const groups = groupConsecutiveAssFragments(events);
|
||||
const matches = new Set<AnnotatedSubtitleCue>();
|
||||
|
||||
for (let start = 0; start < groups.length; start += 1) {
|
||||
let combined = '';
|
||||
for (let end = start; end < groups.length; end += 1) {
|
||||
const group = groups[end]!;
|
||||
// A complete rendered copy cannot prove that the neighboring events are its
|
||||
// fragments. Exact full-line animation is handled separately for comments.
|
||||
if (group.text.length >= canonicalText.length) {
|
||||
break;
|
||||
}
|
||||
const next = combined + group.text;
|
||||
if (!canonicalText.startsWith(next)) {
|
||||
break;
|
||||
}
|
||||
combined = next;
|
||||
if (combined !== canonicalText) {
|
||||
continue;
|
||||
}
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
for (const event of groups[index]!.events) {
|
||||
matches.add(event);
|
||||
}
|
||||
}
|
||||
start = end;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...matches];
|
||||
}
|
||||
|
||||
function matchingAssAnimationEvents(options: {
|
||||
candidate: AnnotatedSubtitleCue;
|
||||
group: AssEventGroupIndex;
|
||||
allowFullLineFrames: boolean;
|
||||
}): AnnotatedSubtitleCue[] {
|
||||
const canonicalText = compactCueMatchText(options.candidate);
|
||||
// The group index already restricts to the candidate's style and name.
|
||||
const nearby = eventsOverlappingWindow(
|
||||
options.group,
|
||||
options.candidate.startTime - CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
options.candidate.endTime + CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
);
|
||||
const fragments = findCanonicalFragmentEvents(nearby, canonicalText);
|
||||
if (fragments.length >= MIN_CANONICAL_ANIMATION_EVENTS && hasAssAnimationEvidence(fragments)) {
|
||||
return fragments;
|
||||
}
|
||||
|
||||
if (!options.allowFullLineFrames) {
|
||||
return [];
|
||||
}
|
||||
const fullLineFrames = nearby.filter((cue) => compactCueMatchText(cue) === canonicalText);
|
||||
return fullLineFrames.length >= MIN_CANONICAL_ANIMATION_EVENTS &&
|
||||
hasAssAnimationEvidence(fullLineFrames)
|
||||
? fullLineFrames
|
||||
: [];
|
||||
}
|
||||
|
||||
// Reductions rather than `Math.min(...events)`: one generated line can carry an
|
||||
// unbounded number of events, and spreading them all as arguments risks the engine's
|
||||
// argument-count limit.
|
||||
function earliestStartTime(events: readonly AnnotatedSubtitleCue[], seed = Infinity): number {
|
||||
return events.reduce((earliest, event) => Math.min(earliest, event.startTime), seed);
|
||||
}
|
||||
|
||||
function latestEndTime(events: readonly AnnotatedSubtitleCue[], seed = -Infinity): number {
|
||||
return events.reduce((latest, event) => Math.max(latest, event.endTime), seed);
|
||||
}
|
||||
|
||||
function includeCanonicalBoundaryEvents(options: {
|
||||
candidate: AnnotatedSubtitleCue;
|
||||
group: AssEventGroupIndex;
|
||||
animationEvents: readonly AnnotatedSubtitleCue[];
|
||||
}): AnnotatedSubtitleCue[] {
|
||||
const canonicalText = compactCueMatchText(options.candidate);
|
||||
const startTime = earliestStartTime(options.animationEvents);
|
||||
const endTime = latestEndTime(options.animationEvents);
|
||||
return eventsOverlappingWindow(
|
||||
options.group,
|
||||
startTime - CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
endTime + CANONICAL_MATCH_MARGIN_SECONDS,
|
||||
).filter((cue) => compactCueMatchText(cue) === canonicalText);
|
||||
}
|
||||
|
||||
function recoverCanonicalAssEvents({
|
||||
dialogue,
|
||||
comments,
|
||||
}: ParsedAssEvents): AnnotatedSubtitleCue[] {
|
||||
const recovered: AnnotatedSubtitleCue[] = [];
|
||||
const suppressed = new Set<AnnotatedSubtitleCue>();
|
||||
// A recovery is only as good as its owning event. When a later candidate proves that
|
||||
// an earlier candidate was itself a generated frame of its animation, the earlier
|
||||
// recovery is a duplicate of the same authored line and must be withdrawn.
|
||||
const recoveredByOwner = new Map<AnnotatedSubtitleCue, AnnotatedSubtitleCue>();
|
||||
const withdrawn = new Set<AnnotatedSubtitleCue>();
|
||||
const eventsByGroup = new Map<string, AnnotatedSubtitleCue[]>();
|
||||
for (const cue of dialogue) {
|
||||
const key = assEventGroupKey(cue);
|
||||
const group = eventsByGroup.get(key);
|
||||
if (group) {
|
||||
group.push(cue);
|
||||
} else {
|
||||
eventsByGroup.set(key, [cue]);
|
||||
}
|
||||
}
|
||||
const indexByGroup = new Map<string, AssEventGroupIndex>();
|
||||
for (const [key, events] of eventsByGroup) {
|
||||
indexByGroup.set(key, buildAssEventGroupIndex(events));
|
||||
}
|
||||
const emptyGroupIndex: AssEventGroupIndex = { byStart: [], prefixMaxEnd: [] };
|
||||
const candidates = [
|
||||
...comments.map((cue) => ({ cue, kind: 'comment' as const })),
|
||||
...dialogue
|
||||
.filter(
|
||||
(cue) =>
|
||||
compactCueMatchText(cue).length >= MIN_CANONICAL_DIALOGUE_TEXT_LENGTH &&
|
||||
hasAssAnimationEvidence([cue]),
|
||||
)
|
||||
.sort((left, right) => right.text.length - left.text.length || left.order - right.order)
|
||||
.map((cue) => ({ cue, kind: 'dialogue' as const })),
|
||||
];
|
||||
|
||||
for (const { cue: candidate, kind } of candidates) {
|
||||
if (candidate.endTime <= candidate.startTime || suppressed.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const canonicalText = compactCueMatchText(candidate);
|
||||
if (!canonicalText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const group = indexByGroup.get(assEventGroupKey(candidate)) ?? emptyGroupIndex;
|
||||
const animationEvents = matchingAssAnimationEvents({
|
||||
candidate,
|
||||
group,
|
||||
allowFullLineFrames: kind === 'comment',
|
||||
});
|
||||
if (animationEvents.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const boundaryEvents = includeCanonicalBoundaryEvents({
|
||||
candidate,
|
||||
group,
|
||||
animationEvents,
|
||||
});
|
||||
const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])];
|
||||
const animationStartTime = earliestStartTime(generatedEvents, candidate.startTime);
|
||||
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
|
||||
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
|
||||
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
|
||||
const recoveredCue: AnnotatedSubtitleCue = {
|
||||
...candidate,
|
||||
startTime,
|
||||
endTime,
|
||||
animationStartTime,
|
||||
animationEndTime,
|
||||
source: 'canonical-ass',
|
||||
};
|
||||
recovered.push(recoveredCue);
|
||||
recoveredByOwner.set(candidate, recoveredCue);
|
||||
for (const event of generatedEvents) {
|
||||
suppressed.add(event);
|
||||
if (event === candidate) {
|
||||
continue;
|
||||
}
|
||||
const priorRecovery = recoveredByOwner.get(event);
|
||||
if (priorRecovery) {
|
||||
// No text is lost by withdrawing: a fragment claim means the withdrawn line is
|
||||
// a contiguous piece of this candidate's text, and a boundary claim means the
|
||||
// texts are equal, so the surviving canonical cue always contains it.
|
||||
withdrawn.add(priorRecovery);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const survivingRecovered = recovered.filter((cue) => !withdrawn.has(cue));
|
||||
if (survivingRecovered.length === 0) {
|
||||
return dialogue;
|
||||
}
|
||||
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...survivingRecovered].sort(
|
||||
(a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order,
|
||||
);
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inEventsSection = false;
|
||||
let eventOrder = 0;
|
||||
const fieldIndex = {
|
||||
start: -1,
|
||||
end: -1,
|
||||
@@ -222,7 +565,12 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
|
||||
const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX)
|
||||
? ASS_DIALOGUE_PREFIX
|
||||
: trimmed.startsWith(ASS_COMMENT_PREFIX)
|
||||
? ASS_COMMENT_PREFIX
|
||||
: null;
|
||||
if (!eventPrefix) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -230,7 +578,7 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
|
||||
const fields = trimmed.slice(eventPrefix.length).split(',');
|
||||
if (
|
||||
fieldIndex.start >= fields.length ||
|
||||
fieldIndex.end >= fields.length ||
|
||||
@@ -254,7 +602,7 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
const effect = readField(fields, fieldIndex.effect);
|
||||
const layer = Number(readField(fields, fieldIndex.layer));
|
||||
const overrides = collectAssOverrideCommands(rawText);
|
||||
cues.push({
|
||||
const cue: AnnotatedSubtitleCue = {
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
@@ -266,11 +614,21 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
effectKind: parseAssEffectField(effect),
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
order: cues.length,
|
||||
});
|
||||
order: eventOrder,
|
||||
};
|
||||
eventOrder += 1;
|
||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||
comments.push(cue);
|
||||
} else {
|
||||
cues.push(cue);
|
||||
}
|
||||
}
|
||||
|
||||
return cues;
|
||||
return { dialogue: cues, comments };
|
||||
}
|
||||
|
||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
|
||||
}
|
||||
|
||||
export function parseAssCues(content: string): SubtitleCue[] {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* layer that keeps the two views consistent by construction.
|
||||
* 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted)
|
||||
* fall back to timing alone. No authoring metadata is available live -- mpv delivers
|
||||
* `sub-text-ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the
|
||||
* `sub-text/ass` after `sub-start`/`sub-end`, so any ASS text read here belongs to the
|
||||
* previous event -- which puts this layer in the same position as the SRT path in
|
||||
* `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds.
|
||||
*/
|
||||
|
||||
@@ -24,10 +24,17 @@ import {
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
applyBackgroundBootstrapCommandLineSwitches,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveAppControlHandoffTimeoutMs,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('app-control handoffs allow for macOS application activation latency', () => {
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('darwin'), 3000);
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('linux'), 500);
|
||||
assert.equal(resolveAppControlHandoffTimeoutMs('win32'), 500);
|
||||
});
|
||||
|
||||
test('detached app launch policy stays in the startup runtime utilities', () => {
|
||||
const entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
||||
const runtimeSource = fs.readFileSync(
|
||||
|
||||
@@ -14,6 +14,8 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||
const APP_NAME = 'SubMiner';
|
||||
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
|
||||
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
'--alang',
|
||||
'--audio-file',
|
||||
@@ -186,6 +188,14 @@ export function shouldForwardStartupArgvViaAppControl(
|
||||
return hasExplicitCommand(args);
|
||||
}
|
||||
|
||||
export function resolveAppControlHandoffTimeoutMs(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): number {
|
||||
return platform === 'darwin'
|
||||
? MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS
|
||||
: DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||
if (rawCount === undefined) {
|
||||
|
||||
+2
-1
@@ -9,6 +9,7 @@ import {
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveAppControlHandoffTimeoutMs,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
@@ -214,7 +215,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
||||
|
||||
const result = await sendAppControlCommand(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
timeoutMs: resolveAppControlHandoffTimeoutMs(),
|
||||
});
|
||||
if (result.ok) {
|
||||
app.exit(0);
|
||||
|
||||
+42
-6
@@ -235,6 +235,7 @@ import {
|
||||
createCycleSecondarySubModeRuntimeHandler,
|
||||
} from './main/runtime/domains/mpv';
|
||||
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
|
||||
import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text';
|
||||
import {
|
||||
createBuildCopyCurrentSubtitleMainDepsHandler,
|
||||
createBuildHandleMineSentenceDigitMainDepsHandler,
|
||||
@@ -331,6 +332,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';
|
||||
@@ -1805,10 +1807,42 @@ async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
|
||||
let appTray: Tray | null = null;
|
||||
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
|
||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
liveText: payload.text,
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||
endTime: appState.mpvClient?.currentSubEnd ?? null,
|
||||
startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null,
|
||||
endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// Same validity bar as the live capture path: an unusable canonical span must fall
|
||||
// back rather than hand mining an empty line or an inverted range.
|
||||
const canonicalText = canonical?.text.trim();
|
||||
if (
|
||||
!canonical ||
|
||||
!canonicalText ||
|
||||
!Number.isFinite(canonical.startTime) ||
|
||||
!Number.isFinite(canonical.endTime) ||
|
||||
canonical.endTime <= canonical.startTime
|
||||
) {
|
||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||
}
|
||||
return {
|
||||
source: 'overlay',
|
||||
text: canonicalText,
|
||||
startTime: canonical.startTime,
|
||||
endTime: canonical.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
};
|
||||
}
|
||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||
@@ -5009,6 +5043,9 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
|
||||
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
|
||||
overlayModalRuntime.primeModalWindow();
|
||||
}
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
@@ -5227,6 +5264,7 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
|
||||
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => appState.ankiIntegration,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
|
||||
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
|
||||
mineSentenceCardCore,
|
||||
recordCardsMined: (count, noteIds) => {
|
||||
@@ -5466,7 +5504,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
senderWindow === modalWindow &&
|
||||
!senderWindow.isDestroyed()
|
||||
) {
|
||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(senderWindow);
|
||||
senderWindow.hide();
|
||||
}
|
||||
handleOverlayModalClosedHandler(modal);
|
||||
@@ -5540,9 +5578,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
recordSubtitleMiningContext: (context) =>
|
||||
recordSubtitleMiningContext(
|
||||
context ?? captureLiveSubtitleMiningContext(appState.mpvClient),
|
||||
),
|
||||
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
|
||||
quitApp: () => requestAppQuit(),
|
||||
toggleVisibleOverlay: () => toggleVisibleOverlay(),
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ type MockWindow = {
|
||||
loading: boolean;
|
||||
url: string;
|
||||
contentReady: boolean;
|
||||
documentLoaded: boolean;
|
||||
loadCallbacks: Array<() => void>;
|
||||
readyToShowCallbacks: Array<() => void>;
|
||||
};
|
||||
@@ -31,6 +32,7 @@ function createMockWindow(): MockWindow & {
|
||||
getShowCount: () => number;
|
||||
getHideCount: () => number;
|
||||
show: () => void;
|
||||
showInactive: () => void;
|
||||
hide: () => void;
|
||||
destroy: () => void;
|
||||
focus: () => void;
|
||||
@@ -61,6 +63,7 @@ function createMockWindow(): MockWindow & {
|
||||
loading: false,
|
||||
url: 'file:///overlay/index.html?layer=modal',
|
||||
contentReady: true,
|
||||
documentLoaded: true,
|
||||
loadCallbacks: [],
|
||||
readyToShowCallbacks: [],
|
||||
};
|
||||
@@ -84,6 +87,10 @@ function createMockWindow(): MockWindow & {
|
||||
state.visible = true;
|
||||
state.showCount += 1;
|
||||
},
|
||||
showInactive: () => {
|
||||
state.visible = true;
|
||||
state.showCount += 1;
|
||||
},
|
||||
hide: () => {
|
||||
state.visible = false;
|
||||
state.hideCount += 1;
|
||||
@@ -96,6 +103,10 @@ function createMockWindow(): MockWindow & {
|
||||
state.focused = true;
|
||||
},
|
||||
emitDidFinishLoad: () => {
|
||||
state.documentLoaded = true;
|
||||
(
|
||||
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||
).__subminerOverlayDocumentLoaded = true;
|
||||
const callbacks = state.loadCallbacks.splice(0);
|
||||
for (const callback of callbacks) {
|
||||
callback();
|
||||
@@ -197,9 +208,22 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'documentLoaded', {
|
||||
get: () => state.documentLoaded,
|
||||
set: (value: boolean) => {
|
||||
state.documentLoaded = value;
|
||||
(
|
||||
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||
).__subminerOverlayDocumentLoaded = value;
|
||||
},
|
||||
});
|
||||
|
||||
(
|
||||
window as typeof window & { __subminerOverlayContentReady?: boolean }
|
||||
).__subminerOverlayContentReady = state.contentReady;
|
||||
(
|
||||
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||
).__subminerOverlayDocumentLoaded = state.documentLoaded;
|
||||
|
||||
return window;
|
||||
}
|
||||
@@ -259,6 +283,73 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
||||
assert.deepEqual(window.sent, [['jimaku:open']]);
|
||||
});
|
||||
|
||||
for (const platform of ['darwin', 'win32'] as const) {
|
||||
test(`primeModalWindow creates and warms a hidden modal on ${platform}`, () => {
|
||||
const modalWindow = createMockWindow();
|
||||
modalWindow.loading = true;
|
||||
modalWindow.url = '';
|
||||
modalWindow.contentReady = false;
|
||||
modalWindow.documentLoaded = false;
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = null;
|
||||
let createCalls = 0;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
currentModal = modalWindow;
|
||||
return modalWindow as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 1, y: 2, width: 300, height: 200 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform },
|
||||
);
|
||||
|
||||
assert.equal(runtime.primeModalWindow(), true);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(modalWindow.isVisible(), false);
|
||||
|
||||
modalWindow.loading = false;
|
||||
modalWindow.url = 'file:///overlay/index.html?layer=modal';
|
||||
modalWindow.emitDidFinishLoad();
|
||||
modalWindow.emitReadyToShow();
|
||||
modalWindow.contentReady = true;
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.deepEqual(modalWindow.sent, [['session-help:open']]);
|
||||
});
|
||||
}
|
||||
|
||||
test('primeModalWindow leaves Linux modal creation lazy', () => {
|
||||
let createCalls = 0;
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => null,
|
||||
createModalWindow: () => {
|
||||
createCalls += 1;
|
||||
return createMockWindow() as never;
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'linux' },
|
||||
);
|
||||
|
||||
assert.equal(runtime.primeModalWindow(), false);
|
||||
assert.equal(createCalls, 0);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -301,7 +392,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
@@ -311,15 +402,18 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed hides modal window only after all pending modals close', () => {
|
||||
test('handleOverlayModalClosed keeps the modal window warm after all pending modals close', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
@@ -342,7 +436,9 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
|
||||
runtime.handleOverlayModalClosed('subsync');
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
assert.equal(window.isVisible(), false);
|
||||
assert.equal(window.ignoreMouseEvents, true);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow prefers visible main overlay window for modal open', () => {
|
||||
@@ -464,6 +560,46 @@ test('modal window path restores visible main overlay before modal input deactiv
|
||||
assert.deepEqual(events, ['state:true:visible:true', 'state:false:visible:true']);
|
||||
});
|
||||
|
||||
test('macOS maps a new modal panel before focusing SubMiner and hiding the subtitle overlay', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
const modalWindow = createMockWindow();
|
||||
const events: string[] = [];
|
||||
const showInactive = modalWindow.showInactive;
|
||||
modalWindow.showInactive = () => {
|
||||
events.push('show-inactive');
|
||||
showInactive();
|
||||
};
|
||||
const hideMainWindow = mainWindow.hide;
|
||||
mainWindow.hide = () => {
|
||||
events.push('hide-main');
|
||||
hideMainWindow();
|
||||
};
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => mainWindow as never,
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{
|
||||
platform: 'darwin',
|
||||
focusApplication: () => events.push('focus-application'),
|
||||
},
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
preferModalWindow: true,
|
||||
});
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.deepEqual(events, ['show-inactive', 'focus-application', 'hide-main']);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
assert.equal(mainWindow.isVisible(), false);
|
||||
});
|
||||
|
||||
test('modal window path runs final close handoff before modal input deactivates', () => {
|
||||
const mainWindow = createMockWindow();
|
||||
mainWindow.visible = true;
|
||||
@@ -650,15 +786,18 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
|
||||
assert.deepEqual(state, []);
|
||||
});
|
||||
|
||||
test('handleOverlayModalClosed destroys modal window for single kiku modal', () => {
|
||||
test('handleOverlayModalClosed hides and retains modal window for single kiku modal', () => {
|
||||
const window = createMockWindow();
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => window as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
{ platform: 'darwin' },
|
||||
);
|
||||
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'kiku:field-grouping-open',
|
||||
@@ -669,7 +808,9 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
|
||||
);
|
||||
runtime.handleOverlayModalClosed('kiku');
|
||||
|
||||
assert.equal(window.isDestroyed(), true);
|
||||
assert.equal(window.isDestroyed(), false);
|
||||
assert.equal(window.isVisible(), false);
|
||||
assert.equal(window.ignoreMouseEvents, true);
|
||||
assert.equal(runtime.getRestoreVisibleOverlayOnModalClose().size, 0);
|
||||
});
|
||||
|
||||
@@ -719,8 +860,10 @@ test('modal fallback reveal skips showing window when content is not ready', asy
|
||||
assert.equal(window.ignoreMouseEvents, false);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering open event', () => {
|
||||
test('sendToActiveOverlayWindow delivers on first modal load without waiting for ready-to-show', () => {
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -738,16 +881,100 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.deepEqual(window.sent, []);
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow delivers when the modal loaded before listeners were registered', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow does not infer document readiness from a pending file URL', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = false;
|
||||
window.documentLoaded = false;
|
||||
window.loading = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow rejects stale content readiness during document reload', () => {
|
||||
const window = createMockWindow();
|
||||
window.contentReady = true;
|
||||
window.documentLoaded = false;
|
||||
window.loading = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => window as never,
|
||||
createModalWindow: () => {
|
||||
throw new Error('modal window should not be created when already present');
|
||||
},
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, [['session-help:open']]);
|
||||
});
|
||||
|
||||
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
|
||||
const window = createMockWindow();
|
||||
window.loading = true;
|
||||
window.url = '';
|
||||
window.contentReady = false;
|
||||
const runtime = createOverlayModalRuntimeService({
|
||||
getMainWindow: () => null,
|
||||
@@ -773,29 +1000,73 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
|
||||
);
|
||||
assert.deepEqual(window.sent, []);
|
||||
|
||||
window.loading = false;
|
||||
window.url = 'file:///overlay/index.html?layer=modal';
|
||||
window.emitDidFinishLoad();
|
||||
assert.deepEqual(window.sent, []);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
|
||||
window.contentReady = true;
|
||||
window.emitReadyToShow();
|
||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
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: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
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: () => {},
|
||||
},
|
||||
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);
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -804,30 +1075,30 @@ test('modal reopen creates a fresh window after close destroys the previous one'
|
||||
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('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||
restoreOnModalClose: 'session-help',
|
||||
});
|
||||
|
||||
assert.equal(sent, true);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
assert.equal(secondWindow.getShowCount(), 0);
|
||||
assert.equal(createCalls, 1);
|
||||
assert.equal(replacementWindow.isVisible(), true);
|
||||
assert.equal(replacementWindow.ignoreMouseEvents, false);
|
||||
assert.deepEqual(replacementWindow.sent, [['session-help:open']]);
|
||||
});
|
||||
|
||||
test('modal reopen after close-destroy notifies state change on fresh window lifecycle', () => {
|
||||
const firstWindow = createMockWindow();
|
||||
const secondWindow = createMockWindow();
|
||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
||||
test('modal reopen on the warm window notifies state change for each lifecycle', () => {
|
||||
const modalWindow = createMockWindow();
|
||||
const state: boolean[] = [];
|
||||
|
||||
const runtime = createOverlayModalRuntimeService(
|
||||
{
|
||||
getMainWindow: () => null,
|
||||
getModalWindow: () => currentModal as never,
|
||||
createModalWindow: () => {
|
||||
currentModal = secondWindow;
|
||||
return secondWindow as never;
|
||||
},
|
||||
getModalWindow: () => modalWindow as never,
|
||||
createModalWindow: () => modalWindow as never,
|
||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||
setModalWindowBounds: () => {},
|
||||
},
|
||||
@@ -835,6 +1106,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
onModalStateChange: (active: boolean): void => {
|
||||
state.push(active);
|
||||
},
|
||||
platform: 'darwin',
|
||||
},
|
||||
);
|
||||
|
||||
@@ -845,7 +1117,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
runtime.handleOverlayModalClosed('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false]);
|
||||
assert.equal(firstWindow.isDestroyed(), true);
|
||||
assert.equal(modalWindow.isDestroyed(), false);
|
||||
|
||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||
restoreOnModalClose: 'runtime-options',
|
||||
@@ -853,7 +1125,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
|
||||
assert.deepEqual(state, [true, false, true]);
|
||||
assert.equal(currentModal, secondWindow);
|
||||
assert.equal(modalWindow.isVisible(), true);
|
||||
});
|
||||
|
||||
test('visible stale modal window is made interactive again before reopening', () => {
|
||||
|
||||
+101
-19
@@ -2,7 +2,11 @@ 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 { OVERLAY_WINDOW_CONTENT_READY_FLAG } from '../core/services/overlay-window-flags';
|
||||
import { applyOverlayClickThrough } from '../core/services/overlay-click-through';
|
||||
import {
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG,
|
||||
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG,
|
||||
} from '../core/services/overlay-window-flags';
|
||||
|
||||
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
||||
@@ -39,6 +43,7 @@ export interface OverlayWindowResolver {
|
||||
}
|
||||
|
||||
export interface OverlayModalRuntime {
|
||||
primeModalWindow: () => boolean;
|
||||
sendToActiveOverlayWindow: (
|
||||
channel: string,
|
||||
payload?: unknown,
|
||||
@@ -59,6 +64,8 @@ export interface OverlayModalRuntime {
|
||||
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||
|
||||
export interface OverlayModalRuntimeOptions {
|
||||
platform?: NodeJS.Platform;
|
||||
focusApplication?: () => void;
|
||||
onModalStateChange?: (isActive: boolean) => void;
|
||||
onFinalModalClosed?: () => void;
|
||||
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
||||
@@ -79,6 +86,11 @@ export function createOverlayModalRuntimeService(
|
||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
||||
const modalWindowPrimeListenersRegistered = new WeakSet<BrowserWindow>();
|
||||
const platform = options.platform ?? process.platform;
|
||||
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);
|
||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||
@@ -134,7 +146,11 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
||||
@@ -145,6 +161,50 @@ export function createOverlayModalRuntimeService(
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
|
||||
const isWindowLoadedForIpc = (window: BrowserWindow): boolean => {
|
||||
if (window.isDestroyed() || window.webContents.isLoading()) {
|
||||
return false;
|
||||
}
|
||||
const overlayWindow = window as BrowserWindow & {
|
||||
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||
};
|
||||
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] !== true) {
|
||||
return false;
|
||||
}
|
||||
const currentURL = window.webContents.getURL();
|
||||
return currentURL !== '' && currentURL !== 'about:blank';
|
||||
};
|
||||
|
||||
const markModalWindowPrimed = (window: BrowserWindow): void => {
|
||||
if (deps.getModalWindow() !== window || !isWindowLoadedForIpc(window)) {
|
||||
return;
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = true;
|
||||
};
|
||||
|
||||
const primeModalWindow = (): boolean => {
|
||||
if (!shouldPrimeModalWindow) {
|
||||
return false;
|
||||
}
|
||||
const modalWindow = resolveModalWindow();
|
||||
if (!modalWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
deps.setModalWindowBounds(deps.getModalGeometry());
|
||||
if (isWindowReadyForIpc(modalWindow)) {
|
||||
modalWindowPrimedForImmediateShow = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!modalWindowPrimeListenersRegistered.has(modalWindow)) {
|
||||
modalWindowPrimeListenersRegistered.add(modalWindow);
|
||||
modalWindow.webContents.once('did-finish-load', () => markModalWindowPrimed(modalWindow));
|
||||
modalWindow.once('ready-to-show', () => markModalWindowPrimed(modalWindow));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||
if (window.isDestroyed()) return;
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
@@ -205,16 +265,19 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
let delivered = false;
|
||||
const deliverWhenReady = (): void => {
|
||||
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
|
||||
const deliver = (isReady: () => boolean): void => {
|
||||
if (delivered || window.isDestroyed() || !isReady()) {
|
||||
return;
|
||||
}
|
||||
delivered = true;
|
||||
sendNow(window);
|
||||
};
|
||||
|
||||
window.webContents.once('did-finish-load', deliverWhenReady);
|
||||
window.once('ready-to-show', deliverWhenReady);
|
||||
// A hidden macOS panel may not emit ready-to-show until it is presented. The
|
||||
// renderer can safely receive IPC as soon as its document has finished loading.
|
||||
window.webContents.once('did-finish-load', () => deliver(() => isWindowLoadedForIpc(window)));
|
||||
window.once('ready-to-show', () => deliver(() => isWindowReadyForIpc(window)));
|
||||
deliver(() => isWindowLoadedForIpc(window));
|
||||
};
|
||||
|
||||
const showModalWindow = (
|
||||
@@ -224,13 +287,20 @@ export function createOverlayModalRuntimeService(
|
||||
} = { passThroughMouseEvents: false },
|
||||
): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
if (!window.isVisible()) {
|
||||
const wasVisible = window.isVisible();
|
||||
if (!wasVisible && platform === 'darwin') {
|
||||
// Mapping the panel first keeps it attached to mpv's active fullscreen Space.
|
||||
window.showInactive();
|
||||
focusApplication();
|
||||
} else {
|
||||
focusApplication();
|
||||
}
|
||||
if (!wasVisible && platform !== 'darwin') {
|
||||
window.show();
|
||||
}
|
||||
elevateModalWindow(window);
|
||||
if (options.passThroughMouseEvents) {
|
||||
window.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(window, platform === 'win32');
|
||||
} else {
|
||||
window.setIgnoreMouseEvents(false);
|
||||
}
|
||||
@@ -245,11 +315,11 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||
setWindowFocusable(window);
|
||||
requestOverlayApplicationFocus();
|
||||
window.setIgnoreMouseEvents(false);
|
||||
elevateModalWindow(window);
|
||||
|
||||
if (window.isVisible()) {
|
||||
focusApplication();
|
||||
window.focus();
|
||||
window.webContents.focus();
|
||||
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||
@@ -291,7 +361,7 @@ export function createOverlayModalRuntimeService(
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
return;
|
||||
}
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
applyOverlayClickThrough(mainWindow, platform === 'win32');
|
||||
mainWindowMousePassthroughForcedByModal = true;
|
||||
return;
|
||||
}
|
||||
@@ -447,9 +517,21 @@ export function createOverlayModalRuntimeService(
|
||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||
clearPendingModalWindowReveal();
|
||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||
modalWindow.destroy();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
modalWindowPrimedForImmediateShow = false;
|
||||
mainWindowMousePassthroughForcedByModal = false;
|
||||
setMainWindowVisibilityForModal(false);
|
||||
try {
|
||||
@@ -478,17 +560,16 @@ export function createOverlayModalRuntimeService(
|
||||
}
|
||||
|
||||
const modalWindow = deps.getModalWindow();
|
||||
if (targetWindow.isVisible()) {
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
} else {
|
||||
showModalWindow(targetWindow);
|
||||
}
|
||||
|
||||
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||
setMainWindowMousePassthroughForModal(true);
|
||||
setMainWindowVisibilityForModal(true);
|
||||
}
|
||||
|
||||
if (targetWindow.isVisible()) {
|
||||
ensureModalWindowInteractive(targetWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
showModalWindow(targetWindow);
|
||||
};
|
||||
|
||||
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
|
||||
@@ -515,6 +596,7 @@ export function createOverlayModalRuntimeService(
|
||||
});
|
||||
|
||||
return {
|
||||
primeModalWindow,
|
||||
sendToActiveOverlayWindow,
|
||||
openRuntimeOptionsPalette,
|
||||
openJimaku,
|
||||
|
||||
@@ -64,11 +64,17 @@ test('anki action main deps builders map callbacks', async () => {
|
||||
const mine = createBuildMineSentenceCardMainDepsHandler({
|
||||
getAnkiIntegration: () => ({ enabled: true }),
|
||||
getMpvClient: () => ({ connected: true }),
|
||||
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
|
||||
showMpvOsd: (text) => calls.push(`mine:${text}`),
|
||||
mineSentenceCardCore: async () => true,
|
||||
recordCardsMined: (count) => calls.push(`cards:${count}`),
|
||||
})();
|
||||
assert.deepEqual(mine.getMpvClient(), { connected: true });
|
||||
assert.deepEqual(mine.getPrimarySubtitle?.(), {
|
||||
text: '正式な字幕',
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
});
|
||||
mine.showMpvOsd('m');
|
||||
await mine.mineSentenceCardCore({
|
||||
ankiIntegration: { enabled: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
|
||||
import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions';
|
||||
|
||||
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
|
||||
|
||||
@@ -72,10 +72,12 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
|
||||
export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
getMpvClient: () => TMpv;
|
||||
getPrimarySubtitle?: () => PrimarySubtitle | null;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
@@ -83,10 +85,14 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
|
||||
return () => ({
|
||||
getAnkiIntegration: () => deps.getAnkiIntegration(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
...(deps.getPrimarySubtitle
|
||||
? { getPrimarySubtitle: () => deps.getPrimarySubtitle?.() ?? null }
|
||||
: {}),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => deps.mineSentenceCardCore(options),
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
|
||||
|
||||
@@ -87,3 +87,20 @@ test('mine sentence handler records mined cards only when core returns true', as
|
||||
await mineSentenceCard();
|
||||
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
|
||||
});
|
||||
|
||||
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
|
||||
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
|
||||
const mineSentenceCard = createMineSentenceCardHandler({
|
||||
getAnkiIntegration: () => ({}),
|
||||
getMpvClient: () => ({}),
|
||||
getPrimarySubtitle: () => primarySubtitle,
|
||||
showMpvOsd: () => {},
|
||||
mineSentenceCardCore: async (options) => {
|
||||
assert.equal(options.primarySubtitle, primarySubtitle);
|
||||
return true;
|
||||
},
|
||||
recordCardsMined: () => {},
|
||||
});
|
||||
|
||||
await mineSentenceCard();
|
||||
});
|
||||
|
||||
@@ -2,6 +2,12 @@ type AnkiIntegrationLike = {
|
||||
refreshKnownWordCache: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type PrimarySubtitle = {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
|
||||
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
readClipboardText: () => string;
|
||||
@@ -69,18 +75,22 @@ export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
|
||||
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
|
||||
getAnkiIntegration: () => TAnki;
|
||||
getMpvClient: () => TMpv;
|
||||
getPrimarySubtitle?: () => PrimarySubtitle | null;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mineSentenceCardCore: (options: {
|
||||
ankiIntegration: TAnki;
|
||||
mpvClient: TMpv;
|
||||
primarySubtitle?: PrimarySubtitle;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}) => Promise<boolean>;
|
||||
recordCardsMined: (count: number, noteIds?: number[]) => void;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const primarySubtitle = deps.getPrimarySubtitle?.();
|
||||
const created = await deps.mineSentenceCardCore({
|
||||
ankiIntegration: deps.getAnkiIntegration(),
|
||||
mpvClient: deps.getMpvClient(),
|
||||
...(primarySubtitle ? { primarySubtitle } : {}),
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
});
|
||||
if (created) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
|
||||
|
||||
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
|
||||
@@ -141,6 +142,16 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveLivePrimarySubtitleText(text: string): string {
|
||||
const client = deps.getMpvClient();
|
||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||
return resolvePrimarySubtitleText({
|
||||
liveText: text,
|
||||
currentTimeSec,
|
||||
cues: deps.getActiveParsedSubtitleCues(),
|
||||
});
|
||||
}
|
||||
|
||||
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||
@@ -155,7 +166,8 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const text = resolveLivePrimarySubtitleText(liveText);
|
||||
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +187,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
|
||||
await primeVisibleOverlaySubtitleFromMpv({
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
resolvePrimarySubtitleText: (text) => resolveLivePrimarySubtitleText(text),
|
||||
setCurrentSubText: (text) => {
|
||||
deps.setCurrentSubText(text);
|
||||
},
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
getMpvClient: () => CurrentSubtitleMpvClient | null;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
resolvePrimarySubtitleText?: (text: string) => string;
|
||||
getCurrentSubtitleData: () => SubtitleData | null;
|
||||
consumeCachedSubtitle: (text: string) => SubtitleData | null;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
@@ -73,7 +74,8 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
|
||||
const text = deps.resolvePrimarySubtitleText?.(liveText) ?? liveText;
|
||||
deps.setCurrentSubText(text);
|
||||
|
||||
const primeSecondarySubtitle = async (): Promise<void> => {
|
||||
|
||||
@@ -26,6 +26,29 @@ test('subtitle change handler updates state and forwards uncached text without r
|
||||
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
|
||||
});
|
||||
|
||||
test('subtitle change handler consistently forwards resolved canonical text', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
resolveSubtitleText: () => '今 手にある物差しでは',
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => {
|
||||
calls.push(`lookup:${text}`);
|
||||
return null;
|
||||
},
|
||||
broadcastSubtitle: () => {},
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => {},
|
||||
});
|
||||
|
||||
handler({ text: '今今今手手手ににに' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set:今 手にある物差しでは',
|
||||
'lookup:今 手にある物差しでは',
|
||||
'process:今 手にある物差しでは',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle change handler clears immediately for empty subtitle text', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
|
||||
@@ -4,7 +4,8 @@ type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
|
||||
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||
|
||||
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
|
||||
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
|
||||
@@ -14,6 +15,7 @@ function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): bo
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
resolveSubtitleText?: (text: string) => string;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
@@ -22,7 +24,8 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
refreshDiscordPresence: () => void;
|
||||
logDebug?: (message: string) => void;
|
||||
}) {
|
||||
return ({ text }: { text: string }): void => {
|
||||
return ({ text: liveText }: { text: string }): void => {
|
||||
const text = deps.resolveSubtitleText?.(liveText) ?? liveText;
|
||||
deps.setCurrentSubText(text);
|
||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||
if (immediatePayload) {
|
||||
|
||||
@@ -43,6 +43,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
|
||||
setCurrentSubText: (text: string) => void;
|
||||
resolveSubtitleText?: (text: string) => string;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||
@@ -117,6 +118,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
});
|
||||
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
|
||||
resolveSubtitleText: deps.resolveSubtitleText,
|
||||
setCurrentSubText: (text) => deps.setCurrentSubText(text),
|
||||
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
|
||||
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
||||
|
||||
@@ -387,3 +387,170 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
||||
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
|
||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||
});
|
||||
|
||||
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 2 },
|
||||
immersionTracker: {
|
||||
recordSubtitleLine: (text: string, start: number, end: number) =>
|
||||
immersion.push({ text, start, end }),
|
||||
},
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string, start: number, end: number) =>
|
||||
timing.push({ text, start, end }),
|
||||
},
|
||||
activeParsedSubtitleCues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある物差しでは',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
{
|
||||
startTime: 3,
|
||||
endTime: 6,
|
||||
text: '飛び越えてみたくて',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
currentMediaPath: '/video.mkv',
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
|
||||
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
|
||||
handlers.recordImmersionSubtitleLine('手', 0.86, 1.56);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
|
||||
// Concurrent dialogue during the song is not part of the animation: it must be
|
||||
// recorded as itself -- without the fragment lines beside it -- and must not cause
|
||||
// the song line to be recorded again when the animation frames resume.
|
||||
assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手');
|
||||
handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2);
|
||||
handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9);
|
||||
handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0);
|
||||
|
||||
assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]);
|
||||
assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]);
|
||||
|
||||
// Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A).
|
||||
// Every recorded cue is remembered, so each authored line still records exactly once.
|
||||
handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4);
|
||||
handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7);
|
||||
handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4);
|
||||
handlers.recordSubtitleTiming('手にある', 3.5, 3.7);
|
||||
|
||||
assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
|
||||
assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
|
||||
|
||||
// A backward seek means the user is rewatching: the timing history (a viewing log)
|
||||
// records the revisited line again, while immersion stays once-per-media.
|
||||
handlers.onTimePosUpdate?.(30);
|
||||
handlers.onTimePosUpdate?.(2);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
assert.equal(immersion.length, 3);
|
||||
|
||||
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
|
||||
// handler's own `>=` boundary.
|
||||
handlers.onTimePosUpdate?.(4.5);
|
||||
handlers.onTimePosUpdate?.(2);
|
||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||
|
||||
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||
});
|
||||
|
||||
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
|
||||
const appState = {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: { currentTimePos: 2 },
|
||||
immersionTracker: { recordSubtitleLine: () => {} },
|
||||
subtitleTimingTracker: { recordSubtitle: () => {} },
|
||||
activeParsedSubtitleCues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある物差しでは',
|
||||
source: 'canonical-ass' as const,
|
||||
},
|
||||
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
|
||||
activeParsedSubtitleSource: 'track-a.ass' as string | null,
|
||||
currentMediaPath: '/video.mkv',
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
};
|
||||
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState,
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
|
||||
|
||||
// The new track's cues arrive only after an async re-parse; until then, the old
|
||||
// track's canonical lyric must not replace the new track's live text.
|
||||
handlers.onSubtitleTrackChange?.(2);
|
||||
|
||||
assert.deepEqual(appState.activeParsedSubtitleCues, []);
|
||||
assert.equal(appState.activeParsedSubtitleSource, null);
|
||||
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
} from './primary-subtitle-text';
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
@@ -36,6 +42,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
||||
} | null;
|
||||
activeParsedSubtitleCues?: SubtitleCue[] | null;
|
||||
/** Cache key of the source the cues were parsed from; cleared with the cues. */
|
||||
activeParsedSubtitleSource?: string | null;
|
||||
currentMediaPath?: string | null;
|
||||
currentSubText: string;
|
||||
currentSubAssText: string;
|
||||
@@ -93,6 +101,38 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
const immersionLineDedupGate = createSubtitleLineDedupGate({
|
||||
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// One seen-set per consumer: canonical cues overlap, so live samples resolve to
|
||||
// shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just
|
||||
// the previous sample -- keeps each authored line recorded exactly once per source.
|
||||
const recordedImmersionCanonicalKeys = new Set<string>();
|
||||
const recordedTimingCanonicalKeys = new Set<string>();
|
||||
// Bumped on track/media changes so an immersion record whose tokenization resolves
|
||||
// after the change is dropped instead of landing in the next session.
|
||||
let subtitleSessionEpoch = 0;
|
||||
let lastTimePosForTimingReset: number | null = null;
|
||||
const canonicalCueKey = (cue: SubtitleCue): string =>
|
||||
`${cue.startTime}|${cue.endTime}|${cue.text}`;
|
||||
const resetSubtitleDeduplication = (): void => {
|
||||
immersionLineDedupGate.reset();
|
||||
recordedImmersionCanonicalKeys.clear();
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
subtitleSessionEpoch += 1;
|
||||
lastTimePosForTimingReset = null;
|
||||
};
|
||||
const resolveCanonicalSample = (liveText: string, startSec: number) =>
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText,
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// When substitution declined because dialogue shares the screen with a song, record
|
||||
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
|
||||
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
|
||||
stripCanonicalFragmentLines({
|
||||
liveText,
|
||||
currentTimeSec: startSec,
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
});
|
||||
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
|
||||
Boolean(
|
||||
deps.appState.initialArgs?.managedPlayback ||
|
||||
@@ -111,45 +151,99 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
quitApp: () => deps.quitApp(),
|
||||
resolveSubtitleText: (liveText: string) =>
|
||||
resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
}),
|
||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
const tracker = deps.appState.immersionTracker;
|
||||
if (!tracker?.recordSubtitleLine) {
|
||||
return;
|
||||
}
|
||||
const recordLine = (lineText: string, startSec: number, endSec: number): void => {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
||||
const cachedTokens =
|
||||
deps.appState.currentSubtitleData?.text === lineText
|
||||
? deps.appState.currentSubtitleData.tokens
|
||||
: null;
|
||||
if (cachedTokens) {
|
||||
tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText);
|
||||
return;
|
||||
}
|
||||
if (!deps.tokenizeSubtitleForImmersion) {
|
||||
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
|
||||
return;
|
||||
}
|
||||
const epochAtRecord = subtitleSessionEpoch;
|
||||
void deps
|
||||
.tokenizeSubtitleForImmersion(lineText)
|
||||
.then((payload) => {
|
||||
if (subtitleSessionEpoch !== epochAtRecord) {
|
||||
return;
|
||||
}
|
||||
tracker.recordSubtitleLine?.(
|
||||
lineText,
|
||||
startSec,
|
||||
endSec,
|
||||
payload?.tokens ?? null,
|
||||
secondaryText,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (subtitleSessionEpoch !== epochAtRecord) {
|
||||
return;
|
||||
}
|
||||
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
|
||||
});
|
||||
};
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (canonical) {
|
||||
for (const cue of canonical.cues) {
|
||||
const key = canonicalCueKey(cue);
|
||||
if (recordedImmersionCanonicalKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
recordedImmersionCanonicalKeys.add(key);
|
||||
recordLine(cue.text, cue.startTime, cue.endTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
text = stripFragmentsForRecording(text, start);
|
||||
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
||||
return;
|
||||
}
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
||||
const cachedTokens =
|
||||
deps.appState.currentSubtitleData?.text === text
|
||||
? deps.appState.currentSubtitleData.tokens
|
||||
: null;
|
||||
if (cachedTokens) {
|
||||
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
|
||||
return;
|
||||
}
|
||||
if (!deps.tokenizeSubtitleForImmersion) {
|
||||
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
|
||||
return;
|
||||
}
|
||||
void deps
|
||||
.tokenizeSubtitleForImmersion(text)
|
||||
.then((payload) => {
|
||||
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
|
||||
})
|
||||
.catch(() => {
|
||||
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
|
||||
});
|
||||
recordLine(text, start, end);
|
||||
},
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
text,
|
||||
start,
|
||||
end,
|
||||
deps.appState.mpvClient?.currentSecondarySubText || undefined,
|
||||
),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (!canonical) {
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
stripFragmentsForRecording(text, start),
|
||||
start,
|
||||
end,
|
||||
secondaryText,
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const cue of canonical.cues) {
|
||||
const key = canonicalCueKey(cue);
|
||||
if (recordedTimingCanonicalKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
recordedTimingCanonicalKeys.add(key);
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
cue.text,
|
||||
cue.startTime,
|
||||
cue.endTime,
|
||||
secondaryText,
|
||||
);
|
||||
}
|
||||
},
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
||||
deps.maybeRunAnilistPostWatchUpdate(options),
|
||||
logSubtitleTimingError: (message: string, error: unknown) =>
|
||||
@@ -170,7 +264,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
? (message: string) => deps.logSubtitleProcessingDebug!(message)
|
||||
: undefined,
|
||||
onSubtitleTrackChange: (sid: number | null) => {
|
||||
immersionLineDedupGate.reset();
|
||||
resetSubtitleDeduplication();
|
||||
// The replacement track's cues arrive only after an async re-read and re-parse.
|
||||
// Clearing synchronously keeps the previous track's canonical cues from
|
||||
// substituting into, or recording against, the new track's live text. The source
|
||||
// key is cleared with the cues so cue-list consumers (the sidebar snapshot)
|
||||
// re-parse on demand instead of trusting the stale pairing.
|
||||
deps.appState.activeParsedSubtitleCues = [];
|
||||
deps.appState.activeParsedSubtitleSource = null;
|
||||
deps.onSubtitleTrackChange?.(sid);
|
||||
},
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
@@ -185,7 +286,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
broadcastSecondarySubtitle: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
updateCurrentMediaPath: (path: string) => {
|
||||
immersionLineDedupGate.reset();
|
||||
resetSubtitleDeduplication();
|
||||
deps.updateCurrentMediaPath(path);
|
||||
},
|
||||
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
||||
@@ -217,9 +318,22 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
onTimePosUpdate: deps.onTimePosUpdate
|
||||
? (time: number) => deps.onTimePosUpdate!(time)
|
||||
: undefined,
|
||||
onTimePosUpdate: (time: number) => {
|
||||
// Timing history is a viewing log: after a real backward seek, a rewatched
|
||||
// canonical line should enter it again. Immersion stats keep their
|
||||
// once-per-media deduplication and are not reset here.
|
||||
if (
|
||||
Number.isFinite(time) &&
|
||||
lastTimePosForTimingReset !== null &&
|
||||
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
|
||||
) {
|
||||
recordedTimingCanonicalKeys.clear();
|
||||
}
|
||||
if (Number.isFinite(time)) {
|
||||
lastTimePosForTimingReset = time;
|
||||
}
|
||||
deps.onTimePosUpdate?.(time);
|
||||
},
|
||||
onFullscreenChange: deps.onFullscreenChange
|
||||
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
|
||||
: undefined,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
resolveCanonicalPrimarySubtitle,
|
||||
resolvePrimarySubtitleText,
|
||||
stripCanonicalFragmentLines,
|
||||
} from './primary-subtitle-text';
|
||||
|
||||
test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => {
|
||||
// mpv renders each simultaneously active ASS event on its own sub-text line.
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '今\n今\n今\n手\n手\n手\nにある\nにある\nにある',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '今 手にある');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText preserves live text outside canonical cue timing', () => {
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '通常の会話',
|
||||
currentTimeSec: 8,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '通常の会話');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of the animation', () => {
|
||||
// An insert song's canonical window can overlap real dialogue on the same track.
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '普通のセリフ\n今\n手にある',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '普通のセリフ\n今\n手にある');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: '次のセリフ',
|
||||
currentTimeSec: 4.1,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '次のセリフ');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText survives overlapping frames of consecutive karaoke lines', () => {
|
||||
// Near a line boundary the previous line's exit frames and the next line's entrance
|
||||
// frames render together; neither line alone explains every live segment.
|
||||
const cues = [
|
||||
{ startTime: 1.2, endTime: 3.8, text: '今 手にある', source: 'canonical-ass' as const },
|
||||
{ startTime: 3.8, endTime: 6.4, text: '物差しでは', source: 'canonical-ass' as const },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '手にある\n物差し\nでは',
|
||||
currentTimeSec: 3.6,
|
||||
cues,
|
||||
}),
|
||||
'今 手にある',
|
||||
);
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '手にある\n物差し\nでは',
|
||||
currentTimeSec: 3.9,
|
||||
cues,
|
||||
}),
|
||||
'物差しでは',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText combines simultaneous canonical cues in source order', () => {
|
||||
const text = resolvePrimarySubtitleText({
|
||||
liveText: 'fir\nst\nsecond',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3, text: 'first', source: 'canonical-ass' },
|
||||
{ startTime: 1.5, endTime: 2.5, text: 'second', source: 'canonical-ass' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, 'first\nsecond');
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
|
||||
const cue = {
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
};
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: '今\n手にある',
|
||||
currentTimeSec: 0.8,
|
||||
cues: [cue],
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
text: '今 手にある',
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
cues: [cue],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle covers exit frames that outlive the authored timing', () => {
|
||||
// Real generated animations keep exit fragments on screen well past the authored
|
||||
// comment window; the recorded animation envelope is what makes them resolvable.
|
||||
const cue = {
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 0.8,
|
||||
animationEndTime: 5.6,
|
||||
};
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: '今\n手にある',
|
||||
currentTimeSec: 5.4,
|
||||
cues: [cue],
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved, {
|
||||
text: '今 手にある',
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
cues: [cue],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText handles late exit frames overlapping the next active line', () => {
|
||||
// The previous line's exit fragments can persist more than a second into the next
|
||||
// authored line. The next line supplies the text; the previous line's envelope
|
||||
// explains its lingering fragments.
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 0.8,
|
||||
animationEndTime: 5.6,
|
||||
},
|
||||
{
|
||||
startTime: 3.8,
|
||||
endTime: 6.4,
|
||||
text: '物差しでは',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 3.4,
|
||||
animationEndTime: 7.0,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '手にある\n手にある\n物差し\nでは',
|
||||
currentTimeSec: 5.2,
|
||||
cues,
|
||||
}),
|
||||
'物差しでは',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle rejects unrelated live text at the animation edge', () => {
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: '次のセリフ',
|
||||
currentTimeSec: 4.1,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
test('stripCanonicalFragmentLines drops fragment lines but keeps concurrent dialogue', () => {
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1.2,
|
||||
endTime: 3.8,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
stripCanonicalFragmentLines({
|
||||
liveText: '普通のセリフ\n今\n手にある',
|
||||
currentTimeSec: 2,
|
||||
cues,
|
||||
}),
|
||||
'普通のセリフ',
|
||||
);
|
||||
// No canonical cue nearby: nothing to strip.
|
||||
assert.equal(
|
||||
stripCanonicalFragmentLines({ liveText: '普通のセリフ\n今', currentTimeSec: 30, cues }),
|
||||
'普通のセリフ\n今',
|
||||
);
|
||||
// Everything matched (defensive): return the input rather than empty text.
|
||||
assert.equal(
|
||||
stripCanonicalFragmentLines({ liveText: '今\n手にある', currentTimeSec: 2, cues }),
|
||||
'今\n手にある',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the nearest', () => {
|
||||
// In the gap between two authored spans, the next line sits closer in time while only
|
||||
// the previous line's exit fragments are on screen: the fragments decide.
|
||||
const cues = [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: '今 手にある',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 0.6,
|
||||
animationEndTime: 3.9,
|
||||
},
|
||||
{
|
||||
startTime: 4,
|
||||
endTime: 6,
|
||||
text: '物差しでは',
|
||||
source: 'canonical-ass' as const,
|
||||
animationStartTime: 3.5,
|
||||
animationEndTime: 6.4,
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
resolveCanonicalPrimarySubtitle({ liveText: '手にある', currentTimeSec: 3.8, cues })?.text,
|
||||
'今 手にある',
|
||||
);
|
||||
// Fragments of both lines in the gap: both envelopes cover the moment (distance 0),
|
||||
// and the earlier line wins the tie while it is still animating out.
|
||||
assert.equal(
|
||||
resolveCanonicalPrimarySubtitle({ liveText: '手にある\n物差し', currentTimeSec: 3.8, cues })
|
||||
?.text,
|
||||
'今 手にある',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { SubtitleCue } from '../../types';
|
||||
|
||||
// Slack on top of each cue's recorded animation envelope, for time-pos observation
|
||||
// staleness and small user sub-delay offsets. The envelope itself covers how far
|
||||
// entrance/exit frames actually run past the authored timing.
|
||||
const CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS = 1;
|
||||
|
||||
export interface ResolvedPrimarySubtitle {
|
||||
text: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
/** The canonical cues behind `text`, for consumers that record lines individually. */
|
||||
cues: SubtitleCue[];
|
||||
}
|
||||
|
||||
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
||||
return {
|
||||
start: cue.animationStartTime ?? cue.startTime,
|
||||
end: cue.animationEndTime ?? cue.endTime,
|
||||
};
|
||||
}
|
||||
|
||||
function nearbyCanonicalCues(
|
||||
cues: readonly SubtitleCue[] | null | undefined,
|
||||
currentTimeSec: number,
|
||||
): SubtitleCue[] {
|
||||
return (cues ?? []).filter((cue) => {
|
||||
if (cue.source !== 'canonical-ass') {
|
||||
return false;
|
||||
}
|
||||
const span = animationSpan(cue);
|
||||
return (
|
||||
span.end >= currentTimeSec - CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS &&
|
||||
span.start <= currentTimeSec + CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function compactWhitespace(text: string): string {
|
||||
return text.replace(/\s+/gu, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
|
||||
* while a generated animation plays every live line is a contiguous piece of the
|
||||
* authored text. A line that is not -- concurrent dialogue during an insert song, or a
|
||||
* fresh line starting just after the animation ended -- proves the live text is not this
|
||||
* animation, and substituting the canonical line would swallow real dialogue.
|
||||
*/
|
||||
function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean {
|
||||
const compactCues = cues.map((cue) => compactWhitespace(cue.text));
|
||||
const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean);
|
||||
return (
|
||||
segments.length > 0 &&
|
||||
segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment)))
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCanonicalPrimarySubtitle(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): ResolvedPrimarySubtitle | null {
|
||||
if (!Number.isFinite(options.currentTimeSec)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Consecutive karaoke lines overlap: one line's exit frames are still on screen while
|
||||
// the next line's entrance frames appear. The fragment check therefore runs against
|
||||
// every canonical cue whose animation envelope reaches the current time, while only
|
||||
// the active (or single nearest) cue supplies the displayed text.
|
||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
|
||||
const active = nearby.filter(
|
||||
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
||||
);
|
||||
const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean);
|
||||
const selected =
|
||||
active.length > 0
|
||||
? active
|
||||
: nearby
|
||||
// Between authored spans, proximity alone can pick the wrong neighbor: the
|
||||
// next line can sit closer while only the previous line's exit fragments are
|
||||
// on screen. Only cues that explain at least one live line may be selected.
|
||||
.filter((cue) => {
|
||||
const cueText = compactWhitespace(cue.text);
|
||||
return liveSegments.some((segment) => cueText.includes(segment));
|
||||
})
|
||||
.map((cue) => {
|
||||
const span = animationSpan(cue);
|
||||
const distance =
|
||||
options.currentTimeSec < span.start
|
||||
? span.start - options.currentTimeSec
|
||||
: Math.max(0, options.currentTimeSec - span.end);
|
||||
return { cue, distance };
|
||||
})
|
||||
.sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime)
|
||||
.slice(0, 1)
|
||||
.map(({ cue }) => cue);
|
||||
if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const texts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const cue of selected) {
|
||||
if (!seen.has(cue.text)) {
|
||||
seen.add(cue.text);
|
||||
texts.push(cue.text);
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: texts.join('\n'),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||
cues: selected,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live text with generated-animation fragment lines removed. Recording paths use this
|
||||
* when full canonical substitution declined -- concurrent dialogue during an insert
|
||||
* song: the dialogue is worth recording, the glyph fragments beside it are not. Returns
|
||||
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
|
||||
*/
|
||||
export function stripCanonicalFragmentLines(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
if (!Number.isFinite(options.currentTimeSec)) {
|
||||
return options.liveText;
|
||||
}
|
||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
|
||||
if (nearby.length === 0) {
|
||||
return options.liveText;
|
||||
}
|
||||
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
|
||||
const kept = options.liveText.split('\n').filter((line) => {
|
||||
const compact = compactWhitespace(line);
|
||||
return compact && !compactCues.some((cueText) => cueText.includes(compact));
|
||||
});
|
||||
return kept.length > 0 ? kept.join('\n') : options.liveText;
|
||||
}
|
||||
|
||||
export function resolvePrimarySubtitleText(options: {
|
||||
liveText: string;
|
||||
currentTimeSec: number;
|
||||
cues: readonly SubtitleCue[] | null | undefined;
|
||||
}): string {
|
||||
if (!options.liveText.trim()) {
|
||||
return options.liveText;
|
||||
}
|
||||
return (
|
||||
resolveCanonicalPrimarySubtitle({
|
||||
liveText: options.liveText,
|
||||
currentTimeSec: options.currentTimeSec,
|
||||
cues: options.cues,
|
||||
})?.text ?? options.liveText
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1424,6 +1424,19 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines drops layered duplicate lines in short stacks', () => {
|
||||
// A word-level animation stacks one event per layer copy; the stack is too short for
|
||||
// the karaoke heuristic but the duplicates are still never distinct content.
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [
|
||||
'Your',
|
||||
'mosaic',
|
||||
]);
|
||||
assert.deepEqual(prepareSecondarySubtitleLines('One line\\NAnother line'), [
|
||||
'One line',
|
||||
'Another line',
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => {
|
||||
// Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers,
|
||||
// joined with \N by mpv's secondary-sub-text.
|
||||
|
||||
@@ -677,10 +677,12 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (!isKaraokeLikeLineSet(lines)) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Identical lines in one render are layered copies of the same event (animation
|
||||
// scripts stack several per glyph), never distinct content -- always drop them, so a
|
||||
// short stack like "Your ×4 / mosaic" collapses without needing the karaoke
|
||||
// heuristic. Karaoke-likeness is still judged on the raw stack, where the layered
|
||||
// repetition is the signal.
|
||||
const seen = new Set<string>();
|
||||
const unique: string[] = [];
|
||||
for (const line of lines) {
|
||||
@@ -688,6 +690,10 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
|
||||
seen.add(line);
|
||||
unique.push(line);
|
||||
}
|
||||
if (!isKaraokeLikeLineSet(lines)) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
return [unique.join(' ')];
|
||||
}
|
||||
|
||||
|
||||
+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