mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-17 00:18:41 -07:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00b1b79bf4 | ||
|
|
e11a5fea0d
|
||
|
|
f73fe179d0
|
||
|
|
2938e7a32a | ||
|
|
82f6b4705a | ||
|
|
a02c33dac4 |
@@ -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,4 +0,0 @@
|
|||||||
type: added
|
|
||||||
area: mining
|
|
||||||
|
|
||||||
- Added optional pre-generation timing review for word, sentence, and audio cards with a compact speech-weighted waveform, clearly labeled mined-line boundaries, drag and keyboard adjustments, audio preview with a sweeping playhead, exact screenshot and AVIF timing, and explicit cancellation choices.
|
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -569,7 +569,6 @@
|
|||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||||
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
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 base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
|
||||||
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
||||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
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 channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
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}` : '/';
|
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 {
|
function pageToCanonicalHref(page: string): string | null {
|
||||||
const route = pageToRoute(page);
|
const route = pageToRoute(page);
|
||||||
if (!route) return null;
|
if (!route) return null;
|
||||||
|
|
||||||
if (channel === 'main') {
|
if (!isIndexableChannel) {
|
||||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
|
||||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +113,9 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
|||||||
const href = pageToCanonicalHref(page);
|
const href = pageToCanonicalHref(page);
|
||||||
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
|
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' }]);
|
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[] = [
|
const nav: DefaultTheme.NavItem[] = [
|
||||||
{ text: 'Home', link: '/' },
|
{ text: 'Home', link: '/' },
|
||||||
{ text: 'Get Started', link: '/installation' },
|
{ text: 'Get Started', link: '/installation' },
|
||||||
@@ -419,14 +461,7 @@ const config: UserConfig = {
|
|||||||
appearance: 'dark',
|
appearance: 'dark',
|
||||||
cleanUrls: true,
|
cleanUrls: true,
|
||||||
metaChunk: true,
|
metaChunk: true,
|
||||||
sitemap: {
|
sitemap,
|
||||||
hostname: DOCS_HOSTNAME,
|
|
||||||
transformItems(items) {
|
|
||||||
return items.filter(
|
|
||||||
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transformHead: transformPageHead,
|
transformHead: transformPageHead,
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
srcExclude: ['subagents/**', 'README.md'],
|
srcExclude: ['subagents/**', 'README.md'],
|
||||||
|
|||||||
+3
-1
@@ -38,8 +38,10 @@ bun run docs:dev
|
|||||||
The public docs root is stable-only:
|
The public docs root is stable-only:
|
||||||
|
|
||||||
- `/` serves the latest stable release docs.
|
- `/` 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.
|
- `/v/<version>/` serves stable release archives.
|
||||||
- Prerelease tags do not update the docs site.
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
|||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
"normalizeAudio": true, // normalize generated clip loudness
|
"normalizeAudio": true, // normalize generated clip loudness
|
||||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||||
"reviewTiming": false, // review and adjust timing before media generation
|
|
||||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||||
"maxMediaDuration": 30 // cap total duration in seconds
|
"maxMediaDuration": 30 // cap total duration in seconds
|
||||||
}
|
}
|
||||||
@@ -179,10 +178,6 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
|
|||||||
|
|
||||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||||
|
|
||||||
Set `media.reviewTiming` to `true` to pause playback and review each word, sentence, or audio card before its media is generated. The review opens with the subtitle range plus configured audio padding. Drag either edge of the clip to trim it, drag the middle to slide it without changing its length, or press anywhere else on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys, by 100 ms alone or 500 ms with Shift, and the 100 ms buttons do the same. Space previews the selection with a playhead that sweeps the clip, Enter confirms, and Escape cancels; buttons reveal another five seconds before or after the visible timeline. A speech-weighted waveform shows the mined subtitle as a tinted band with labeled line-start and line-end rails, making adjacent dialogue easier to distinguish. SubMiner uses a center channel when one carries dialogue, then falls back to a speech-band mono mix. Waveform analysis failure leaves the timing controls available. The confirmed range is exact: SubMiner does not apply audio padding a second time. Static screenshots use its midpoint, and animated AVIF clips use the full confirmed range.
|
|
||||||
|
|
||||||
Canceling the review lets you keep editing, finish with the original timing, or discard the card. Discard deletes an existing Yomitan or audio card and skips creation for a direct sentence card. Clipboard updates and stats-dashboard mining do not open timing review. Audio preview failure does not block confirmation or card creation. The option is disabled by default and hot-reloads.
|
|
||||||
|
|
||||||
### Screenshots (Static)
|
### Screenshots (Static)
|
||||||
|
|
||||||
A single frame is captured at the current playback position.
|
A single frame is captured at the current playback position.
|
||||||
|
|||||||
@@ -967,7 +967,6 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
"normalizeAudio": true,
|
"normalizeAudio": true,
|
||||||
"mirrorMpvVolume": true,
|
"mirrorMpvVolume": true,
|
||||||
"reviewTiming": false,
|
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"maxMediaDuration": 30
|
||||||
@@ -1020,7 +1019,6 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. Changes apply live. |
|
||||||
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
| `media.mirrorMpvVolume` | `true`, `false` | Apply mpv's cubic software-volume curve to each generated sentence-audio clip (default: `true`). This ignores mpv's separate mute state, falls back to unity scaling if volume cannot be read, and applies changes live. |
|
||||||
| `media.reviewTiming` | `true`, `false` | Pause playback and review word, sentence, and audio card timing before media generation (default: `false`). Clipboard updates and stats-dashboard mining do not open the review. |
|
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -569,7 +569,6 @@
|
|||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Changes apply live. Values: true | false
|
||||||
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
"mirrorMpvVolume": true, // Apply mpv's current software volume curve to generated sentence audio. Changes apply live. Values: true | false
|
||||||
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
|
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
|
|||||||
+39
-11
@@ -56,34 +56,43 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
|
|||||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
||||||
]);
|
]);
|
||||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||||
|
expect(mainDocsConfig.sitemap).toBeUndefined();
|
||||||
|
|
||||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('latest stable archive canonical points to root equivalent', async () => {
|
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 previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||||
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
|
process.env.SUBMINER_DOCS_BASE = base;
|
||||||
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
|
process.env.SUBMINER_DOCS_VERSION = version;
|
||||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||||
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
|
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' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
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_CHANNEL = previousChannel;
|
||||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('stable archive theme links stay on the selected version', async () => {
|
test('stable archive theme links stay on the selected version', async () => {
|
||||||
const previousCwd = process.cwd();
|
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']);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
|
||||||
overlay window remained mapped above mpv.
|
overlay window remained mapped above mpv.
|
||||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
- 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
|
- 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
|
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
|
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"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",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
|
|||||||
@@ -106,9 +106,12 @@ function M.create(ctx)
|
|||||||
|
|
||||||
local function get_subtitle_ass_property()
|
local function get_subtitle_ass_property()
|
||||||
local ass_text = mp.get_property("sub-text/ass")
|
local ass_text = mp.get_property("sub-text/ass")
|
||||||
|
if ass_text ~= nil then
|
||||||
if type(ass_text) == "string" and ass_text ~= "" then
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
return ass_text
|
return ass_text
|
||||||
end
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
ass_text = mp.get_property("sub-text-ass")
|
ass_text = mp.get_property("sub-text-ass")
|
||||||
if type(ass_text) == "string" and ass_text ~= "" then
|
if type(ass_text) == "string" and ass_text ~= "" then
|
||||||
return ass_text
|
return ass_text
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
|||||||
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
|
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
|
||||||
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
|
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
|
||||||
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
|
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_LOADING_OSD = "Loading subtitle tokenization..."
|
||||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
||||||
@@ -265,10 +267,19 @@ function M.create(ctx)
|
|||||||
state.overlay_loading_osd_timer = nil
|
state.overlay_loading_osd_timer = nil
|
||||||
end
|
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()
|
local function stop_overlay_loading_osd()
|
||||||
state.overlay_loading_osd_active = false
|
state.overlay_loading_osd_active = false
|
||||||
state.overlay_loading_osd_frame = 1
|
state.overlay_loading_osd_frame = 1
|
||||||
clear_overlay_loading_osd_timer()
|
clear_overlay_loading_osd_timer()
|
||||||
|
clear_overlay_loading_osd_deadline()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function start_overlay_loading_osd()
|
local function start_overlay_loading_osd()
|
||||||
@@ -291,6 +302,21 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
end)
|
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
|
end
|
||||||
|
|
||||||
local function disarm_auto_play_ready_gate(options)
|
local function disarm_auto_play_ready_gate(options)
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ function M.create(ctx)
|
|||||||
elseif action_id == "triggerFieldGrouping" then
|
elseif action_id == "triggerFieldGrouping" then
|
||||||
return { "--trigger-field-grouping" }
|
return { "--trigger-field-grouping" }
|
||||||
elseif action_id == "triggerSubsync" then
|
elseif action_id == "triggerSubsync" then
|
||||||
return { "--trigger-subsync" }
|
return { "--session-action", '{"actionId":"triggerSubsync"}' }
|
||||||
elseif action_id == "mineSentence" then
|
elseif action_id == "mineSentence" then
|
||||||
return { "--mine-sentence" }
|
return { "--mine-sentence" }
|
||||||
elseif action_id == "mineSentenceMultiple" then
|
elseif action_id == "mineSentenceMultiple" then
|
||||||
@@ -251,7 +251,7 @@ function M.create(ctx)
|
|||||||
elseif action_id == "markWatched" then
|
elseif action_id == "markWatched" then
|
||||||
return { "--mark-watched" }
|
return { "--mark-watched" }
|
||||||
elseif action_id == "openRuntimeOptions" then
|
elseif action_id == "openRuntimeOptions" then
|
||||||
return { "--open-runtime-options" }
|
return { "--session-action", '{"actionId":"openRuntimeOptions"}' }
|
||||||
elseif action_id == "openJimaku" then
|
elseif action_id == "openJimaku" then
|
||||||
return { "--open-jimaku" }
|
return { "--open-jimaku" }
|
||||||
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
||||||
@@ -259,7 +259,7 @@ function M.create(ctx)
|
|||||||
elseif action_id == "openYoutubePicker" then
|
elseif action_id == "openYoutubePicker" then
|
||||||
return { "--open-youtube-picker" }
|
return { "--open-youtube-picker" }
|
||||||
elseif action_id == "openSessionHelp" then
|
elseif action_id == "openSessionHelp" then
|
||||||
return { "--open-session-help" }
|
return { "--session-action", '{"actionId":"openSessionHelp"}' }
|
||||||
elseif action_id == "openCharacterDictionaryManager" then
|
elseif action_id == "openCharacterDictionaryManager" then
|
||||||
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
|
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
|
||||||
elseif action_id == "openControllerSelect" then
|
elseif action_id == "openControllerSelect" then
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function M.new()
|
|||||||
auto_play_ready_initial_pause_ownership_consumed = false,
|
auto_play_ready_initial_pause_ownership_consumed = false,
|
||||||
overlay_loading_osd_active = false,
|
overlay_loading_osd_active = false,
|
||||||
overlay_loading_osd_timer = nil,
|
overlay_loading_osd_timer = nil,
|
||||||
|
overlay_loading_osd_deadline = nil,
|
||||||
overlay_loading_osd_frame = 1,
|
overlay_loading_osd_frame = 1,
|
||||||
pending_visible_overlay_hide_timer = nil,
|
pending_visible_overlay_hide_timer = nil,
|
||||||
pending_visible_overlay_hide_generation = 0,
|
pending_visible_overlay_hide_generation = 0,
|
||||||
|
|||||||
+13
-1
@@ -4,6 +4,7 @@ function M.create(ctx)
|
|||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
local input = ctx.input
|
local input = ctx.input
|
||||||
local process = ctx.process
|
local process = ctx.process
|
||||||
|
local state = ctx.state
|
||||||
local subminer_log = ctx.log.subminer_log
|
local subminer_log = ctx.log.subminer_log
|
||||||
local show_osd = ctx.log.show_osd
|
local show_osd = ctx.log.show_osd
|
||||||
|
|
||||||
@@ -93,7 +94,18 @@ function M.create(ctx)
|
|||||||
if not ensure_binary_for_menu() then
|
if not ensure_binary_for_menu() then
|
||||||
return
|
return
|
||||||
end
|
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)
|
||||||
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.
|
> 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
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
|
- **Library Merge and Move**
|
||||||
- **Sync Stats & History**
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
- 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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Fixed
|
### 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**
|
### Docs
|
||||||
- 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.
|
- **Feature Demos Page**
|
||||||
|
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
- 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
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,17 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
|
|||||||
const maxCloudflareFiles = 20_000;
|
const maxCloudflareFiles = 20_000;
|
||||||
const maxCloudflareFileBytes = 25 * 1024 * 1024;
|
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(
|
function run(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
@@ -173,6 +184,7 @@ function buildDocs(options: {
|
|||||||
SUBMINER_DOCS_BASE: options.base,
|
SUBMINER_DOCS_BASE: options.base,
|
||||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||||
|
SUBMINER_DOCS_REPO_DIR: currentDocsSite,
|
||||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
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, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
|
||||||
assertCloudflarePagesLimits(aggregateOutDir);
|
assertCloudflarePagesLimits(aggregateOutDir);
|
||||||
const prunedArchives = pruneArchiveCacheGenerations({
|
const prunedArchives = pruneArchiveCacheGenerations({
|
||||||
cacheRoot: archiveCacheRoot,
|
cacheRoot: archiveCacheRoot,
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ local function run_plugin_scenario(config)
|
|||||||
|
|
||||||
function mp.add_timeout(seconds, callback)
|
function mp.add_timeout(seconds, callback)
|
||||||
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
||||||
|
local delay = tonumber(seconds) or 0
|
||||||
local timeout = {
|
local timeout = {
|
||||||
|
seconds = delay,
|
||||||
killed = false,
|
killed = false,
|
||||||
callback = callback,
|
callback = callback,
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,6 @@ local function run_plugin_scenario(config)
|
|||||||
self.killed = true
|
self.killed = true
|
||||||
end
|
end
|
||||||
|
|
||||||
local delay = tonumber(seconds) or 0
|
|
||||||
if callback and delay < 5 and not config.defer_timeouts then
|
if callback and delay < 5 and not config.defer_timeouts then
|
||||||
callback()
|
callback()
|
||||||
end
|
end
|
||||||
@@ -514,6 +515,15 @@ local function has_timeout(timeouts, target)
|
|||||||
return false
|
return false
|
||||||
end
|
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 function env_has(call, target)
|
||||||
local env = (call and call.env) or {}
|
local env = (call and call.env) or {}
|
||||||
for _, value in ipairs(env) do
|
for _, value in ipairs(env) do
|
||||||
@@ -1636,6 +1646,8 @@ do
|
|||||||
#recorded.periodic_timers == 1,
|
#recorded.periodic_timers == 1,
|
||||||
"auto-start visible overlay should refresh the early overlay loading OSD"
|
"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]
|
local overlay_loading_timer = recorded.periodic_timers[1]
|
||||||
recorded.periodic_timers[1].callback()
|
recorded.periodic_timers[1].callback()
|
||||||
assert_true(
|
assert_true(
|
||||||
@@ -1670,6 +1682,46 @@ do
|
|||||||
recorded.periodic_timers[1].killed == true,
|
recorded.periodic_timers[1].killed == true,
|
||||||
"overlay loading ready should stop the early overlay loading OSD refresher"
|
"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
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
+2
-38
@@ -28,8 +28,6 @@ import {
|
|||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
type WordCardKind,
|
type WordCardKind,
|
||||||
type MediaTimingReviewDecision,
|
|
||||||
type MediaTimingReviewRequest,
|
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -240,9 +238,6 @@ export class AnkiIntegration {
|
|||||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||||
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
|
||||||
private mediaTimingReviewCallback:
|
|
||||||
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
|
|
||||||
| null = null;
|
|
||||||
private noteIdRedirects = new Map<number, number>();
|
private noteIdRedirects = new Map<number, number>();
|
||||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||||
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
private getCachedMediaPath: MediaGenerationInputResolverOptions['getCachedMediaPath'] | null =
|
||||||
@@ -514,7 +509,6 @@ export class AnkiIntegration {
|
|||||||
findNotes: async (query, options) =>
|
findNotes: async (query, options) =>
|
||||||
(await this.client.findNotes(query, options)) as number[],
|
(await this.client.findNotes(query, options)) as number[],
|
||||||
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
retrieveMediaFile: (filename) => this.client.retrieveMediaFile(filename),
|
||||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: (
|
generateAudio: (
|
||||||
@@ -572,7 +566,6 @@ export class AnkiIntegration {
|
|||||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||||
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
getFallbackDurationSeconds: () => this.getFallbackDurationSeconds(),
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||||
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
|
||||||
isUpdateInProgress: () => this.updateInProgress,
|
isUpdateInProgress: () => this.updateInProgress,
|
||||||
setUpdateInProgress: (value) => {
|
setUpdateInProgress: (value) => {
|
||||||
this.updateInProgress = value;
|
this.updateInProgress = value;
|
||||||
@@ -588,7 +581,6 @@ export class AnkiIntegration {
|
|||||||
recordCardsMinedCallback: (count, noteIds) => {
|
recordCardsMinedCallback: (count, noteIds) => {
|
||||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||||
},
|
},
|
||||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,14 +637,12 @@ export class AnkiIntegration {
|
|||||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||||
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
updateNoteFields: (noteId, fields) => this.client.updateNoteFields(noteId, fields),
|
||||||
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
storeMediaFile: (filename, data) => this.client.storeMediaFile(filename, data),
|
||||||
deleteNotes: (noteIds) => this.client.deleteNotes(noteIds),
|
|
||||||
},
|
},
|
||||||
getConfig: () => this.config,
|
getConfig: () => this.config,
|
||||||
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
getCurrentSubtitleText: () => this.mpvClient.currentSubText,
|
||||||
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
getCurrentSubtitleStart: () => this.mpvClient.currentSubStart,
|
||||||
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
getEffectiveSentenceCardConfig: () => this.getEffectiveSentenceCardConfig(),
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
appendKnownWordsFromNoteInfo: (noteInfo) => this.appendKnownWordsFromNoteInfo(noteInfo),
|
||||||
removeKnownWordNote: (noteId) => this.removeKnownWordNote(noteId),
|
|
||||||
extractFields: (fields) => this.extractFields(fields),
|
extractFields: (fields) => this.extractFields(fields),
|
||||||
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
findDuplicateNote: (expression, excludeNoteId, noteInfo) =>
|
||||||
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
this.findDuplicateNote(expression, excludeNoteId, noteInfo),
|
||||||
@@ -690,7 +680,6 @@ export class AnkiIntegration {
|
|||||||
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)),
|
||||||
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)),
|
||||||
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
logError: (...args) => log.error(args[0] as string, ...args.slice(1)),
|
||||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,12 +799,6 @@ export class AnkiIntegration {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeKnownWordNote(noteId: number): void {
|
|
||||||
if (this.knownWordCache.removeNote(noteId)) {
|
|
||||||
this.notifyKnownWordCacheUpdated();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private notifyKnownWordCacheUpdated(): void {
|
private notifyKnownWordCacheUpdated(): void {
|
||||||
if (!this.knownWordCacheUpdatedCallback) {
|
if (!this.knownWordCacheUpdatedCallback) {
|
||||||
return;
|
return;
|
||||||
@@ -1056,7 +1039,7 @@ export class AnkiIntegration {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
this.config.media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
this.config.media?.normalizeAudio !== false,
|
this.config.media?.normalizeAudio !== false,
|
||||||
await this.getMpvVolumeScale(),
|
await this.getMpvVolumeScale(),
|
||||||
@@ -1089,7 +1072,7 @@ export class AnkiIntegration {
|
|||||||
videoPath,
|
videoPath,
|
||||||
mediaRange.startTime,
|
mediaRange.startTime,
|
||||||
mediaRange.endTime,
|
mediaRange.endTime,
|
||||||
context?.mediaPaddingSeconds ?? this.config.media?.audioPadding,
|
this.config.media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: this.config.media?.animatedFps,
|
fps: this.config.media?.animatedFps,
|
||||||
maxWidth: this.config.media?.animatedMaxWidth,
|
maxWidth: this.config.media?.animatedMaxWidth,
|
||||||
@@ -1740,25 +1723,6 @@ export class AnkiIntegration {
|
|||||||
this.consumeSubtitleMiningContextCallback = callback;
|
this.consumeSubtitleMiningContextCallback = callback;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMediaTimingReviewCallback(
|
|
||||||
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
|
|
||||||
): void {
|
|
||||||
this.mediaTimingReviewCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async reviewMediaTiming(
|
|
||||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
|
||||||
): Promise<MediaTimingReviewDecision> {
|
|
||||||
if (this.config.media?.reviewTiming !== true || !this.mediaTimingReviewCallback) {
|
|
||||||
return { action: 'use-original' };
|
|
||||||
}
|
|
||||||
return await this.mediaTimingReviewCallback({
|
|
||||||
...request,
|
|
||||||
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
|
|
||||||
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveCurrentNoteId(noteId: number): number {
|
resolveCurrentNoteId(noteId: number): number {
|
||||||
let resolved = noteId;
|
let resolved = noteId;
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => Buffer.from('audio'),
|
generateAudio: async () => Buffer.from('audio'),
|
||||||
@@ -130,7 +129,6 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -203,7 +201,6 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
getEffectiveSentenceCardConfig: () => ({
|
getEffectiveSentenceCardConfig: () => ({
|
||||||
model: 'Sentence',
|
model: 'Sentence',
|
||||||
@@ -251,7 +248,6 @@ test('manual clipboard subtitle update skips audio when sentence audio field is
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,7 +335,6 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
},
|
},
|
||||||
findNotes: async () => [42],
|
findNotes: async () => [42],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
@@ -388,47 +383,3 @@ test('createSentenceCard relies on Anki progress notification without standalone
|
|||||||
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
assert.deepEqual(progressMessages, ['Creating sentence card']);
|
||||||
assert.deepEqual(statusMessages, []);
|
assert.deepEqual(statusMessages, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('discarding an audio-card timing review deletes the note before evicting its cache entry', async () => {
|
|
||||||
const events: string[] = [];
|
|
||||||
const statusMessages: string[] = [];
|
|
||||||
const { service } = createManualUpdateService({
|
|
||||||
getMpvClient: () =>
|
|
||||||
({
|
|
||||||
currentVideoPath: '/video.mp4',
|
|
||||||
currentSubText: '字幕',
|
|
||||||
currentSubStart: 4,
|
|
||||||
currentSubEnd: 6,
|
|
||||||
currentTimePos: 5,
|
|
||||||
}) as never,
|
|
||||||
client: {
|
|
||||||
addNote: async () => 0,
|
|
||||||
addTags: async () => undefined,
|
|
||||||
notesInfo: async () => [
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: { Expression: { value: '単語' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
updateNoteFields: async () => undefined,
|
|
||||||
storeMediaFile: async () => undefined,
|
|
||||||
findNotes: async () => [42],
|
|
||||||
retrieveMediaFile: async () => '',
|
|
||||||
deleteNotes: async (noteIds) => {
|
|
||||||
events.push(`delete:${noteIds.join(',')}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reviewMediaTiming: async () => ({ action: 'discard' }),
|
|
||||||
removeKnownWordNote: (noteId) => {
|
|
||||||
events.push(`cache:${noteId}`);
|
|
||||||
},
|
|
||||||
showStatusNotification: (message) => {
|
|
||||||
statusMessages.push(message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.markLastCardAsAudioCard();
|
|
||||||
|
|
||||||
assert.deepEqual(events, ['delete:42', 'cache:42']);
|
|
||||||
assert.deepEqual(statusMessages, ['Card deleted.']);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
const storedMedia: string[] = [];
|
const storedMedia: string[] = [];
|
||||||
const requestedProperties: string[] = [];
|
const requestedProperties: string[] = [];
|
||||||
const audioVolumeScales: Array<number | undefined> = [];
|
const audioVolumeScales: Array<number | undefined> = [];
|
||||||
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
|
|
||||||
|
|
||||||
const deps: CardCreationDeps = {
|
const deps: CardCreationDeps = {
|
||||||
getConfig: () =>
|
getConfig: () =>
|
||||||
@@ -74,19 +73,17 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
},
|
},
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (
|
generateAudio: async (
|
||||||
_path,
|
_path,
|
||||||
startTime,
|
_startTime,
|
||||||
endTime,
|
_endTime,
|
||||||
audioPadding,
|
_audioPadding,
|
||||||
_audioStreamIndex,
|
_audioStreamIndex,
|
||||||
_normalizeAudio,
|
_normalizeAudio,
|
||||||
volumeScale,
|
volumeScale,
|
||||||
) => {
|
) => {
|
||||||
audioRanges.push({ start: startTime, end: endTime, padding: audioPadding });
|
|
||||||
audioVolumeScales.push(volumeScale);
|
audioVolumeScales.push(volumeScale);
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
@@ -125,15 +122,17 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
reviewMediaTiming: async () => ({ action: 'confirm', startTime: 11.4, endTime: 14.2 }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new CardCreationService(deps);
|
const created = await new CardCreationService(deps).createSentenceCard(
|
||||||
const created = await service.createSentenceCard('字幕', 12, 14, 'Subtitle');
|
'字幕',
|
||||||
|
12,
|
||||||
|
14,
|
||||||
|
'Subtitle',
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(created, true);
|
assert.equal(created, true);
|
||||||
assert.deepEqual(addedFields[0], {
|
assert.deepEqual(addedFields[0], {
|
||||||
@@ -145,12 +144,7 @@ test('sentence card writes generated audio only to sentence audio field', async
|
|||||||
assert.equal(storedMedia.length, 1);
|
assert.equal(storedMedia.length, 1);
|
||||||
assert.deepEqual(requestedProperties, ['volume']);
|
assert.deepEqual(requestedProperties, ['volume']);
|
||||||
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
assert.deepEqual(audioVolumeScales, [0.4 ** 3]);
|
||||||
assert.deepEqual(audioRanges, [{ start: 11.4, end: 14.2, padding: 0 }]);
|
|
||||||
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
const mediaUpdate = updatedFields.find((fields) => 'SentenceAudio' in fields);
|
||||||
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
assert.equal(mediaUpdate?.SentenceAudio, `[sound:${storedMedia[0]}]`);
|
||||||
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
assert.equal('ExpressionAudio' in mediaUpdate!, false);
|
||||||
|
|
||||||
deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
|
||||||
assert.equal(await service.createSentenceCard('作らない', 20, 22), false);
|
|
||||||
assert.equal(addedFields.length, 1);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -75,7 +74,6 @@ test('CardCreationService counts locally created sentence cards', async () => {
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -141,7 +139,6 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -176,7 +173,6 @@ test('CardCreationService keeps updating after trackLastAddedNoteId throws', asy
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => {
|
trackLastAddedNoteId: () => {
|
||||||
@@ -242,7 +238,6 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -277,7 +272,6 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
recordCardsMinedCallback: () => {
|
recordCardsMinedCallback: () => {
|
||||||
@@ -354,7 +348,6 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path) => {
|
generateAudio: async (path) => {
|
||||||
@@ -399,7 +392,6 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -462,7 +454,6 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
generateAudio: async (path, _startTime, _endTime, _padding, audioStreamIndex) => {
|
||||||
@@ -504,7 +495,6 @@ test('CardCreationService does not use mpv stream indexes for ready cached YouTu
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -600,7 +590,6 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => {
|
generateAudio: async () => {
|
||||||
@@ -645,7 +634,6 @@ test('CardCreationService queues YouTube media when required cache is not ready'
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -713,7 +701,6 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -746,7 +733,6 @@ test('CardCreationService tracks pre-add duplicate note ids for kiku sentence ca
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
@@ -804,7 +790,6 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
|||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
findNotes: async () => [],
|
findNotes: async () => [],
|
||||||
retrieveMediaFile: async () => '',
|
retrieveMediaFile: async () => '',
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
mediaGenerator: {
|
mediaGenerator: {
|
||||||
generateAudio: async () => null,
|
generateAudio: async () => null,
|
||||||
@@ -837,7 +822,6 @@ test('CardCreationService does not track duplicate ids when pre-add lookup retur
|
|||||||
}),
|
}),
|
||||||
getFallbackDurationSeconds: () => 10,
|
getFallbackDurationSeconds: () => 10,
|
||||||
appendKnownWordsFromNoteInfo: () => undefined,
|
appendKnownWordsFromNoteInfo: () => undefined,
|
||||||
removeKnownWordNote: () => undefined,
|
|
||||||
isUpdateInProgress: () => false,
|
isUpdateInProgress: () => false,
|
||||||
setUpdateInProgress: () => undefined,
|
setUpdateInProgress: () => undefined,
|
||||||
trackLastAddedNoteId: () => undefined,
|
trackLastAddedNoteId: () => undefined,
|
||||||
|
|||||||
@@ -3,13 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import {
|
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||||
AnkiConnectConfig,
|
|
||||||
type CardKind,
|
|
||||||
type MediaTimingReviewDecision,
|
|
||||||
type MediaTimingReviewRequest,
|
|
||||||
type WordCardKind,
|
|
||||||
} from '../types/anki';
|
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -61,7 +55,6 @@ interface CardCreationClient {
|
|||||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||||
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
findNotes(query: string, options?: { maxRetries?: number }): Promise<number[]>;
|
||||||
retrieveMediaFile(filename: string): Promise<string>;
|
retrieveMediaFile(filename: string): Promise<string>;
|
||||||
deleteNotes(noteIds: number[]): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardCreationMediaGenerator {
|
interface CardCreationMediaGenerator {
|
||||||
@@ -145,16 +138,12 @@ interface CardCreationDeps {
|
|||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
removeKnownWordNote: (noteId: number) => void;
|
|
||||||
isUpdateInProgress: () => boolean;
|
isUpdateInProgress: () => boolean;
|
||||||
setUpdateInProgress: (value: boolean) => void;
|
setUpdateInProgress: (value: boolean) => void;
|
||||||
trackLastAddedNoteId?: (noteId: number) => void;
|
trackLastAddedNoteId?: (noteId: number) => void;
|
||||||
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||||
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
|
||||||
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
|
||||||
reviewMediaTiming?: (
|
|
||||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
|
||||||
) => Promise<MediaTimingReviewDecision>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardCreationService {
|
export class CardCreationService {
|
||||||
@@ -271,7 +260,6 @@ export class CardCreationService {
|
|||||||
fields,
|
fields,
|
||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||||
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
const sentenceCardConfig = this.deps.getEffectiveSentenceCardConfig();
|
||||||
const sentenceField = sentenceCardConfig.sentenceField;
|
const sentenceField = sentenceCardConfig.sentenceField;
|
||||||
@@ -463,27 +451,6 @@ export class CardCreationService {
|
|||||||
this.deps.getConfig(),
|
this.deps.getConfig(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const timingDecision = this.deps.reviewMediaTiming
|
|
||||||
? await this.deps.reviewMediaTiming({
|
|
||||||
kind: 'audio',
|
|
||||||
text: mpvClient.currentSubText,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
noteId,
|
|
||||||
})
|
|
||||||
: ({ action: 'use-original' } as const);
|
|
||||||
if (timingDecision.action === 'discard') {
|
|
||||||
await this.deps.client.deleteNotes([noteId]);
|
|
||||||
this.deps.removeKnownWordNote(noteId);
|
|
||||||
this.deps.showStatusNotification('Card deleted.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
|
||||||
if (timingDecision.action === 'confirm') {
|
|
||||||
startTime = timingDecision.startTime;
|
|
||||||
endTime = timingDecision.endTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedFields: Record<string, string> = {};
|
const updatedFields: Record<string, string> = {};
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
let miscInfoFilename: string | null = null;
|
let miscInfoFilename: string | null = null;
|
||||||
@@ -504,7 +471,6 @@ export class CardCreationService {
|
|||||||
mpvClient.currentVideoPath,
|
mpvClient.currentVideoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
exactReviewedRange ? 0 : undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
@@ -526,7 +492,6 @@ export class CardCreationService {
|
|||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
exactReviewedRange,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const imageField = this.deps.getConfig().fields?.image;
|
const imageField = this.deps.getConfig().fields?.image;
|
||||||
@@ -599,24 +564,6 @@ export class CardCreationService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
|
||||||
const timingDecision = this.deps.reviewMediaTiming
|
|
||||||
? await this.deps.reviewMediaTiming({
|
|
||||||
kind: 'sentence',
|
|
||||||
text: sentence,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
})
|
|
||||||
: ({ action: 'use-original' } as const);
|
|
||||||
if (timingDecision.action === 'discard') {
|
|
||||||
this.deps.showStatusNotification('Card creation cancelled.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const exactReviewedRange = timingDecision.action === 'confirm';
|
|
||||||
if (timingDecision.action === 'confirm') {
|
|
||||||
startTime = timingDecision.startTime;
|
|
||||||
endTime = timingDecision.endTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
const generateAudio = shouldGenerateAudio(config);
|
const generateAudio = shouldGenerateAudio(config);
|
||||||
const generateImage = shouldGenerateImage(config);
|
const generateImage = shouldGenerateImage(config);
|
||||||
@@ -785,7 +732,6 @@ export class CardCreationService {
|
|||||||
generateAudio,
|
generateAudio,
|
||||||
generateImage,
|
generateImage,
|
||||||
volumeScale,
|
volumeScale,
|
||||||
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
|
|
||||||
});
|
});
|
||||||
await this.deps.showNotification(noteId, label, 'media queued');
|
await this.deps.showNotification(noteId, label, 'media queued');
|
||||||
return true;
|
return true;
|
||||||
@@ -801,12 +747,7 @@ export class CardCreationService {
|
|||||||
try {
|
try {
|
||||||
const audioFilename = this.generateAudioFilename();
|
const audioFilename = this.generateAudioFilename();
|
||||||
const audioBuffer = audioSourcePath
|
const audioBuffer = audioSourcePath
|
||||||
? await this.mediaGenerateAudio(
|
? await this.mediaGenerateAudio(audioSourcePath, startTime, endTime)
|
||||||
audioSourcePath,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
exactReviewedRange ? 0 : undefined,
|
|
||||||
)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
@@ -824,13 +765,7 @@ export class CardCreationService {
|
|||||||
if (generateImage) {
|
if (generateImage) {
|
||||||
try {
|
try {
|
||||||
const imageFilename = this.generateImageFilename();
|
const imageFilename = this.generateImageFilename();
|
||||||
const imageBuffer = await this.generateImageBuffer(
|
const imageBuffer = await this.generateImageBuffer(videoPath!, startTime, endTime);
|
||||||
videoPath!,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
0,
|
|
||||||
exactReviewedRange,
|
|
||||||
);
|
|
||||||
|
|
||||||
const imageField = config.fields?.image;
|
const imageField = config.fields?.image;
|
||||||
if (imageBuffer && imageField) {
|
if (imageBuffer && imageField) {
|
||||||
@@ -898,7 +833,6 @@ export class CardCreationService {
|
|||||||
videoPath: MediaInput,
|
videoPath: MediaInput,
|
||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
audioPaddingOverride?: number,
|
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const mpvClient = this.deps.getMpvClient();
|
const mpvClient = this.deps.getMpvClient();
|
||||||
if (!mpvClient) {
|
if (!mpvClient) {
|
||||||
@@ -909,7 +843,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
audioPaddingOverride ?? this.deps.getConfig().media?.audioPadding,
|
this.deps.getConfig().media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(
|
resolveAudioStreamIndexForMediaGeneration(
|
||||||
videoPath,
|
videoPath,
|
||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
@@ -927,16 +861,13 @@ export class CardCreationService {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
exactReviewedRange = false,
|
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const mpvClient = this.deps.getMpvClient();
|
const mpvClient = this.deps.getMpvClient();
|
||||||
if (!mpvClient) {
|
if (!mpvClient) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = exactReviewedRange
|
const timestamp = mpvClient.currentTimePos || 0;
|
||||||
? startTime + (endTime - startTime) / 2
|
|
||||||
: mpvClient.currentTimePos || 0;
|
|
||||||
|
|
||||||
if (this.deps.getConfig().media?.imageType === 'avif') {
|
if (this.deps.getConfig().media?.imageType === 'avif') {
|
||||||
let imageStart = startTime;
|
let imageStart = startTime;
|
||||||
@@ -952,7 +883,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
imageStart,
|
imageStart,
|
||||||
imageEnd,
|
imageEnd,
|
||||||
exactReviewedRange ? 0 : this.deps.getConfig().media?.audioPadding,
|
this.deps.getConfig().media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: this.deps.getConfig().media?.animatedFps,
|
fps: this.deps.getConfig().media?.animatedFps,
|
||||||
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
maxWidth: this.deps.getConfig().media?.animatedMaxWidth,
|
||||||
|
|||||||
@@ -261,32 +261,6 @@ test('KnownWordCacheManager invalidates persisted cache when fields.word changes
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('KnownWordCacheManager removes a deleted note from memory and persisted state', () => {
|
|
||||||
const config: AnkiConnectConfig = {
|
|
||||||
fields: { word: 'Word' },
|
|
||||||
knownWords: { highlightEnabled: true },
|
|
||||||
};
|
|
||||||
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
|
||||||
|
|
||||||
try {
|
|
||||||
manager.appendFromNoteInfo({
|
|
||||||
noteId: 42,
|
|
||||||
fields: { Word: { value: '猫' } },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(manager.removeNote(42), true);
|
|
||||||
assert.equal(manager.removeNote(42), false);
|
|
||||||
assert.equal(manager.isKnownWord('猫'), false);
|
|
||||||
|
|
||||||
const persisted = JSON.parse(fs.readFileSync(statePath, 'utf-8')) as {
|
|
||||||
notes?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
assert.deepEqual(persisted.notes, {});
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
test('KnownWordCacheManager refresh incrementally reconciles deleted and edited note words', async () => {
|
||||||
const config: AnkiConnectConfig = {
|
const config: AnkiConnectConfig = {
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
@@ -350,17 +350,6 @@ export class KnownWordCacheManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeNote(noteId: number): boolean {
|
|
||||||
if (!this.noteEntriesById.has(noteId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.removeNoteSnapshot(noteId);
|
|
||||||
this.persistKnownWordCacheState();
|
|
||||||
log.info('Known-word cache removed deleted note', `noteId=${noteId}`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearKnownWordCacheState(): void {
|
clearKnownWordCacheState(): void {
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ function createWorkflowHarness() {
|
|||||||
updates.push({ noteId, fields });
|
updates.push({ noteId, fields });
|
||||||
},
|
},
|
||||||
storeMediaFile: async () => undefined,
|
storeMediaFile: async () => undefined,
|
||||||
deleteNotes: async () => undefined,
|
|
||||||
},
|
},
|
||||||
getConfig: () => ({
|
getConfig: () => ({
|
||||||
fields: {
|
fields: {
|
||||||
@@ -62,7 +61,6 @@ function createWorkflowHarness() {
|
|||||||
kikuFieldGrouping: 'disabled' as const,
|
kikuFieldGrouping: 'disabled' as const,
|
||||||
}),
|
}),
|
||||||
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
appendKnownWordsFromNoteInfo: (_noteInfo: NoteUpdateWorkflowNoteInfo) => undefined,
|
||||||
removeKnownWordNote: (_noteId: number) => undefined,
|
|
||||||
extractFields: (fields: Record<string, { value: string }>) => {
|
extractFields: (fields: Record<string, { value: string }>) => {
|
||||||
const out: Record<string, string> = {};
|
const out: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(fields)) {
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
@@ -594,62 +592,3 @@ test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', as
|
|||||||
assert.equal(queuedUpdates[0]?.context, undefined);
|
assert.equal(queuedUpdates[0]?.context, undefined);
|
||||||
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
assert.deepEqual(harness.updates, [{ noteId: 42, fields: { Sentence: 'subtitle-text' } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow deletes an existing word card when timing review discards it', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
const deletedNoteIds: number[][] = [];
|
|
||||||
const removedKnownWordNoteIds: number[] = [];
|
|
||||||
let appendedKnownWords = false;
|
|
||||||
harness.deps.captureSubtitleMediaContext = () => ({
|
|
||||||
source: 'overlay',
|
|
||||||
text: 'subtitle-text',
|
|
||||||
startTime: 4,
|
|
||||||
endTime: 6,
|
|
||||||
});
|
|
||||||
harness.deps.client.deleteNotes = async (noteIds) => {
|
|
||||||
deletedNoteIds.push(noteIds);
|
|
||||||
};
|
|
||||||
harness.deps.appendKnownWordsFromNoteInfo = () => {
|
|
||||||
appendedKnownWords = true;
|
|
||||||
};
|
|
||||||
harness.deps.removeKnownWordNote = (noteId) => {
|
|
||||||
removedKnownWordNoteIds.push(noteId);
|
|
||||||
};
|
|
||||||
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.deepEqual(deletedNoteIds, [[42]]);
|
|
||||||
assert.deepEqual(removedKnownWordNoteIds, [42]);
|
|
||||||
assert.equal(appendedKnownWords, false);
|
|
||||||
assert.deepEqual(harness.updates, []);
|
|
||||||
assert.deepEqual(harness.notifications, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
const statusMessages: string[] = [];
|
|
||||||
let removedKnownWord = false;
|
|
||||||
harness.deps.captureSubtitleMediaContext = () => ({
|
|
||||||
source: 'overlay',
|
|
||||||
text: 'subtitle-text',
|
|
||||||
startTime: 4,
|
|
||||||
endTime: 6,
|
|
||||||
});
|
|
||||||
harness.deps.client.deleteNotes = async () => {
|
|
||||||
throw new Error('delete failed');
|
|
||||||
};
|
|
||||||
harness.deps.removeKnownWordNote = () => {
|
|
||||||
removedKnownWord = true;
|
|
||||||
};
|
|
||||||
harness.deps.showOsdNotification = (message) => {
|
|
||||||
statusMessages.push(message);
|
|
||||||
};
|
|
||||||
harness.deps.reviewMediaTiming = async () => ({ action: 'discard' });
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(removedKnownWord, false);
|
|
||||||
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
|
|
||||||
assert.ok(harness.warnings.length === 0);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import type {
|
import type { CardKind, WordCardKind } from '../types/anki';
|
||||||
CardKind,
|
|
||||||
MediaTimingReviewDecision,
|
|
||||||
MediaTimingReviewRequest,
|
|
||||||
WordCardKind,
|
|
||||||
} from '../types/anki';
|
|
||||||
import { resolveWordCardKind } from './note-field-utils';
|
import { resolveWordCardKind } from './note-field-utils';
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
@@ -19,7 +14,6 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
notesInfo(noteIds: number[]): Promise<unknown>;
|
notesInfo(noteIds: number[]): Promise<unknown>;
|
||||||
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
updateNoteFields(noteId: number, fields: Record<string, string>): Promise<void>;
|
||||||
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
storeMediaFile(filename: string, data: Buffer): Promise<void>;
|
||||||
deleteNotes(noteIds: number[]): Promise<void>;
|
|
||||||
};
|
};
|
||||||
getConfig: () => {
|
getConfig: () => {
|
||||||
fields?: {
|
fields?: {
|
||||||
@@ -49,7 +43,6 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
wordCardKind?: WordCardKind;
|
wordCardKind?: WordCardKind;
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
removeKnownWordNote: (noteId: number) => void;
|
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
findDuplicateNote: (
|
findDuplicateNote: (
|
||||||
expression: string,
|
expression: string,
|
||||||
@@ -109,9 +102,6 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
logWarn: (message: string, ...args: unknown[]) => void;
|
logWarn: (message: string, ...args: unknown[]) => void;
|
||||||
logInfo: (message: string, ...args: unknown[]) => void;
|
logInfo: (message: string, ...args: unknown[]) => void;
|
||||||
logError: (message: string, ...args: unknown[]) => void;
|
logError: (message: string, ...args: unknown[]) => void;
|
||||||
reviewMediaTiming?: (
|
|
||||||
request: Omit<MediaTimingReviewRequest, 'audioPadding' | 'maxMediaDuration'>,
|
|
||||||
) => Promise<MediaTimingReviewDecision>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSubtitleContextText(text: string): string {
|
function normalizeSubtitleContextText(text: string): string {
|
||||||
@@ -181,6 +171,7 @@ export class NoteUpdateWorkflow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const noteInfo = notesInfo[0]!;
|
const noteInfo = notesInfo[0]!;
|
||||||
|
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
||||||
const fields = this.deps.extractFields(noteInfo.fields);
|
const fields = this.deps.extractFields(noteInfo.fields);
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
|
|
||||||
@@ -216,45 +207,10 @@ export class NoteUpdateWorkflow {
|
|||||||
// Audio and image generation run sequentially and audio extraction can take tens of
|
// Audio and image generation run sequentially and audio extraction can take tens of
|
||||||
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
||||||
// timings per generator clips whichever line is on screen when each one starts.
|
// timings per generator clips whichever line is on screen when each one starts.
|
||||||
let mediaTimingContext =
|
const mediaTimingContext =
|
||||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||||
|
|
||||||
if (mediaTimingContext) {
|
|
||||||
const timingDecision = this.deps.reviewMediaTiming
|
|
||||||
? await this.deps.reviewMediaTiming({
|
|
||||||
kind: 'word',
|
|
||||||
text: mediaTimingContext.text,
|
|
||||||
startTime: mediaTimingContext.startTime,
|
|
||||||
endTime: mediaTimingContext.endTime,
|
|
||||||
noteId,
|
|
||||||
})
|
|
||||||
: ({ action: 'use-original' } as const);
|
|
||||||
if (timingDecision.action === 'discard') {
|
|
||||||
try {
|
|
||||||
await this.deps.client.deleteNotes([noteId]);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.deps.logError('Failed to delete discarded card:', message);
|
|
||||||
this.deps.showOsdNotification(`Card deletion failed: ${message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.deps.removeKnownWordNote(noteId);
|
|
||||||
this.deps.showOsdNotification('Card deleted.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (timingDecision.action === 'confirm') {
|
|
||||||
mediaTimingContext = {
|
|
||||||
...mediaTimingContext,
|
|
||||||
startTime: timingDecision.startTime,
|
|
||||||
endTime: timingDecision.endTime,
|
|
||||||
mediaPaddingSeconds: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deps.appendKnownWordsFromNoteInfo(noteInfo);
|
|
||||||
|
|
||||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
|
|||||||
@@ -148,9 +148,6 @@ export class PendingYoutubeMediaQueue {
|
|||||||
generateAudio: shouldGenerateAudio(config),
|
generateAudio: shouldGenerateAudio(config),
|
||||||
generateImage: shouldGenerateImage(config),
|
generateImage: shouldGenerateImage(config),
|
||||||
volumeScale,
|
volumeScale,
|
||||||
...(job.context?.mediaPaddingSeconds !== undefined
|
|
||||||
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -278,7 +275,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
cachedMediaInput,
|
cachedMediaInput,
|
||||||
job.startTime,
|
job.startTime,
|
||||||
job.endTime,
|
job.endTime,
|
||||||
job.mediaPaddingSeconds ?? config.media?.audioPadding,
|
config.media?.audioPadding,
|
||||||
undefined,
|
undefined,
|
||||||
config.media?.normalizeAudio !== false,
|
config.media?.normalizeAudio !== false,
|
||||||
job.volumeScale,
|
job.volumeScale,
|
||||||
@@ -312,7 +309,6 @@ export class PendingYoutubeMediaQueue {
|
|||||||
job.startTime,
|
job.startTime,
|
||||||
job.endTime,
|
job.endTime,
|
||||||
animatedLeadInSeconds,
|
animatedLeadInSeconds,
|
||||||
job.mediaPaddingSeconds,
|
|
||||||
);
|
);
|
||||||
if (imageBuffer) {
|
if (imageBuffer) {
|
||||||
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
|
||||||
@@ -373,7 +369,6 @@ export class PendingYoutubeMediaQueue {
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
animatedLeadInSeconds = 0,
|
animatedLeadInSeconds = 0,
|
||||||
mediaPaddingSeconds?: number,
|
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const config = this.deps.getConfig();
|
const config = this.deps.getConfig();
|
||||||
if (config.media?.imageType === 'avif') {
|
if (config.media?.imageType === 'avif') {
|
||||||
@@ -381,7 +376,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
videoPath,
|
videoPath,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
mediaPaddingSeconds ?? config.media?.audioPadding,
|
config.media?.audioPadding,
|
||||||
{
|
{
|
||||||
fps: config.media?.animatedFps,
|
fps: config.media?.animatedFps,
|
||||||
maxWidth: config.media?.animatedMaxWidth,
|
maxWidth: config.media?.animatedMaxWidth,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface PendingYoutubeMediaUpdate {
|
|||||||
generateAudio: boolean;
|
generateAudio: boolean;
|
||||||
generateImage: boolean;
|
generateImage: boolean;
|
||||||
volumeScale?: number;
|
volumeScale?: number;
|
||||||
mediaPaddingSeconds?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
normalizeAudio: true,
|
normalizeAudio: true,
|
||||||
mirrorMpvVolume: true,
|
mirrorMpvVolume: true,
|
||||||
reviewTiming: false,
|
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
|
|||||||
@@ -196,13 +196,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
"Apply mpv's current software volume curve to generated sentence audio. Changes apply live.",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'ankiConnect.media.reviewTiming',
|
|
||||||
kind: 'boolean',
|
|
||||||
defaultValue: defaultConfig.ankiConnect.media.reviewTiming,
|
|
||||||
description:
|
|
||||||
'Review and preview subtitle media timing before SubMiner creates or enriches a mined card.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.media.generateImage',
|
path: 'ankiConnect.media.generateImage',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'AnkiConnect Integration',
|
title: 'AnkiConnect Integration',
|
||||||
description: ['Automatic Anki updates and media generation options.'],
|
description: ['Automatic Anki updates and media generation options.'],
|
||||||
notes: [
|
notes: [
|
||||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||||
'Most other AnkiConnect settings still require restart.',
|
'Most other AnkiConnect settings still require restart.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,34 +21,6 @@ function makeContext(ankiConnect: unknown): {
|
|||||||
return { context, warnings };
|
return { context, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('media timing review is disabled by default and accepts a boolean override', () => {
|
|
||||||
const defaultContext = makeContext({});
|
|
||||||
applyAnkiConnectResolution(defaultContext.context);
|
|
||||||
assert.equal(defaultContext.context.resolved.ankiConnect.media.reviewTiming, false);
|
|
||||||
|
|
||||||
const enabledContext = makeContext({ media: { reviewTiming: true } });
|
|
||||||
applyAnkiConnectResolution(enabledContext.context);
|
|
||||||
assert.equal(enabledContext.context.resolved.ankiConnect.media.reviewTiming, true);
|
|
||||||
assert.deepEqual(enabledContext.warnings, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('modern media duration accepts zero as the disabled cap sentinel', () => {
|
|
||||||
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
|
|
||||||
applyAnkiConnectResolution(disabledCap.context);
|
|
||||||
assert.equal(disabledCap.context.resolved.ankiConnect.media.maxMediaDuration, 0);
|
|
||||||
assert.deepEqual(disabledCap.warnings, []);
|
|
||||||
|
|
||||||
const invalidCap = makeContext({ media: { maxMediaDuration: -1 } });
|
|
||||||
applyAnkiConnectResolution(invalidCap.context);
|
|
||||||
assert.equal(
|
|
||||||
invalidCap.context.resolved.ankiConnect.media.maxMediaDuration,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
invalidCap.warnings.some((warning) => warning.path === 'ankiConnect.media.maxMediaDuration'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
test('modern invalid knownWords.highlightEnabled warns modern key and does not fallback to legacy', () => {
|
||||||
const { context, warnings } = makeContext({
|
const { context, warnings } = makeContext({
|
||||||
nPlusOne: { highlightEnabled: true },
|
nPlusOne: { highlightEnabled: true },
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export function applyModernMediaResolution(
|
|||||||
'syncAnimatedImageToWordAudio',
|
'syncAnimatedImageToWordAudio',
|
||||||
'normalizeAudio',
|
'normalizeAudio',
|
||||||
'mirrorMpvVolume',
|
'mirrorMpvVolume',
|
||||||
'reviewTiming',
|
|
||||||
] as const) {
|
] as const) {
|
||||||
applyModernValue(
|
applyModernValue(
|
||||||
context,
|
context,
|
||||||
@@ -129,28 +128,18 @@ export function applyModernMediaResolution(
|
|||||||
'Expected non-negative number.',
|
'Expected non-negative number.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const key of ['fallbackDuration', 'maxMediaDuration'] as const) {
|
||||||
applyModernValue(
|
applyModernValue(
|
||||||
context,
|
context,
|
||||||
media,
|
media,
|
||||||
'fallbackDuration',
|
key,
|
||||||
'ankiConnect.media.fallbackDuration',
|
`ankiConnect.media.${key}`,
|
||||||
asPositiveNumber,
|
asPositiveNumber,
|
||||||
DEFAULT_CONFIG.ankiConnect.media.fallbackDuration,
|
DEFAULT_CONFIG.ankiConnect.media[key],
|
||||||
(value) => {
|
(value) => {
|
||||||
context.resolved.ankiConnect.media.fallbackDuration = value;
|
context.resolved.ankiConnect.media[key] = value;
|
||||||
},
|
},
|
||||||
'Expected positive number.',
|
'Expected positive number.',
|
||||||
);
|
);
|
||||||
applyModernValue(
|
}
|
||||||
context,
|
|
||||||
media,
|
|
||||||
'maxMediaDuration',
|
|
||||||
'ankiConnect.media.maxMediaDuration',
|
|
||||||
asNonNegativeNumber,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.media.maxMediaDuration,
|
|
||||||
(value) => {
|
|
||||||
context.resolved.ankiConnect.media.maxMediaDuration = value;
|
|
||||||
},
|
|
||||||
'Expected non-negative number.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'mpv.aniskipEnabled': 'Enable AniSkip',
|
'mpv.aniskipEnabled': 'Enable AniSkip',
|
||||||
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
'mpv.aniskipButtonKey': 'AniSkip Button Key',
|
||||||
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
'ankiConnect.media.mirrorMpvVolume': 'Mirror mpv Volume',
|
||||||
'ankiConnect.media.reviewTiming': 'Review Media Timing',
|
|
||||||
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
'discordPresence.updateIntervalMs': 'Update Interval (ms)',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -695,7 +694,6 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'ankiConnect.ai.enabled' ||
|
path === 'ankiConnect.ai.enabled' ||
|
||||||
path === 'ankiConnect.media.normalizeAudio' ||
|
path === 'ankiConnect.media.normalizeAudio' ||
|
||||||
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
path === 'ankiConnect.media.mirrorMpvVolume' ||
|
||||||
path === 'ankiConnect.media.reviewTiming' ||
|
|
||||||
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
path === 'ankiConnect.behavior.autoUpdateNewCards' ||
|
||||||
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
path === 'ankiConnect.knownWords.highlightEnabled' ||
|
||||||
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
path === 'ankiConnect.knownWords.refreshMinutes' ||
|
||||||
|
|||||||
+7
-124
@@ -19,13 +19,6 @@ import type {
|
|||||||
YoutubePickerResolveRequest,
|
YoutubePickerResolveRequest,
|
||||||
YoutubePickerResolveResult,
|
YoutubePickerResolveResult,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import type {
|
|
||||||
MediaTimingReviewActionResult,
|
|
||||||
MediaTimingReviewPreviewRequest,
|
|
||||||
MediaTimingReviewResolveRequest,
|
|
||||||
MediaTimingReviewWaveformRequest,
|
|
||||||
MediaTimingReviewWaveformResult,
|
|
||||||
} from '../../types/anki';
|
|
||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
||||||
import {
|
import {
|
||||||
parseMpvCommand,
|
parseMpvCommand,
|
||||||
@@ -41,6 +34,7 @@ import {
|
|||||||
parseSubsyncManualRunRequest,
|
parseSubsyncManualRunRequest,
|
||||||
parseYoutubePickerResolveRequest,
|
parseYoutubePickerResolveRequest,
|
||||||
} from '../../shared/ipc/validators';
|
} from '../../shared/ipc/validators';
|
||||||
|
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||||
|
|
||||||
const { ipcMain } = electron;
|
const { ipcMain } = electron;
|
||||||
|
|
||||||
@@ -105,16 +99,6 @@ export interface IpcServiceDeps {
|
|||||||
onYoutubePickerResolve: (
|
onYoutubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
previewMediaTimingReview?: (
|
|
||||||
request: MediaTimingReviewPreviewRequest,
|
|
||||||
) => Promise<MediaTimingReviewActionResult>;
|
|
||||||
getMediaTimingReviewWaveform?: (
|
|
||||||
request: MediaTimingReviewWaveformRequest,
|
|
||||||
) => Promise<MediaTimingReviewWaveformResult>;
|
|
||||||
stopMediaTimingReviewPreview?: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
|
||||||
resolveMediaTimingReview?: (
|
|
||||||
request: MediaTimingReviewResolveRequest,
|
|
||||||
) => MediaTimingReviewActionResult | Promise<MediaTimingReviewActionResult>;
|
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
@@ -238,65 +222,6 @@ function parseOverlayNotificationActionPayload(
|
|||||||
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
return { notificationId, actionId, ...(typeof noteId === 'number' ? { noteId } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMediaTimingReviewPreviewRequest(
|
|
||||||
payload: unknown,
|
|
||||||
): MediaTimingReviewPreviewRequest | null {
|
|
||||||
if (!payload || typeof payload !== 'object') return null;
|
|
||||||
const record = payload as Record<string, unknown>;
|
|
||||||
if (
|
|
||||||
typeof record.reviewId !== 'string' ||
|
|
||||||
!record.reviewId ||
|
|
||||||
typeof record.startTime !== 'number' ||
|
|
||||||
!Number.isFinite(record.startTime) ||
|
|
||||||
typeof record.endTime !== 'number' ||
|
|
||||||
!Number.isFinite(record.endTime)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
reviewId: record.reviewId,
|
|
||||||
startTime: record.startTime,
|
|
||||||
endTime: record.endTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMediaTimingReviewWaveformRequest(
|
|
||||||
payload: unknown,
|
|
||||||
): MediaTimingReviewWaveformRequest | null {
|
|
||||||
return parseMediaTimingReviewPreviewRequest(payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMediaTimingReviewResolveRequest(
|
|
||||||
payload: unknown,
|
|
||||||
): MediaTimingReviewResolveRequest | null {
|
|
||||||
if (!payload || typeof payload !== 'object') return null;
|
|
||||||
const record = payload as Record<string, unknown>;
|
|
||||||
if (typeof record.reviewId !== 'string' || !record.reviewId) return null;
|
|
||||||
const decision = record.decision;
|
|
||||||
if (!decision || typeof decision !== 'object') return null;
|
|
||||||
const decisionRecord = decision as Record<string, unknown>;
|
|
||||||
if (decisionRecord.action === 'use-original' || decisionRecord.action === 'discard') {
|
|
||||||
return { reviewId: record.reviewId, decision: { action: decisionRecord.action } };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
decisionRecord.action === 'confirm' &&
|
|
||||||
typeof decisionRecord.startTime === 'number' &&
|
|
||||||
Number.isFinite(decisionRecord.startTime) &&
|
|
||||||
typeof decisionRecord.endTime === 'number' &&
|
|
||||||
Number.isFinite(decisionRecord.endTime)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
reviewId: record.reviewId,
|
|
||||||
decision: {
|
|
||||||
action: 'confirm',
|
|
||||||
startTime: decisionRecord.startTime,
|
|
||||||
endTime: decisionRecord.endTime,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IpcDepsRuntimeOptions {
|
export interface IpcDepsRuntimeOptions {
|
||||||
getMainWindow: () => WindowLike | null;
|
getMainWindow: () => WindowLike | null;
|
||||||
getVisibleOverlayVisibility: () => boolean;
|
getVisibleOverlayVisibility: () => boolean;
|
||||||
@@ -353,10 +278,6 @@ export interface IpcDepsRuntimeOptions {
|
|||||||
onYoutubePickerResolve: (
|
onYoutubePickerResolve: (
|
||||||
request: YoutubePickerResolveRequest,
|
request: YoutubePickerResolveRequest,
|
||||||
) => Promise<YoutubePickerResolveResult>;
|
) => Promise<YoutubePickerResolveResult>;
|
||||||
previewMediaTimingReview?: IpcServiceDeps['previewMediaTimingReview'];
|
|
||||||
getMediaTimingReviewWaveform?: IpcServiceDeps['getMediaTimingReviewWaveform'];
|
|
||||||
stopMediaTimingReviewPreview?: IpcServiceDeps['stopMediaTimingReviewPreview'];
|
|
||||||
resolveMediaTimingReview?: IpcServiceDeps['resolveMediaTimingReview'];
|
|
||||||
getAnkiConnectStatus: () => boolean;
|
getAnkiConnectStatus: () => boolean;
|
||||||
getRuntimeOptions: () => unknown;
|
getRuntimeOptions: () => unknown;
|
||||||
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
setRuntimeOption: (id: RuntimeOptionId, value: RuntimeOptionValue) => unknown;
|
||||||
@@ -450,10 +371,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
|||||||
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
options.activatePlaybackWindowForOverlayInteraction ?? (() => false),
|
||||||
runSubsyncManual: options.runSubsyncManual,
|
runSubsyncManual: options.runSubsyncManual,
|
||||||
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
onYoutubePickerResolve: options.onYoutubePickerResolve,
|
||||||
previewMediaTimingReview: options.previewMediaTimingReview,
|
|
||||||
getMediaTimingReviewWaveform: options.getMediaTimingReviewWaveform,
|
|
||||||
stopMediaTimingReviewPreview: options.stopMediaTimingReviewPreview,
|
|
||||||
resolveMediaTimingReview: options.resolveMediaTimingReview,
|
|
||||||
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
getAnkiConnectStatus: options.getAnkiConnectStatus,
|
||||||
getRuntimeOptions: options.getRuntimeOptions,
|
getRuntimeOptions: options.getRuntimeOptions,
|
||||||
setRuntimeOption: options.setRuntimeOption,
|
setRuntimeOption: options.setRuntimeOption,
|
||||||
@@ -526,8 +443,14 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
const senderWindow =
|
const senderWindow =
|
||||||
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
|
||||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||||
|
// 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);
|
senderWindow.setIgnoreMouseEvents(ignore, parsedOptions);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -575,46 +498,6 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.request.mediaTimingReviewPreview,
|
|
||||||
async (_event: unknown, payload: unknown) => {
|
|
||||||
const request = parseMediaTimingReviewPreviewRequest(payload);
|
|
||||||
if (!request || !deps.previewMediaTimingReview) {
|
|
||||||
return { ok: false, message: 'Timing preview is unavailable.' };
|
|
||||||
}
|
|
||||||
return await deps.previewMediaTimingReview(request);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.request.mediaTimingReviewWaveform,
|
|
||||||
async (_event: unknown, payload: unknown) => {
|
|
||||||
const request = parseMediaTimingReviewWaveformRequest(payload);
|
|
||||||
if (!request || !deps.getMediaTimingReviewWaveform) {
|
|
||||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
|
||||||
}
|
|
||||||
return await deps.getMediaTimingReviewWaveform(request);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.request.mediaTimingReviewStopPreview,
|
|
||||||
async (_event: unknown, reviewId: unknown) => {
|
|
||||||
if (typeof reviewId !== 'string' || !reviewId || !deps.stopMediaTimingReviewPreview) {
|
|
||||||
return { ok: false, message: 'Timing preview is unavailable.' };
|
|
||||||
}
|
|
||||||
return await deps.stopMediaTimingReviewPreview(reviewId);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
ipc.handle(
|
|
||||||
IPC_CHANNELS.request.mediaTimingReviewResolve,
|
|
||||||
async (_event: unknown, payload: unknown) => {
|
|
||||||
const request = parseMediaTimingReviewResolveRequest(payload);
|
|
||||||
if (!request || !deps.resolveMediaTimingReview) {
|
|
||||||
return { ok: false, message: 'Timing review is unavailable.' };
|
|
||||||
}
|
|
||||||
return await deps.resolveMediaTimingReview(request);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
ipc.on(IPC_CHANNELS.command.openYomitanSettings, () => {
|
||||||
deps.openYomitanSettings();
|
deps.openYomitanSettings();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import net from 'node:net';
|
|
||||||
import { describe, test } from 'node:test';
|
|
||||||
import { buildMediaTimingPreviewArgs, MediaTimingPreviewSession } from './media-timing-preview';
|
|
||||||
|
|
||||||
describe('buildMediaTimingPreviewArgs', () => {
|
|
||||||
test('creates a hidden audio-only reusable mpv session', () => {
|
|
||||||
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
|
||||||
mediaPath: '/video/show.mkv',
|
|
||||||
audioTrackId: 3,
|
|
||||||
volume: 55,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(args.includes('--no-video'));
|
|
||||||
assert.ok(args.includes('--force-window=no'));
|
|
||||||
assert.ok(args.includes('--idle=yes'));
|
|
||||||
assert.ok(args.includes('--pause=yes'));
|
|
||||||
assert.ok(args.includes('--input-ipc-server=/tmp/review.sock'));
|
|
||||||
assert.ok(args.includes('--aid=3'));
|
|
||||||
assert.ok(args.includes('--volume=55'));
|
|
||||||
assert.equal(args.at(-2), '--');
|
|
||||||
assert.equal(args.at(-1), '/video/show.mkv');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('separates an option-like media path without adding optional audio arguments', () => {
|
|
||||||
const args = buildMediaTimingPreviewArgs('/tmp/review.sock', {
|
|
||||||
mediaPath: '--fullscreen',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(args.at(-2), '--');
|
|
||||||
assert.equal(args.at(-1), '--fullscreen');
|
|
||||||
assert.equal(
|
|
||||||
args.some((arg) => arg.startsWith('--aid=')),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
args.some((arg) => arg.startsWith('--volume=')),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview session handles socket errors after connecting', async () => {
|
|
||||||
const socket = new net.Socket();
|
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
||||||
child.kill = () => true;
|
|
||||||
const session = new MediaTimingPreviewSession({
|
|
||||||
platform: 'linux',
|
|
||||||
spawnProcess: () => child as never,
|
|
||||||
connectSocket: () => {
|
|
||||||
queueMicrotask(() => socket.emit('connect'));
|
|
||||||
return socket;
|
|
||||||
},
|
|
||||||
removeSocketFile: () => undefined,
|
|
||||||
createSocketPath: () => '/tmp/review.sock',
|
|
||||||
});
|
|
||||||
|
|
||||||
await session.start({ mediaPath: '/video/show.mkv' });
|
|
||||||
assert.doesNotThrow(() => socket.emit('error', new Error('pipe closed')));
|
|
||||||
await assert.rejects(session.play(1, 2), /not ready/);
|
|
||||||
session.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview session keeps failed connection errors handled through destruction', async () => {
|
|
||||||
const socket = new EventEmitter() as EventEmitter & {
|
|
||||||
destroy: () => void;
|
|
||||||
};
|
|
||||||
socket.destroy = () => {
|
|
||||||
socket.emit('error', new Error('socket failed again while closing'));
|
|
||||||
};
|
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
||||||
child.kill = () => true;
|
|
||||||
const times = [0, 0, 0, 6_000];
|
|
||||||
const session = new MediaTimingPreviewSession({
|
|
||||||
platform: 'linux',
|
|
||||||
spawnProcess: () => child as never,
|
|
||||||
connectSocket: () => {
|
|
||||||
queueMicrotask(() => socket.emit('error', new Error('connection failed')));
|
|
||||||
return socket as never;
|
|
||||||
},
|
|
||||||
now: () => times.shift() ?? 6_000,
|
|
||||||
removeSocketFile: () => undefined,
|
|
||||||
createSocketPath: () => '/tmp/review.sock',
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview session rejects a connection that finishes after disposal', async () => {
|
|
||||||
const socket = new net.Socket();
|
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
||||||
child.kill = () => true;
|
|
||||||
const session = new MediaTimingPreviewSession({
|
|
||||||
platform: 'linux',
|
|
||||||
spawnProcess: () => child as never,
|
|
||||||
connectSocket: () => socket,
|
|
||||||
removeSocketFile: () => undefined,
|
|
||||||
createSocketPath: () => '/tmp/review.sock',
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendingStart = session.start({ mediaPath: '-playlist' });
|
|
||||||
session.dispose();
|
|
||||||
socket.emit('connect');
|
|
||||||
|
|
||||||
await assert.rejects(pendingStart, /closed/);
|
|
||||||
assert.equal(socket.destroyed, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview session shares one startup across concurrent start calls', async () => {
|
|
||||||
const socket = new net.Socket();
|
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
||||||
child.kill = () => true;
|
|
||||||
let spawnCount = 0;
|
|
||||||
const session = new MediaTimingPreviewSession({
|
|
||||||
platform: 'linux',
|
|
||||||
spawnProcess: () => {
|
|
||||||
spawnCount += 1;
|
|
||||||
return child as never;
|
|
||||||
},
|
|
||||||
connectSocket: () => socket,
|
|
||||||
removeSocketFile: () => undefined,
|
|
||||||
createSocketPath: () => '/tmp/review.sock',
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstStart = session.start({ mediaPath: '/video/show.mkv' });
|
|
||||||
const secondStart = session.start({ mediaPath: '/video/show.mkv' });
|
|
||||||
socket.emit('connect');
|
|
||||||
|
|
||||||
await Promise.all([firstStart, secondStart]);
|
|
||||||
assert.equal(spawnCount, 1);
|
|
||||||
session.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview session can start again after a startup failure', async () => {
|
|
||||||
const socket = new net.Socket();
|
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
||||||
child.kill = () => true;
|
|
||||||
let spawnCount = 0;
|
|
||||||
const session = new MediaTimingPreviewSession({
|
|
||||||
platform: 'linux',
|
|
||||||
spawnProcess: () => {
|
|
||||||
spawnCount += 1;
|
|
||||||
if (spawnCount === 1) throw new Error('spawn failed');
|
|
||||||
return child as never;
|
|
||||||
},
|
|
||||||
connectSocket: () => {
|
|
||||||
queueMicrotask(() => socket.emit('connect'));
|
|
||||||
return socket;
|
|
||||||
},
|
|
||||||
removeSocketFile: () => undefined,
|
|
||||||
createSocketPath: () => '/tmp/review.sock',
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /spawn failed/);
|
|
||||||
await session.start({ mediaPath: '/video/show.mkv' });
|
|
||||||
assert.equal(spawnCount, 2);
|
|
||||||
session.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview session bounds a connection attempt that never settles', async () => {
|
|
||||||
const child = new EventEmitter() as EventEmitter & { kill: () => boolean };
|
|
||||||
child.kill = () => true;
|
|
||||||
let nowMs = 0;
|
|
||||||
let connectAttempts = 0;
|
|
||||||
const session = new MediaTimingPreviewSession({
|
|
||||||
platform: 'linux',
|
|
||||||
spawnProcess: () => child as never,
|
|
||||||
connectSocket: () => {
|
|
||||||
connectAttempts += 1;
|
|
||||||
const socket = new net.Socket();
|
|
||||||
socket.destroy = (() => {
|
|
||||||
socket.emit('error', new Error('socket failed while timing out'));
|
|
||||||
return socket;
|
|
||||||
}) as typeof socket.destroy;
|
|
||||||
return socket;
|
|
||||||
},
|
|
||||||
now: () => {
|
|
||||||
const current = nowMs;
|
|
||||||
nowMs += 1_000;
|
|
||||||
return current;
|
|
||||||
},
|
|
||||||
schedule: (callback) => setTimeout(callback, 0),
|
|
||||||
cancelSchedule: (timeout) => clearTimeout(timeout),
|
|
||||||
removeSocketFile: () => undefined,
|
|
||||||
createSocketPath: () => '/tmp/review.sock',
|
|
||||||
});
|
|
||||||
|
|
||||||
await assert.rejects(session.start({ mediaPath: '/video/show.mkv' }), /Timed out starting/);
|
|
||||||
assert.equal(connectAttempts, 1);
|
|
||||||
});
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { spawn, type ChildProcess } from 'child_process';
|
|
||||||
import fs from 'fs';
|
|
||||||
import net, { type Socket } from 'net';
|
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
|
|
||||||
const CONNECT_TIMEOUT_MS = 5_000;
|
|
||||||
const CONNECT_ATTEMPT_TIMEOUT_MS = 500;
|
|
||||||
const CONNECT_RETRY_MS = 40;
|
|
||||||
|
|
||||||
export interface MediaTimingPreviewStartOptions {
|
|
||||||
mediaPath: string;
|
|
||||||
executablePath?: string;
|
|
||||||
audioTrackId?: number;
|
|
||||||
volume?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewProcess = Pick<ChildProcess, 'kill' | 'once'>;
|
|
||||||
|
|
||||||
interface MediaTimingPreviewDeps {
|
|
||||||
platform: NodeJS.Platform;
|
|
||||||
spawnProcess: (command: string, args: string[]) => PreviewProcess;
|
|
||||||
connectSocket: (socketPath: string) => Socket;
|
|
||||||
now: () => number;
|
|
||||||
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
|
||||||
cancelSchedule: (timeout: ReturnType<typeof setTimeout>) => void;
|
|
||||||
removeSocketFile: (socketPath: string) => void;
|
|
||||||
createSocketPath: () => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMediaTimingPreviewArgs(
|
|
||||||
socketPath: string,
|
|
||||||
options: MediaTimingPreviewStartOptions,
|
|
||||||
): string[] {
|
|
||||||
const args = [
|
|
||||||
'--no-config',
|
|
||||||
'--no-video',
|
|
||||||
'--audio-display=no',
|
|
||||||
'--force-window=no',
|
|
||||||
'--idle=yes',
|
|
||||||
'--keep-open=yes',
|
|
||||||
'--pause=yes',
|
|
||||||
'--terminal=no',
|
|
||||||
'--msg-level=all=warn',
|
|
||||||
`--input-ipc-server=${socketPath}`,
|
|
||||||
];
|
|
||||||
if (typeof options.audioTrackId === 'number' && Number.isInteger(options.audioTrackId)) {
|
|
||||||
args.push(`--aid=${options.audioTrackId}`);
|
|
||||||
}
|
|
||||||
if (typeof options.volume === 'number' && Number.isFinite(options.volume)) {
|
|
||||||
args.push(`--volume=${Math.max(0, options.volume)}`);
|
|
||||||
}
|
|
||||||
args.push('--', options.mediaPath);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultSocketPath(): string {
|
|
||||||
const suffix = `${process.pid}-${randomUUID()}`;
|
|
||||||
return process.platform === 'win32'
|
|
||||||
? `\\\\.\\pipe\\subminer-timing-preview-${suffix}`
|
|
||||||
: path.join(os.tmpdir(), `subminer-timing-preview-${suffix}.sock`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePosixSocketFile(socketPath: string): void {
|
|
||||||
if (process.platform === 'win32') return;
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(socketPath);
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MediaTimingPreviewSession {
|
|
||||||
private readonly deps: MediaTimingPreviewDeps;
|
|
||||||
private socketPath: string | null = null;
|
|
||||||
private socket: Socket | null = null;
|
|
||||||
private process: PreviewProcess | null = null;
|
|
||||||
private startupError: Error | null = null;
|
|
||||||
private startPromise: Promise<void> | null = null;
|
|
||||||
private retryWait: {
|
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
|
||||||
resolve: () => void;
|
|
||||||
} | null = null;
|
|
||||||
private disposed = false;
|
|
||||||
|
|
||||||
constructor(deps: Partial<MediaTimingPreviewDeps> = {}) {
|
|
||||||
this.deps = {
|
|
||||||
platform: process.platform,
|
|
||||||
spawnProcess: (command, args) => spawn(command, args, { stdio: 'ignore' }),
|
|
||||||
connectSocket: (socketPath) => net.createConnection(socketPath),
|
|
||||||
now: Date.now,
|
|
||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
||||||
cancelSchedule: (timeout) => clearTimeout(timeout),
|
|
||||||
removeSocketFile: removePosixSocketFile,
|
|
||||||
createSocketPath: createDefaultSocketPath,
|
|
||||||
...deps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(options: MediaTimingPreviewStartOptions): Promise<void> {
|
|
||||||
if (this.disposed) throw new Error('Preview session is closed');
|
|
||||||
if (this.socket) return;
|
|
||||||
if (this.startPromise) return await this.startPromise;
|
|
||||||
|
|
||||||
const startPromise = this.startOnce(options);
|
|
||||||
this.startPromise = startPromise;
|
|
||||||
try {
|
|
||||||
await startPromise;
|
|
||||||
} catch (error) {
|
|
||||||
this.releaseResources();
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (this.startPromise === startPromise) this.startPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startOnce(options: MediaTimingPreviewStartOptions): Promise<void> {
|
|
||||||
const mediaPath = options.mediaPath.trim();
|
|
||||||
if (!mediaPath) throw new Error('No media source is available for preview');
|
|
||||||
|
|
||||||
const socketPath = this.deps.createSocketPath();
|
|
||||||
this.socketPath = socketPath;
|
|
||||||
if (this.deps.platform !== 'win32') {
|
|
||||||
this.deps.removeSocketFile(socketPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = options.executablePath?.trim() || 'mpv';
|
|
||||||
this.startupError = null;
|
|
||||||
const child = this.deps.spawnProcess(
|
|
||||||
command,
|
|
||||||
buildMediaTimingPreviewArgs(socketPath, { ...options, mediaPath }),
|
|
||||||
);
|
|
||||||
this.process = child;
|
|
||||||
child.once('error', (error) => {
|
|
||||||
if (this.process !== child) return;
|
|
||||||
this.startupError = error;
|
|
||||||
});
|
|
||||||
child.once('exit', () => {
|
|
||||||
if (this.process !== child) return;
|
|
||||||
if (!this.socket && !this.disposed && !this.startupError) {
|
|
||||||
this.startupError = new Error('The hidden mpv preview player exited during startup');
|
|
||||||
}
|
|
||||||
this.socket?.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
this.process = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.connectWithRetry(socketPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
async play(startTime: number, endTime: number): Promise<void> {
|
|
||||||
if (!this.socket || this.socket.destroyed) {
|
|
||||||
throw new Error('Preview player is not ready');
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
|
||||||
throw new Error('Preview timing is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.send(['set_property', 'pause', true]);
|
|
||||||
this.send(['set_property', 'ab-loop-a', startTime]);
|
|
||||||
this.send(['set_property', 'ab-loop-b', endTime]);
|
|
||||||
this.send(['seek', startTime, 'absolute+exact']);
|
|
||||||
this.send(['set_property', 'pause', false]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
if (!this.socket || this.socket.destroyed) return;
|
|
||||||
this.send(['set_property', 'pause', true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.disposed) return;
|
|
||||||
this.disposed = true;
|
|
||||||
this.releaseResources();
|
|
||||||
}
|
|
||||||
|
|
||||||
private releaseResources(): void {
|
|
||||||
this.cancelRetryWait();
|
|
||||||
try {
|
|
||||||
this.send(['quit']);
|
|
||||||
} catch {
|
|
||||||
// The process may already have exited.
|
|
||||||
}
|
|
||||||
this.socket?.end();
|
|
||||||
this.socket?.destroy();
|
|
||||||
this.socket = null;
|
|
||||||
const child = this.process;
|
|
||||||
this.process = null;
|
|
||||||
child?.kill();
|
|
||||||
if (this.socketPath && this.deps.platform !== 'win32') {
|
|
||||||
try {
|
|
||||||
this.deps.removeSocketFile(this.socketPath);
|
|
||||||
} catch {
|
|
||||||
// mpv may still be releasing the socket. The OS temp directory owns cleanup.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.socketPath = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(command: Array<string | number | boolean>): void {
|
|
||||||
if (!this.socket || this.socket.destroyed) {
|
|
||||||
throw new Error('Preview player is not connected');
|
|
||||||
}
|
|
||||||
this.socket.write(`${JSON.stringify({ command })}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async connectWithRetry(socketPath: string): Promise<void> {
|
|
||||||
const deadline = this.deps.now() + CONNECT_TIMEOUT_MS;
|
|
||||||
while (!this.disposed && this.deps.now() < deadline) {
|
|
||||||
if (this.startupError) {
|
|
||||||
throw this.startupError;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const remainingMs = deadline - this.deps.now();
|
|
||||||
if (remainingMs <= 0) break;
|
|
||||||
const socket = await this.connectOnce(
|
|
||||||
socketPath,
|
|
||||||
Math.min(CONNECT_ATTEMPT_TIMEOUT_MS, remainingMs),
|
|
||||||
);
|
|
||||||
if (this.disposed) {
|
|
||||||
socket.destroy();
|
|
||||||
throw new Error('Preview session is closed');
|
|
||||||
}
|
|
||||||
this.socket = socket;
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
if (this.disposed) {
|
|
||||||
throw new Error('Preview session is closed');
|
|
||||||
}
|
|
||||||
const remainingMs = deadline - this.deps.now();
|
|
||||||
if (remainingMs <= 0) break;
|
|
||||||
await this.waitForRetry(Math.min(CONNECT_RETRY_MS, remainingMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.startupError) {
|
|
||||||
throw this.startupError;
|
|
||||||
}
|
|
||||||
if (this.disposed) {
|
|
||||||
throw new Error('Preview session is closed');
|
|
||||||
}
|
|
||||||
throw new Error('Timed out starting the hidden mpv preview player');
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForRetry(delayMs: number): Promise<void> {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timeout = this.deps.schedule(() => {
|
|
||||||
if (this.retryWait?.timeout === timeout) this.retryWait = null;
|
|
||||||
resolve();
|
|
||||||
}, delayMs);
|
|
||||||
this.retryWait = { timeout, resolve };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private cancelRetryWait(): void {
|
|
||||||
const pending = this.retryWait;
|
|
||||||
this.retryWait = null;
|
|
||||||
if (!pending) return;
|
|
||||||
this.deps.cancelSchedule(pending.timeout);
|
|
||||||
pending.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
private connectOnce(socketPath: string, timeoutMs: number): Promise<Socket> {
|
|
||||||
return new Promise<Socket>((resolve, reject) => {
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let settled = false;
|
|
||||||
const clearAttemptTimeout = (): void => {
|
|
||||||
if (timeout !== null) this.deps.cancelSchedule(timeout);
|
|
||||||
timeout = null;
|
|
||||||
};
|
|
||||||
const socket = this.deps.connectSocket(socketPath);
|
|
||||||
const onConnect = (): void => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearAttemptTimeout();
|
|
||||||
socket.off('error', onError);
|
|
||||||
socket.on('error', () => {
|
|
||||||
socket.destroy();
|
|
||||||
if (this.socket === socket) this.socket = null;
|
|
||||||
});
|
|
||||||
socket.once('close', () => {
|
|
||||||
if (this.socket === socket) this.socket = null;
|
|
||||||
});
|
|
||||||
resolve(socket);
|
|
||||||
};
|
|
||||||
const onError = (error: Error): void => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearAttemptTimeout();
|
|
||||||
socket.off('connect', onConnect);
|
|
||||||
socket.on('error', () => {});
|
|
||||||
socket.destroy();
|
|
||||||
reject(error);
|
|
||||||
};
|
|
||||||
socket.once('connect', onConnect);
|
|
||||||
socket.once('error', onError);
|
|
||||||
timeout = this.deps.schedule(() => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
timeout = null;
|
|
||||||
socket.off('connect', onConnect);
|
|
||||||
socket.off('error', onError);
|
|
||||||
socket.on('error', () => {});
|
|
||||||
socket.destroy();
|
|
||||||
reject(new Error('Timed out connecting to the hidden mpv preview player'));
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
buildSpeechWaveformArgs,
|
|
||||||
computeWaveformPeaks,
|
|
||||||
generateSpeechWaveform,
|
|
||||||
} from './media-timing-waveform';
|
|
||||||
|
|
||||||
function pcm(samples: number[]): Buffer {
|
|
||||||
const result = Buffer.alloc(samples.length * 2);
|
|
||||||
samples.forEach((sample, index) => result.writeInt16LE(sample, index * 2));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('speech waveform maps the selected FFmpeg stream and visible range', () => {
|
|
||||||
const args = buildSpeechWaveformArgs(
|
|
||||||
{
|
|
||||||
mediaPath: '/video/show.mkv',
|
|
||||||
startTime: 8,
|
|
||||||
endTime: 15,
|
|
||||||
audioStreamIndex: 3,
|
|
||||||
},
|
|
||||||
'center',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(args.slice(args.indexOf('-ss'), args.indexOf('-t') + 2), [
|
|
||||||
'-ss',
|
|
||||||
'8',
|
|
||||||
'-i',
|
|
||||||
'/video/show.mkv',
|
|
||||||
'-t',
|
|
||||||
'7',
|
|
||||||
]);
|
|
||||||
assert.deepEqual(args.slice(args.indexOf('-map'), args.indexOf('-map') + 2), ['-map', '0:3']);
|
|
||||||
assert.match(args[args.indexOf('-af') + 1] ?? '', /c0=FC/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('waveform peaks are normalized without flattening quieter sections', () => {
|
|
||||||
const peaks = computeWaveformPeaks(pcm([0, 1_000, -2_000, 4_000, -8_000, 16_000]), 3);
|
|
||||||
|
|
||||||
assert.equal(peaks.length, 3);
|
|
||||||
assert.ok((peaks[0] ?? 0) > 0);
|
|
||||||
assert.ok((peaks[0] ?? 0) < (peaks[1] ?? 0));
|
|
||||||
assert.ok((peaks[1] ?? 0) < (peaks[2] ?? 0));
|
|
||||||
assert.equal(peaks[2], 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('speech waveform uses a mono downmix when the source has no center activity', async () => {
|
|
||||||
const calls: string[][] = [];
|
|
||||||
const peaks = await generateSpeechWaveform(
|
|
||||||
{ mediaPath: '/video/show.mkv', startTime: 0, endTime: 2 },
|
|
||||||
async (args) => {
|
|
||||||
calls.push(args);
|
|
||||||
return calls.length === 1 ? pcm([0, 0, 0, 0]) : pcm([0, 4_000, -8_000, 16_000]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(calls.length, 2);
|
|
||||||
assert.match(calls[1]?.[calls[1].indexOf('-af') + 1] ?? '', /channel_layouts=mono/);
|
|
||||||
assert.equal(Math.max(...peaks), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('speech waveform keeps an active center channel without doing a second decode', async () => {
|
|
||||||
let calls = 0;
|
|
||||||
await generateSpeechWaveform(
|
|
||||||
{ mediaPath: '/video/show.mkv', startTime: 0, endTime: 2 },
|
|
||||||
async () => {
|
|
||||||
calls += 1;
|
|
||||||
return pcm([0, 4_000, -8_000, 16_000]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(calls, 1);
|
|
||||||
});
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
|
|
||||||
const WAVEFORM_SAMPLE_RATE = 8_000;
|
|
||||||
const WAVEFORM_POINT_COUNT = 480;
|
|
||||||
const WAVEFORM_TIMEOUT_MS = 15_000;
|
|
||||||
const MAX_WAVEFORM_BYTES = 16 * 1024 * 1024;
|
|
||||||
const SPEECH_FILTER = 'highpass=f=120,lowpass=f=4000';
|
|
||||||
const CENTER_CHANNEL_FILTER = `pan=mono|c0=FC,${SPEECH_FILTER}`;
|
|
||||||
const DOWNMIX_FILTER = `aformat=channel_layouts=mono,${SPEECH_FILTER}`;
|
|
||||||
|
|
||||||
export interface SpeechWaveformOptions {
|
|
||||||
mediaPath: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
audioStreamIndex?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunFfmpeg = (args: string[]) => Promise<Buffer>;
|
|
||||||
|
|
||||||
export function buildSpeechWaveformArgs(
|
|
||||||
options: SpeechWaveformOptions,
|
|
||||||
mode: 'center' | 'downmix',
|
|
||||||
): string[] {
|
|
||||||
const duration = options.endTime - options.startTime;
|
|
||||||
const args = [
|
|
||||||
'-hide_banner',
|
|
||||||
'-nostdin',
|
|
||||||
'-loglevel',
|
|
||||||
'error',
|
|
||||||
'-ss',
|
|
||||||
String(options.startTime),
|
|
||||||
'-i',
|
|
||||||
options.mediaPath,
|
|
||||||
'-t',
|
|
||||||
String(duration),
|
|
||||||
];
|
|
||||||
if (
|
|
||||||
options.audioStreamIndex !== undefined &&
|
|
||||||
Number.isInteger(options.audioStreamIndex) &&
|
|
||||||
options.audioStreamIndex >= 0
|
|
||||||
) {
|
|
||||||
args.push('-map', `0:${options.audioStreamIndex}`);
|
|
||||||
}
|
|
||||||
args.push(
|
|
||||||
'-vn',
|
|
||||||
'-sn',
|
|
||||||
'-dn',
|
|
||||||
'-af',
|
|
||||||
mode === 'center' ? CENTER_CHANNEL_FILTER : DOWNMIX_FILTER,
|
|
||||||
'-ac',
|
|
||||||
'1',
|
|
||||||
'-ar',
|
|
||||||
String(WAVEFORM_SAMPLE_RATE),
|
|
||||||
'-f',
|
|
||||||
's16le',
|
|
||||||
'pipe:1',
|
|
||||||
);
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
function runFfmpeg(args: string[]): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
let byteLength = 0;
|
|
||||||
let stderr = '';
|
|
||||||
let settled = false;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
reject(new Error(`FFmpeg waveform analysis timed out after ${WAVEFORM_TIMEOUT_MS}ms`));
|
|
||||||
}, WAVEFORM_TIMEOUT_MS);
|
|
||||||
|
|
||||||
const settle = (callback: () => void): void => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
callback();
|
|
||||||
};
|
|
||||||
|
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
|
||||||
if (settled) return;
|
|
||||||
byteLength += chunk.byteLength;
|
|
||||||
if (byteLength > MAX_WAVEFORM_BYTES) {
|
|
||||||
settle(() => {
|
|
||||||
child.kill('SIGKILL');
|
|
||||||
reject(new Error('The visible waveform range is too large to analyze.'));
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
child.stderr.setEncoding('utf8');
|
|
||||||
child.stderr.on('data', (chunk) => {
|
|
||||||
if (stderr.length < 4_000) stderr += String(chunk);
|
|
||||||
});
|
|
||||||
child.once('error', (error) => settle(() => reject(error)));
|
|
||||||
child.once('close', (code) => {
|
|
||||||
settle(() => {
|
|
||||||
if (code === 0) {
|
|
||||||
resolve(Buffer.concat(chunks, byteLength));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reject(new Error(stderr.trim() || `FFmpeg exited with status ${code ?? 'unknown'}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeWaveformPeaks(pcm: Buffer, pointCount = WAVEFORM_POINT_COUNT): number[] {
|
|
||||||
const sampleCount = Math.floor(pcm.byteLength / 2);
|
|
||||||
if (sampleCount === 0 || pointCount <= 0) return [];
|
|
||||||
const resolvedPointCount = Math.min(pointCount, sampleCount);
|
|
||||||
const peaks = Array.from({ length: resolvedPointCount }, () => 0);
|
|
||||||
|
|
||||||
for (let point = 0; point < resolvedPointCount; point += 1) {
|
|
||||||
const sampleStart = Math.floor((point * sampleCount) / resolvedPointCount);
|
|
||||||
const sampleEnd = Math.max(
|
|
||||||
sampleStart + 1,
|
|
||||||
Math.floor(((point + 1) * sampleCount) / resolvedPointCount),
|
|
||||||
);
|
|
||||||
let peak = 0;
|
|
||||||
for (let sample = sampleStart; sample < sampleEnd; sample += 1) {
|
|
||||||
peak = Math.max(peak, Math.abs(pcm.readInt16LE(sample * 2)) / 32_768);
|
|
||||||
}
|
|
||||||
peaks[point] = peak;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedPeaks = [...peaks].sort((left, right) => left - right);
|
|
||||||
const referenceIndex = Math.min(sortedPeaks.length - 1, Math.floor(sortedPeaks.length * 0.95));
|
|
||||||
const referencePeak = Math.max(sortedPeaks[referenceIndex] ?? 0, 0.01);
|
|
||||||
return peaks.map(
|
|
||||||
(peak) => Math.round(Math.sqrt(Math.min(1, peak / referencePeak)) * 1_000) / 1_000,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasAudibleSamples(pcm: Buffer): boolean {
|
|
||||||
for (let offset = 0; offset + 1 < pcm.byteLength; offset += 2) {
|
|
||||||
if (Math.abs(pcm.readInt16LE(offset)) >= 164) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateSpeechWaveform(
|
|
||||||
options: SpeechWaveformOptions,
|
|
||||||
execute: RunFfmpeg = runFfmpeg,
|
|
||||||
): Promise<number[]> {
|
|
||||||
try {
|
|
||||||
const centerPcm = await execute(buildSpeechWaveformArgs(options, 'center'));
|
|
||||||
if (hasAudibleSamples(centerPcm)) return computeWaveformPeaks(centerPcm);
|
|
||||||
} catch {
|
|
||||||
// Sources without a named center channel can reject the center-only filter.
|
|
||||||
}
|
|
||||||
|
|
||||||
const downmixPcm = await execute(buildSpeechWaveformArgs(options, 'downmix'));
|
|
||||||
return computeWaveformPeaks(downmixPcm);
|
|
||||||
}
|
|
||||||
@@ -53,7 +53,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
|||||||
'sub-scale-by-window',
|
'sub-scale-by-window',
|
||||||
'osd-height',
|
'osd-height',
|
||||||
'osd-dimensions',
|
'osd-dimensions',
|
||||||
'sub-text-ass',
|
'sub-text/ass',
|
||||||
'sub-border-size',
|
'sub-border-size',
|
||||||
'sub-shadow-offset',
|
'sub-shadow-offset',
|
||||||
'sub-ass-override',
|
'sub-ass-override',
|
||||||
@@ -74,7 +74,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
|||||||
request_id: MPV_REQUEST_ID_SUBTEXT,
|
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,
|
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 }]);
|
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 () => {
|
test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||||
const { deps, state } = createDeps({
|
const { deps, state } = createDeps({
|
||||||
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export async function dispatchMpvProtocolMessage(
|
|||||||
isOverlayVisible: overlayVisible,
|
isOverlayVisible: overlayVisible,
|
||||||
});
|
});
|
||||||
deps.setCurrentSubText(nextSubText);
|
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) || '' });
|
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
|
||||||
} else if (msg.name === 'sub-start') {
|
} else if (msg.name === 'sub-start') {
|
||||||
deps.setCurrentSubStart((msg.data as number) || 0);
|
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', () => {
|
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
||||||
@@ -232,6 +240,75 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
|
|||||||
assert.deepEqual(events, []);
|
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 () => {
|
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
const sockets: ManualCloseSocket[] = [];
|
const sockets: ManualCloseSocket[] = [];
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ interface MpvSocketTransportEvents {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const MPV_CONNECT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
export interface MpvSocketTransportOptions {
|
export interface MpvSocketTransportOptions {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
onConnect: () => void;
|
onConnect: () => void;
|
||||||
@@ -69,13 +71,16 @@ export interface MpvSocketTransportOptions {
|
|||||||
onError: (error: Error) => void;
|
onError: (error: Error) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
socketFactory?: () => net.Socket;
|
socketFactory?: () => net.Socket;
|
||||||
|
connectTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MpvSocketTransport {
|
export class MpvSocketTransport {
|
||||||
private socketPath: string;
|
private socketPath: string;
|
||||||
private readonly callbacks: MpvSocketTransportEvents;
|
private readonly callbacks: MpvSocketTransportEvents;
|
||||||
private readonly socketFactory: () => net.Socket;
|
private readonly socketFactory: () => net.Socket;
|
||||||
|
private readonly connectTimeoutMs: number;
|
||||||
private socketRef: net.Socket | null = null;
|
private socketRef: net.Socket | null = null;
|
||||||
|
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
public socket: net.Socket | null = null;
|
public socket: net.Socket | null = null;
|
||||||
public connected = false;
|
public connected = false;
|
||||||
public connecting = false;
|
public connecting = false;
|
||||||
@@ -83,6 +88,7 @@ export class MpvSocketTransport {
|
|||||||
constructor(options: MpvSocketTransportOptions) {
|
constructor(options: MpvSocketTransportOptions) {
|
||||||
this.socketPath = options.socketPath;
|
this.socketPath = options.socketPath;
|
||||||
this.socketFactory = options.socketFactory ?? (() => new net.Socket());
|
this.socketFactory = options.socketFactory ?? (() => new net.Socket());
|
||||||
|
this.connectTimeoutMs = options.connectTimeoutMs ?? MPV_CONNECT_TIMEOUT_MS;
|
||||||
this.callbacks = {
|
this.callbacks = {
|
||||||
onConnect: options.onConnect,
|
onConnect: options.onConnect,
|
||||||
onData: options.onData,
|
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 {
|
setSocketPath(socketPath: string): void {
|
||||||
this.socketPath = socketPath;
|
this.socketPath = socketPath;
|
||||||
}
|
}
|
||||||
@@ -111,6 +142,7 @@ export class MpvSocketTransport {
|
|||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
if (this.socketRef !== socket) return;
|
if (this.socketRef !== socket) return;
|
||||||
|
this.clearConnectTimeout();
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.callbacks.onConnect();
|
this.callbacks.onConnect();
|
||||||
@@ -123,6 +155,7 @@ export class MpvSocketTransport {
|
|||||||
|
|
||||||
socket.on('error', (error: Error) => {
|
socket.on('error', (error: Error) => {
|
||||||
if (this.socketRef !== socket) return;
|
if (this.socketRef !== socket) return;
|
||||||
|
this.clearConnectTimeout();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.callbacks.onError(error);
|
this.callbacks.onError(error);
|
||||||
@@ -130,12 +163,14 @@ export class MpvSocketTransport {
|
|||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', () => {
|
||||||
if (this.socketRef !== socket) return;
|
if (this.socketRef !== socket) return;
|
||||||
|
this.clearConnectTimeout();
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
this.callbacks.onClose();
|
this.callbacks.onClose();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.connect(this.socketPath);
|
socket.connect(this.socketPath);
|
||||||
|
this.armConnectTimeout(socket);
|
||||||
}
|
}
|
||||||
|
|
||||||
send(payload: MpvSocketMessagePayload): boolean {
|
send(payload: MpvSocketMessagePayload): boolean {
|
||||||
@@ -149,6 +184,7 @@ export class MpvSocketTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdown(): void {
|
shutdown(): void {
|
||||||
|
this.clearConnectTimeout();
|
||||||
const socket = this.socketRef;
|
const socket = this.socketRef;
|
||||||
this.socketRef = null;
|
this.socketRef = null;
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
import {
|
import {
|
||||||
MpvIpcClient,
|
MpvIpcClient,
|
||||||
MpvIpcClientDeps,
|
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[] {
|
function captureWarnLogs(run: () => void): string[] {
|
||||||
const originalWarn = console.warn;
|
const originalWarn = console.warn;
|
||||||
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
|
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[1] === 1 &&
|
||||||
(command as { command: unknown[] }).command[2] === 'sub-text',
|
(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(
|
const hasPathRequest = commands.some(
|
||||||
(command) =>
|
(command) =>
|
||||||
Array.isArray((command as { command: unknown[] }).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(hasSecondaryVisibilityReset, true);
|
||||||
assert.equal(hasTrackSubscription, true);
|
assert.equal(hasTrackSubscription, true);
|
||||||
|
assert.equal(hasAssSubtitleSubscription, true);
|
||||||
|
assert.equal(hasDeprecatedAssSubtitleProperty, false);
|
||||||
assert.equal(hasPathRequest, true);
|
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.equal((client as any).pendingPauseAtSubEnd, true);
|
||||||
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
|
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,
|
splitMpvMessagesFromBuffer,
|
||||||
} from './mpv-protocol';
|
} from './mpv-protocol';
|
||||||
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
|
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
|
||||||
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
|
import {
|
||||||
|
scheduleMpvReconnect,
|
||||||
|
MpvSocketTransport,
|
||||||
|
MpvSocketTransportOptions,
|
||||||
|
} from './mpv-transport';
|
||||||
import { createLogger } from '../../logger';
|
import { createLogger } from '../../logger';
|
||||||
|
|
||||||
const logger = createLogger('main:mpv');
|
const logger = createLogger('main:mpv');
|
||||||
@@ -110,6 +114,8 @@ export interface MpvIpcClientProtocolDeps {
|
|||||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||||
shouldQuitOnMpvShutdown?: () => boolean;
|
shouldQuitOnMpvShutdown?: () => boolean;
|
||||||
requestAppQuit?: () => void;
|
requestAppQuit?: () => void;
|
||||||
|
socketFactory?: MpvSocketTransportOptions['socketFactory'];
|
||||||
|
connectTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||||
@@ -188,6 +194,8 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
|
|
||||||
this.transport = new MpvSocketTransport({
|
this.transport = new MpvSocketTransport({
|
||||||
socketPath,
|
socketPath,
|
||||||
|
socketFactory: deps.socketFactory,
|
||||||
|
connectTimeoutMs: deps.connectTimeoutMs,
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.connecting = false;
|
this.connecting = false;
|
||||||
@@ -289,6 +297,14 @@ export class MpvIpcClient implements MpvClient {
|
|||||||
previousSocketPath: this.socketPath,
|
previousSocketPath: this.socketPath,
|
||||||
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.socketPath = socketPath;
|
||||||
this.transport.setSocketPath(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);
|
} as never);
|
||||||
|
|
||||||
assert.ok(calls.includes('opacity:0'));
|
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('show-inactive'));
|
||||||
assert.ok(calls.includes('sync-windows-z-order'));
|
assert.ok(calls.includes('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
assert.ok(!calls.includes('move-top'));
|
||||||
@@ -1060,7 +1060,7 @@ test('tracked Windows overlay refresh rebinds while already visible', () => {
|
|||||||
isWindowsPlatform: true,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} 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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
assert.ok(!calls.includes('move-top'));
|
||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
@@ -1134,7 +1134,7 @@ test('forced passthrough still reapplies while visible on Windows', () => {
|
|||||||
forceMousePassthrough: true,
|
forceMousePassthrough: true,
|
||||||
} as never);
|
} 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('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
assert.ok(!calls.includes('move-top'));
|
||||||
assert.ok(calls.includes('sync-windows-z-order'));
|
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('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(!calls.includes('enforce-order'));
|
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,
|
isWindowsPlatform: true,
|
||||||
} as never);
|
} 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-inactive'));
|
||||||
assert.ok(!calls.includes('show'));
|
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('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
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('show'));
|
||||||
assert.ok(!calls.includes('always-on-top:false'));
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
assert.ok(!calls.includes('move-top'));
|
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('sync-windows-z-order'));
|
||||||
assert.ok(!calls.includes('ensure-level'));
|
assert.ok(!calls.includes('ensure-level'));
|
||||||
assert.ok(calls.includes('sync-shortcuts'));
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { BrowserWindow } from 'electron';
|
import type { BrowserWindow } from 'electron';
|
||||||
import { BaseWindowTracker } from '../../window-trackers';
|
import { BaseWindowTracker } from '../../window-trackers';
|
||||||
import { WindowGeometry } from '../../types';
|
import { WindowGeometry } from '../../types';
|
||||||
|
import { applyOverlayClickThrough } from './overlay-click-through';
|
||||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||||
|
|
||||||
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
const WINDOWS_OVERLAY_REVEAL_DELAY_MS = 48;
|
||||||
@@ -117,7 +118,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
clearPendingWindowsOverlayReveal(mainWindow);
|
clearPendingWindowsOverlayReveal(mainWindow);
|
||||||
setOverlayWindowOpacity(mainWindow, 0);
|
setOverlayWindowOpacity(mainWindow, 0);
|
||||||
}
|
}
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
releaseOverlayWindowLevel(mainWindow);
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
@@ -215,7 +216,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
shouldPreserveWindowsOverlayDuringFocusHandoff ||
|
||||||
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
(hasWindowsForegroundProcessSignal && windowsForegroundProcessName === 'mpv');
|
||||||
if (shouldIgnoreMouseEvents) {
|
if (shouldIgnoreMouseEvents) {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
@@ -263,7 +264,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
if (hasNonNativeInputRegion) {
|
if (hasNonNativeInputRegion) {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
}
|
}
|
||||||
if (args.isWindowsPlatform) {
|
if (args.isWindowsPlatform) {
|
||||||
scheduleWindowsOverlayReveal(
|
scheduleWindowsOverlayReveal(
|
||||||
@@ -424,7 +425,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
args.setTrackerNotReadyWarningShown(false);
|
args.setTrackerNotReadyWarningShown(false);
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, args.isWindowsPlatform);
|
||||||
releaseOverlayWindowLevel(mainWindow);
|
releaseOverlayWindowLevel(mainWindow);
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
|
|||||||
@@ -15,6 +15,32 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
|||||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
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', () => {
|
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
export const OVERLAY_WINDOW_CONTENT_READY_FLAG = '__subminerOverlayContentReady';
|
||||||
|
export const OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG = '__subminerOverlayDocumentLoaded';
|
||||||
|
|||||||
@@ -12,15 +12,17 @@ export function buildOverlayWindowOptions(
|
|||||||
options: {
|
options: {
|
||||||
isDev: boolean;
|
isDev: boolean;
|
||||||
linuxX11FullscreenOverlay?: boolean;
|
linuxX11FullscreenOverlay?: boolean;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
yomitanSession?: Session | null;
|
yomitanSession?: Session | null;
|
||||||
},
|
},
|
||||||
): BrowserWindowConstructorOptions {
|
): BrowserWindowConstructorOptions {
|
||||||
const showNativeDebugFrame = process.platform === 'win32' && options.isDev;
|
const platform = options.platform ?? process.platform;
|
||||||
const isLinuxVisibleOverlay = process.platform === 'linux' && kind === 'visible';
|
const showNativeDebugFrame = platform === 'win32' && options.isDev;
|
||||||
|
const isLinuxVisibleOverlay = platform === 'linux' && kind === 'visible';
|
||||||
const isLinuxFullscreenOverlay =
|
const isLinuxFullscreenOverlay =
|
||||||
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
|
isLinuxVisibleOverlay && options.linuxX11FullscreenOverlay === true;
|
||||||
const shouldStartAlwaysOnTop =
|
const shouldStartAlwaysOnTop =
|
||||||
!(process.platform === 'win32' && kind === 'visible') &&
|
!(platform === 'win32' && kind === 'visible') &&
|
||||||
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
|
(!isLinuxVisibleOverlay || isLinuxFullscreenOverlay);
|
||||||
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
|
const shouldAllowCompositorResize = isLinuxVisibleOverlay && !isLinuxFullscreenOverlay;
|
||||||
|
|
||||||
@@ -41,7 +43,10 @@ export function buildOverlayWindowOptions(
|
|||||||
hasShadow: false,
|
hasShadow: false,
|
||||||
focusable: !isLinuxFullscreenOverlay,
|
focusable: !isLinuxFullscreenOverlay,
|
||||||
acceptFirstMouse: true,
|
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: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
preload: path.join(__dirname, '..', '..', 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
} from './hyprland-window-placement';
|
} from './hyprland-window-placement';
|
||||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
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';
|
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||||
|
|
||||||
const logger = createLogger('main:overlay-window');
|
const logger = createLogger('main:overlay-window');
|
||||||
@@ -133,6 +136,9 @@ export function createOverlayWindow(
|
|||||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||||
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||||
] = false;
|
] = false;
|
||||||
|
(window as BrowserWindow & { [OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean })[
|
||||||
|
OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG
|
||||||
|
] = false;
|
||||||
|
|
||||||
if (!(process.platform === 'win32' && kind === 'visible')) {
|
if (!(process.platform === 'win32' && kind === 'visible')) {
|
||||||
options.ensureOverlayWindowLevel(window);
|
options.ensureOverlayWindowLevel(window);
|
||||||
@@ -144,11 +150,20 @@ export function createOverlayWindow(
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.webContents.on('did-finish-load', () => {
|
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]);
|
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||||
options.onRuntimeOptionsChanged();
|
options.onRuntimeOptionsChanged();
|
||||||
options.onWindowDidFinishLoad?.();
|
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) => {
|
window.webContents.on('page-title-updated', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export function shouldHideStatsWindowForInput(input: Electron.Input, toggleKey:
|
|||||||
export function buildStatsWindowOptions(options: {
|
export function buildStatsWindowOptions(options: {
|
||||||
preloadPath: string;
|
preloadPath: string;
|
||||||
bounds?: WindowGeometry | null;
|
bounds?: WindowGeometry | null;
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
}): BrowserWindowConstructorOptions {
|
}): BrowserWindowConstructorOptions {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
return {
|
return {
|
||||||
title: STATS_WINDOW_TITLE,
|
title: STATS_WINDOW_TITLE,
|
||||||
x: options.bounds?.x,
|
x: options.bounds?.x,
|
||||||
@@ -73,6 +75,9 @@ export function buildStatsWindowOptions(options: {
|
|||||||
focusable: true,
|
focusable: true,
|
||||||
acceptFirstMouse: true,
|
acceptFirstMouse: true,
|
||||||
fullscreenable: false,
|
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',
|
backgroundColor: '#24273a',
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -84,6 +89,12 @@ export function buildStatsWindowOptions(options: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldPresentStatsWindowAfterLoad(
|
||||||
|
platform: NodeJS.Platform = process.platform,
|
||||||
|
): boolean {
|
||||||
|
return platform === 'darwin';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStatsWindowOuterBoundsForContent(
|
export function resolveStatsWindowOuterBoundsForContent(
|
||||||
window: StatsWindowBoundsController,
|
window: StatsWindowBoundsController,
|
||||||
target: WindowGeometry,
|
target: WindowGeometry,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
scheduleStatsWindowPostShowReconciles,
|
scheduleStatsWindowPostShowReconciles,
|
||||||
showStatsNativeConfirmDialog,
|
showStatsNativeConfirmDialog,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
|
shouldPresentStatsWindowAfterLoad,
|
||||||
} from './stats-window-runtime';
|
} from './stats-window-runtime';
|
||||||
|
|
||||||
test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly web preferences', () => {
|
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);
|
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', () => {
|
test('shouldHideStatsWindowForInput matches Escape and configured bare toggle key', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldHideStatsWindowForInput(
|
shouldHideStatsWindowForInput(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
scheduleStatsWindowPostShowReconciles,
|
scheduleStatsWindowPostShowReconciles,
|
||||||
showStatsNativeConfirmDialog,
|
showStatsNativeConfirmDialog,
|
||||||
shouldHideStatsWindowForInput,
|
shouldHideStatsWindowForInput,
|
||||||
|
shouldPresentStatsWindowAfterLoad,
|
||||||
STATS_WINDOW_TITLE,
|
STATS_WINDOW_TITLE,
|
||||||
} from './stats-window-runtime.js';
|
} from './stats-window-runtime.js';
|
||||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
||||||
@@ -209,10 +210,15 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
|||||||
options.onVisibilityChanged?.(false);
|
options.onVisibilityChanged?.(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
statsWindow.once('ready-to-show', () => {
|
const showInitialStatsWindow = () => {
|
||||||
if (!statsWindow) return;
|
if (!statsWindow) return;
|
||||||
showStatsWindow(statsWindow, options);
|
showStatsWindow(statsWindow, options);
|
||||||
});
|
};
|
||||||
|
if (shouldPresentStatsWindowAfterLoad()) {
|
||||||
|
statsWindow.webContents.once('did-finish-load', showInitialStatsWindow);
|
||||||
|
} else {
|
||||||
|
statsWindow.once('ready-to-show', showInitialStatsWindow);
|
||||||
|
}
|
||||||
|
|
||||||
statsWindow.on('blur', () => {
|
statsWindow.on('blur', () => {
|
||||||
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
|
if (!statsWindow || statsWindow.isDestroyed() || !statsWindow.isVisible()) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* layer that keeps the two views consistent by construction.
|
* layer that keeps the two views consistent by construction.
|
||||||
* 2. Otherwise (embedded track nobody parsed, a source whose timings mpv has shifted)
|
* 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
|
* 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
|
* 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.
|
* `subtitle-cue-dedup`, and it uses that path's deliberately strict bounds.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,10 +24,17 @@ import {
|
|||||||
shouldForwardStartupArgvViaAppControl,
|
shouldForwardStartupArgvViaAppControl,
|
||||||
applyBackgroundBootstrapCommandLineSwitches,
|
applyBackgroundBootstrapCommandLineSwitches,
|
||||||
applyEarlyLinuxCommandLineSwitches,
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
|
resolveAppControlHandoffTimeoutMs,
|
||||||
resolveLinuxPasswordStoreValue,
|
resolveLinuxPasswordStoreValue,
|
||||||
spawnDetachedApp,
|
spawnDetachedApp,
|
||||||
} from './main-entry-runtime';
|
} 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', () => {
|
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 entrySource = fs.readFileSync(path.join(process.cwd(), 'src/main-entry.ts'), 'utf8');
|
||||||
const runtimeSource = fs.readFileSync(
|
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 TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
const MAX_TRANSPORTED_APP_ARGS = 256;
|
const MAX_TRANSPORTED_APP_ARGS = 256;
|
||||||
const APP_NAME = 'SubMiner';
|
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([
|
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||||
'--alang',
|
'--alang',
|
||||||
'--audio-file',
|
'--audio-file',
|
||||||
@@ -186,6 +188,14 @@ export function shouldForwardStartupArgvViaAppControl(
|
|||||||
return hasExplicitCommand(args);
|
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 {
|
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||||
if (rawCount === undefined) {
|
if (rawCount === undefined) {
|
||||||
|
|||||||
+2
-1
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeLaunchMpvTargets,
|
normalizeLaunchMpvTargets,
|
||||||
normalizeStartupArgv,
|
normalizeStartupArgv,
|
||||||
applyEarlyLinuxCommandLineSwitches,
|
applyEarlyLinuxCommandLineSwitches,
|
||||||
|
resolveAppControlHandoffTimeoutMs,
|
||||||
sanitizeStartupEnv,
|
sanitizeStartupEnv,
|
||||||
sanitizeBackgroundEnv,
|
sanitizeBackgroundEnv,
|
||||||
sanitizeHelpEnv,
|
sanitizeHelpEnv,
|
||||||
@@ -214,7 +215,7 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
|||||||
|
|
||||||
const result = await sendAppControlCommand(process.argv, {
|
const result = await sendAppControlCommand(process.argv, {
|
||||||
configDir: userDataPath,
|
configDir: userDataPath,
|
||||||
timeoutMs: 500,
|
timeoutMs: resolveAppControlHandoffTimeoutMs(),
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
|
|||||||
+5
-30
@@ -331,6 +331,7 @@ import {
|
|||||||
acquireYoutubeSubtitleTrack,
|
acquireYoutubeSubtitleTrack,
|
||||||
acquireYoutubeSubtitleTracks,
|
acquireYoutubeSubtitleTracks,
|
||||||
} from './core/services/youtube/generate';
|
} from './core/services/youtube/generate';
|
||||||
|
import { applyOverlayClickThrough } from './core/services/overlay-click-through';
|
||||||
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
import { createYoutubeMediaCacheService } from './core/services/youtube/media-cache';
|
||||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||||
@@ -462,10 +463,6 @@ import { createMainBootServices, type MainBootServicesResult } from './main/boot
|
|||||||
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||||
import { MediaTimingPreviewSession } from './core/services/media-timing-preview';
|
|
||||||
import { generateSpeechWaveform } from './core/services/media-timing-waveform';
|
|
||||||
import { createMediaTimingReviewRuntime } from './main/runtime/media-timing-review';
|
|
||||||
import { openMediaTimingReviewModal } from './main/runtime/media-timing-review-open';
|
|
||||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||||
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
|
||||||
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
|
||||||
@@ -2801,19 +2798,6 @@ function createOverlayHostedModalOpenDeps(): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => appState.mpvClient,
|
|
||||||
getCurrentMediaPath: () =>
|
|
||||||
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
|
||||||
getMpvExecutablePath: () =>
|
|
||||||
configService.getConfig().mpv.executablePath || process.env.SUBMINER_MPV_PATH?.trim() || '',
|
|
||||||
createPreviewSession: () => new MediaTimingPreviewSession(),
|
|
||||||
generateWaveform: (options) => generateSpeechWaveform(options),
|
|
||||||
openModal: (payload) => openMediaTimingReviewModal(createOverlayHostedModalOpenDeps(), payload),
|
|
||||||
showStatus: (message) =>
|
|
||||||
overlayNotificationsRuntime.showConfiguredStatusNotification(message, { variant: 'warning' }),
|
|
||||||
});
|
|
||||||
|
|
||||||
function openOverlayHostedModalWithOsd(
|
function openOverlayHostedModalWithOsd(
|
||||||
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
openModal: (deps: ReturnType<typeof createOverlayHostedModalOpenDeps>) => Promise<boolean>,
|
||||||
unavailableMessage: string,
|
unavailableMessage: string,
|
||||||
@@ -5026,12 +5010,14 @@ function syncLinuxVisibleOverlayMpvFullscreenMode(fullscreen: boolean): void {
|
|||||||
|
|
||||||
function initializeOverlayRuntime(): void {
|
function initializeOverlayRuntime(): void {
|
||||||
initializeOverlayRuntimeHandler();
|
initializeOverlayRuntimeHandler();
|
||||||
|
if (!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs))) {
|
||||||
|
overlayModalRuntime.primeModalWindow();
|
||||||
|
}
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
appState.ankiIntegration?.setMediaTimingReviewCallback(mediaTimingReviewRuntime.requestReview);
|
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5441,10 +5427,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
showMpvOsd: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
},
|
},
|
||||||
mainDeps: {
|
mainDeps: {
|
||||||
previewMediaTimingReview: (request) => mediaTimingReviewRuntime.previewRange(request),
|
|
||||||
getMediaTimingReviewWaveform: (request) => mediaTimingReviewRuntime.getWaveform(request),
|
|
||||||
stopMediaTimingReviewPreview: (reviewId) => mediaTimingReviewRuntime.stopPreview(reviewId),
|
|
||||||
resolveMediaTimingReview: (request) => mediaTimingReviewRuntime.resolveReview(request),
|
|
||||||
getMainWindow: () => overlayManager.getMainWindow(),
|
getMainWindow: () => overlayManager.getMainWindow(),
|
||||||
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
|
||||||
focusMainWindow: () => {
|
focusMainWindow: () => {
|
||||||
@@ -5478,9 +5460,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOverlayModalClosed: (modal, senderWindow) => {
|
onOverlayModalClosed: (modal, senderWindow) => {
|
||||||
if (modal === 'media-timing-review') {
|
|
||||||
void mediaTimingReviewRuntime.dispose();
|
|
||||||
}
|
|
||||||
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
if (modal === 'subtitle-sidebar' && senderWindow === overlayManager.getMainWindow()) {
|
||||||
subtitleSidebarRequestedOpen = false;
|
subtitleSidebarRequestedOpen = false;
|
||||||
}
|
}
|
||||||
@@ -5491,7 +5470,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
senderWindow === modalWindow &&
|
senderWindow === modalWindow &&
|
||||||
!senderWindow.isDestroyed()
|
!senderWindow.isDestroyed()
|
||||||
) {
|
) {
|
||||||
senderWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(senderWindow);
|
||||||
senderWindow.hide();
|
senderWindow.hide();
|
||||||
}
|
}
|
||||||
handleOverlayModalClosedHandler(modal);
|
handleOverlayModalClosedHandler(modal);
|
||||||
@@ -5836,9 +5815,6 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||||
consumePendingSubtitleMiningContext,
|
consumePendingSubtitleMiningContext,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setMediaTimingReviewCallback(
|
|
||||||
mediaTimingReviewRuntime.requestReview,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||||
getCachedMediaPath: (currentVideoPath, kind) =>
|
getCachedMediaPath: (currentVideoPath, kind) =>
|
||||||
@@ -6161,7 +6137,6 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
|||||||
if (overlayManager.getModalWindow() !== window) {
|
if (overlayManager.getModalWindow() !== window) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void mediaTimingReviewRuntime.dispose();
|
|
||||||
overlayManager.setModalWindow(null);
|
overlayManager.setModalWindow(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,10 +62,6 @@ export interface MainIpcRuntimeServiceDepsParams {
|
|||||||
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
|
||||||
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
|
||||||
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
|
||||||
previewMediaTimingReview?: IpcDepsRuntimeOptions['previewMediaTimingReview'];
|
|
||||||
getMediaTimingReviewWaveform?: IpcDepsRuntimeOptions['getMediaTimingReviewWaveform'];
|
|
||||||
stopMediaTimingReviewPreview?: IpcDepsRuntimeOptions['stopMediaTimingReviewPreview'];
|
|
||||||
resolveMediaTimingReview?: IpcDepsRuntimeOptions['resolveMediaTimingReview'];
|
|
||||||
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
|
||||||
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
quitApp: IpcDepsRuntimeOptions['quitApp'];
|
||||||
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
toggleVisibleOverlay: IpcDepsRuntimeOptions['toggleVisibleOverlay'];
|
||||||
@@ -260,10 +256,6 @@ export function createMainIpcRuntimeServiceDeps(
|
|||||||
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
|
||||||
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
|
||||||
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
onYoutubePickerResolve: params.onYoutubePickerResolve,
|
||||||
previewMediaTimingReview: params.previewMediaTimingReview,
|
|
||||||
getMediaTimingReviewWaveform: params.getMediaTimingReviewWaveform,
|
|
||||||
stopMediaTimingReviewPreview: params.stopMediaTimingReviewPreview,
|
|
||||||
resolveMediaTimingReview: params.resolveMediaTimingReview,
|
|
||||||
openYomitanSettings: params.openYomitanSettings,
|
openYomitanSettings: params.openYomitanSettings,
|
||||||
quitApp: params.quitApp,
|
quitApp: params.quitApp,
|
||||||
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
toggleVisibleOverlay: params.toggleVisibleOverlay,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type MockWindow = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
url: string;
|
url: string;
|
||||||
contentReady: boolean;
|
contentReady: boolean;
|
||||||
|
documentLoaded: boolean;
|
||||||
loadCallbacks: Array<() => void>;
|
loadCallbacks: Array<() => void>;
|
||||||
readyToShowCallbacks: Array<() => void>;
|
readyToShowCallbacks: Array<() => void>;
|
||||||
};
|
};
|
||||||
@@ -31,6 +32,7 @@ function createMockWindow(): MockWindow & {
|
|||||||
getShowCount: () => number;
|
getShowCount: () => number;
|
||||||
getHideCount: () => number;
|
getHideCount: () => number;
|
||||||
show: () => void;
|
show: () => void;
|
||||||
|
showInactive: () => void;
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
destroy: () => void;
|
destroy: () => void;
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
@@ -61,6 +63,7 @@ function createMockWindow(): MockWindow & {
|
|||||||
loading: false,
|
loading: false,
|
||||||
url: 'file:///overlay/index.html?layer=modal',
|
url: 'file:///overlay/index.html?layer=modal',
|
||||||
contentReady: true,
|
contentReady: true,
|
||||||
|
documentLoaded: true,
|
||||||
loadCallbacks: [],
|
loadCallbacks: [],
|
||||||
readyToShowCallbacks: [],
|
readyToShowCallbacks: [],
|
||||||
};
|
};
|
||||||
@@ -84,6 +87,10 @@ function createMockWindow(): MockWindow & {
|
|||||||
state.visible = true;
|
state.visible = true;
|
||||||
state.showCount += 1;
|
state.showCount += 1;
|
||||||
},
|
},
|
||||||
|
showInactive: () => {
|
||||||
|
state.visible = true;
|
||||||
|
state.showCount += 1;
|
||||||
|
},
|
||||||
hide: () => {
|
hide: () => {
|
||||||
state.visible = false;
|
state.visible = false;
|
||||||
state.hideCount += 1;
|
state.hideCount += 1;
|
||||||
@@ -96,6 +103,10 @@ function createMockWindow(): MockWindow & {
|
|||||||
state.focused = true;
|
state.focused = true;
|
||||||
},
|
},
|
||||||
emitDidFinishLoad: () => {
|
emitDidFinishLoad: () => {
|
||||||
|
state.documentLoaded = true;
|
||||||
|
(
|
||||||
|
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||||
|
).__subminerOverlayDocumentLoaded = true;
|
||||||
const callbacks = state.loadCallbacks.splice(0);
|
const callbacks = state.loadCallbacks.splice(0);
|
||||||
for (const callback of callbacks) {
|
for (const callback of callbacks) {
|
||||||
callback();
|
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 }
|
window as typeof window & { __subminerOverlayContentReady?: boolean }
|
||||||
).__subminerOverlayContentReady = state.contentReady;
|
).__subminerOverlayContentReady = state.contentReady;
|
||||||
|
(
|
||||||
|
window as typeof window & { __subminerOverlayDocumentLoaded?: boolean }
|
||||||
|
).__subminerOverlayDocumentLoaded = state.documentLoaded;
|
||||||
|
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
@@ -259,6 +283,73 @@ test('sendToActiveOverlayWindow creates modal window lazily when absent', () =>
|
|||||||
assert.deepEqual(window.sent, [['jimaku:open']]);
|
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', () => {
|
test('sendToActiveOverlayWindow does not retain restore state when modal creation fails', () => {
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
@@ -301,7 +392,7 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
|||||||
window.loading = false;
|
window.loading = false;
|
||||||
window.url = 'file:///overlay/index.html?layer=modal';
|
window.url = 'file:///overlay/index.html?layer=modal';
|
||||||
window.emitDidFinishLoad();
|
window.emitDidFinishLoad();
|
||||||
assert.deepEqual(window.sent, []);
|
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||||
|
|
||||||
window.contentReady = true;
|
window.contentReady = true;
|
||||||
window.emitReadyToShow();
|
window.emitReadyToShow();
|
||||||
@@ -311,15 +402,18 @@ test('sendToActiveOverlayWindow waits for blank modal URL before sending open co
|
|||||||
assert.equal(window.getShowCount(), 1);
|
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 window = createMockWindow();
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getModalWindow: () => window as never,
|
getModalWindow: () => window as never,
|
||||||
createModalWindow: () => window as never,
|
createModalWindow: () => window as never,
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
setModalWindowBounds: () => {},
|
setModalWindowBounds: () => {},
|
||||||
});
|
},
|
||||||
|
{ platform: 'darwin' },
|
||||||
|
);
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
restoreOnModalClose: 'runtime-options',
|
restoreOnModalClose: 'runtime-options',
|
||||||
@@ -342,7 +436,9 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
|||||||
assert.equal(window.isDestroyed(), false);
|
assert.equal(window.isDestroyed(), false);
|
||||||
|
|
||||||
runtime.handleOverlayModalClosed('subsync');
|
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', () => {
|
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']);
|
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', () => {
|
test('modal window path runs final close handoff before modal input deactivates', () => {
|
||||||
const mainWindow = createMockWindow();
|
const mainWindow = createMockWindow();
|
||||||
mainWindow.visible = true;
|
mainWindow.visible = true;
|
||||||
@@ -650,15 +786,18 @@ test('handleOverlayModalClosed is a no-op when no modal window can be targeted',
|
|||||||
assert.deepEqual(state, []);
|
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 window = createMockWindow();
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getModalWindow: () => window as never,
|
getModalWindow: () => window as never,
|
||||||
createModalWindow: () => window as never,
|
createModalWindow: () => window as never,
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
setModalWindowBounds: () => {},
|
setModalWindowBounds: () => {},
|
||||||
});
|
},
|
||||||
|
{ platform: 'darwin' },
|
||||||
|
);
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow(
|
runtime.sendToActiveOverlayWindow(
|
||||||
'kiku:field-grouping-open',
|
'kiku:field-grouping-open',
|
||||||
@@ -669,7 +808,9 @@ test('handleOverlayModalClosed destroys modal window for single kiku modal', ()
|
|||||||
);
|
);
|
||||||
runtime.handleOverlayModalClosed('kiku');
|
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);
|
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);
|
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();
|
const window = createMockWindow();
|
||||||
|
window.loading = true;
|
||||||
|
window.url = '';
|
||||||
window.contentReady = false;
|
window.contentReady = false;
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
@@ -738,16 +881,100 @@ test('sendToActiveOverlayWindow waits for modal ready-to-show before delivering
|
|||||||
|
|
||||||
assert.equal(sent, true);
|
assert.equal(sent, true);
|
||||||
assert.deepEqual(window.sent, []);
|
assert.deepEqual(window.sent, []);
|
||||||
|
window.loading = false;
|
||||||
|
window.url = 'file:///overlay/index.html?layer=modal';
|
||||||
window.emitDidFinishLoad();
|
window.emitDidFinishLoad();
|
||||||
assert.deepEqual(window.sent, []);
|
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||||
|
|
||||||
window.contentReady = true;
|
window.contentReady = true;
|
||||||
window.emitReadyToShow();
|
window.emitReadyToShow();
|
||||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
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', () => {
|
test('sendToActiveOverlayWindow flushes every queued load and ready listener before sending', () => {
|
||||||
const window = createMockWindow();
|
const window = createMockWindow();
|
||||||
|
window.loading = true;
|
||||||
|
window.url = '';
|
||||||
window.contentReady = false;
|
window.contentReady = false;
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService({
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
@@ -773,29 +1000,73 @@ test('sendToActiveOverlayWindow flushes every queued load and ready listener bef
|
|||||||
);
|
);
|
||||||
assert.deepEqual(window.sent, []);
|
assert.deepEqual(window.sent, []);
|
||||||
|
|
||||||
|
window.loading = false;
|
||||||
|
window.url = 'file:///overlay/index.html?layer=modal';
|
||||||
window.emitDidFinishLoad();
|
window.emitDidFinishLoad();
|
||||||
assert.deepEqual(window.sent, []);
|
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||||
|
|
||||||
window.contentReady = true;
|
window.contentReady = true;
|
||||||
window.emitReadyToShow();
|
window.emitReadyToShow();
|
||||||
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
assert.deepEqual(window.sent, [['runtime-options:open'], ['session-help:open']]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('modal reopen creates a fresh window after close destroys the previous one', () => {
|
test('modal reopen reuses the warm window and shows it immediately on macOS', () => {
|
||||||
const firstWindow = createMockWindow();
|
const modalWindow = createMockWindow();
|
||||||
const secondWindow = createMockWindow();
|
let createCalls = 0;
|
||||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
|
||||||
|
|
||||||
const runtime = createOverlayModalRuntimeService({
|
const runtime = createOverlayModalRuntimeService(
|
||||||
|
{
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getModalWindow: () => currentModal as never,
|
getModalWindow: () => modalWindow as never,
|
||||||
createModalWindow: () => {
|
createModalWindow: () => {
|
||||||
currentModal = secondWindow;
|
createCalls += 1;
|
||||||
return secondWindow as never;
|
return modalWindow as never;
|
||||||
},
|
},
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
setModalWindowBounds: () => {},
|
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, {
|
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
restoreOnModalClose: 'runtime-options',
|
restoreOnModalClose: 'runtime-options',
|
||||||
@@ -804,30 +1075,30 @@ test('modal reopen creates a fresh window after close destroys the previous one'
|
|||||||
runtime.handleOverlayModalClosed('runtime-options');
|
runtime.handleOverlayModalClosed('runtime-options');
|
||||||
|
|
||||||
assert.equal(firstWindow.isDestroyed(), true);
|
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, {
|
const sent = runtime.sendToActiveOverlayWindow('session-help:open', undefined, {
|
||||||
restoreOnModalClose: 'runtime-options',
|
restoreOnModalClose: 'session-help',
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(sent, true);
|
assert.equal(sent, true);
|
||||||
assert.equal(currentModal, secondWindow);
|
assert.equal(createCalls, 1);
|
||||||
assert.equal(secondWindow.getShowCount(), 0);
|
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', () => {
|
test('modal reopen on the warm window notifies state change for each lifecycle', () => {
|
||||||
const firstWindow = createMockWindow();
|
const modalWindow = createMockWindow();
|
||||||
const secondWindow = createMockWindow();
|
|
||||||
let currentModal: ReturnType<typeof createMockWindow> | null = firstWindow;
|
|
||||||
const state: boolean[] = [];
|
const state: boolean[] = [];
|
||||||
|
|
||||||
const runtime = createOverlayModalRuntimeService(
|
const runtime = createOverlayModalRuntimeService(
|
||||||
{
|
{
|
||||||
getMainWindow: () => null,
|
getMainWindow: () => null,
|
||||||
getModalWindow: () => currentModal as never,
|
getModalWindow: () => modalWindow as never,
|
||||||
createModalWindow: () => {
|
createModalWindow: () => modalWindow as never,
|
||||||
currentModal = secondWindow;
|
|
||||||
return secondWindow as never;
|
|
||||||
},
|
|
||||||
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
getModalGeometry: () => ({ x: 0, y: 0, width: 400, height: 300 }),
|
||||||
setModalWindowBounds: () => {},
|
setModalWindowBounds: () => {},
|
||||||
},
|
},
|
||||||
@@ -835,6 +1106,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
|||||||
onModalStateChange: (active: boolean): void => {
|
onModalStateChange: (active: boolean): void => {
|
||||||
state.push(active);
|
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');
|
runtime.handleOverlayModalClosed('runtime-options');
|
||||||
|
|
||||||
assert.deepEqual(state, [true, false]);
|
assert.deepEqual(state, [true, false]);
|
||||||
assert.equal(firstWindow.isDestroyed(), true);
|
assert.equal(modalWindow.isDestroyed(), false);
|
||||||
|
|
||||||
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
runtime.sendToActiveOverlayWindow('runtime-options:open', undefined, {
|
||||||
restoreOnModalClose: 'runtime-options',
|
restoreOnModalClose: 'runtime-options',
|
||||||
@@ -853,7 +1125,7 @@ test('modal reopen after close-destroy notifies state change on fresh window lif
|
|||||||
runtime.notifyOverlayModalOpened('runtime-options');
|
runtime.notifyOverlayModalOpened('runtime-options');
|
||||||
|
|
||||||
assert.deepEqual(state, [true, false, true]);
|
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', () => {
|
test('visible stale modal window is made interactive again before reopening', () => {
|
||||||
|
|||||||
+100
-18
@@ -2,7 +2,11 @@ import type { BrowserWindow } from 'electron';
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import type { WindowGeometry } from '../types';
|
import type { WindowGeometry } from '../types';
|
||||||
import type { HyprlandPlacementStatus } from '../core/services/hyprland-window-placement';
|
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;
|
const MODAL_REVEAL_FALLBACK_DELAY_MS = 250;
|
||||||
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
// The dedicated modal window maps asynchronously on Wayland; a single reconcile can fire
|
||||||
@@ -39,6 +43,7 @@ export interface OverlayWindowResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface OverlayModalRuntime {
|
export interface OverlayModalRuntime {
|
||||||
|
primeModalWindow: () => boolean;
|
||||||
sendToActiveOverlayWindow: (
|
sendToActiveOverlayWindow: (
|
||||||
channel: string,
|
channel: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
@@ -59,6 +64,8 @@ export interface OverlayModalRuntime {
|
|||||||
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
type RevealFallbackHandle = NonNullable<Parameters<typeof globalThis.clearTimeout>[0]>;
|
||||||
|
|
||||||
export interface OverlayModalRuntimeOptions {
|
export interface OverlayModalRuntimeOptions {
|
||||||
|
platform?: NodeJS.Platform;
|
||||||
|
focusApplication?: () => void;
|
||||||
onModalStateChange?: (isActive: boolean) => void;
|
onModalStateChange?: (isActive: boolean) => void;
|
||||||
onFinalModalClosed?: () => void;
|
onFinalModalClosed?: () => void;
|
||||||
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
scheduleRevealFallback?: (callback: () => void, delayMs: number) => RevealFallbackHandle;
|
||||||
@@ -79,6 +86,11 @@ export function createOverlayModalRuntimeService(
|
|||||||
let pendingModalWindowReveal: BrowserWindow | null = null;
|
let pendingModalWindowReveal: BrowserWindow | null = null;
|
||||||
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
let pendingModalWindowRevealTimeout: RevealFallbackHandle | null = null;
|
||||||
const modalWindowBoundsReconcileGenerations = new WeakMap<BrowserWindow, number>();
|
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 =>
|
const scheduleRevealFallback = (callback: () => void, delayMs: number): RevealFallbackHandle =>
|
||||||
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
(options.scheduleRevealFallback ?? globalThis.setTimeout)(callback, delayMs);
|
||||||
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
const clearRevealFallback = (timeout: RevealFallbackHandle): void =>
|
||||||
@@ -134,7 +146,11 @@ export function createOverlayModalRuntimeService(
|
|||||||
}
|
}
|
||||||
const overlayWindow = window as BrowserWindow & {
|
const overlayWindow = window as BrowserWindow & {
|
||||||
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
[OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean;
|
||||||
|
[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG]?: boolean;
|
||||||
};
|
};
|
||||||
|
if (overlayWindow[OVERLAY_WINDOW_DOCUMENT_LOADED_FLAG] === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
typeof overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] === 'boolean' &&
|
||||||
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
overlayWindow[OVERLAY_WINDOW_CONTENT_READY_FLAG] !== true
|
||||||
@@ -145,6 +161,50 @@ export function createOverlayModalRuntimeService(
|
|||||||
return currentURL !== '' && currentURL !== 'about:blank';
|
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 => {
|
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||||
if (window.isDestroyed()) return;
|
if (window.isDestroyed()) return;
|
||||||
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||||
@@ -205,16 +265,19 @@ export function createOverlayModalRuntimeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let delivered = false;
|
let delivered = false;
|
||||||
const deliverWhenReady = (): void => {
|
const deliver = (isReady: () => boolean): void => {
|
||||||
if (delivered || window.isDestroyed() || !isWindowReadyForIpc(window)) {
|
if (delivered || window.isDestroyed() || !isReady()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delivered = true;
|
delivered = true;
|
||||||
sendNow(window);
|
sendNow(window);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.webContents.once('did-finish-load', deliverWhenReady);
|
// A hidden macOS panel may not emit ready-to-show until it is presented. The
|
||||||
window.once('ready-to-show', deliverWhenReady);
|
// 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 = (
|
const showModalWindow = (
|
||||||
@@ -224,13 +287,20 @@ export function createOverlayModalRuntimeService(
|
|||||||
} = { passThroughMouseEvents: false },
|
} = { passThroughMouseEvents: false },
|
||||||
): void => {
|
): void => {
|
||||||
setWindowFocusable(window);
|
setWindowFocusable(window);
|
||||||
requestOverlayApplicationFocus();
|
const wasVisible = window.isVisible();
|
||||||
if (!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();
|
window.show();
|
||||||
}
|
}
|
||||||
elevateModalWindow(window);
|
elevateModalWindow(window);
|
||||||
if (options.passThroughMouseEvents) {
|
if (options.passThroughMouseEvents) {
|
||||||
window.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(window, platform === 'win32');
|
||||||
} else {
|
} else {
|
||||||
window.setIgnoreMouseEvents(false);
|
window.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
@@ -245,11 +315,11 @@ export function createOverlayModalRuntimeService(
|
|||||||
|
|
||||||
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
const ensureModalWindowInteractive = (window: BrowserWindow): void => {
|
||||||
setWindowFocusable(window);
|
setWindowFocusable(window);
|
||||||
requestOverlayApplicationFocus();
|
|
||||||
window.setIgnoreMouseEvents(false);
|
window.setIgnoreMouseEvents(false);
|
||||||
elevateModalWindow(window);
|
elevateModalWindow(window);
|
||||||
|
|
||||||
if (window.isVisible()) {
|
if (window.isVisible()) {
|
||||||
|
focusApplication();
|
||||||
window.focus();
|
window.focus();
|
||||||
window.webContents.focus();
|
window.webContents.focus();
|
||||||
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
const reconcileGeneration = nextModalWindowBoundsReconcileGeneration(window);
|
||||||
@@ -291,7 +361,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
mainWindowMousePassthroughForcedByModal = false;
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow, platform === 'win32');
|
||||||
mainWindowMousePassthroughForcedByModal = true;
|
mainWindowMousePassthroughForcedByModal = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -447,9 +517,21 @@ export function createOverlayModalRuntimeService(
|
|||||||
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
if (restoreVisibleOverlayOnModalClose.size === 0) {
|
||||||
clearPendingModalWindowReveal();
|
clearPendingModalWindowReveal();
|
||||||
if (modalWindow && !modalWindow.isDestroyed()) {
|
if (modalWindow && !modalWindow.isDestroyed()) {
|
||||||
|
if (reuseModalWindowAfterClose) {
|
||||||
|
applyOverlayClickThrough(modalWindow, false);
|
||||||
|
modalWindow.hide();
|
||||||
|
markModalWindowPrimed(modalWindow);
|
||||||
|
} else {
|
||||||
modalWindow.destroy();
|
modalWindow.destroy();
|
||||||
}
|
|
||||||
modalWindowPrimedForImmediateShow = false;
|
modalWindowPrimedForImmediateShow = false;
|
||||||
|
// Reusing a transparent click-through BrowserWindow can leave later modal sessions
|
||||||
|
// non-interactive on Windows. Recycle the renderer after every close, then warm its
|
||||||
|
// replacement so the next shortcut still opens promptly.
|
||||||
|
if (platform === 'win32') {
|
||||||
|
primeModalWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
mainWindowMousePassthroughForcedByModal = false;
|
mainWindowMousePassthroughForcedByModal = false;
|
||||||
setMainWindowVisibilityForModal(false);
|
setMainWindowVisibilityForModal(false);
|
||||||
try {
|
try {
|
||||||
@@ -478,17 +560,16 @@ export function createOverlayModalRuntimeService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modalWindow = deps.getModalWindow();
|
const modalWindow = deps.getModalWindow();
|
||||||
|
if (targetWindow.isVisible()) {
|
||||||
|
ensureModalWindowInteractive(targetWindow);
|
||||||
|
} else {
|
||||||
|
showModalWindow(targetWindow);
|
||||||
|
}
|
||||||
|
|
||||||
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
if (modalWindow && !modalWindow.isDestroyed() && targetWindow === modalWindow) {
|
||||||
setMainWindowMousePassthroughForModal(true);
|
setMainWindowMousePassthroughForModal(true);
|
||||||
setMainWindowVisibilityForModal(true);
|
setMainWindowVisibilityForModal(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetWindow.isVisible()) {
|
|
||||||
ensureModalWindowInteractive(targetWindow);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showModalWindow(targetWindow);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
|
const waitForModalOpen = async (modal: OverlayHostedModal, timeoutMs: number): Promise<boolean> =>
|
||||||
@@ -515,6 +596,7 @@ export function createOverlayModalRuntimeService(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
primeModalWindow,
|
||||||
sendToActiveOverlayWindow,
|
sendToActiveOverlayWindow,
|
||||||
openRuntimeOptionsPalette,
|
openRuntimeOptionsPalette,
|
||||||
openJimaku,
|
openJimaku,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { IPC_CHANNELS, type OverlayHostedModal } from '../../shared/ipc/contracts';
|
|
||||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
|
||||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
|
||||||
|
|
||||||
const MODAL: OverlayHostedModal = 'media-timing-review';
|
|
||||||
|
|
||||||
export async function openMediaTimingReviewModal(
|
|
||||||
deps: {
|
|
||||||
ensureOverlayStartupPrereqs: () => void;
|
|
||||||
ensureOverlayWindowsReadyForVisibilityActions: () => void;
|
|
||||||
sendToActiveOverlayWindow: (
|
|
||||||
channel: string,
|
|
||||||
payload?: unknown,
|
|
||||||
runtimeOptions?: {
|
|
||||||
restoreOnModalClose?: OverlayHostedModal;
|
|
||||||
preferModalWindow?: boolean;
|
|
||||||
},
|
|
||||||
) => boolean;
|
|
||||||
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
|
|
||||||
logWarn: (message: string) => void;
|
|
||||||
},
|
|
||||||
payload: MediaTimingReviewOpenPayload,
|
|
||||||
): Promise<boolean> {
|
|
||||||
return await retryOverlayModalOpen(
|
|
||||||
{ waitForModalOpen: deps.waitForModalOpen, logWarn: deps.logWarn },
|
|
||||||
{
|
|
||||||
modal: MODAL,
|
|
||||||
timeoutMs: 1_500,
|
|
||||||
retryWarning:
|
|
||||||
'Media timing review did not acknowledge modal open; retrying the dedicated modal window.',
|
|
||||||
sendOpen: () =>
|
|
||||||
openOverlayHostedModal(deps, {
|
|
||||||
channel: IPC_CHANNELS.event.mediaTimingReviewOpen,
|
|
||||||
modal: MODAL,
|
|
||||||
payload,
|
|
||||||
preferModalWindow: true,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { describe, test } from 'node:test';
|
|
||||||
import type { MediaTimingReviewOpenPayload } from '../../types/anki';
|
|
||||||
import {
|
|
||||||
buildMediaTimingReviewPayload,
|
|
||||||
createMediaTimingReviewRuntime,
|
|
||||||
} from './media-timing-review';
|
|
||||||
|
|
||||||
describe('buildMediaTimingReviewPayload', () => {
|
|
||||||
test('starts from the padded range and leaves two seconds to drag on each side', () => {
|
|
||||||
const payload = buildMediaTimingReviewPayload(
|
|
||||||
{
|
|
||||||
kind: 'sentence',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
audioPadding: 0.5,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
},
|
|
||||||
{ reviewId: 'review-1', mediaDuration: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(payload.selectionStartTime, 9.5);
|
|
||||||
assert.equal(payload.selectionEndTime, 12.5);
|
|
||||||
assert.equal(payload.timelineStartTime, 7.5);
|
|
||||||
assert.equal(payload.timelineEndTime, 14.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clamps the padded selection and timeline to media bounds', () => {
|
|
||||||
const payload = buildMediaTimingReviewPayload(
|
|
||||||
{
|
|
||||||
kind: 'word',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 0.2,
|
|
||||||
endTime: 9.8,
|
|
||||||
audioPadding: 1,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
},
|
|
||||||
{ reviewId: 'review-2', mediaDuration: 10 },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(payload.selectionStartTime, 0);
|
|
||||||
assert.equal(payload.selectionEndTime, 10);
|
|
||||||
assert.equal(payload.timelineStartTime, 0);
|
|
||||||
assert.equal(payload.timelineEndTime, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keeps an uncapped selection when max media duration is disabled', () => {
|
|
||||||
const payload = buildMediaTimingReviewPayload(
|
|
||||||
{
|
|
||||||
kind: 'sentence',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 55,
|
|
||||||
audioPadding: 1,
|
|
||||||
maxMediaDuration: 0,
|
|
||||||
},
|
|
||||||
{ reviewId: 'review-unlimited', mediaDuration: 100 },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(payload.selectionStartTime, 9);
|
|
||||||
assert.equal(payload.selectionEndTime, 56);
|
|
||||||
assert.equal(payload.maxMediaDuration, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function startActiveMediaTimingReview(
|
|
||||||
options: {
|
|
||||||
maxMediaDuration?: number;
|
|
||||||
decisionTimeoutMs?: number;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const previewCalls: Array<[number, number]> = [];
|
|
||||||
let publishPayload!: (payload: MediaTimingReviewOpenPayload) => void;
|
|
||||||
const openedPayload = new Promise<MediaTimingReviewOpenPayload>((resolve) => {
|
|
||||||
publishPayload = resolve;
|
|
||||||
});
|
|
||||||
const runtime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: '/video/show.mkv',
|
|
||||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
|
||||||
send: () => undefined,
|
|
||||||
}),
|
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
|
||||||
getMpvExecutablePath: () => 'mpv',
|
|
||||||
generateWaveform: async () => [],
|
|
||||||
decisionTimeoutMs: options.decisionTimeoutMs,
|
|
||||||
createPreviewSession: () => ({
|
|
||||||
start: async () => undefined,
|
|
||||||
play: async (startTime, endTime) => {
|
|
||||||
previewCalls.push([startTime, endTime]);
|
|
||||||
},
|
|
||||||
stop: async () => undefined,
|
|
||||||
dispose: () => undefined,
|
|
||||||
}),
|
|
||||||
openModal: async (payload) => {
|
|
||||||
publishPayload(payload);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
showStatus: () => undefined,
|
|
||||||
});
|
|
||||||
const pendingDecision = runtime.requestReview({
|
|
||||||
kind: 'sentence',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
audioPadding: 0,
|
|
||||||
maxMediaDuration: options.maxMediaDuration ?? 30,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { runtime, payload: await openedPayload, pendingDecision, previewCalls };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('media timing review pauses playback, resolves exact timing, and restores playing state', async () => {
|
|
||||||
const commands: Array<Array<string | number>> = [];
|
|
||||||
const previewCalls: Array<[number, number]> = [];
|
|
||||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
|
||||||
runtime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: '/video/show.mkv',
|
|
||||||
requestProperty: async (name) =>
|
|
||||||
({ pause: false, duration: 100, aid: 2, volume: 60 })[
|
|
||||||
name as 'pause' | 'duration' | 'aid' | 'volume'
|
|
||||||
],
|
|
||||||
send: ({ command }) => commands.push(command),
|
|
||||||
}),
|
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
|
||||||
getMpvExecutablePath: () => 'mpv',
|
|
||||||
generateWaveform: async () => [],
|
|
||||||
createPreviewSession: () => ({
|
|
||||||
start: async () => undefined,
|
|
||||||
play: async (startTime, endTime) => {
|
|
||||||
previewCalls.push([startTime, endTime]);
|
|
||||||
},
|
|
||||||
stop: async () => undefined,
|
|
||||||
dispose: () => undefined,
|
|
||||||
}),
|
|
||||||
openModal: async (payload) => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
void runtime
|
|
||||||
.previewRange({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
startTime: 9.5,
|
|
||||||
endTime: 12.5,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
runtime.resolveReview({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
decision: { action: 'confirm', startTime: 9.5, endTime: 12.5 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
showStatus: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const decision = await runtime.requestReview({
|
|
||||||
kind: 'word',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
noteId: 42,
|
|
||||||
audioPadding: 0.5,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(decision, { action: 'confirm', startTime: 9.5, endTime: 12.5 });
|
|
||||||
assert.deepEqual(commands, [
|
|
||||||
['set_property', 'pause', 'yes'],
|
|
||||||
['set_property', 'pause', 'no'],
|
|
||||||
]);
|
|
||||||
assert.deepEqual(previewCalls, [[9.5, 12.5]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media timing review analyzes the visible range on the selected audio stream', async () => {
|
|
||||||
const waveformCalls: Array<{
|
|
||||||
mediaPath: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
audioStreamIndex?: number;
|
|
||||||
}> = [];
|
|
||||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
|
||||||
runtime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: '/video/show.mkv',
|
|
||||||
currentAudioStreamIndex: 4,
|
|
||||||
requestProperty: async (name) => (name === 'duration' ? 100 : name === 'pause' ? true : null),
|
|
||||||
send: () => undefined,
|
|
||||||
}),
|
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
|
||||||
getMpvExecutablePath: () => 'mpv',
|
|
||||||
generateWaveform: async (options) => {
|
|
||||||
waveformCalls.push(options);
|
|
||||||
return [0.1, 0.8, 0.2];
|
|
||||||
},
|
|
||||||
createPreviewSession: () => ({
|
|
||||||
start: async () => undefined,
|
|
||||||
play: async () => undefined,
|
|
||||||
stop: async () => undefined,
|
|
||||||
dispose: () => undefined,
|
|
||||||
}),
|
|
||||||
openModal: async (payload) => {
|
|
||||||
const waveform = await runtime.getWaveform({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
startTime: payload.timelineStartTime,
|
|
||||||
endTime: payload.timelineEndTime,
|
|
||||||
});
|
|
||||||
assert.deepEqual(waveform, { ok: true, peaks: [0.1, 0.8, 0.2] });
|
|
||||||
runtime.resolveReview({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
decision: { action: 'use-original' },
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
showStatus: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.requestReview({
|
|
||||||
kind: 'sentence',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
audioPadding: 0.5,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(waveformCalls, [
|
|
||||||
{
|
|
||||||
mediaPath: '/video/show.mkv',
|
|
||||||
startTime: 7.5,
|
|
||||||
endTime: 14.5,
|
|
||||||
audioStreamIndex: 4,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media timing review rejects stale and out-of-range actions before allowing discard', async () => {
|
|
||||||
const { runtime, payload, pendingDecision, previewCalls } = await startActiveMediaTimingReview({
|
|
||||||
maxMediaDuration: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
await runtime.previewRange({ reviewId: 'stale-review', startTime: 10, endTime: 12 }),
|
|
||||||
{ ok: false, message: 'This timing review is no longer active.' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
runtime.resolveReview({
|
|
||||||
reviewId: 'stale-review',
|
|
||||||
decision: { action: 'confirm', startTime: 10, endTime: 12 },
|
|
||||||
}),
|
|
||||||
{ ok: false, message: 'This timing review is no longer active.' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
runtime.resolveReview({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
decision: { action: 'confirm', startTime: 10, endTime: 14 },
|
|
||||||
}),
|
|
||||||
{ ok: false, message: 'The selected timing range is invalid.' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
runtime.resolveReview({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
decision: { action: 'confirm', startTime: 99, endTime: 100.5 },
|
|
||||||
}),
|
|
||||||
{ ok: false, message: 'The selected timing range is invalid.' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
runtime.resolveReview({ reviewId: payload.reviewId, decision: { action: 'discard' } }),
|
|
||||||
{ ok: true },
|
|
||||||
);
|
|
||||||
assert.deepEqual(await pendingDecision, { action: 'discard' });
|
|
||||||
assert.deepEqual(previewCalls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media timing review watchdog falls back when the renderer stops responding', async () => {
|
|
||||||
const { pendingDecision } = await startActiveMediaTimingReview({ decisionTimeoutMs: 0 });
|
|
||||||
|
|
||||||
assert.deepEqual(await pendingDecision, { action: 'use-original' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media timing review does not resume playback when the prior state is unavailable', async () => {
|
|
||||||
const commands: Array<Array<string | number>> = [];
|
|
||||||
let runtime: ReturnType<typeof createMediaTimingReviewRuntime>;
|
|
||||||
runtime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: '/video/show.mkv',
|
|
||||||
requestProperty: async () => null,
|
|
||||||
send: ({ command }) => commands.push(command),
|
|
||||||
}),
|
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
|
||||||
getMpvExecutablePath: () => '',
|
|
||||||
generateWaveform: async () => [],
|
|
||||||
createPreviewSession: () => ({
|
|
||||||
start: async () => {
|
|
||||||
throw new Error('preview unavailable');
|
|
||||||
},
|
|
||||||
play: async () => undefined,
|
|
||||||
stop: async () => undefined,
|
|
||||||
dispose: () => undefined,
|
|
||||||
}),
|
|
||||||
openModal: async (payload) => {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
runtime.resolveReview({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
decision: { action: 'use-original' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
showStatus: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
await runtime.requestReview({
|
|
||||||
kind: 'sentence',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
audioPadding: 0,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
}),
|
|
||||||
{ action: 'use-original' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(commands, [['set_property', 'pause', 'yes']]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('media timing review restores playback when setup fails after pausing', async () => {
|
|
||||||
const commands: Array<Array<string | number>> = [];
|
|
||||||
const runtime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: '/video/show.mkv',
|
|
||||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
|
||||||
send: ({ command }) => commands.push(command),
|
|
||||||
}),
|
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
|
||||||
getMpvExecutablePath: () => 'mpv',
|
|
||||||
generateWaveform: async () => [],
|
|
||||||
createPreviewSession: () => {
|
|
||||||
throw new Error('preview setup failed');
|
|
||||||
},
|
|
||||||
openModal: async () => true,
|
|
||||||
showStatus: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
await runtime.requestReview({
|
|
||||||
kind: 'word',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
audioPadding: 0,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
}),
|
|
||||||
{ action: 'use-original' },
|
|
||||||
);
|
|
||||||
assert.deepEqual(commands, [
|
|
||||||
['set_property', 'pause', 'yes'],
|
|
||||||
['set_property', 'pause', 'no'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('disposing an open review settles it with original timing and restores playback', async () => {
|
|
||||||
const commands: Array<Array<string | number>> = [];
|
|
||||||
const runtime = createMediaTimingReviewRuntime({
|
|
||||||
getMpvClient: () => ({
|
|
||||||
connected: true,
|
|
||||||
currentVideoPath: '/video/show.mkv',
|
|
||||||
requestProperty: async (name) => (name === 'pause' ? false : null),
|
|
||||||
send: ({ command }) => commands.push(command),
|
|
||||||
}),
|
|
||||||
getCurrentMediaPath: () => '/video/show.mkv',
|
|
||||||
getMpvExecutablePath: () => 'mpv',
|
|
||||||
generateWaveform: async () => [],
|
|
||||||
createPreviewSession: () => ({
|
|
||||||
start: async () => undefined,
|
|
||||||
play: async () => undefined,
|
|
||||||
stop: async () => undefined,
|
|
||||||
dispose: () => undefined,
|
|
||||||
}),
|
|
||||||
openModal: async () => true,
|
|
||||||
showStatus: () => undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pending = runtime.requestReview({
|
|
||||||
kind: 'word',
|
|
||||||
text: '字幕',
|
|
||||||
startTime: 10,
|
|
||||||
endTime: 12,
|
|
||||||
audioPadding: 0,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
||||||
await runtime.dispose();
|
|
||||||
|
|
||||||
assert.deepEqual(await pending, { action: 'use-original' });
|
|
||||||
assert.deepEqual(commands, [
|
|
||||||
['set_property', 'pause', 'yes'],
|
|
||||||
['set_property', 'pause', 'no'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import { randomUUID } from 'crypto';
|
|
||||||
import type {
|
|
||||||
MediaTimingReviewActionResult,
|
|
||||||
MediaTimingReviewDecision,
|
|
||||||
MediaTimingReviewOpenPayload,
|
|
||||||
MediaTimingReviewPreviewRequest,
|
|
||||||
MediaTimingReviewRequest,
|
|
||||||
MediaTimingReviewResolveRequest,
|
|
||||||
MediaTimingReviewWaveformRequest,
|
|
||||||
MediaTimingReviewWaveformResult,
|
|
||||||
} from '../../types/anki';
|
|
||||||
import type { SpeechWaveformOptions } from '../../core/services/media-timing-waveform';
|
|
||||||
|
|
||||||
const INITIAL_TIMELINE_MARGIN_SECONDS = 2;
|
|
||||||
const REVIEW_DECISION_TIMEOUT_MS = 5 * 60_000;
|
|
||||||
|
|
||||||
interface ReviewMpvClient {
|
|
||||||
connected: boolean;
|
|
||||||
currentVideoPath: string;
|
|
||||||
currentAudioStreamIndex?: number | null;
|
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
|
||||||
send: (payload: { command: Array<string | number> }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviewSession {
|
|
||||||
start(options: {
|
|
||||||
mediaPath: string;
|
|
||||||
executablePath?: string;
|
|
||||||
audioTrackId?: number;
|
|
||||||
volume?: number;
|
|
||||||
}): Promise<void>;
|
|
||||||
play(startTime: number, endTime: number): Promise<void>;
|
|
||||||
stop(): Promise<void>;
|
|
||||||
dispose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveReview {
|
|
||||||
payload: MediaTimingReviewOpenPayload;
|
|
||||||
mediaPath: string;
|
|
||||||
audioStreamIndex?: number;
|
|
||||||
mpvClient: ReviewMpvClient;
|
|
||||||
restorePlayback: boolean;
|
|
||||||
preview: Promise<PreviewSession>;
|
|
||||||
resolve: (decision: MediaTimingReviewDecision) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaTimingReviewRuntimeDeps {
|
|
||||||
getMpvClient: () => ReviewMpvClient | null;
|
|
||||||
getCurrentMediaPath: () => string | null;
|
|
||||||
getMpvExecutablePath: () => string;
|
|
||||||
createPreviewSession: () => PreviewSession;
|
|
||||||
generateWaveform: (options: SpeechWaveformOptions) => Promise<number[]>;
|
|
||||||
decisionTimeoutMs?: number;
|
|
||||||
openModal: (payload: MediaTimingReviewOpenPayload) => Promise<boolean>;
|
|
||||||
showStatus: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function finiteNumber(value: unknown): number | null {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function booleanProperty(value: unknown): boolean | null {
|
|
||||||
if (typeof value === 'boolean') return value;
|
|
||||||
if (value === 'yes' || value === 1) return true;
|
|
||||||
if (value === 'no' || value === 0) return false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidMediaTimingRange(
|
|
||||||
payload: MediaTimingReviewOpenPayload,
|
|
||||||
startTime: number,
|
|
||||||
endTime: number,
|
|
||||||
): boolean {
|
|
||||||
return (
|
|
||||||
Number.isFinite(startTime) &&
|
|
||||||
Number.isFinite(endTime) &&
|
|
||||||
startTime >= 0 &&
|
|
||||||
endTime > startTime &&
|
|
||||||
(payload.maxMediaDuration <= 0 || endTime - startTime <= payload.maxMediaDuration + 0.001) &&
|
|
||||||
(payload.mediaDuration === undefined || endTime <= payload.mediaDuration + 0.001)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMediaTimingReviewPayload(
|
|
||||||
request: MediaTimingReviewRequest,
|
|
||||||
options: { reviewId: string; mediaDuration?: number },
|
|
||||||
): MediaTimingReviewOpenPayload {
|
|
||||||
const duration = finiteNumber(options.mediaDuration);
|
|
||||||
const maxTime = duration !== null && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
|
||||||
const paddedStart = Math.max(0, request.startTime - request.audioPadding);
|
|
||||||
let paddedEnd = Math.min(maxTime, request.endTime + request.audioPadding);
|
|
||||||
const maxMediaDuration = Math.max(0, request.maxMediaDuration);
|
|
||||||
if (maxMediaDuration > 0 && paddedEnd - paddedStart > maxMediaDuration) {
|
|
||||||
paddedEnd = paddedStart + maxMediaDuration;
|
|
||||||
}
|
|
||||||
if (paddedEnd <= paddedStart) {
|
|
||||||
paddedEnd = Math.min(maxTime, paddedStart + 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timelineStartTime = Math.max(0, paddedStart - INITIAL_TIMELINE_MARGIN_SECONDS);
|
|
||||||
const timelineEndTime = Math.max(
|
|
||||||
paddedEnd,
|
|
||||||
Math.min(maxTime, paddedEnd + INITIAL_TIMELINE_MARGIN_SECONDS),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
reviewId: options.reviewId,
|
|
||||||
kind: request.kind,
|
|
||||||
text: request.text,
|
|
||||||
...(request.noteId !== undefined ? { noteId: request.noteId } : {}),
|
|
||||||
originalStartTime: request.startTime,
|
|
||||||
originalEndTime: request.endTime,
|
|
||||||
selectionStartTime: paddedStart,
|
|
||||||
selectionEndTime: paddedEnd,
|
|
||||||
timelineStartTime,
|
|
||||||
timelineEndTime,
|
|
||||||
...(duration !== null && duration > 0 ? { mediaDuration: duration } : {}),
|
|
||||||
maxMediaDuration,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMediaTimingReviewRuntime(deps: MediaTimingReviewRuntimeDeps) {
|
|
||||||
let active: ActiveReview | null = null;
|
|
||||||
let reviewInProgress = false;
|
|
||||||
let pendingPauseRestore: ReviewMpvClient | null = null;
|
|
||||||
|
|
||||||
function restorePendingPlayback(): void {
|
|
||||||
const mpvClient = pendingPauseRestore;
|
|
||||||
pendingPauseRestore = null;
|
|
||||||
if (mpvClient?.connected) {
|
|
||||||
mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runReview(request: MediaTimingReviewRequest): Promise<MediaTimingReviewDecision> {
|
|
||||||
const mpvClient = deps.getMpvClient();
|
|
||||||
const mediaPath =
|
|
||||||
deps.getCurrentMediaPath()?.trim() || mpvClient?.currentVideoPath?.trim() || '';
|
|
||||||
if (!mpvClient?.connected || !mediaPath) {
|
|
||||||
deps.showStatus('Timing review unavailable. Using the original subtitle timing.');
|
|
||||||
return { action: 'use-original' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pauseRaw, durationRaw, audioTrackRaw, volumeRaw] = await Promise.all([
|
|
||||||
mpvClient.requestProperty?.('pause').catch(() => null) ?? null,
|
|
||||||
mpvClient.requestProperty?.('duration').catch(() => null) ?? null,
|
|
||||||
mpvClient.requestProperty?.('aid').catch(() => null) ?? null,
|
|
||||||
mpvClient.requestProperty?.('volume').catch(() => null) ?? null,
|
|
||||||
]);
|
|
||||||
const pauseState = booleanProperty(pauseRaw);
|
|
||||||
mpvClient.send({ command: ['set_property', 'pause', 'yes'] });
|
|
||||||
pendingPauseRestore = pauseState === false ? mpvClient : null;
|
|
||||||
|
|
||||||
const payload = buildMediaTimingReviewPayload(request, {
|
|
||||||
reviewId: randomUUID(),
|
|
||||||
mediaDuration: finiteNumber(durationRaw) ?? undefined,
|
|
||||||
});
|
|
||||||
const previewSession = deps.createPreviewSession();
|
|
||||||
const preview = previewSession
|
|
||||||
.start({
|
|
||||||
mediaPath,
|
|
||||||
executablePath: deps.getMpvExecutablePath(),
|
|
||||||
audioTrackId: finiteNumber(audioTrackRaw) ?? undefined,
|
|
||||||
volume: finiteNumber(volumeRaw) ?? undefined,
|
|
||||||
})
|
|
||||||
.then(() => previewSession)
|
|
||||||
.catch((error) => {
|
|
||||||
previewSession.dispose();
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
void preview.catch(() => {});
|
|
||||||
|
|
||||||
let resolveDecision!: (decision: MediaTimingReviewDecision) => void;
|
|
||||||
const decisionPromise = new Promise<MediaTimingReviewDecision>((resolve) => {
|
|
||||||
resolveDecision = resolve;
|
|
||||||
});
|
|
||||||
active = {
|
|
||||||
payload,
|
|
||||||
mediaPath,
|
|
||||||
...(mpvClient.currentAudioStreamIndex !== null &&
|
|
||||||
mpvClient.currentAudioStreamIndex !== undefined
|
|
||||||
? { audioStreamIndex: mpvClient.currentAudioStreamIndex }
|
|
||||||
: {}),
|
|
||||||
mpvClient,
|
|
||||||
restorePlayback: pendingPauseRestore === mpvClient,
|
|
||||||
preview,
|
|
||||||
resolve: resolveDecision,
|
|
||||||
};
|
|
||||||
pendingPauseRestore = null;
|
|
||||||
|
|
||||||
const opened = await deps.openModal(payload).catch(() => false);
|
|
||||||
if (!opened) {
|
|
||||||
await cleanupActiveReview();
|
|
||||||
deps.showStatus('Timing review could not open. Using the original subtitle timing.');
|
|
||||||
return { action: 'use-original' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const decisionWatchdog = setTimeout(
|
|
||||||
() => resolveDecision({ action: 'use-original' }),
|
|
||||||
Math.max(0, deps.decisionTimeoutMs ?? REVIEW_DECISION_TIMEOUT_MS),
|
|
||||||
);
|
|
||||||
let decision: MediaTimingReviewDecision;
|
|
||||||
try {
|
|
||||||
decision = await decisionPromise;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(decisionWatchdog);
|
|
||||||
}
|
|
||||||
await cleanupActiveReview();
|
|
||||||
return decision;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestReview(
|
|
||||||
request: MediaTimingReviewRequest,
|
|
||||||
): Promise<MediaTimingReviewDecision> {
|
|
||||||
if (active || reviewInProgress) {
|
|
||||||
deps.showStatus('Finish the current timing review before mining another card.');
|
|
||||||
return { action: 'use-original' };
|
|
||||||
}
|
|
||||||
reviewInProgress = true;
|
|
||||||
try {
|
|
||||||
return await runReview(request);
|
|
||||||
} catch {
|
|
||||||
await cleanupActiveReview();
|
|
||||||
restorePendingPlayback();
|
|
||||||
deps.showStatus('Timing review failed. Using the original subtitle timing.');
|
|
||||||
return { action: 'use-original' };
|
|
||||||
} finally {
|
|
||||||
reviewInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function previewRange(
|
|
||||||
request: MediaTimingReviewPreviewRequest,
|
|
||||||
): Promise<MediaTimingReviewActionResult> {
|
|
||||||
const current = active;
|
|
||||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
|
||||||
return { ok: false, message: 'This timing review is no longer active.' };
|
|
||||||
}
|
|
||||||
if (!isValidMediaTimingRange(current.payload, request.startTime, request.endTime)) {
|
|
||||||
return { ok: false, message: 'The selected preview range is invalid.' };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const previewSession = await current.preview;
|
|
||||||
await previewSession.play(request.startTime, request.endTime);
|
|
||||||
return { ok: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: `Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getWaveform(
|
|
||||||
request: MediaTimingReviewWaveformRequest,
|
|
||||||
): Promise<MediaTimingReviewWaveformResult> {
|
|
||||||
const current = active;
|
|
||||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
|
||||||
return { ok: false, message: 'This timing review is no longer active.' };
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!Number.isFinite(request.startTime) ||
|
|
||||||
!Number.isFinite(request.endTime) ||
|
|
||||||
request.startTime < 0 ||
|
|
||||||
request.endTime <= request.startTime ||
|
|
||||||
(current.payload.mediaDuration !== undefined &&
|
|
||||||
request.endTime > current.payload.mediaDuration + 0.001)
|
|
||||||
) {
|
|
||||||
return { ok: false, message: 'The waveform range is invalid.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const peaks = await deps.generateWaveform({
|
|
||||||
mediaPath: current.mediaPath,
|
|
||||||
startTime: request.startTime,
|
|
||||||
endTime: request.endTime,
|
|
||||||
...(current.audioStreamIndex !== undefined
|
|
||||||
? { audioStreamIndex: current.audioStreamIndex }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
if (active !== current || peaks.length < 2 || peaks.some((peak) => !Number.isFinite(peak))) {
|
|
||||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
|
||||||
}
|
|
||||||
return { ok: true, peaks };
|
|
||||||
} catch {
|
|
||||||
return { ok: false, message: 'Timing waveform is unavailable.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopPreview(reviewId: string): Promise<MediaTimingReviewActionResult> {
|
|
||||||
const current = active;
|
|
||||||
if (!current || reviewId !== current.payload.reviewId) {
|
|
||||||
return { ok: false, message: 'This timing review is no longer active.' };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const previewSession = await current.preview;
|
|
||||||
await previewSession.stop();
|
|
||||||
return { ok: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
message: `Could not stop preview: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveReview(request: MediaTimingReviewResolveRequest): MediaTimingReviewActionResult {
|
|
||||||
const current = active;
|
|
||||||
if (!current || request.reviewId !== current.payload.reviewId) {
|
|
||||||
return { ok: false, message: 'This timing review is no longer active.' };
|
|
||||||
}
|
|
||||||
if (request.decision.action === 'confirm') {
|
|
||||||
const { startTime, endTime } = request.decision;
|
|
||||||
if (!isValidMediaTimingRange(current.payload, startTime, endTime)) {
|
|
||||||
return { ok: false, message: 'The selected timing range is invalid.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current.resolve(request.decision);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupActiveReview(): Promise<void> {
|
|
||||||
const current = active;
|
|
||||||
active = null;
|
|
||||||
if (!current) return;
|
|
||||||
void current.preview.then((session) => session.dispose()).catch(() => {});
|
|
||||||
if (current.restorePlayback && current.mpvClient.connected) {
|
|
||||||
current.mpvClient.send({ command: ['set_property', 'pause', 'no'] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dispose(): Promise<void> {
|
|
||||||
active?.resolve({ action: 'use-original' });
|
|
||||||
await cleanupActiveReview();
|
|
||||||
restorePendingPlayback();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestReview,
|
|
||||||
previewRange,
|
|
||||||
getWaveform,
|
|
||||||
stopPreview,
|
|
||||||
resolveReview,
|
|
||||||
dispose,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||||
|
|
||||||
type StatsOverlayVisibilityWindow = {
|
type StatsOverlayVisibilityWindow = {
|
||||||
isDestroyed: () => boolean;
|
isDestroyed: () => boolean;
|
||||||
isVisible: () => boolean;
|
isVisible: () => boolean;
|
||||||
@@ -8,7 +10,7 @@ function makeOverlayMousePassive(window: StatsOverlayVisibilityWindow | null): v
|
|||||||
if (!window || window.isDestroyed() || !window.isVisible()) {
|
if (!window || window.isDestroyed() || !window.isVisible()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createStatsOverlayVisibilityChangeHandler(deps: {
|
export function createStatsOverlayVisibilityChangeHandler(deps: {
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ function makeSpawn(): { spawn: SyncLauncherSpawn; children: FakeChild[]; command
|
|||||||
return { spawn, children, commands };
|
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 () => {
|
test('runSyncLauncher parses NDJSON events across chunk boundaries', async () => {
|
||||||
const { spawn, children, commands } = makeSpawn();
|
const { spawn, children, commands } = makeSpawn();
|
||||||
const events: SyncProgressEvent[] = [];
|
const events: SyncProgressEvent[] = [];
|
||||||
@@ -96,7 +110,9 @@ test('runSyncLauncher settles after exit when close never arrives', async () =>
|
|||||||
// so `close` never fires.
|
// so `close` never fires.
|
||||||
child.emit('exit', 1, null);
|
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.equal(result.ok, false);
|
||||||
assert.match(result.error ?? '', /remote refused/);
|
assert.match(result.error ?? '', /remote refused/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function runSyncLauncher(options: {
|
|||||||
spawn?: SyncLauncherSpawn;
|
spawn?: SyncLauncherSpawn;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
}): SyncLauncherRunHandle {
|
}): SyncLauncherRunHandle {
|
||||||
const spawn =
|
const spawn: SyncLauncherSpawn =
|
||||||
options.spawn ??
|
options.spawn ??
|
||||||
((command, args) => {
|
((command, args) => {
|
||||||
// The child must boot as a full Electron app (its entry handles
|
// The child must boot as a full Electron app (its entry handles
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type BrowserWindow, screen } from 'electron';
|
import { type BrowserWindow, screen } from 'electron';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
import { startOverlayWindowTracker as startOverlayWindowTrackerCore } from '../../core/services';
|
||||||
|
import { applyOverlayClickThrough } from '../../core/services/overlay-click-through';
|
||||||
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
import { isHeadlessInitialCommand, type CliArgs } from '../../cli/args';
|
||||||
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
import type { OverlayContentMeasurement, WindowGeometry } from '../../types';
|
||||||
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
import { createWindowTracker as createWindowTrackerCore } from '../../window-trackers';
|
||||||
@@ -603,7 +604,7 @@ export function createVisibleOverlayInteractionRuntime(deps: VisibleOverlayInter
|
|||||||
if (active) {
|
if (active) {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
applyOverlayClickThrough(mainWindow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,11 +69,6 @@ import type {
|
|||||||
OverlayNotificationEventPayload,
|
OverlayNotificationEventPayload,
|
||||||
OverlayNotificationPosition,
|
OverlayNotificationPosition,
|
||||||
ChangelogSnapshot,
|
ChangelogSnapshot,
|
||||||
MediaTimingReviewActionResult,
|
|
||||||
MediaTimingReviewOpenPayload,
|
|
||||||
MediaTimingReviewPreviewRequest,
|
|
||||||
MediaTimingReviewResolveRequest,
|
|
||||||
MediaTimingReviewWaveformRequest,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||||
|
|
||||||
@@ -186,11 +181,6 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
|
|||||||
IPC_CHANNELS.event.youtubePickerOpen,
|
IPC_CHANNELS.event.youtubePickerOpen,
|
||||||
(payload) => payload as YoutubePickerOpenPayload,
|
(payload) => payload as YoutubePickerOpenPayload,
|
||||||
);
|
);
|
||||||
const onOpenMediaTimingReviewEvent =
|
|
||||||
createQueuedIpcListenerWithPayload<MediaTimingReviewOpenPayload>(
|
|
||||||
IPC_CHANNELS.event.mediaTimingReviewOpen,
|
|
||||||
(payload) => payload as MediaTimingReviewOpenPayload,
|
|
||||||
);
|
|
||||||
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||||
IPC_CHANNELS.event.youtubePickerCancel,
|
IPC_CHANNELS.event.youtubePickerCancel,
|
||||||
@@ -468,19 +458,6 @@ const electronAPI: ElectronAPI = {
|
|||||||
onOpenJimaku: onOpenJimakuEvent,
|
onOpenJimaku: onOpenJimakuEvent,
|
||||||
onOpenTsukihime: onOpenTsukihimeEvent,
|
onOpenTsukihime: onOpenTsukihimeEvent,
|
||||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||||
onOpenMediaTimingReview: onOpenMediaTimingReviewEvent,
|
|
||||||
previewMediaTimingReview: (
|
|
||||||
request: MediaTimingReviewPreviewRequest,
|
|
||||||
): Promise<MediaTimingReviewActionResult> =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewPreview, request),
|
|
||||||
getMediaTimingReviewWaveform: (request: MediaTimingReviewWaveformRequest) =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewWaveform, request),
|
|
||||||
stopMediaTimingReviewPreview: (reviewId: string): Promise<MediaTimingReviewActionResult> =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewStopPreview, reviewId),
|
|
||||||
resolveMediaTimingReview: (
|
|
||||||
request: MediaTimingReviewResolveRequest,
|
|
||||||
): Promise<MediaTimingReviewActionResult> =>
|
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.request.mediaTimingReviewResolve, request),
|
|
||||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||||
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
|
||||||
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
|
||||||
|
|||||||
@@ -452,7 +452,6 @@ function createKeyboardHandlerHarness() {
|
|||||||
const testGlobals = installKeyboardTestGlobals();
|
const testGlobals = installKeyboardTestGlobals();
|
||||||
const subtitleRootClassList = createClassList();
|
const subtitleRootClassList = createClassList();
|
||||||
const subtitleContainerClassList = createClassList();
|
const subtitleContainerClassList = createClassList();
|
||||||
let mediaTimingReviewKeydownCount = 0;
|
|
||||||
let controllerSelectKeydownCount = 0;
|
let controllerSelectKeydownCount = 0;
|
||||||
let openControllerSelectCount = 0;
|
let openControllerSelectCount = 0;
|
||||||
let openControllerDebugCount = 0;
|
let openControllerDebugCount = 0;
|
||||||
@@ -495,10 +494,6 @@ function createKeyboardHandlerHarness() {
|
|||||||
handleKikuKeydown: () => false,
|
handleKikuKeydown: () => false,
|
||||||
handleJimakuKeydown: () => false,
|
handleJimakuKeydown: () => false,
|
||||||
handleTsukihimeKeydown: () => false,
|
handleTsukihimeKeydown: () => false,
|
||||||
handleMediaTimingReviewKeydown: () => {
|
|
||||||
mediaTimingReviewKeydownCount += 1;
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
handleControllerSelectKeydown: () => {
|
handleControllerSelectKeydown: () => {
|
||||||
controllerSelectKeydownCount += 1;
|
controllerSelectKeydownCount += 1;
|
||||||
return true;
|
return true;
|
||||||
@@ -528,7 +523,6 @@ function createKeyboardHandlerHarness() {
|
|||||||
ctx,
|
ctx,
|
||||||
handlers,
|
handlers,
|
||||||
testGlobals,
|
testGlobals,
|
||||||
mediaTimingReviewKeydownCount: () => mediaTimingReviewKeydownCount,
|
|
||||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||||
openControllerSelectCount: () => openControllerSelectCount,
|
openControllerSelectCount: () => openControllerSelectCount,
|
||||||
openControllerDebugCount: () => openControllerDebugCount,
|
openControllerDebugCount: () => openControllerDebugCount,
|
||||||
@@ -1373,29 +1367,6 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('media timing review modal handles keys before later modal handlers', async () => {
|
|
||||||
const {
|
|
||||||
ctx,
|
|
||||||
testGlobals,
|
|
||||||
handlers,
|
|
||||||
mediaTimingReviewKeydownCount,
|
|
||||||
controllerSelectKeydownCount,
|
|
||||||
} = createKeyboardHandlerHarness();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await handlers.setupMpvInputForwarding();
|
|
||||||
ctx.state.mediaTimingReviewModalOpen = true;
|
|
||||||
ctx.state.controllerSelectModalOpen = true;
|
|
||||||
|
|
||||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
|
||||||
|
|
||||||
assert.equal(mediaTimingReviewKeydownCount(), 1);
|
|
||||||
assert.equal(controllerSelectKeydownCount(), 0);
|
|
||||||
} finally {
|
|
||||||
testGlobals.restore();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
|
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
|
||||||
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
||||||
createKeyboardHandlerHarness();
|
createKeyboardHandlerHarness();
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function createKeyboardHandlers(
|
|||||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleMediaTimingReviewKeydown: (e: KeyboardEvent) => boolean;
|
|
||||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||||
@@ -1079,11 +1078,6 @@ export function createKeyboardHandlers(
|
|||||||
);
|
);
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
if (ctx.state.mediaTimingReviewModalOpen) {
|
|
||||||
options.handleMediaTimingReviewKeydown(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
if (isKeyboardDrivenModeToggle(e) && ctx.platform.isModalLayer) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleKeyboardModeToggleRequested();
|
handleKeyboardModeToggleRequested();
|
||||||
|
|||||||
@@ -196,232 +196,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
id="mediaTimingReviewModal"
|
|
||||||
class="modal media-timing-review-modal hidden"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="modal-content media-timing-review-content"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="mediaTimingReviewTitle"
|
|
||||||
>
|
|
||||||
<div class="media-timing-review-header">
|
|
||||||
<div class="media-timing-review-heading">
|
|
||||||
<div id="mediaTimingReviewTitle" class="media-timing-review-title">
|
|
||||||
Review media timing
|
|
||||||
</div>
|
|
||||||
<div id="mediaTimingReviewKind" class="media-timing-review-kind">Sentence card</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewCancel"
|
|
||||||
class="media-timing-review-quiet-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mediaTimingReviewEditor" class="media-timing-review-editor">
|
|
||||||
<blockquote id="mediaTimingReviewText" class="media-timing-review-text"></blockquote>
|
|
||||||
|
|
||||||
<div class="media-timing-review-readout" aria-live="polite">
|
|
||||||
<div>
|
|
||||||
<span>Starts</span>
|
|
||||||
<strong id="mediaTimingReviewStartValue">00:00.000</strong>
|
|
||||||
</div>
|
|
||||||
<div class="media-timing-review-duration">
|
|
||||||
<span>Clip length</span>
|
|
||||||
<strong id="mediaTimingReviewDuration">0.00s</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Ends</span>
|
|
||||||
<strong id="mediaTimingReviewEndValue">00:00.000</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="media-timing-review-timeline-shell">
|
|
||||||
<div class="media-timing-review-timeline-labels">
|
|
||||||
<span id="mediaTimingReviewTimelineStart">00:00</span>
|
|
||||||
<span id="mediaTimingReviewWaveformLabel">speech-weighted waveform</span>
|
|
||||||
<span id="mediaTimingReviewTimelineEnd">00:00</span>
|
|
||||||
</div>
|
|
||||||
<div id="mediaTimingReviewSelectionTrack" class="media-timing-review-track">
|
|
||||||
<svg
|
|
||||||
class="media-timing-review-waveform"
|
|
||||||
viewBox="0 0 1000 100"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="mediaTimingReviewWaveformFill" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop class="media-timing-review-waveform-edge-stop" offset="0%" />
|
|
||||||
<stop class="media-timing-review-waveform-core-stop" offset="50%" />
|
|
||||||
<stop class="media-timing-review-waveform-edge-stop" offset="100%" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<path id="mediaTimingReviewWaveformPath"></path>
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
class="media-timing-review-original-range"
|
|
||||||
title="Mined subtitle timing"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<span class="media-timing-review-original-boundary is-start"></span>
|
|
||||||
<span class="media-timing-review-original-boundary is-end"></span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
id="mediaTimingReviewSelectedRange"
|
|
||||||
class="media-timing-review-selected-range"
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
<div class="media-timing-review-playhead" aria-hidden="true"></div>
|
|
||||||
<div
|
|
||||||
id="mediaTimingReviewStartHandle"
|
|
||||||
class="media-timing-review-handle media-timing-review-handle-start"
|
|
||||||
role="slider"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Clip start"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
id="mediaTimingReviewEndHandle"
|
|
||||||
class="media-timing-review-handle media-timing-review-handle-end"
|
|
||||||
role="slider"
|
|
||||||
tabindex="0"
|
|
||||||
aria-label="Clip end"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="media-timing-review-expand-row">
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewShowEarlier"
|
|
||||||
class="media-timing-review-expand-button"
|
|
||||||
type="button"
|
|
||||||
aria-label="Reveal five more seconds before the timeline"
|
|
||||||
>
|
|
||||||
−5s
|
|
||||||
</button>
|
|
||||||
<span>Drag an edge to trim, or drag the middle to slide the clip.</span>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewShowLater"
|
|
||||||
class="media-timing-review-expand-button"
|
|
||||||
type="button"
|
|
||||||
aria-label="Reveal five more seconds after the timeline"
|
|
||||||
>
|
|
||||||
+5s
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="media-timing-review-fine-grid">
|
|
||||||
<div class="media-timing-review-fine-control">
|
|
||||||
<span>Start</span>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewStartBack"
|
|
||||||
type="button"
|
|
||||||
aria-label="Move start backward 100 milliseconds"
|
|
||||||
>
|
|
||||||
−0.1s
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewStartForward"
|
|
||||||
type="button"
|
|
||||||
aria-label="Move start forward 100 milliseconds"
|
|
||||||
>
|
|
||||||
+0.1s
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="media-timing-review-fine-control">
|
|
||||||
<span>End</span>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewEndBack"
|
|
||||||
type="button"
|
|
||||||
aria-label="Move end backward 100 milliseconds"
|
|
||||||
>
|
|
||||||
−0.1s
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewEndForward"
|
|
||||||
type="button"
|
|
||||||
aria-label="Move end forward 100 milliseconds"
|
|
||||||
>
|
|
||||||
+0.1s
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="mediaTimingReviewStatus"
|
|
||||||
class="media-timing-review-status"
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
></div>
|
|
||||||
<div class="media-timing-review-footer">
|
|
||||||
<div class="media-timing-review-preview-actions">
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewPlay"
|
|
||||||
class="media-timing-review-play-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<span class="media-timing-review-play-glyph" aria-hidden="true"></span>
|
|
||||||
<span id="mediaTimingReviewPlayLabel">Play selection</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewReset"
|
|
||||||
class="media-timing-review-quiet-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Reset timing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewConfirm"
|
|
||||||
class="media-timing-review-confirm-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Use this timing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="media-timing-review-hints" aria-hidden="true">
|
|
||||||
<span><kbd>Space</kbd> preview</span>
|
|
||||||
<span><kbd>←</kbd><kbd>→</kbd> nudge focused edge</span>
|
|
||||||
<span><kbd>Enter</kbd> confirm</span>
|
|
||||||
<span><kbd>Esc</kbd> cancel</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="mediaTimingReviewCancelStep" class="media-timing-review-cancel-step hidden">
|
|
||||||
<div class="media-timing-review-cancel-mark" aria-hidden="true">?</div>
|
|
||||||
<h2>Stop reviewing?</h2>
|
|
||||||
<p id="mediaTimingReviewCancelMessage">Choose what should happen to this card.</p>
|
|
||||||
<div class="media-timing-review-cancel-actions">
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewCancelBack"
|
|
||||||
class="media-timing-review-quiet-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Keep editing
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewUseOriginal"
|
|
||||||
class="media-timing-review-original-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Use original timing
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="mediaTimingReviewDiscard"
|
|
||||||
class="media-timing-review-discard-button"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Delete card
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
<div id="kikuFieldGroupingModal" class="modal hidden" aria-hidden="true">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
import {
|
|
||||||
buildMediaTimingWaveformPath,
|
|
||||||
constrainMediaTimingSelection,
|
|
||||||
createMediaTimingPreviewRequestGuard,
|
|
||||||
formatMediaTimingTimestamp,
|
|
||||||
mediaTimingTimeFromPointer,
|
|
||||||
slideMediaTimingSelection,
|
|
||||||
} from './media-timing-review';
|
|
||||||
|
|
||||||
test('waveform path mirrors normalized peaks around its center line', () => {
|
|
||||||
const path = buildMediaTimingWaveformPath([0, 0.5, 1]);
|
|
||||||
|
|
||||||
assert.match(path, /^M 0\.00 50\.00 L 500\.00 28\.00 L 1000\.00 6\.00/);
|
|
||||||
assert.match(path, /1000\.00 94\.00 L 500\.00 72\.00 L 0\.00 50\.00 Z$/);
|
|
||||||
assert.equal(buildMediaTimingWaveformPath([1]), '');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatMediaTimingTimestamp renders stable review readouts', () => {
|
|
||||||
assert.equal(formatMediaTimingTimestamp(65.4321), '01:05.432');
|
|
||||||
assert.equal(formatMediaTimingTimestamp(65.4321, false), '01:05');
|
|
||||||
assert.equal(formatMediaTimingTimestamp(-1), '00:00.000');
|
|
||||||
assert.equal(formatMediaTimingTimestamp(119.9999), '02:00.000');
|
|
||||||
assert.equal(formatMediaTimingTimestamp(119.6, false), '02:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selection constraints preserve the handle that did not move', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
constrainMediaTimingSelection({
|
|
||||||
nextStart: 3,
|
|
||||||
nextEnd: 2,
|
|
||||||
currentStart: 1,
|
|
||||||
timelineStart: 0,
|
|
||||||
timelineEnd: 10,
|
|
||||||
mediaEnd: 10,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
}),
|
|
||||||
{ start: 1.9, end: 2 },
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
constrainMediaTimingSelection({
|
|
||||||
nextStart: 1,
|
|
||||||
nextEnd: 0.5,
|
|
||||||
currentStart: 1,
|
|
||||||
timelineStart: 0,
|
|
||||||
timelineEnd: 10,
|
|
||||||
mediaEnd: 10,
|
|
||||||
maxMediaDuration: 30,
|
|
||||||
}),
|
|
||||||
{ start: 1, end: 1.1 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pointer positions map onto the visible timeline and clamp past its edges', () => {
|
|
||||||
const track = { trackLeft: 100, trackWidth: 400, timelineStart: 10, timelineEnd: 20 };
|
|
||||||
|
|
||||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 100, ...track }), 10);
|
|
||||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 300, ...track }), 15);
|
|
||||||
assert.equal(mediaTimingTimeFromPointer({ clientX: -40, ...track }), 10);
|
|
||||||
assert.equal(mediaTimingTimeFromPointer({ clientX: 900, ...track }), 20);
|
|
||||||
assert.equal(mediaTimingTimeFromPointer({ ...track, clientX: 300, trackWidth: 0 }), 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sliding keeps the clip length and stops at the timeline and media bounds', () => {
|
|
||||||
const timeline = { span: 2, timelineStart: 4, timelineEnd: 12, mediaEnd: 10 };
|
|
||||||
|
|
||||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 6, ...timeline }), { start: 6, end: 8 });
|
|
||||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 1, ...timeline }), { start: 4, end: 6 });
|
|
||||||
assert.deepEqual(slideMediaTimingSelection({ nextStart: 99, ...timeline }), {
|
|
||||||
start: 8,
|
|
||||||
end: 10,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preview request guard blocks overlap and invalidates stale responses', () => {
|
|
||||||
const guard = createMediaTimingPreviewRequestGuard();
|
|
||||||
const first = guard.begin();
|
|
||||||
|
|
||||||
assert.equal(typeof first, 'number');
|
|
||||||
assert.equal(guard.begin(), null);
|
|
||||||
assert.equal(guard.isCurrent(first!), true);
|
|
||||||
|
|
||||||
guard.invalidate();
|
|
||||||
assert.equal(guard.isCurrent(first!), false);
|
|
||||||
assert.equal(guard.isInFlight(), false);
|
|
||||||
|
|
||||||
const second = guard.begin();
|
|
||||||
assert.equal(typeof second, 'number');
|
|
||||||
guard.finish(first!);
|
|
||||||
assert.equal(guard.isCurrent(second!), true);
|
|
||||||
guard.finish(second!);
|
|
||||||
assert.equal(guard.isInFlight(), false);
|
|
||||||
});
|
|
||||||
@@ -1,688 +0,0 @@
|
|||||||
import type { MediaTimingReviewDecision, MediaTimingReviewOpenPayload } from '../../types/anki';
|
|
||||||
import type { ModalStateReader, RendererContext } from '../context';
|
|
||||||
import { createModalFocusGuard } from './modal-focus-guard';
|
|
||||||
|
|
||||||
const MINIMUM_CLIP_SECONDS = 0.1;
|
|
||||||
const FINE_ADJUST_SECONDS = 0.1;
|
|
||||||
const COARSE_ADJUST_SECONDS = 0.5;
|
|
||||||
const TIMELINE_EXPANSION_SECONDS = 5;
|
|
||||||
|
|
||||||
function clamp(value: number, minimum: number, maximum: number): number {
|
|
||||||
return Math.min(maximum, Math.max(minimum, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatMediaTimingTimestamp(seconds: number, includeMilliseconds = true): string {
|
|
||||||
const unitsPerSecond = includeMilliseconds ? 1_000 : 1;
|
|
||||||
const totalUnits = Math.max(0, Math.round(seconds * unitsPerSecond));
|
|
||||||
const unitsPerMinute = 60 * unitsPerSecond;
|
|
||||||
const minutes = Math.floor(totalUnits / unitsPerMinute);
|
|
||||||
const remainingUnits = totalUnits % unitsPerMinute;
|
|
||||||
const remaining = includeMilliseconds
|
|
||||||
? (remainingUnits / unitsPerSecond).toFixed(3)
|
|
||||||
: String(remainingUnits);
|
|
||||||
return `${String(minutes).padStart(2, '0')}:${remaining.padStart(includeMilliseconds ? 6 : 2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMediaTimingPreviewRequestGuard() {
|
|
||||||
let sequence = 0;
|
|
||||||
let activeRequestId: number | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
begin(): number | null {
|
|
||||||
if (activeRequestId !== null) return null;
|
|
||||||
sequence += 1;
|
|
||||||
activeRequestId = sequence;
|
|
||||||
return activeRequestId;
|
|
||||||
},
|
|
||||||
invalidate(): void {
|
|
||||||
sequence += 1;
|
|
||||||
activeRequestId = null;
|
|
||||||
},
|
|
||||||
isCurrent(requestId: number): boolean {
|
|
||||||
return requestId === activeRequestId;
|
|
||||||
},
|
|
||||||
finish(requestId: number): void {
|
|
||||||
if (activeRequestId === requestId) activeRequestId = null;
|
|
||||||
},
|
|
||||||
isInFlight(): boolean {
|
|
||||||
return activeRequestId !== null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMediaTimingWaveformPath(peaks: number[]): string {
|
|
||||||
if (peaks.length < 2) return '';
|
|
||||||
const points = peaks.map((peak, index) => ({
|
|
||||||
x: (index / (peaks.length - 1)) * 1_000,
|
|
||||||
amplitude: clamp(Number.isFinite(peak) ? peak : 0, 0, 1) * 44,
|
|
||||||
}));
|
|
||||||
const upper = points.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 - amplitude).toFixed(2)}`);
|
|
||||||
const lower = [...points]
|
|
||||||
.reverse()
|
|
||||||
.map(({ x, amplitude }) => `${x.toFixed(2)} ${(50 + amplitude).toFixed(2)}`);
|
|
||||||
return `M ${upper.join(' L ')} L ${lower.join(' L ')} Z`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function constrainMediaTimingSelection(options: {
|
|
||||||
nextStart: number;
|
|
||||||
nextEnd: number;
|
|
||||||
currentStart: number;
|
|
||||||
timelineStart: number;
|
|
||||||
timelineEnd: number;
|
|
||||||
mediaEnd: number;
|
|
||||||
maxMediaDuration: number;
|
|
||||||
}): { start: number; end: number } {
|
|
||||||
let start = clamp(options.nextStart, options.timelineStart, options.timelineEnd);
|
|
||||||
let end = clamp(options.nextEnd, options.timelineStart, options.timelineEnd);
|
|
||||||
const startMoved = options.nextStart !== options.currentStart;
|
|
||||||
if (end - start < MINIMUM_CLIP_SECONDS) {
|
|
||||||
if (startMoved) start = end - MINIMUM_CLIP_SECONDS;
|
|
||||||
else end = start + MINIMUM_CLIP_SECONDS;
|
|
||||||
}
|
|
||||||
if (options.maxMediaDuration > 0 && end - start > options.maxMediaDuration) {
|
|
||||||
if (startMoved) start = end - options.maxMediaDuration;
|
|
||||||
else end = start + options.maxMediaDuration;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
start: Math.max(0, start),
|
|
||||||
end: Math.min(options.mediaEnd, end),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps a pointer position over the timeline track back onto a media timestamp. */
|
|
||||||
export function mediaTimingTimeFromPointer(options: {
|
|
||||||
clientX: number;
|
|
||||||
trackLeft: number;
|
|
||||||
trackWidth: number;
|
|
||||||
timelineStart: number;
|
|
||||||
timelineEnd: number;
|
|
||||||
}): number {
|
|
||||||
if (options.trackWidth <= 0) return options.timelineStart;
|
|
||||||
const ratio = clamp((options.clientX - options.trackLeft) / options.trackWidth, 0, 1);
|
|
||||||
return options.timelineStart + ratio * (options.timelineEnd - options.timelineStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Slides the selection without resizing it, keeping the whole clip inside the visible timeline. */
|
|
||||||
export function slideMediaTimingSelection(options: {
|
|
||||||
nextStart: number;
|
|
||||||
span: number;
|
|
||||||
timelineStart: number;
|
|
||||||
timelineEnd: number;
|
|
||||||
mediaEnd: number;
|
|
||||||
}): { start: number; end: number } {
|
|
||||||
const latestEnd = Math.min(options.timelineEnd, options.mediaEnd);
|
|
||||||
const maxStart = Math.max(options.timelineStart, latestEnd - options.span);
|
|
||||||
const start = clamp(options.nextStart, options.timelineStart, maxStart);
|
|
||||||
return { start, end: start + options.span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createMediaTimingReviewModal(
|
|
||||||
ctx: RendererContext,
|
|
||||||
options: {
|
|
||||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
|
||||||
syncSettingsModalSubtitleSuppression: () => void;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
let payload: MediaTimingReviewOpenPayload | null = null;
|
|
||||||
let selectionStart = 0;
|
|
||||||
let selectionEnd = 0;
|
|
||||||
let timelineStart = 0;
|
|
||||||
let timelineEnd = 0;
|
|
||||||
let resolveInFlight = false;
|
|
||||||
let previewPlaying = false;
|
|
||||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let waveformTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let waveformSequence = 0;
|
|
||||||
let drag: {
|
|
||||||
edge: 'start' | 'end' | 'both';
|
|
||||||
pointerId: number;
|
|
||||||
trackLeft: number;
|
|
||||||
trackWidth: number;
|
|
||||||
grabOffset: number;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
const focus = createModalFocusGuard({
|
|
||||||
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
|
||||||
getModalRoot: () => ctx.dom.mediaTimingReviewModal,
|
|
||||||
getPreferredFocusTargets: () => [
|
|
||||||
ctx.dom.mediaTimingReviewStartHandle,
|
|
||||||
ctx.dom.mediaTimingReviewCancelBack,
|
|
||||||
],
|
|
||||||
getFallbackFocusTarget: () => ctx.dom.mediaTimingReviewCancel,
|
|
||||||
isModalLayer: ctx.platform.isModalLayer,
|
|
||||||
});
|
|
||||||
const previewRequest = createMediaTimingPreviewRequestGuard();
|
|
||||||
|
|
||||||
function setStatus(message: string, isError = false): void {
|
|
||||||
ctx.dom.mediaTimingReviewStatus.textContent = message;
|
|
||||||
ctx.dom.mediaTimingReviewStatus.classList.toggle('is-error', isError);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearPreviewTimer(): void {
|
|
||||||
if (previewTimer !== null) clearTimeout(previewTimer);
|
|
||||||
previewTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drives the play button label plus the playhead sweep that mirrors the hidden audio player. */
|
|
||||||
function setPreviewPlaying(playing: boolean): void {
|
|
||||||
previewPlaying = playing;
|
|
||||||
ctx.dom.mediaTimingReviewPlayLabel.textContent = playing ? 'Stop preview' : 'Play selection';
|
|
||||||
ctx.dom.mediaTimingReviewPlay.classList.toggle('is-playing', playing);
|
|
||||||
clearPreviewTimer();
|
|
||||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
|
||||||
track.classList.remove('is-previewing');
|
|
||||||
if (!playing) return;
|
|
||||||
const clipSeconds = Math.max(MINIMUM_CLIP_SECONDS, selectionEnd - selectionStart);
|
|
||||||
track.style.setProperty('--playhead-duration', `${clipSeconds}s`);
|
|
||||||
void track.offsetWidth;
|
|
||||||
track.classList.add('is-previewing');
|
|
||||||
previewTimer = setTimeout(() => stopPreview(), clipSeconds * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Callers that need to report a failure set their own status after stopping the preview. */
|
|
||||||
function stopPreview(): void {
|
|
||||||
previewRequest.invalidate();
|
|
||||||
setPreviewPlaying(false);
|
|
||||||
setStatus('');
|
|
||||||
if (payload) {
|
|
||||||
void window.electronAPI.stopMediaTimingReviewPreview(payload.reviewId).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHandle(handle: HTMLDivElement, value: number): void {
|
|
||||||
handle.setAttribute('aria-valuemin', timelineStart.toFixed(3));
|
|
||||||
handle.setAttribute('aria-valuemax', timelineEnd.toFixed(3));
|
|
||||||
handle.setAttribute('aria-valuenow', value.toFixed(3));
|
|
||||||
handle.setAttribute('aria-valuetext', formatMediaTimingTimestamp(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSelection(): void {
|
|
||||||
const span = Math.max(MINIMUM_CLIP_SECONDS, timelineEnd - timelineStart);
|
|
||||||
const startPercent = ((selectionStart - timelineStart) / span) * 100;
|
|
||||||
const endPercent = ((selectionEnd - timelineStart) / span) * 100;
|
|
||||||
const originalStartPercent = payload
|
|
||||||
? ((payload.originalStartTime - timelineStart) / span) * 100
|
|
||||||
: 0;
|
|
||||||
const originalEndPercent = payload
|
|
||||||
? ((payload.originalEndTime - timelineStart) / span) * 100
|
|
||||||
: 0;
|
|
||||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
|
||||||
'--selection-start',
|
|
||||||
`${clamp(startPercent, 0, 100)}%`,
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
|
||||||
'--selection-end',
|
|
||||||
`${clamp(endPercent, 0, 100)}%`,
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
|
||||||
'--original-start',
|
|
||||||
`${clamp(originalStartPercent, 0, 100)}%`,
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewSelectionTrack.style.setProperty(
|
|
||||||
'--original-end',
|
|
||||||
`${clamp(originalEndPercent, 0, 100)}%`,
|
|
||||||
);
|
|
||||||
renderHandle(ctx.dom.mediaTimingReviewStartHandle, selectionStart);
|
|
||||||
renderHandle(ctx.dom.mediaTimingReviewEndHandle, selectionEnd);
|
|
||||||
ctx.dom.mediaTimingReviewStartValue.textContent = formatMediaTimingTimestamp(selectionStart);
|
|
||||||
ctx.dom.mediaTimingReviewEndValue.textContent = formatMediaTimingTimestamp(selectionEnd);
|
|
||||||
ctx.dom.mediaTimingReviewDuration.textContent = `${(selectionEnd - selectionStart).toFixed(2)}s`;
|
|
||||||
ctx.dom.mediaTimingReviewTimelineStart.textContent = formatMediaTimingTimestamp(
|
|
||||||
timelineStart,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewTimelineEnd.textContent = formatMediaTimingTimestamp(
|
|
||||||
timelineEnd,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewShowEarlier.disabled = timelineStart <= 0;
|
|
||||||
ctx.dom.mediaTimingReviewShowLater.disabled =
|
|
||||||
payload?.mediaDuration !== undefined && timelineEnd >= payload.mediaDuration;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setWaveformState(state: 'loading' | 'ready' | 'unavailable'): void {
|
|
||||||
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle('is-loading', state === 'loading');
|
|
||||||
ctx.dom.mediaTimingReviewSelectionTrack.classList.toggle(
|
|
||||||
'is-waveform-unavailable',
|
|
||||||
state === 'unavailable',
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewWaveformLabel.textContent =
|
|
||||||
state === 'loading'
|
|
||||||
? 'isolating dialogue...'
|
|
||||||
: state === 'ready'
|
|
||||||
? 'speech-weighted waveform'
|
|
||||||
: 'waveform unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadWaveform(): Promise<void> {
|
|
||||||
if (!payload || !ctx.state.mediaTimingReviewModalOpen) return;
|
|
||||||
waveformSequence += 1;
|
|
||||||
const sequence = waveformSequence;
|
|
||||||
const reviewId = payload.reviewId;
|
|
||||||
const startTime = timelineStart;
|
|
||||||
const endTime = timelineEnd;
|
|
||||||
setWaveformState('loading');
|
|
||||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.getMediaTimingReviewWaveform({
|
|
||||||
reviewId,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
sequence !== waveformSequence ||
|
|
||||||
payload?.reviewId !== reviewId ||
|
|
||||||
timelineStart !== startTime ||
|
|
||||||
timelineEnd !== endTime
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const path = result.ok ? buildMediaTimingWaveformPath(result.peaks ?? []) : '';
|
|
||||||
if (!path) {
|
|
||||||
setWaveformState('unavailable');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', path);
|
|
||||||
setWaveformState('ready');
|
|
||||||
} catch {
|
|
||||||
if (sequence === waveformSequence && payload?.reviewId === reviewId) {
|
|
||||||
setWaveformState('unavailable');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueWaveformLoad(delayMs = 0): void {
|
|
||||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
|
||||||
waveformSequence += 1;
|
|
||||||
if (payload && ctx.state.mediaTimingReviewModalOpen) {
|
|
||||||
ctx.dom.mediaTimingReviewWaveformPath.setAttribute('d', '');
|
|
||||||
setWaveformState('loading');
|
|
||||||
}
|
|
||||||
waveformTimer = setTimeout(() => {
|
|
||||||
waveformTimer = null;
|
|
||||||
void loadWaveform();
|
|
||||||
}, delayMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSelection(nextStart: number, nextEnd: number): void {
|
|
||||||
if (!payload) return;
|
|
||||||
const mediaEnd = payload.mediaDuration ?? Number.POSITIVE_INFINITY;
|
|
||||||
const nextSelection = constrainMediaTimingSelection({
|
|
||||||
nextStart,
|
|
||||||
nextEnd,
|
|
||||||
currentStart: selectionStart,
|
|
||||||
timelineStart,
|
|
||||||
timelineEnd,
|
|
||||||
mediaEnd,
|
|
||||||
maxMediaDuration: payload.maxMediaDuration,
|
|
||||||
});
|
|
||||||
selectionStart = nextSelection.start;
|
|
||||||
selectionEnd = nextSelection.end;
|
|
||||||
if (previewPlaying || previewRequest.isInFlight()) stopPreview();
|
|
||||||
setStatus('');
|
|
||||||
renderSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shifts the whole clip without changing its length. */
|
|
||||||
function moveSelection(nextStart: number): void {
|
|
||||||
if (!payload) return;
|
|
||||||
const slid = slideMediaTimingSelection({
|
|
||||||
nextStart,
|
|
||||||
span: selectionEnd - selectionStart,
|
|
||||||
timelineStart,
|
|
||||||
timelineEnd,
|
|
||||||
mediaEnd: payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
|
||||||
});
|
|
||||||
updateSelection(slid.start, slid.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setEdge(edge: 'start' | 'end', value: number): void {
|
|
||||||
if (edge === 'start') updateSelection(value, selectionEnd);
|
|
||||||
else updateSelection(selectionStart, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyDrag(clientX: number): void {
|
|
||||||
if (!drag) return;
|
|
||||||
const pointerTime = mediaTimingTimeFromPointer({
|
|
||||||
clientX,
|
|
||||||
trackLeft: drag.trackLeft,
|
|
||||||
trackWidth: drag.trackWidth,
|
|
||||||
timelineStart,
|
|
||||||
timelineEnd,
|
|
||||||
});
|
|
||||||
const time = pointerTime - drag.grabOffset;
|
|
||||||
if (drag.edge === 'both') moveSelection(time);
|
|
||||||
else setEdge(drag.edge, time);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grabbing a handle drags that edge, grabbing the highlighted clip slides the whole selection,
|
|
||||||
* and pressing anywhere else snaps the nearest edge to that point and keeps dragging it.
|
|
||||||
*/
|
|
||||||
function beginDrag(event: PointerEvent): void {
|
|
||||||
if (!payload || resolveInFlight || drag !== null || event.button !== 0) return;
|
|
||||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
|
||||||
const rect = track.getBoundingClientRect();
|
|
||||||
const pointerTime = mediaTimingTimeFromPointer({
|
|
||||||
clientX: event.clientX,
|
|
||||||
trackLeft: rect.left,
|
|
||||||
trackWidth: rect.width,
|
|
||||||
timelineStart,
|
|
||||||
timelineEnd,
|
|
||||||
});
|
|
||||||
const target = event.target;
|
|
||||||
let edge: 'start' | 'end' | 'both';
|
|
||||||
let grabOffset: number;
|
|
||||||
if (target === ctx.dom.mediaTimingReviewStartHandle) {
|
|
||||||
edge = 'start';
|
|
||||||
grabOffset = pointerTime - selectionStart;
|
|
||||||
} else if (target === ctx.dom.mediaTimingReviewEndHandle) {
|
|
||||||
edge = 'end';
|
|
||||||
grabOffset = pointerTime - selectionEnd;
|
|
||||||
} else if (target === ctx.dom.mediaTimingReviewSelectedRange) {
|
|
||||||
edge = 'both';
|
|
||||||
grabOffset = pointerTime - selectionStart;
|
|
||||||
} else {
|
|
||||||
edge =
|
|
||||||
Math.abs(pointerTime - selectionStart) <= Math.abs(pointerTime - selectionEnd)
|
|
||||||
? 'start'
|
|
||||||
: 'end';
|
|
||||||
grabOffset = 0;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
drag = {
|
|
||||||
edge,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
trackLeft: rect.left,
|
|
||||||
trackWidth: rect.width,
|
|
||||||
grabOffset,
|
|
||||||
};
|
|
||||||
track.setPointerCapture(event.pointerId);
|
|
||||||
ctx.dom.mediaTimingReviewModal.classList.add(edge === 'both' ? 'is-sliding' : 'is-scrubbing');
|
|
||||||
if (edge !== 'both') {
|
|
||||||
const handle =
|
|
||||||
edge === 'start'
|
|
||||||
? ctx.dom.mediaTimingReviewStartHandle
|
|
||||||
: ctx.dom.mediaTimingReviewEndHandle;
|
|
||||||
handle.focus();
|
|
||||||
if (grabOffset === 0) applyDrag(event.clientX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function endDrag(event: PointerEvent): void {
|
|
||||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
||||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
|
||||||
if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId);
|
|
||||||
drag = null;
|
|
||||||
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDrag(): void {
|
|
||||||
drag = null;
|
|
||||||
ctx.dom.mediaTimingReviewModal.classList.remove('is-scrubbing', 'is-sliding');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEdgeKeydown(event: KeyboardEvent, edge: 'start' | 'end'): void {
|
|
||||||
const step = event.shiftKey ? COARSE_ADJUST_SECONDS : FINE_ADJUST_SECONDS;
|
|
||||||
const current = edge === 'start' ? selectionStart : selectionEnd;
|
|
||||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') setEdge(edge, current - step);
|
|
||||||
else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') setEdge(edge, current + step);
|
|
||||||
else if (event.key === 'Home') setEdge(edge, timelineStart);
|
|
||||||
else if (event.key === 'End') setEdge(edge, timelineEnd);
|
|
||||||
else return;
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showEditor(): void {
|
|
||||||
ctx.dom.mediaTimingReviewCancelStep.classList.add('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewEditor.classList.remove('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewCancel.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestCancel(): void {
|
|
||||||
if (!ctx.state.mediaTimingReviewModalOpen || !payload || resolveInFlight) return;
|
|
||||||
stopPreview();
|
|
||||||
ctx.dom.mediaTimingReviewEditor.classList.add('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewCancel.classList.add('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewCancelStep.classList.remove('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewCancelMessage.textContent =
|
|
||||||
payload.noteId !== undefined
|
|
||||||
? 'Keep editing, finish this card with its original timing, or delete the card.'
|
|
||||||
: 'Keep editing, finish this card with its original timing, or do not create it.';
|
|
||||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
|
||||||
payload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
|
||||||
ctx.dom.mediaTimingReviewCancelBack.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeResolvedReview(): void {
|
|
||||||
if (!ctx.state.mediaTimingReviewModalOpen) return;
|
|
||||||
cancelDrag();
|
|
||||||
clearPreviewTimer();
|
|
||||||
if (waveformTimer !== null) clearTimeout(waveformTimer);
|
|
||||||
waveformTimer = null;
|
|
||||||
waveformSequence += 1;
|
|
||||||
previewPlaying = false;
|
|
||||||
ctx.state.mediaTimingReviewModalOpen = false;
|
|
||||||
ctx.dom.mediaTimingReviewModal.classList.add('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'true');
|
|
||||||
focus.detach();
|
|
||||||
window.electronAPI.notifyOverlayModalClosed('media-timing-review');
|
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
|
||||||
payload = null;
|
|
||||||
if (!options.modalStateReader.isAnyModalOpen()) {
|
|
||||||
ctx.dom.overlay.classList.remove('interactive');
|
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) {
|
|
||||||
window.electronAPI.setIgnoreMouseEvents(true, { forward: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveReview(decision: MediaTimingReviewDecision): Promise<void> {
|
|
||||||
if (!payload || resolveInFlight) return;
|
|
||||||
resolveInFlight = true;
|
|
||||||
stopPreview();
|
|
||||||
const controls = ctx.dom.mediaTimingReviewModal.querySelectorAll<HTMLButtonElement>('button');
|
|
||||||
controls.forEach((button) => {
|
|
||||||
button.disabled = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.resolveMediaTimingReview({
|
|
||||||
reviewId: payload.reviewId,
|
|
||||||
decision,
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
setStatus(result.message ?? 'The timing review could not be resolved.', true);
|
|
||||||
showEditor();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeResolvedReview();
|
|
||||||
} catch (error) {
|
|
||||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
|
||||||
showEditor();
|
|
||||||
} finally {
|
|
||||||
resolveInFlight = false;
|
|
||||||
controls.forEach((button) => {
|
|
||||||
button.disabled = false;
|
|
||||||
});
|
|
||||||
renderSelection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function togglePreview(): Promise<void> {
|
|
||||||
if (!payload || resolveInFlight) return;
|
|
||||||
if (previewPlaying) {
|
|
||||||
stopPreview();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const requestId = previewRequest.begin();
|
|
||||||
if (requestId === null) return;
|
|
||||||
const requestedReviewId = payload.reviewId;
|
|
||||||
setStatus('Starting audio preview...');
|
|
||||||
try {
|
|
||||||
const result = await window.electronAPI.previewMediaTimingReview({
|
|
||||||
reviewId: requestedReviewId,
|
|
||||||
startTime: selectionStart,
|
|
||||||
endTime: selectionEnd,
|
|
||||||
});
|
|
||||||
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) {
|
|
||||||
if (result.ok && !previewRequest.isInFlight() && !previewPlaying) {
|
|
||||||
void window.electronAPI.stopMediaTimingReviewPreview(requestedReviewId).catch(() => {});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!result.ok) {
|
|
||||||
setStatus(result.message ?? 'Audio preview is unavailable.', true);
|
|
||||||
setPreviewPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus('Previewing in the hidden audio player.');
|
|
||||||
setPreviewPlaying(true);
|
|
||||||
} catch (error) {
|
|
||||||
if (!previewRequest.isCurrent(requestId) || payload?.reviewId !== requestedReviewId) return;
|
|
||||||
setStatus(
|
|
||||||
`Audio preview unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
setPreviewPlaying(false);
|
|
||||||
} finally {
|
|
||||||
previewRequest.finish(requestId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMediaTimingReviewModal(nextPayload: MediaTimingReviewOpenPayload): void {
|
|
||||||
previewRequest.invalidate();
|
|
||||||
cancelDrag();
|
|
||||||
payload = nextPayload;
|
|
||||||
selectionStart = nextPayload.selectionStartTime;
|
|
||||||
selectionEnd = nextPayload.selectionEndTime;
|
|
||||||
timelineStart = nextPayload.timelineStartTime;
|
|
||||||
timelineEnd = nextPayload.timelineEndTime;
|
|
||||||
resolveInFlight = false;
|
|
||||||
setPreviewPlaying(false);
|
|
||||||
ctx.dom.mediaTimingReviewKind.textContent =
|
|
||||||
nextPayload.kind === 'word'
|
|
||||||
? 'Word card'
|
|
||||||
: nextPayload.kind === 'audio'
|
|
||||||
? 'Audio card'
|
|
||||||
: 'Sentence card';
|
|
||||||
ctx.dom.mediaTimingReviewKind.dataset.kind = nextPayload.kind;
|
|
||||||
ctx.dom.mediaTimingReviewText.textContent = nextPayload.text;
|
|
||||||
ctx.dom.mediaTimingReviewDiscard.textContent =
|
|
||||||
nextPayload.noteId !== undefined ? 'Delete card' : "Don't create card";
|
|
||||||
setStatus('');
|
|
||||||
showEditor();
|
|
||||||
renderSelection();
|
|
||||||
ctx.state.mediaTimingReviewModalOpen = true;
|
|
||||||
options.syncSettingsModalSubtitleSuppression();
|
|
||||||
ctx.dom.overlay.classList.add('interactive');
|
|
||||||
if (ctx.platform.shouldToggleMouseIgnore) window.electronAPI.setIgnoreMouseEvents(false);
|
|
||||||
ctx.dom.mediaTimingReviewModal.classList.remove('hidden');
|
|
||||||
ctx.dom.mediaTimingReviewModal.setAttribute('aria-hidden', 'false');
|
|
||||||
window.electronAPI.notifyOverlayModalOpened('media-timing-review');
|
|
||||||
focus.attach();
|
|
||||||
focus.requestOverlayFocus();
|
|
||||||
window.focus();
|
|
||||||
focus.enforceModalFocus();
|
|
||||||
queueWaveformLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
function expandTimeline(direction: 'earlier' | 'later'): void {
|
|
||||||
if (!payload) return;
|
|
||||||
if (direction === 'earlier') {
|
|
||||||
timelineStart = Math.max(0, timelineStart - TIMELINE_EXPANSION_SECONDS);
|
|
||||||
} else {
|
|
||||||
timelineEnd = Math.min(
|
|
||||||
payload.mediaDuration ?? Number.POSITIVE_INFINITY,
|
|
||||||
timelineEnd + TIMELINE_EXPANSION_SECONDS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
renderSelection();
|
|
||||||
queueWaveformLoad(120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMediaTimingReviewKeydown(event: KeyboardEvent): boolean {
|
|
||||||
if (!ctx.state.mediaTimingReviewModalOpen) return false;
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden')) showEditor();
|
|
||||||
else requestCancel();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
event.code === 'Space' &&
|
|
||||||
event.target instanceof Element &&
|
|
||||||
!event.target.closest('button')
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
void togglePreview();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
event.key === 'Enter' &&
|
|
||||||
ctx.dom.mediaTimingReviewCancelStep.classList.contains('hidden') &&
|
|
||||||
!(event.target instanceof Element && event.target.closest('button'))
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
|
||||||
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wireDomEvents(): void {
|
|
||||||
const track = ctx.dom.mediaTimingReviewSelectionTrack;
|
|
||||||
track.addEventListener('pointerdown', beginDrag);
|
|
||||||
track.addEventListener('pointermove', (event) => {
|
|
||||||
if (drag?.pointerId === event.pointerId) applyDrag(event.clientX);
|
|
||||||
});
|
|
||||||
track.addEventListener('pointerup', endDrag);
|
|
||||||
track.addEventListener('pointercancel', endDrag);
|
|
||||||
ctx.dom.mediaTimingReviewStartHandle.addEventListener('keydown', (event) =>
|
|
||||||
handleEdgeKeydown(event, 'start'),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewEndHandle.addEventListener('keydown', (event) =>
|
|
||||||
handleEdgeKeydown(event, 'end'),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewShowEarlier.addEventListener('click', () => expandTimeline('earlier'));
|
|
||||||
ctx.dom.mediaTimingReviewShowLater.addEventListener('click', () => expandTimeline('later'));
|
|
||||||
ctx.dom.mediaTimingReviewStartBack.addEventListener('click', () =>
|
|
||||||
updateSelection(selectionStart - FINE_ADJUST_SECONDS, selectionEnd),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewStartForward.addEventListener('click', () =>
|
|
||||||
updateSelection(selectionStart + FINE_ADJUST_SECONDS, selectionEnd),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewEndBack.addEventListener('click', () =>
|
|
||||||
updateSelection(selectionStart, selectionEnd - FINE_ADJUST_SECONDS),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewEndForward.addEventListener('click', () =>
|
|
||||||
updateSelection(selectionStart, selectionEnd + FINE_ADJUST_SECONDS),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewPlay.addEventListener('click', () => void togglePreview());
|
|
||||||
ctx.dom.mediaTimingReviewReset.addEventListener('click', () => {
|
|
||||||
if (!payload) return;
|
|
||||||
updateSelection(payload.selectionStartTime, payload.selectionEndTime);
|
|
||||||
});
|
|
||||||
ctx.dom.mediaTimingReviewCancel.addEventListener('click', requestCancel);
|
|
||||||
ctx.dom.mediaTimingReviewCancelBack.addEventListener('click', showEditor);
|
|
||||||
ctx.dom.mediaTimingReviewUseOriginal.addEventListener(
|
|
||||||
'click',
|
|
||||||
() => void resolveReview({ action: 'use-original' }),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewDiscard.addEventListener(
|
|
||||||
'click',
|
|
||||||
() => void resolveReview({ action: 'discard' }),
|
|
||||||
);
|
|
||||||
ctx.dom.mediaTimingReviewConfirm.addEventListener(
|
|
||||||
'click',
|
|
||||||
() =>
|
|
||||||
void resolveReview({ action: 'confirm', startTime: selectionStart, endTime: selectionEnd }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
openMediaTimingReviewModal,
|
|
||||||
requestCancel,
|
|
||||||
handleMediaTimingReviewKeydown,
|
|
||||||
wireDomEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -45,7 +45,6 @@ import { createCharacterDictionaryModal } from './modals/character-dictionary.js
|
|||||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||||
import { createSubsyncModal } from './modals/subsync.js';
|
import { createSubsyncModal } from './modals/subsync.js';
|
||||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||||
import { createMediaTimingReviewModal } from './modals/media-timing-review.js';
|
|
||||||
import { createPositioningController } from './positioning.js';
|
import { createPositioningController } from './positioning.js';
|
||||||
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js';
|
||||||
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
|
||||||
@@ -115,12 +114,6 @@ const modalDescriptors = [
|
|||||||
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
close: () => youtubePickerModal.closeYoutubePickerModal(),
|
||||||
suppressesSubtitles: true,
|
suppressesSubtitles: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'media-timing-review',
|
|
||||||
isOpen: () => ctx.state.mediaTimingReviewModalOpen,
|
|
||||||
close: () => mediaTimingReviewModal.requestCancel(),
|
|
||||||
suppressesSubtitles: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'playlist-browser',
|
id: 'playlist-browser',
|
||||||
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
isOpen: () => ctx.state.playlistBrowserModalOpen,
|
||||||
@@ -272,10 +265,6 @@ const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
|||||||
modalStateReader: { isAnyModalOpen },
|
modalStateReader: { isAnyModalOpen },
|
||||||
syncSettingsModalSubtitleSuppression,
|
syncSettingsModalSubtitleSuppression,
|
||||||
});
|
});
|
||||||
const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
|
||||||
modalStateReader: { isAnyModalOpen },
|
|
||||||
syncSettingsModalSubtitleSuppression,
|
|
||||||
});
|
|
||||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||||
@@ -284,7 +273,6 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
|
|||||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||||
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
|
||||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||||
handleMediaTimingReviewKeydown: mediaTimingReviewModal.handleMediaTimingReviewKeydown,
|
|
||||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||||
@@ -586,11 +574,6 @@ function registerModalOpenHandlers(): void {
|
|||||||
youtubePickerModal.openYoutubePickerModal(payload);
|
youtubePickerModal.openYoutubePickerModal(payload);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
window.electronAPI.onOpenMediaTimingReview((payload) => {
|
|
||||||
runGuarded('media-timing-review:open', () => {
|
|
||||||
mediaTimingReviewModal.openMediaTimingReviewModal(payload);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||||
runGuardedAsync('playlist-browser:open', async () => {
|
runGuardedAsync('playlist-browser:open', async () => {
|
||||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||||
@@ -822,7 +805,6 @@ async function init(): Promise<void> {
|
|||||||
jimakuModal.wireDomEvents();
|
jimakuModal.wireDomEvents();
|
||||||
tsukihimeModal.wireDomEvents();
|
tsukihimeModal.wireDomEvents();
|
||||||
youtubePickerModal.wireDomEvents();
|
youtubePickerModal.wireDomEvents();
|
||||||
mediaTimingReviewModal.wireDomEvents();
|
|
||||||
playlistBrowserModal.wireDomEvents();
|
playlistBrowserModal.wireDomEvents();
|
||||||
kikuModal.wireDomEvents();
|
kikuModal.wireDomEvents();
|
||||||
runtimeOptionsModal.wireDomEvents();
|
runtimeOptionsModal.wireDomEvents();
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ export type RendererState = {
|
|||||||
youtubePickerSecondaryTrackId: string | null;
|
youtubePickerSecondaryTrackId: string | null;
|
||||||
youtubePickerStatus: string;
|
youtubePickerStatus: string;
|
||||||
|
|
||||||
mediaTimingReviewModalOpen: boolean;
|
|
||||||
|
|
||||||
kikuModalOpen: boolean;
|
kikuModalOpen: boolean;
|
||||||
kikuSelectedCard: 1 | 2;
|
kikuSelectedCard: 1 | 2;
|
||||||
kikuOriginalData: KikuDuplicateCardInfo | null;
|
kikuOriginalData: KikuDuplicateCardInfo | null;
|
||||||
@@ -196,8 +194,6 @@ export function createRendererState(): RendererState {
|
|||||||
youtubePickerSecondaryTrackId: null,
|
youtubePickerSecondaryTrackId: null,
|
||||||
youtubePickerStatus: '',
|
youtubePickerStatus: '',
|
||||||
|
|
||||||
mediaTimingReviewModalOpen: false,
|
|
||||||
|
|
||||||
kikuModalOpen: false,
|
kikuModalOpen: false,
|
||||||
kikuSelectedCard: 1,
|
kikuSelectedCard: 1,
|
||||||
kikuOriginalData: null,
|
kikuOriginalData: null,
|
||||||
|
|||||||
@@ -1323,701 +1323,6 @@ body:focus-visible,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Media timing review uses the Catppuccin Macchiato palette without opacity-shifted colors. */
|
|
||||||
.media-timing-review-modal {
|
|
||||||
background: rgba(24, 25, 38, 0.76);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pointer capture keeps events on the track, so the cursor is forced from the modal root. */
|
|
||||||
.media-timing-review-modal.is-scrubbing,
|
|
||||||
.media-timing-review-modal.is-scrubbing * {
|
|
||||||
cursor: ew-resize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-modal.is-sliding,
|
|
||||||
.media-timing-review-modal.is-sliding * {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-content {
|
|
||||||
width: min(720px, calc(100vw - 32px));
|
|
||||||
max-height: min(700px, calc(100vh - 32px));
|
|
||||||
overflow: auto;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-radius: 16px;
|
|
||||||
background: var(--ctp-base);
|
|
||||||
color: var(--ctp-text);
|
|
||||||
box-shadow:
|
|
||||||
0 30px 90px rgba(24, 25, 38, 0.8),
|
|
||||||
0 0 0 1px rgba(183, 189, 248, 0.07) inset;
|
|
||||||
animation: media-timing-review-enter 180ms cubic-bezier(0.2, 0.9, 0.25, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes media-timing-review-enter {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px) scale(0.985);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-header {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 2;
|
|
||||||
top: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 16px 20px 13px;
|
|
||||||
border-bottom: 1px solid var(--ctp-surface0);
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-heading {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-title {
|
|
||||||
color: var(--ctp-text);
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: -0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-kind {
|
|
||||||
--kind-accent: var(--ctp-teal);
|
|
||||||
|
|
||||||
padding: 3px 9px;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--kind-accent) 42%, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: color-mix(in srgb, var(--kind-accent) 13%, transparent);
|
|
||||||
color: var(--kind-accent);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-kind[data-kind='word'] {
|
|
||||||
--kind-accent: var(--ctp-mauve);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-kind[data-kind='audio'] {
|
|
||||||
--kind-accent: var(--ctp-sky);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-editor {
|
|
||||||
padding: 14px 20px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-text {
|
|
||||||
max-height: 88px;
|
|
||||||
overflow: auto;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-left: 3px solid var(--ctp-mauve);
|
|
||||||
border-radius: 0 10px 10px 0;
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
color: var(--ctp-subtext1);
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 560;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout > div {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout > div + div {
|
|
||||||
border-left: 1px solid var(--ctp-surface0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout span {
|
|
||||||
color: var(--ctp-overlay1);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout strong {
|
|
||||||
color: var(--ctp-lavender);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 720;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout .media-timing-review-duration {
|
|
||||||
align-items: center;
|
|
||||||
min-width: 128px;
|
|
||||||
background: var(--ctp-surface0);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout > div:last-child {
|
|
||||||
align-items: flex-end;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout .media-timing-review-duration strong {
|
|
||||||
color: var(--ctp-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-timeline-shell {
|
|
||||||
padding: 10px 12px 8px;
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-timeline-labels,
|
|
||||||
.media-timing-review-expand-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--ctp-overlay1);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 650;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-timeline-labels span:first-child,
|
|
||||||
.media-timing-review-timeline-labels span:last-child {
|
|
||||||
color: var(--ctp-subtext0);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-track {
|
|
||||||
--selection-start: 20%;
|
|
||||||
--selection-end: 80%;
|
|
||||||
--original-start: 25%;
|
|
||||||
--original-end: 75%;
|
|
||||||
--playhead-duration: 1s;
|
|
||||||
|
|
||||||
position: relative;
|
|
||||||
height: 76px;
|
|
||||||
margin: 8px 0 9px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid var(--ctp-surface2);
|
|
||||||
border-radius: 10px;
|
|
||||||
background:
|
|
||||||
linear-gradient(var(--ctp-surface1), var(--ctp-surface1)) center / 100% 1px no-repeat,
|
|
||||||
repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent 0,
|
|
||||||
transparent calc(10% - 1px),
|
|
||||||
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) calc(10% - 1px),
|
|
||||||
color-mix(in srgb, var(--ctp-surface1) 65%, transparent) 10%
|
|
||||||
),
|
|
||||||
linear-gradient(180deg, var(--ctp-crust), var(--ctp-mantle));
|
|
||||||
cursor: ew-resize;
|
|
||||||
touch-action: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-waveform {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
inset: 6px 0;
|
|
||||||
width: 100%;
|
|
||||||
height: calc(100% - 12px);
|
|
||||||
pointer-events: none;
|
|
||||||
transition: opacity 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mediaTimingReviewWaveformPath {
|
|
||||||
fill: url('#mediaTimingReviewWaveformFill');
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-waveform-edge-stop {
|
|
||||||
stop-color: var(--ctp-sky);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-waveform-core-stop {
|
|
||||||
stop-color: var(--ctp-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sits above the selection scrim so adjacent dialogue reads as outside the mined subtitle. */
|
|
||||||
.media-timing-review-original-range {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 4;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: var(--original-start);
|
|
||||||
right: calc(100% - var(--original-end));
|
|
||||||
min-width: 1px;
|
|
||||||
background: color-mix(in srgb, var(--ctp-peach) 11%, transparent);
|
|
||||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ctp-peach) 28%, transparent);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-boundary {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--ctp-peach);
|
|
||||||
box-shadow: 0 0 8px color-mix(in srgb, var(--ctp-peach) 45%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-boundary.is-start {
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-boundary.is-end {
|
|
||||||
right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-boundary::after {
|
|
||||||
position: absolute;
|
|
||||||
padding: 3px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--ctp-peach);
|
|
||||||
color: var(--ctp-crust);
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
line-height: 1;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-boundary.is-start::after {
|
|
||||||
top: 5px;
|
|
||||||
left: 5px;
|
|
||||||
content: 'line start';
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-boundary.is-end::after {
|
|
||||||
right: 5px;
|
|
||||||
bottom: 5px;
|
|
||||||
content: 'line end';
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-track.is-loading::after {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 2;
|
|
||||||
inset: 0;
|
|
||||||
content: '';
|
|
||||||
background: linear-gradient(
|
|
||||||
105deg,
|
|
||||||
transparent 20%,
|
|
||||||
rgba(138, 173, 244, 0.16) 44%,
|
|
||||||
rgba(183, 189, 248, 0.22) 50%,
|
|
||||||
transparent 76%
|
|
||||||
);
|
|
||||||
transform: translateX(-100%);
|
|
||||||
animation: media-timing-waveform-loading 1.1s ease-in-out infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-track.is-waveform-unavailable .media-timing-review-waveform {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes media-timing-waveform-loading {
|
|
||||||
to {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The oversized shadow spread dims everything outside the clip; the track clips it. */
|
|
||||||
.media-timing-review-selected-range {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 3;
|
|
||||||
inset: 0 calc(100% - var(--selection-end)) 0 var(--selection-start);
|
|
||||||
border-inline: 1px solid color-mix(in srgb, var(--ctp-teal) 65%, transparent);
|
|
||||||
background: color-mix(in srgb, var(--ctp-teal) 9%, transparent);
|
|
||||||
box-shadow: 0 0 0 2000px color-mix(in srgb, var(--ctp-crust) 62%, transparent);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-playhead {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 5;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: var(--selection-start);
|
|
||||||
width: 2px;
|
|
||||||
background: var(--ctp-yellow);
|
|
||||||
box-shadow: 0 0 12px color-mix(in srgb, var(--ctp-yellow) 75%, transparent);
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
|
||||||
opacity: 1;
|
|
||||||
animation: media-timing-review-playhead var(--playhead-duration) linear forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes media-timing-review-playhead {
|
|
||||||
from {
|
|
||||||
left: var(--selection-start);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
left: var(--selection-end);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-handle {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 6;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 14px;
|
|
||||||
border: 1px solid var(--ctp-teal);
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
var(--ctp-teal),
|
|
||||||
color-mix(in srgb, var(--ctp-teal) 74%, var(--ctp-crust))
|
|
||||||
);
|
|
||||||
cursor: ew-resize;
|
|
||||||
touch-action: none;
|
|
||||||
transition: box-shadow 130ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Widens the grab area past the visible bracket without moving the clip edge. */
|
|
||||||
.media-timing-review-handle::before {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 -7px;
|
|
||||||
content: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-handle::after {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
width: 6px;
|
|
||||||
height: 18px;
|
|
||||||
content: '';
|
|
||||||
border-inline: 1px solid color-mix(in srgb, var(--ctp-crust) 55%, transparent);
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-handle-start {
|
|
||||||
left: var(--selection-start);
|
|
||||||
border-radius: 5px 0 0 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-handle-end {
|
|
||||||
left: var(--selection-end);
|
|
||||||
border-radius: 0 5px 5px 0;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-handle:hover {
|
|
||||||
box-shadow: 0 0 16px color-mix(in srgb, var(--ctp-teal) 60%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-handle:focus-visible {
|
|
||||||
outline: 2px solid transparent;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 0 0 2px var(--ctp-yellow),
|
|
||||||
0 0 16px color-mix(in srgb, var(--ctp-yellow) 55%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-expand-row span {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-expand-button,
|
|
||||||
.media-timing-review-quiet-button,
|
|
||||||
.media-timing-review-original-button,
|
|
||||||
.media-timing-review-discard-button,
|
|
||||||
.media-timing-review-play-button,
|
|
||||||
.media-timing-review-confirm-button,
|
|
||||||
.media-timing-review-fine-control button {
|
|
||||||
border: 1px solid var(--ctp-surface2);
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--ctp-surface0);
|
|
||||||
color: var(--ctp-text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 720;
|
|
||||||
cursor: pointer;
|
|
||||||
transition:
|
|
||||||
background 130ms ease,
|
|
||||||
border-color 130ms ease,
|
|
||||||
transform 130ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-expand-button {
|
|
||||||
padding: 5px 10px;
|
|
||||||
color: var(--ctp-sky);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-expand-button:disabled,
|
|
||||||
.media-timing-review-content button:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-content button:not(:disabled):hover {
|
|
||||||
border-color: var(--ctp-lavender);
|
|
||||||
background: var(--ctp-surface1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-content button:not(:disabled):active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-content button:focus-visible {
|
|
||||||
outline: 2px solid var(--ctp-yellow);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-fine-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-fine-control {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: 1px solid var(--ctp-surface0);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
color: var(--ctp-subtext1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-fine-control span {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 750;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-fine-control button {
|
|
||||||
min-width: 58px;
|
|
||||||
padding: 5px 8px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-status {
|
|
||||||
min-height: 21px;
|
|
||||||
padding-top: 8px;
|
|
||||||
color: var(--ctp-teal);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 620;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-status.is-error {
|
|
||||||
color: var(--ctp-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-footer,
|
|
||||||
.media-timing-review-preview-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-footer {
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-play-button,
|
|
||||||
.media-timing-review-confirm-button,
|
|
||||||
.media-timing-review-quiet-button,
|
|
||||||
.media-timing-review-original-button,
|
|
||||||
.media-timing-review-discard-button {
|
|
||||||
padding: 9px 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-play-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
border-color: var(--ctp-teal);
|
|
||||||
background: var(--ctp-teal);
|
|
||||||
color: var(--ctp-crust);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-play-glyph {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-block: 5px solid transparent;
|
|
||||||
border-left: 9px solid currentcolor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-play-button.is-playing {
|
|
||||||
border-color: var(--ctp-yellow);
|
|
||||||
background: var(--ctp-yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-play-button.is-playing .media-timing-review-play-glyph {
|
|
||||||
width: 9px;
|
|
||||||
height: 10px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: currentcolor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-confirm-button {
|
|
||||||
border-color: var(--ctp-blue);
|
|
||||||
background: var(--ctp-blue);
|
|
||||||
color: var(--ctp-crust);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-original-button {
|
|
||||||
border-color: var(--ctp-yellow);
|
|
||||||
color: var(--ctp-yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-discard-button {
|
|
||||||
border-color: var(--ctp-red);
|
|
||||||
background: var(--ctp-red);
|
|
||||||
color: var(--ctp-crust);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-hints {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px 16px;
|
|
||||||
padding-top: 14px;
|
|
||||||
color: var(--ctp-overlay1);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 650;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-hints kbd {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 15px;
|
|
||||||
margin-right: 3px;
|
|
||||||
padding: 1px 5px;
|
|
||||||
border: 1px solid var(--ctp-surface1);
|
|
||||||
border-bottom-width: 2px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--ctp-mantle);
|
|
||||||
color: var(--ctp-subtext0);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 750;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-cancel-step {
|
|
||||||
min-height: 300px;
|
|
||||||
padding: 44px 38px 34px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-cancel-mark {
|
|
||||||
display: grid;
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
margin: 0 auto 16px;
|
|
||||||
place-items: center;
|
|
||||||
border: 2px solid var(--ctp-yellow);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: color-mix(in srgb, var(--ctp-yellow) 12%, transparent);
|
|
||||||
color: var(--ctp-yellow);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 760;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-cancel-step h2 {
|
|
||||||
color: var(--ctp-text);
|
|
||||||
font-size: 21px;
|
|
||||||
font-weight: 760;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-cancel-step p {
|
|
||||||
max-width: 520px;
|
|
||||||
margin: 8px auto 24px;
|
|
||||||
color: var(--ctp-subtext0);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-cancel-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.media-timing-review-content {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-track.is-loading::after,
|
|
||||||
.media-timing-review-track.is-previewing .media-timing-review-playhead {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.media-timing-review-content {
|
|
||||||
width: calc(100vw - 18px);
|
|
||||||
max-height: calc(100vh - 18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-header,
|
|
||||||
.media-timing-review-editor {
|
|
||||||
padding-right: 15px;
|
|
||||||
padding-left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-readout > div + div {
|
|
||||||
border-top: 1px solid var(--ctp-surface0);
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-fine-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-footer {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-timing-review-preview-actions > *,
|
|
||||||
.media-timing-review-confirm-button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
body.subtitle-sidebar-embedded-open #subtitleContainer {
|
||||||
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
max-width: min(80%, calc(100vw - var(--subtitle-sidebar-reserved-width) - 24px));
|
||||||
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
transform: translateX(calc(var(--subtitle-sidebar-reserved-width) * -0.5));
|
||||||
|
|||||||
@@ -44,39 +44,6 @@ export type RendererDom = {
|
|||||||
youtubePickerStatus: HTMLDivElement;
|
youtubePickerStatus: HTMLDivElement;
|
||||||
youtubePickerTracks: HTMLUListElement;
|
youtubePickerTracks: HTMLUListElement;
|
||||||
|
|
||||||
mediaTimingReviewModal: HTMLDivElement;
|
|
||||||
mediaTimingReviewKind: HTMLDivElement;
|
|
||||||
mediaTimingReviewText: HTMLElement;
|
|
||||||
mediaTimingReviewStartValue: HTMLElement;
|
|
||||||
mediaTimingReviewEndValue: HTMLElement;
|
|
||||||
mediaTimingReviewDuration: HTMLElement;
|
|
||||||
mediaTimingReviewTimelineStart: HTMLElement;
|
|
||||||
mediaTimingReviewTimelineEnd: HTMLElement;
|
|
||||||
mediaTimingReviewSelectionTrack: HTMLDivElement;
|
|
||||||
mediaTimingReviewWaveformLabel: HTMLElement;
|
|
||||||
mediaTimingReviewWaveformPath: SVGPathElement;
|
|
||||||
mediaTimingReviewSelectedRange: HTMLDivElement;
|
|
||||||
mediaTimingReviewStartHandle: HTMLDivElement;
|
|
||||||
mediaTimingReviewEndHandle: HTMLDivElement;
|
|
||||||
mediaTimingReviewShowEarlier: HTMLButtonElement;
|
|
||||||
mediaTimingReviewShowLater: HTMLButtonElement;
|
|
||||||
mediaTimingReviewStartBack: HTMLButtonElement;
|
|
||||||
mediaTimingReviewStartForward: HTMLButtonElement;
|
|
||||||
mediaTimingReviewEndBack: HTMLButtonElement;
|
|
||||||
mediaTimingReviewEndForward: HTMLButtonElement;
|
|
||||||
mediaTimingReviewPlay: HTMLButtonElement;
|
|
||||||
mediaTimingReviewPlayLabel: HTMLElement;
|
|
||||||
mediaTimingReviewReset: HTMLButtonElement;
|
|
||||||
mediaTimingReviewCancel: HTMLButtonElement;
|
|
||||||
mediaTimingReviewConfirm: HTMLButtonElement;
|
|
||||||
mediaTimingReviewStatus: HTMLDivElement;
|
|
||||||
mediaTimingReviewEditor: HTMLDivElement;
|
|
||||||
mediaTimingReviewCancelStep: HTMLDivElement;
|
|
||||||
mediaTimingReviewCancelMessage: HTMLParagraphElement;
|
|
||||||
mediaTimingReviewCancelBack: HTMLButtonElement;
|
|
||||||
mediaTimingReviewUseOriginal: HTMLButtonElement;
|
|
||||||
mediaTimingReviewDiscard: HTMLButtonElement;
|
|
||||||
|
|
||||||
kikuModal: HTMLDivElement;
|
kikuModal: HTMLDivElement;
|
||||||
kikuCard1: HTMLDivElement;
|
kikuCard1: HTMLDivElement;
|
||||||
kikuCard2: HTMLDivElement;
|
kikuCard2: HTMLDivElement;
|
||||||
@@ -178,8 +145,8 @@ export type RendererDom = {
|
|||||||
playlistBrowserClose: HTMLButtonElement;
|
playlistBrowserClose: HTMLButtonElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getRequiredElement<T extends Element>(id: string): T {
|
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||||
const element = document.querySelector(`#${id}`);
|
const element = document.getElementById(id);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
throw new Error(`Missing required DOM element #${id}`);
|
throw new Error(`Missing required DOM element #${id}`);
|
||||||
}
|
}
|
||||||
@@ -237,63 +204,6 @@ export function resolveRendererDom(): RendererDom {
|
|||||||
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
|
youtubePickerStatus: getRequiredElement<HTMLDivElement>('youtubePickerStatus'),
|
||||||
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
|
youtubePickerTracks: getRequiredElement<HTMLUListElement>('youtubePickerTracks'),
|
||||||
|
|
||||||
mediaTimingReviewModal: getRequiredElement<HTMLDivElement>('mediaTimingReviewModal'),
|
|
||||||
mediaTimingReviewKind: getRequiredElement<HTMLDivElement>('mediaTimingReviewKind'),
|
|
||||||
mediaTimingReviewText: getRequiredElement<HTMLElement>('mediaTimingReviewText'),
|
|
||||||
mediaTimingReviewStartValue: getRequiredElement<HTMLElement>('mediaTimingReviewStartValue'),
|
|
||||||
mediaTimingReviewEndValue: getRequiredElement<HTMLElement>('mediaTimingReviewEndValue'),
|
|
||||||
mediaTimingReviewDuration: getRequiredElement<HTMLElement>('mediaTimingReviewDuration'),
|
|
||||||
mediaTimingReviewTimelineStart: getRequiredElement<HTMLElement>(
|
|
||||||
'mediaTimingReviewTimelineStart',
|
|
||||||
),
|
|
||||||
mediaTimingReviewTimelineEnd: getRequiredElement<HTMLElement>('mediaTimingReviewTimelineEnd'),
|
|
||||||
mediaTimingReviewSelectionTrack: getRequiredElement<HTMLDivElement>(
|
|
||||||
'mediaTimingReviewSelectionTrack',
|
|
||||||
),
|
|
||||||
mediaTimingReviewWaveformLabel: getRequiredElement<HTMLElement>(
|
|
||||||
'mediaTimingReviewWaveformLabel',
|
|
||||||
),
|
|
||||||
mediaTimingReviewWaveformPath: getRequiredElement<SVGPathElement>(
|
|
||||||
'mediaTimingReviewWaveformPath',
|
|
||||||
),
|
|
||||||
mediaTimingReviewSelectedRange: getRequiredElement<HTMLDivElement>(
|
|
||||||
'mediaTimingReviewSelectedRange',
|
|
||||||
),
|
|
||||||
mediaTimingReviewStartHandle: getRequiredElement<HTMLDivElement>(
|
|
||||||
'mediaTimingReviewStartHandle',
|
|
||||||
),
|
|
||||||
mediaTimingReviewEndHandle: getRequiredElement<HTMLDivElement>('mediaTimingReviewEndHandle'),
|
|
||||||
mediaTimingReviewShowEarlier: getRequiredElement<HTMLButtonElement>(
|
|
||||||
'mediaTimingReviewShowEarlier',
|
|
||||||
),
|
|
||||||
mediaTimingReviewShowLater: getRequiredElement<HTMLButtonElement>('mediaTimingReviewShowLater'),
|
|
||||||
mediaTimingReviewStartBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewStartBack'),
|
|
||||||
mediaTimingReviewStartForward: getRequiredElement<HTMLButtonElement>(
|
|
||||||
'mediaTimingReviewStartForward',
|
|
||||||
),
|
|
||||||
mediaTimingReviewEndBack: getRequiredElement<HTMLButtonElement>('mediaTimingReviewEndBack'),
|
|
||||||
mediaTimingReviewEndForward: getRequiredElement<HTMLButtonElement>(
|
|
||||||
'mediaTimingReviewEndForward',
|
|
||||||
),
|
|
||||||
mediaTimingReviewPlay: getRequiredElement<HTMLButtonElement>('mediaTimingReviewPlay'),
|
|
||||||
mediaTimingReviewPlayLabel: getRequiredElement<HTMLElement>('mediaTimingReviewPlayLabel'),
|
|
||||||
mediaTimingReviewReset: getRequiredElement<HTMLButtonElement>('mediaTimingReviewReset'),
|
|
||||||
mediaTimingReviewCancel: getRequiredElement<HTMLButtonElement>('mediaTimingReviewCancel'),
|
|
||||||
mediaTimingReviewConfirm: getRequiredElement<HTMLButtonElement>('mediaTimingReviewConfirm'),
|
|
||||||
mediaTimingReviewStatus: getRequiredElement<HTMLDivElement>('mediaTimingReviewStatus'),
|
|
||||||
mediaTimingReviewEditor: getRequiredElement<HTMLDivElement>('mediaTimingReviewEditor'),
|
|
||||||
mediaTimingReviewCancelStep: getRequiredElement<HTMLDivElement>('mediaTimingReviewCancelStep'),
|
|
||||||
mediaTimingReviewCancelMessage: getRequiredElement<HTMLParagraphElement>(
|
|
||||||
'mediaTimingReviewCancelMessage',
|
|
||||||
),
|
|
||||||
mediaTimingReviewCancelBack: getRequiredElement<HTMLButtonElement>(
|
|
||||||
'mediaTimingReviewCancelBack',
|
|
||||||
),
|
|
||||||
mediaTimingReviewUseOriginal: getRequiredElement<HTMLButtonElement>(
|
|
||||||
'mediaTimingReviewUseOriginal',
|
|
||||||
),
|
|
||||||
mediaTimingReviewDiscard: getRequiredElement<HTMLButtonElement>('mediaTimingReviewDiscard'),
|
|
||||||
|
|
||||||
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
kikuModal: getRequiredElement<HTMLDivElement>('kikuFieldGroupingModal'),
|
||||||
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
|
kikuCard1: getRequiredElement<HTMLDivElement>('kikuCard1'),
|
||||||
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
|
kikuCard2: getRequiredElement<HTMLDivElement>('kikuCard2'),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const OVERLAY_HOSTED_MODALS = [
|
|||||||
'jimaku',
|
'jimaku',
|
||||||
'tsukihime',
|
'tsukihime',
|
||||||
'youtube-track-picker',
|
'youtube-track-picker',
|
||||||
'media-timing-review',
|
|
||||||
'playlist-browser',
|
'playlist-browser',
|
||||||
'kiku',
|
'kiku',
|
||||||
'controller-select',
|
'controller-select',
|
||||||
@@ -126,10 +125,6 @@ export const IPC_CHANNELS = {
|
|||||||
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
syncUiRevealSnapshot: 'sync-ui:reveal-snapshot',
|
||||||
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
syncUiPickSnapshotFile: 'sync-ui:pick-snapshot-file',
|
||||||
getChangelogSnapshot: 'changelog:get-snapshot',
|
getChangelogSnapshot: 'changelog:get-snapshot',
|
||||||
mediaTimingReviewPreview: 'media-timing-review:preview',
|
|
||||||
mediaTimingReviewWaveform: 'media-timing-review:waveform',
|
|
||||||
mediaTimingReviewStopPreview: 'media-timing-review:stop-preview',
|
|
||||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
subtitleSet: 'subtitle:set',
|
subtitleSet: 'subtitle:set',
|
||||||
@@ -147,7 +142,6 @@ export const IPC_CHANNELS = {
|
|||||||
jimakuOpen: 'jimaku:open',
|
jimakuOpen: 'jimaku:open',
|
||||||
tsukihimeOpen: 'tsukihime:open',
|
tsukihimeOpen: 'tsukihime:open',
|
||||||
youtubePickerOpen: 'youtube:picker-open',
|
youtubePickerOpen: 'youtube:picker-open',
|
||||||
mediaTimingReviewOpen: 'media-timing-review:open',
|
|
||||||
youtubePickerCancel: 'youtube:picker-cancel',
|
youtubePickerCancel: 'youtube:picker-cancel',
|
||||||
playlistBrowserOpen: 'playlist-browser:open',
|
playlistBrowserOpen: 'playlist-browser:open',
|
||||||
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
sessionNumericSelectionStart: 'session:numeric-selection-start',
|
||||||
|
|||||||
@@ -11,64 +11,6 @@ export type CardKind = 'sentence' | 'audio' | 'word-and-sentence' | 'click';
|
|||||||
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
/** Card kind SubMiner flags on word cards; 'none' leaves the flag fields untouched. */
|
||||||
export type WordCardKind = CardKind | 'none';
|
export type WordCardKind = CardKind | 'none';
|
||||||
|
|
||||||
export type MediaTimingReviewKind = 'word' | 'sentence' | 'audio';
|
|
||||||
|
|
||||||
export interface MediaTimingReviewRequest {
|
|
||||||
kind: MediaTimingReviewKind;
|
|
||||||
text: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
noteId?: number;
|
|
||||||
audioPadding: number;
|
|
||||||
maxMediaDuration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MediaTimingReviewDecision =
|
|
||||||
| { action: 'confirm'; startTime: number; endTime: number }
|
|
||||||
| { action: 'use-original' }
|
|
||||||
| { action: 'discard' };
|
|
||||||
|
|
||||||
export interface MediaTimingReviewOpenPayload {
|
|
||||||
reviewId: string;
|
|
||||||
kind: MediaTimingReviewKind;
|
|
||||||
text: string;
|
|
||||||
noteId?: number;
|
|
||||||
originalStartTime: number;
|
|
||||||
originalEndTime: number;
|
|
||||||
selectionStartTime: number;
|
|
||||||
selectionEndTime: number;
|
|
||||||
timelineStartTime: number;
|
|
||||||
timelineEndTime: number;
|
|
||||||
mediaDuration?: number;
|
|
||||||
maxMediaDuration: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaTimingReviewPreviewRequest {
|
|
||||||
reviewId: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaTimingReviewWaveformRequest {
|
|
||||||
reviewId: string;
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaTimingReviewWaveformResult extends MediaTimingReviewActionResult {
|
|
||||||
peaks?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaTimingReviewResolveRequest {
|
|
||||||
reviewId: string;
|
|
||||||
decision: MediaTimingReviewDecision;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaTimingReviewActionResult {
|
|
||||||
ok: boolean;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationOptions {
|
export interface NotificationOptions {
|
||||||
body?: string;
|
body?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
@@ -143,7 +85,6 @@ export interface AnkiConnectConfig {
|
|||||||
syncAnimatedImageToWordAudio?: boolean;
|
syncAnimatedImageToWordAudio?: boolean;
|
||||||
normalizeAudio?: boolean;
|
normalizeAudio?: boolean;
|
||||||
mirrorMpvVolume?: boolean;
|
mirrorMpvVolume?: boolean;
|
||||||
reviewTiming?: boolean;
|
|
||||||
audioPadding?: number;
|
audioPadding?: number;
|
||||||
fallbackDuration?: number;
|
fallbackDuration?: number;
|
||||||
maxMediaDuration?: number;
|
maxMediaDuration?: number;
|
||||||
|
|||||||
@@ -248,7 +248,6 @@ export interface ResolvedConfig {
|
|||||||
syncAnimatedImageToWordAudio: boolean;
|
syncAnimatedImageToWordAudio: boolean;
|
||||||
normalizeAudio: boolean;
|
normalizeAudio: boolean;
|
||||||
mirrorMpvVolume: boolean;
|
mirrorMpvVolume: boolean;
|
||||||
reviewTiming: boolean;
|
|
||||||
audioPadding: number;
|
audioPadding: number;
|
||||||
fallbackDuration: number;
|
fallbackDuration: number;
|
||||||
maxMediaDuration: number;
|
maxMediaDuration: number;
|
||||||
|
|||||||
@@ -3,12 +3,6 @@ import type {
|
|||||||
KikuFieldGroupingRequestData,
|
KikuFieldGroupingRequestData,
|
||||||
KikuMergePreviewRequest,
|
KikuMergePreviewRequest,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
MediaTimingReviewActionResult,
|
|
||||||
MediaTimingReviewOpenPayload,
|
|
||||||
MediaTimingReviewPreviewRequest,
|
|
||||||
MediaTimingReviewResolveRequest,
|
|
||||||
MediaTimingReviewWaveformRequest,
|
|
||||||
MediaTimingReviewWaveformResult,
|
|
||||||
} from './anki';
|
} from './anki';
|
||||||
import type { ChangelogSnapshot } from './changelog';
|
import type { ChangelogSnapshot } from './changelog';
|
||||||
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
import type { ResolvedConfig, ShortcutsConfig } from './config';
|
||||||
@@ -522,17 +516,6 @@ export interface ElectronAPI {
|
|||||||
onOpenJimaku: (callback: () => void) => void;
|
onOpenJimaku: (callback: () => void) => void;
|
||||||
onOpenTsukihime: (callback: () => void) => void;
|
onOpenTsukihime: (callback: () => void) => void;
|
||||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||||
onOpenMediaTimingReview: (callback: (payload: MediaTimingReviewOpenPayload) => void) => void;
|
|
||||||
previewMediaTimingReview: (
|
|
||||||
request: MediaTimingReviewPreviewRequest,
|
|
||||||
) => Promise<MediaTimingReviewActionResult>;
|
|
||||||
getMediaTimingReviewWaveform: (
|
|
||||||
request: MediaTimingReviewWaveformRequest,
|
|
||||||
) => Promise<MediaTimingReviewWaveformResult>;
|
|
||||||
stopMediaTimingReviewPreview: (reviewId: string) => Promise<MediaTimingReviewActionResult>;
|
|
||||||
resolveMediaTimingReview: (
|
|
||||||
request: MediaTimingReviewResolveRequest,
|
|
||||||
) => Promise<MediaTimingReviewActionResult>;
|
|
||||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||||
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
onOpenCharacterDictionaryManager: (callback: () => void) => void;
|
||||||
onSubtitleSidebarToggle: (callback: () => void) => void;
|
onSubtitleSidebarToggle: (callback: () => void) => void;
|
||||||
@@ -578,7 +561,6 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'tsukihime'
|
| 'tsukihime'
|
||||||
| 'youtube-track-picker'
|
| 'youtube-track-picker'
|
||||||
| 'media-timing-review'
|
|
||||||
| 'playlist-browser'
|
| 'playlist-browser'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
@@ -595,7 +577,6 @@ export interface ElectronAPI {
|
|||||||
| 'jimaku'
|
| 'jimaku'
|
||||||
| 'tsukihime'
|
| 'tsukihime'
|
||||||
| 'youtube-track-picker'
|
| 'youtube-track-picker'
|
||||||
| 'media-timing-review'
|
|
||||||
| 'playlist-browser'
|
| 'playlist-browser'
|
||||||
| 'kiku'
|
| 'kiku'
|
||||||
| 'controller-select'
|
| 'controller-select'
|
||||||
|
|||||||
@@ -243,8 +243,6 @@ export interface SubtitleMiningContext {
|
|||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
capturedAtMs?: number;
|
capturedAtMs?: number;
|
||||||
/** Explicit generator padding. Confirmed timing-review ranges set this to zero. */
|
|
||||||
mediaPaddingSeconds?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleHoverTokenPayload {
|
export interface SubtitleHoverTokenPayload {
|
||||||
|
|||||||
+102
-30
@@ -1,4 +1,4 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import koffi from 'koffi';
|
import koffi from 'koffi';
|
||||||
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
import { matchesMpvSocketPathInCommandLine } from './mpv-socket-match';
|
||||||
|
|
||||||
@@ -173,16 +173,52 @@ 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 getProcessCommandLineByPid(pid: number): string | null {
|
function getCachedProcessNameByPid(pid: number): string | null {
|
||||||
if (processCommandLineCache.has(pid)) {
|
const nowMs = Date.now();
|
||||||
return processCommandLineCache.get(pid) ?? null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
let commandLine: string | null = null;
|
function pruneExpiredProcessNames(nowMs: number): void {
|
||||||
try {
|
if (processNameCache.size <= PROCESS_NAME_CACHE_PRUNE_THRESHOLD) return;
|
||||||
const output = execFileSync(
|
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',
|
'powershell.exe',
|
||||||
[
|
[
|
||||||
'-NoProfile',
|
'-NoProfile',
|
||||||
@@ -195,21 +231,67 @@ function getProcessCommandLineByPid(pid: number): string | null {
|
|||||||
{
|
{
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
|
||||||
timeout: 1500,
|
timeout: 1500,
|
||||||
},
|
},
|
||||||
).trim();
|
(error, stdout) => {
|
||||||
commandLine = output.length > 0 ? output : null;
|
const output = error ? '' : stdout.trim();
|
||||||
} catch {
|
onResult(output.length > 0 ? output : null);
|
||||||
commandLine = null;
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commandLine !== null) {
|
// Resolves a process command line via a background PowerShell lookup. Returns null until the
|
||||||
processCommandLineCache.set(pid, commandLine);
|
// first lookup completes; the caller's next poll picks up the cached result. Failures are
|
||||||
} else {
|
// negative-cached with exponential backoff: the synchronous version of this lookup could
|
||||||
processCommandLineCache.delete(pid);
|
// 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 {
|
||||||
|
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 commandLine;
|
return entry.commandLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry?.state === 'pending') return null;
|
||||||
|
if (entry?.state === 'failed' && nowMs < entry.retryAtMs) return null;
|
||||||
|
|
||||||
|
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 {
|
export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult {
|
||||||
@@ -217,8 +299,7 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
|||||||
const matches: MpvWindowMatch[] = [];
|
const matches: MpvWindowMatch[] = [];
|
||||||
let hasMinimized = false;
|
let hasMinimized = false;
|
||||||
let hasFocused = false;
|
let hasFocused = false;
|
||||||
const processNameCache = new Map<number, string | null>();
|
pruneExpiredProcessNames(Date.now());
|
||||||
const processCommandLineLookupCache = new Map<number, string | null>();
|
|
||||||
|
|
||||||
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
const cb = koffi.register((hwnd: number, _lParam: number) => {
|
||||||
if (!IsWindowVisible(hwnd)) return true;
|
if (!IsWindowVisible(hwnd)) return true;
|
||||||
@@ -228,21 +309,12 @@ export function findMpvWindows(targetSocketPath?: string | null): MpvPollResult
|
|||||||
const pidValue = pid[0]!;
|
const pidValue = pid[0]!;
|
||||||
if (pidValue === 0) return true;
|
if (pidValue === 0) return true;
|
||||||
|
|
||||||
let processName = processNameCache.get(pidValue);
|
const processName = getCachedProcessNameByPid(pidValue);
|
||||||
if (processName === undefined) {
|
|
||||||
processName = getProcessNameByPid(pidValue);
|
|
||||||
processNameCache.set(pidValue, processName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
if (!processName || processName.toLowerCase() !== 'mpv') return true;
|
||||||
|
|
||||||
let commandLine: string | null = null;
|
let commandLine: string | null = null;
|
||||||
if (targetSocketPath) {
|
if (targetSocketPath) {
|
||||||
commandLine = processCommandLineLookupCache.get(pidValue) ?? null;
|
|
||||||
if (!processCommandLineLookupCache.has(pidValue)) {
|
|
||||||
commandLine = getProcessCommandLineByPid(pidValue);
|
commandLine = getProcessCommandLineByPid(pidValue);
|
||||||
processCommandLineLookupCache.set(pidValue, commandLine);
|
|
||||||
}
|
|
||||||
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
if (!commandLine || !matchesMpvSocketPathInCommandLine(commandLine, targetSocketPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user