mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-13 08:12:54 -07:00
feat(tokenizer): use Yomitan word classes for subtitle POS filtering (#57)
* feat(tokenizer): use Yomitan word classes for subtitle POS filtering - Carry matched headword wordClasses from termsFind into YomitanScanToken - Map recognized Yomitan wordClasses to SubMiner coarse POS before annotation - MeCab enrichment now fills only missing POS fields, preserving existing coarse pos1 - Exclude standalone grammar particles, して helper fragments, and single-kana surfaces from annotations - Respect source-text punctuation gaps when counting N+1 sentence words - Preserve known-word highlight on excluded kanji-containing tokens - Add backlog tasks 304 (N+1 boundary bug) and 305 (wordClasses POS, done) * fix(tokenizer): preserve annotation and enrichment behavior * fix: restore jlpt subtitle underlines * fix: exclude kana-only n+1 targets * fix: refresh overlay on Hyprland fullscreen * fix: address fullscreen and n-plus-one review notes * fix: address CodeRabbit review comments * fix: accept modified digits for multi-line sentence mining * Cancel pending Linux MPV fullscreen overlay refresh bursts - return a cancel handle from the Linux refresh burst scheduler - clear pending refresh bursts when overlays hide or windows close - tighten the burst test polling to wait for the async refresh * fix: suppress N+1 for kana-only candidates and fix minSentenceWords coun - Treat kana-only tokens with surrounding subtitle punctuation (…, ―, etc.) as kana-only so they are not promoted to N+1 targets - Exclude unknown tokens filtered from N+1 targeting from the minSentenceWords count so filtered kana-only unknowns cannot satisfy sentence length threshold - Add regression tests for kana-only candidate suppression and filtered-unknown padding cases * Suppress subtitle annotations for grammar fragments - Hide annotation metadata for auxiliary inflection and ja-nai endings - Preserve lexical `くれる` forms and add regression coverage * Fix kana-only N+1 tokenizer regression test - Use a pure-kana fixture for the subtitle token N+1 case - Update task notes for the latest CodeRabbit follow-up * Fix managed playback exit and tokenizer grammar splits - Ignore background stats daemons during regular app startup - Split standalone grammar endings before applying annotations - Clear helper-span annotations for auxiliary-only tokens * fix: refresh current subtitle after known-word mining * fix: suppress sigh interjection annotations * fix: preserve jlpt underline color after lookup * Replace grammar-ending permutations with shared matcher; preserve word a - Extract `grammar-ending.ts` with `isStandaloneGrammarEndingText` / `isSubtitleGrammarEndingText` pattern matchers - Replace `STANDALONE_GRAMMAR_ENDINGS` set in parser-selection-stage with shared matcher - Replace generated phrase sets in subtitle-annotation-filter with shared matcher - Remove stale duplicate subtitle-exclusion constants and helpers from annotation-stage - Manual clipboard card updates now write only to the sentence audio field, leaving word/expression audio untouched * fix: CI changelog, annotation options threading, and Jellyfin quit - Add `type: fixed` / `area:` frontmatter to `changes/319` to pass `changelog:lint` - Thread `TokenizerAnnotationOptions` through `stripSubtitleAnnotationMetadata` so `sourceText` is honored - Include `jellyfinPlay` in `shouldQuitOnDisconnectWhenOverlayRuntimeInitialized` predicate - Make mouse test `elementFromPoint` stubs coordinate-sensitive - Make Lua test `.tmp` mkdir portable on Windows * Preserve overlay across macOS flaps and mpv playlist changes - keep visible overlays alive during transient macOS tracker loss - reuse the running mpv overlay path on playlist navigation - update regression coverage and changelog fragments * fix: restore stats daemon deferral * fix: keep subtitle prefetch alive after cache hits * Fix JLPT underline color drift and AniList skipped-threshold sync - Replace JLPT `text-decoration` underlines with `border-bottom` so Chromium selection/hover cannot repaint them to another annotation's color - Lock JLPT underline color for combined annotation selectors (known, n+1, frequency) and character hover/selection states - Trigger AniList post-watch check on every mpv time-position update to catch skipped completion thresholds - Fall back to filename-parser season/episode when guessit omits them * fix: address coderabbit feedback * fix: sync AniList after seeked completion * fix: preserve ordinal frequency annotations * fix: preserve known highlighting for filtered tokens * fix: address PR #57 CodeRabbit feedback - Acquire AniList post-watch in-flight lock before async gating to prevent duplicate writes - Isolate manual watched mark result from AniList post-watch callback failures - Report known-word cache clears as mutations during immediate append when state existed - Add regression tests for each fix * fix: stop AniList setup reopening on Linux when keyring token exists - Gate setup success on token persistence: `saveToken` now returns `boolean`; on failure, keeps the setup window open instead of reporting success - Config reload passes `allowSetupPrompt: false` so playback reloads don't re-open the setup window - Add regression test for persistence-failure path * fix: suppress known highlights for subtitle particles * fix: retry transient AniList safeStorage failures * fix: hide overlay focus ring * fix: align Hyprland fullscreen overlays * fix: restore subtitle playback keybindings * fix: align Hyprland overlay windows to mpv and stop pinning them - Force-apply exact Hyprland move/resize/setprop dispatches when bounds are provided - Stop pinning overlay windows; toggle pin off when Hyprland reports pinned=true - Compensate stats overlay outer placement for Electron/Wayland content insets - Make stats overlay window and page opaque so mpv cannot show through transparent insets - Constrain stats app to h-screen with internal scroll so content covers mpv from y=0 - Lock overlay/stats window titles against page-title-updated events - Add regression coverage for placement dispatches, inset compensation, and CSS overlay mode * fix: retain frequency rank for honorific prefix-noun tokens - Add `shouldAllowHonorificPrefixNounFrequency` to exempt お/ご/御 + noun merged tokens from frequency exclusion - Add regression test for `ご機嫌` asserting rank 5484 is preserved after MeCab enrichment and annotation - Close TASK-341 * fix: map openCharacterDictionary session action to --open-character-dict - Add missing Lua CLI dispatch entry for openCharacterDictionary - Add regression test for Alt+Meta+A binding and CLI flag forwarding * fix: keep macOS overlay interactive while mpv remains active - Overlay no longer hides or becomes click-through during tracker refreshes when mpv is the focused window - Preserve already-visible overlay when tracker is temporarily not ready but mpv target signal is active - Add regression tests for active-mpv tracker refresh and transient tracker-not-ready paths * fix: address coderabbit subtitle follow-ups * fix: resolve media detail from sessions when lifetime summary is absent - Change `getMediaDetail` JOIN to LEFT JOIN on `imm_lifetime_media` and fall back to aggregated session metrics when no lifetime row exists - Add filter `AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)` to keep results valid - Add regression test covering the session-visible / media-detail-missing mismatch * fix: address PR-57 CodeRabbit findings and CI failures - use filtered word counts in media detail session token aggregation - cancel fullscreen refresh burst on exit via updateLinuxMpvFullscreenOverlayRefreshBurst - guard Hyprland JSON.parse in try/catch; exclude windowtitle from geometry events - narrow focus suppression from :focus to :focus-visible - apply JLPT lock selectors to word-name-match tokens (N1–N5) * fix: macOS overlay z-order and Yomitan compound token known highlighting - Release always-on-top when tracked mpv loses foreground on macOS - Skip visible overlay blur restacking on macOS to avoid covering unrelated windows - Prefer Yomitan internal parse tokens over fragmented scanner output for known-word decisions - Add regression tests for both behaviors * fix: macOS visible-overlay blur no longer invokes Windows-only blur call - Split win32/darwin branches in handleOverlayWindowBlurred so darwin visible blur returns early without calling onWindowsVisibleOverlayBlur - Add regression test asserting Windows callback stays inactive on macOS visible overlay blur - Close TASK-347
This commit is contained in:
@@ -177,6 +177,44 @@ test('AnkiIntegration.refreshKnownWordCache skips work when highlight mode is di
|
||||
}
|
||||
});
|
||||
|
||||
test('AnkiIntegration notifies when mined note info updates known words', () => {
|
||||
const ctx = createIntegrationTestContext({
|
||||
stateDirPrefix: 'subminer-anki-integration-known-update-',
|
||||
});
|
||||
let notifications = 0;
|
||||
|
||||
try {
|
||||
const integrationState = ctx.integration as unknown as {
|
||||
config: AnkiConnectConfig;
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
}) => void;
|
||||
};
|
||||
integrationState.config.deck = 'Mining';
|
||||
integrationState.config.knownWords = {
|
||||
...integrationState.config.knownWords,
|
||||
decks: {
|
||||
Mining: ['Word'],
|
||||
},
|
||||
};
|
||||
ctx.integration.setKnownWordCacheUpdatedCallback(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
integrationState.appendKnownWordsFromNoteInfo({
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Word: { value: '食べる' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(ctx.integration.isKnownWord('食べる'), true);
|
||||
assert.equal(notifications, 1);
|
||||
} finally {
|
||||
cleanupIntegrationTestContext(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
test('AnkiIntegration.refreshKnownWordCache deduplicates concurrent refreshes', async () => {
|
||||
let releaseFindNotes: (() => void) | undefined;
|
||||
const findNotesPromise = new Promise<void>((resolve) => {
|
||||
|
||||
+21
-1
@@ -148,6 +148,7 @@ export class AnkiIntegration {
|
||||
private runtime: AnkiIntegrationRuntime;
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
|
||||
@@ -552,10 +553,25 @@ export class AnkiIntegration {
|
||||
return;
|
||||
}
|
||||
|
||||
this.knownWordCache.appendFromNoteInfo({
|
||||
const changed = this.knownWordCache.appendFromNoteInfo({
|
||||
noteId: noteInfo.noteId,
|
||||
fields: noteInfo.fields,
|
||||
});
|
||||
if (changed) {
|
||||
this.notifyKnownWordCacheUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyKnownWordCacheUpdated(): void {
|
||||
if (!this.knownWordCacheUpdatedCallback) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.knownWordCacheUpdatedCallback();
|
||||
} catch (error) {
|
||||
log.warn('Known-word cache update callback failed:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private getLapisConfig(): {
|
||||
@@ -1267,6 +1283,10 @@ export class AnkiIntegration {
|
||||
this.recordCardsMinedCallback = callback;
|
||||
}
|
||||
|
||||
setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
|
||||
this.knownWordCacheUpdatedCallback = callback;
|
||||
}
|
||||
|
||||
resolveCurrentNoteId(noteId: number): number {
|
||||
let resolved = noteId;
|
||||
const seen = new Set<number>();
|
||||
|
||||
@@ -126,7 +126,7 @@ function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
};
|
||||
}
|
||||
|
||||
test('manual clipboard subtitle update replaces expression and sentence audio even when overwriteAudio is disabled', async () => {
|
||||
test('manual clipboard subtitle update replaces sentence audio without touching expression audio', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService();
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
@@ -134,10 +134,44 @@ test('manual clipboard subtitle update replaces expression and sentence audio ev
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.equal(storedMedia.length, 1);
|
||||
const audioValue = `[sound:${storedMedia[0]}]`;
|
||||
assert.equal(updatedFields[0]?.ExpressionAudio, audioValue);
|
||||
assert.equal(updatedFields[0]?.SentenceAudio, audioValue);
|
||||
assert.equal('ExpressionAudio' in updatedFields[0]!, false);
|
||||
assert.deepEqual(
|
||||
mergeCalls.map((call) => call.overwrite),
|
||||
[true, true],
|
||||
[true],
|
||||
);
|
||||
});
|
||||
|
||||
test('manual clipboard subtitle update skips audio when sentence audio field is missing', async () => {
|
||||
const { service, updatedFields, mergeCalls, storedMedia } = createManualUpdateService({
|
||||
client: {
|
||||
addNote: async () => 0,
|
||||
addTags: async () => undefined,
|
||||
notesInfo: async () => [
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: '単語' },
|
||||
Sentence: { value: '' },
|
||||
ExpressionAudio: { value: '[sound:auto-expression.mp3]' },
|
||||
},
|
||||
},
|
||||
],
|
||||
updateNoteFields: async (_noteId, fields) => {
|
||||
updatedFields.push(fields);
|
||||
},
|
||||
storeMediaFile: async (filename) => {
|
||||
storedMedia.push(filename);
|
||||
},
|
||||
findNotes: async () => [42],
|
||||
retrieveMediaFile: async () => '',
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
|
||||
assert.equal(storedMedia.length, 1);
|
||||
assert.equal(updatedFields.length, 1);
|
||||
assert.deepEqual(updatedFields[0], { Sentence: '字幕' });
|
||||
assert.equal(mergeCalls.length, 0);
|
||||
});
|
||||
|
||||
@@ -218,11 +218,7 @@ export class CardCreationService {
|
||||
fields,
|
||||
this.deps.getConfig(),
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceAudioFieldName(noteInfo);
|
||||
const expressionAudioField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
this.deps.getConfig().fields?.audio || 'ExpressionAudio',
|
||||
);
|
||||
const sentenceAudioField = this.getResolvedSentenceOnlyAudioFieldName(noteInfo);
|
||||
const sentenceField = this.deps.getEffectiveSentenceCardConfig().sentenceField;
|
||||
|
||||
const sentence = blocks.join(' ');
|
||||
@@ -252,22 +248,15 @@ export class CardCreationService {
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
if (sentenceAudioField || expressionAudioField) {
|
||||
if (sentenceAudioField) {
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
const audioFields = new Set(
|
||||
[sentenceAudioField, expressionAudioField].filter(
|
||||
(fieldName): fieldName is string => Boolean(fieldName),
|
||||
),
|
||||
const existingAudio = noteInfo.fields[sentenceAudioField]?.value || '';
|
||||
// Manual clipboard updates intentionally replace old captured sentence audio.
|
||||
updatedFields[sentenceAudioField] = this.deps.mergeFieldValue(
|
||||
existingAudio,
|
||||
audioValue,
|
||||
true,
|
||||
);
|
||||
for (const audioField of audioFields) {
|
||||
const existingAudio = noteInfo.fields[audioField]?.value || '';
|
||||
// Manual clipboard updates intentionally replace old captured audio.
|
||||
updatedFields[audioField] = this.deps.mergeFieldValue(
|
||||
existingAudio,
|
||||
audioValue,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
miscInfoFilename = audioFilename;
|
||||
updatePerformed = true;
|
||||
@@ -732,6 +721,13 @@ export class CardCreationService {
|
||||
);
|
||||
}
|
||||
|
||||
private getResolvedSentenceOnlyAudioFieldName(noteInfo: CardCreationNoteInfo): string | null {
|
||||
return this.deps.resolveNoteFieldName(
|
||||
noteInfo,
|
||||
this.deps.getEffectiveSentenceCardConfig().audioField || 'SentenceAudio',
|
||||
);
|
||||
}
|
||||
|
||||
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
|
||||
return {
|
||||
noteId: -1,
|
||||
|
||||
@@ -520,6 +520,51 @@ test('KnownWordCacheManager uses the current deck fields for immediate append',
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager reports immediate append cache clears as mutations', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
fields: {
|
||||
word: 'Expression',
|
||||
},
|
||||
knownWords: {
|
||||
highlightEnabled: true,
|
||||
refreshMinutes: 60,
|
||||
},
|
||||
};
|
||||
const { manager, statePath, cleanup } = createKnownWordCacheHarness(config);
|
||||
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
refreshedAtMs: Date.now(),
|
||||
scope: '{"refreshMinutes":60,"scope":"is:note","fieldsWord":"Expression"}',
|
||||
words: ['猫'],
|
||||
notes: {
|
||||
'1': ['猫'],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
manager.startLifecycle();
|
||||
assert.equal(manager.isKnownWord('猫'), true);
|
||||
|
||||
config.fields = { word: 'Word' };
|
||||
const changed = manager.appendFromNoteInfo({
|
||||
noteId: 2,
|
||||
fields: {
|
||||
Word: { value: '' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(manager.isKnownWord('猫'), false);
|
||||
} finally {
|
||||
manager.stopLifecycle();
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('KnownWordCacheManager skips immediate append when addMinedWordsImmediately is disabled', () => {
|
||||
const config: AnkiConnectConfig = {
|
||||
knownWords: {
|
||||
|
||||
@@ -165,13 +165,15 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
}
|
||||
|
||||
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): void {
|
||||
appendFromNoteInfo(noteInfo: KnownWordCacheNoteInfo): boolean {
|
||||
if (!this.isKnownWordCacheEnabled() || !this.shouldAddMinedWordsImmediately()) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let didMutateCache = false;
|
||||
const currentStateKey = this.getKnownWordCacheStateKey();
|
||||
if (this.knownWordsStateKey && this.knownWordsStateKey !== currentStateKey) {
|
||||
didMutateCache = this.knownWords.size > 0 || this.noteWordsById.size > 0;
|
||||
this.clearKnownWordCacheState();
|
||||
}
|
||||
if (!this.knownWordsStateKey) {
|
||||
@@ -180,13 +182,13 @@ export class KnownWordCacheManager {
|
||||
|
||||
const preferredFields = this.getImmediateAppendFields();
|
||||
if (!preferredFields) {
|
||||
return;
|
||||
return didMutateCache;
|
||||
}
|
||||
|
||||
const nextWords = this.extractNormalizedKnownWordsFromNoteInfo(noteInfo, preferredFields);
|
||||
const changed = this.replaceNoteSnapshot(noteInfo.noteId, nextWords);
|
||||
if (!changed) {
|
||||
return;
|
||||
return didMutateCache;
|
||||
}
|
||||
|
||||
if (this.knownWordsLastRefreshedAtMs <= 0) {
|
||||
@@ -199,6 +201,7 @@ export class KnownWordCacheManager {
|
||||
`wordCount=${nextWords.length}`,
|
||||
`scope=${getKnownWordCacheScopeForConfig(this.deps.getConfig())}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
clearKnownWordCacheState(): void {
|
||||
|
||||
@@ -89,7 +89,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openCharacterDictionary: 'CommandOrControl+Alt+A',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openSessionHelp: 'CommandOrControl+Shift+H',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
|
||||
@@ -92,3 +92,11 @@ test('default keybindings include fullscreen on F', () => {
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
|
||||
});
|
||||
|
||||
test('default keybindings include replay and next subtitle controls', () => {
|
||||
const keybindingMap = new Map(
|
||||
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||
);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyH'), ['__replay-subtitle']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Shift+KeyL'), ['__play-next-subtitle']);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,24 @@ function createPassthroughStorage(): SafeStorageLike {
|
||||
};
|
||||
}
|
||||
|
||||
function createTransientUnavailableStorage(): SafeStorageLike & {
|
||||
setAvailable: (next: boolean) => void;
|
||||
} {
|
||||
let available = false;
|
||||
return {
|
||||
isEncryptionAvailable: () => available,
|
||||
encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'),
|
||||
decryptString: (value: Buffer) => {
|
||||
const raw = value.toString('utf-8');
|
||||
return raw.startsWith('enc:') ? raw.slice(4) : raw;
|
||||
},
|
||||
getSelectedStorageBackend: () => (available ? 'gnome_libsecret' : 'unknown'),
|
||||
setAvailable(next: boolean) {
|
||||
available = next;
|
||||
},
|
||||
} as SafeStorageLike & { setAvailable: (next: boolean) => void };
|
||||
}
|
||||
|
||||
test('anilist token store saves and loads encrypted token', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true));
|
||||
@@ -61,6 +79,27 @@ test('anilist token store refuses to persist token when encryption unavailable',
|
||||
assert.equal(store.loadToken(), null);
|
||||
});
|
||||
|
||||
test('anilist token store retries safeStorage after transient encryption unavailability', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
encryptedToken: Buffer.from('enc:stored-token', 'utf-8').toString('base64'),
|
||||
updatedAt: Date.now(),
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const storage = createTransientUnavailableStorage();
|
||||
const store = createAnilistTokenStore(filePath, createLogger(), storage);
|
||||
|
||||
assert.equal(store.loadToken(), null);
|
||||
storage.setAvailable(true);
|
||||
|
||||
assert.equal(store.loadToken(), 'stored-token');
|
||||
assert.equal(store.saveToken('new-token'), true);
|
||||
assert.equal(store.loadToken(), 'new-token');
|
||||
});
|
||||
|
||||
test('anilist token store migrates legacy plaintext to encrypted', () => {
|
||||
const filePath = createTempTokenFile();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -69,7 +69,6 @@ export function createAnilistTokenStore(
|
||||
`AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` +
|
||||
`Context: ${getSafeStorageDebugContext()}`,
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
const probe = storage.encryptString('__subminer_anilist_probe__');
|
||||
@@ -77,7 +76,6 @@ export function createAnilistTokenStore(
|
||||
notifyUser(
|
||||
'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.',
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
const roundTrip = storage.decryptString(probe);
|
||||
@@ -85,7 +83,6 @@ export function createAnilistTokenStore(
|
||||
notifyUser(
|
||||
'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.',
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
safeStorageUsable = true;
|
||||
@@ -96,7 +93,6 @@ export function createAnilistTokenStore(
|
||||
`AniList token encryption unavailable: safeStorage probe threw an error. ` +
|
||||
`Context: ${getSafeStorageDebugContext()}`,
|
||||
);
|
||||
safeStorageUsable = false;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,6 +22,44 @@ test('guessAnilistMediaInfo uses guessit output when available', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo fills missing guessit episode from filename parser', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/Guessit Title S01E09.mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
season: 1,
|
||||
episode: 9,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo ignores low-confidence parser details when guessit omits them', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/Season 2/Guessit Title.mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
season: null,
|
||||
episode: null,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo parses Little Witch Academia release filename', async () => {
|
||||
const filename =
|
||||
'/tmp/Little Witch Academia (2017) - S01E02 - 002 - Papiliodia [Bluray-1080p][10bit][h265][AC3 2.0][JA].mkv';
|
||||
const result = await guessAnilistMediaInfo(filename, null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Little Witch Academia' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Little Witch Academia',
|
||||
season: 1,
|
||||
episode: 2,
|
||||
source: 'guessit',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo falls back to parser when guessit fails', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/My Anime S01E03.mkv', null, {
|
||||
runGuessit: async () => {
|
||||
@@ -54,7 +92,7 @@ test('guessAnilistMediaInfo uses basename for guessit input', async () => {
|
||||
]);
|
||||
assert.deepEqual(result, {
|
||||
title: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||
season: null,
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'guessit',
|
||||
});
|
||||
|
||||
@@ -236,12 +236,14 @@ export async function guessAnilistMediaInfo(
|
||||
const season = firstPositiveInteger(parsed.season);
|
||||
const year = firstYear(parsed.year);
|
||||
if (title) {
|
||||
const fallback = parseMediaInfo(target);
|
||||
const canUseFallbackDetails = fallback.confidence !== 'low';
|
||||
return {
|
||||
title: buildGuessitTitle(title, alternativeTitle),
|
||||
...(alternativeTitle ? { alternativeTitle } : {}),
|
||||
...(year ? { year } : {}),
|
||||
season,
|
||||
episode,
|
||||
season: season ?? (canUseFallbackDetails ? fallback.season : null),
|
||||
episode: episode ?? (canUseFallbackDetails ? fallback.episode : null),
|
||||
source: 'guessit',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildHyprlandPlacementDispatches,
|
||||
ensureHyprlandWindowFloatingByTitle,
|
||||
findHyprlandWindowForPlacement,
|
||||
shouldAttemptHyprlandWindowPlacement,
|
||||
} from './hyprland-window-placement';
|
||||
|
||||
test('shouldAttemptHyprlandWindowPlacement only enables on Hyprland Linux sessions', () => {
|
||||
assert.equal(
|
||||
shouldAttemptHyprlandWindowPlacement('linux', {
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAttemptHyprlandWindowPlacement('linux', {
|
||||
WAYLAND_DISPLAY: 'wayland-1',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAttemptHyprlandWindowPlacement('darwin', {
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('findHyprlandWindowForPlacement matches current process by title', () => {
|
||||
const client = findHyprlandWindowForPlacement(
|
||||
[
|
||||
{
|
||||
address: '0xother',
|
||||
pid: 123,
|
||||
title: 'SubMiner Stats',
|
||||
mapped: true,
|
||||
},
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Stats',
|
||||
mapped: true,
|
||||
},
|
||||
],
|
||||
{
|
||||
pid: 456,
|
||||
title: 'SubMiner Stats',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(client?.address, '0xmatch');
|
||||
});
|
||||
|
||||
test('buildHyprlandPlacementDispatches floats tiled overlay windows without pinning them', () => {
|
||||
assert.deepEqual(
|
||||
buildHyprlandPlacementDispatches({
|
||||
address: '0xabc',
|
||||
floating: false,
|
||||
pinned: false,
|
||||
}),
|
||||
[['dispatch', 'setfloating', 'address:0xabc']],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to target bounds', () => {
|
||||
assert.deepEqual(
|
||||
buildHyprlandPlacementDispatches(
|
||||
{
|
||||
address: '0xabc',
|
||||
floating: true,
|
||||
pinned: false,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
),
|
||||
[
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
|
||||
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'],
|
||||
['dispatch', 'setprop', 'address:0xabc rounding 0'],
|
||||
['dispatch', 'setprop', 'address:0xabc border_size 0'],
|
||||
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
|
||||
['dispatch', 'setprop', 'address:0xabc no_blur 1'],
|
||||
['dispatch', 'setprop', 'address:0xabc decorate 0'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildHyprlandPlacementDispatches does not pin already floating overlay windows', () => {
|
||||
assert.deepEqual(
|
||||
buildHyprlandPlacementDispatches({
|
||||
address: '0xabc',
|
||||
floating: true,
|
||||
pinned: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => {
|
||||
assert.deepEqual(
|
||||
buildHyprlandPlacementDispatches({
|
||||
address: '0xabc',
|
||||
floating: true,
|
||||
pinned: true,
|
||||
}),
|
||||
[['dispatch', 'pin', 'address:0xabc']],
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for matching tiled window', () => {
|
||||
const calls: unknown[][] = [];
|
||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||
title: 'SubMiner Stats',
|
||||
platform: 'linux',
|
||||
env: {
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
|
||||
},
|
||||
pid: 456,
|
||||
execFileSync: ((command: string, args: string[], options: unknown) => {
|
||||
calls.push([command, args, options]);
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Stats',
|
||||
mapped: true,
|
||||
floating: false,
|
||||
pinned: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.equal(placed, true);
|
||||
assert.deepEqual(
|
||||
calls.map(([, args]) => args),
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['dispatch', 'setfloating', 'address:0xmatch'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry when bounds are provided', () => {
|
||||
const calls: unknown[][] = [];
|
||||
const placed = ensureHyprlandWindowFloatingByTitle({
|
||||
title: 'SubMiner Stats',
|
||||
platform: 'linux',
|
||||
env: {
|
||||
HYPRLAND_INSTANCE_SIGNATURE: 'abc',
|
||||
},
|
||||
pid: 456,
|
||||
bounds: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
execFileSync: ((command: string, args: string[], options: unknown) => {
|
||||
calls.push([command, args, options]);
|
||||
if (args.join(' ') === '-j clients') {
|
||||
return JSON.stringify([
|
||||
{
|
||||
address: '0xmatch',
|
||||
pid: 456,
|
||||
title: 'SubMiner Stats',
|
||||
mapped: true,
|
||||
floating: true,
|
||||
pinned: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return '';
|
||||
}) as never,
|
||||
});
|
||||
|
||||
assert.equal(placed, true);
|
||||
assert.deepEqual(
|
||||
calls.map(([, args]) => args),
|
||||
[
|
||||
['-j', 'clients'],
|
||||
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'],
|
||||
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'],
|
||||
['dispatch', 'setprop', 'address:0xmatch rounding 0'],
|
||||
['dispatch', 'setprop', 'address:0xmatch border_size 0'],
|
||||
['dispatch', 'setprop', 'address:0xmatch no_shadow 1'],
|
||||
['dispatch', 'setprop', 'address:0xmatch no_blur 1'],
|
||||
['dispatch', 'setprop', 'address:0xmatch decorate 0'],
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface HyprlandPlacementClient {
|
||||
address?: string;
|
||||
floating?: boolean;
|
||||
hidden?: boolean;
|
||||
initialTitle?: string;
|
||||
mapped?: boolean;
|
||||
pid?: number;
|
||||
pinned?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface HyprlandPlacementBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type ExecFileSync = typeof execFileSync;
|
||||
|
||||
export function shouldAttemptHyprlandWindowPlacement(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return platform === 'linux' && Boolean(env.HYPRLAND_INSTANCE_SIGNATURE);
|
||||
}
|
||||
|
||||
function parseHyprlandClients(output: string): HyprlandPlacementClient[] {
|
||||
const payloadStart = output.indexOf('[');
|
||||
if (payloadStart < 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(output.slice(payloadStart)) as unknown;
|
||||
return Array.isArray(parsed) ? (parsed as HyprlandPlacementClient[]) : [];
|
||||
}
|
||||
|
||||
export function findHyprlandWindowForPlacement(
|
||||
clients: HyprlandPlacementClient[],
|
||||
options: {
|
||||
pid: number;
|
||||
title: string;
|
||||
},
|
||||
): HyprlandPlacementClient | null {
|
||||
const title = options.title.trim();
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
clients.find(
|
||||
(client) =>
|
||||
client.pid === options.pid &&
|
||||
client.address &&
|
||||
client.mapped !== false &&
|
||||
client.hidden !== true &&
|
||||
(client.title === title || client.initialTitle === title),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function buildHyprlandPlacementDispatches(
|
||||
client: HyprlandPlacementClient,
|
||||
bounds?: HyprlandPlacementBounds | null,
|
||||
): string[][] {
|
||||
if (!client.address) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const windowAddress = `address:${client.address}`;
|
||||
const dispatches: string[][] = [];
|
||||
if (client.floating !== true) {
|
||||
dispatches.push(['dispatch', 'setfloating', windowAddress]);
|
||||
}
|
||||
if (client.pinned === true) {
|
||||
dispatches.push(['dispatch', 'pin', windowAddress]);
|
||||
}
|
||||
const roundedBounds = roundPlacementBounds(bounds);
|
||||
if (roundedBounds) {
|
||||
dispatches.push([
|
||||
'dispatch',
|
||||
'movewindowpixel',
|
||||
`exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`,
|
||||
]);
|
||||
dispatches.push([
|
||||
'dispatch',
|
||||
'resizewindowpixel',
|
||||
`exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`,
|
||||
]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]);
|
||||
dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]);
|
||||
}
|
||||
return dispatches;
|
||||
}
|
||||
|
||||
function roundPlacementBounds(
|
||||
bounds?: HyprlandPlacementBounds | null,
|
||||
): HyprlandPlacementBounds | null {
|
||||
if (!bounds) {
|
||||
return null;
|
||||
}
|
||||
const rounded = {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.round(bounds.width),
|
||||
height: Math.round(bounds.height),
|
||||
};
|
||||
return Number.isFinite(rounded.x) &&
|
||||
Number.isFinite(rounded.y) &&
|
||||
Number.isFinite(rounded.width) &&
|
||||
Number.isFinite(rounded.height) &&
|
||||
rounded.width > 0 &&
|
||||
rounded.height > 0
|
||||
? rounded
|
||||
: null;
|
||||
}
|
||||
|
||||
export function ensureHyprlandWindowFloatingByTitle(options: {
|
||||
title: string;
|
||||
bounds?: HyprlandPlacementBounds | null;
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pid?: number;
|
||||
execFileSync?: ExecFileSync;
|
||||
}): boolean {
|
||||
if (!shouldAttemptHyprlandWindowPlacement(options.platform, options.env)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const run = options.execFileSync ?? execFileSync;
|
||||
try {
|
||||
const clients = parseHyprlandClients(
|
||||
String(run('hyprctl', ['-j', 'clients'], { encoding: 'utf-8' })),
|
||||
);
|
||||
const client = findHyprlandWindowForPlacement(clients, {
|
||||
pid: options.pid ?? process.pid,
|
||||
title: options.title,
|
||||
});
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dispatches = buildHyprlandPlacementDispatches(client, options.bounds);
|
||||
for (const args of dispatches) {
|
||||
run('hyprctl', args, { stdio: 'ignore' });
|
||||
}
|
||||
return dispatches.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3050,6 +3050,59 @@ test('anime and media detail prefer lifetime totals over partial retained sessio
|
||||
}
|
||||
});
|
||||
|
||||
test('media detail resolves retained sessions before lifetime summary exists', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/recent-session.mkv', {
|
||||
canonicalTitle: 'Recent Session Episode',
|
||||
sourcePath: '/tmp/recent-session.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const startedAtMs = 1_700_000_000_000;
|
||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET ended_at_ms = ?, status = 2, active_watched_ms = ?, lines_seen = ?, tokens_seen = ?, cards_mined = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(startedAtMs + 600_000, 600_000, 100, 990, 1, sessionId);
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId,
|
||||
videoId,
|
||||
occurrenceCount: 4,
|
||||
startedAtMs,
|
||||
});
|
||||
|
||||
assert.equal(getSessionSummaries(db, 1)[0]?.videoId, videoId);
|
||||
assert.equal(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?')
|
||||
.get(videoId) as { total: number }
|
||||
).total,
|
||||
0,
|
||||
);
|
||||
|
||||
const detail = getMediaDetail(db, videoId);
|
||||
assert.ok(detail);
|
||||
assert.equal(detail.canonicalTitle, 'Recent Session Episode');
|
||||
assert.equal(detail.totalSessions, 1);
|
||||
assert.equal(detail.totalActiveMs, 600_000);
|
||||
assert.equal(detail.totalLinesSeen, 100);
|
||||
assert.equal(detail.totalTokensSeen, 4);
|
||||
assert.equal(detail.totalCards, 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('media library and detail queries read lifetime totals', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
@@ -243,6 +243,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
}
|
||||
|
||||
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
|
||||
const wordsExpr = sessionDisplayWordsExpr('s', 'swc', 'COALESCE(asm.tokensSeen, s.tokens_seen)');
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
@@ -251,11 +252,26 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
||||
v.video_id AS videoId,
|
||||
v.canonical_title AS canonicalTitle,
|
||||
v.anime_id AS animeId,
|
||||
COALESCE(lm.total_sessions, 0) AS totalSessions,
|
||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
|
||||
COALESCE(lm.total_cards, 0) AS totalCards,
|
||||
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
|
||||
COALESCE(lm.total_lines_seen, 0) AS totalLinesSeen,
|
||||
CASE
|
||||
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_sessions, 0)
|
||||
ELSE COUNT(DISTINCT s.session_id)
|
||||
END AS totalSessions,
|
||||
CASE
|
||||
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_active_ms, 0)
|
||||
ELSE COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0)
|
||||
END AS totalActiveMs,
|
||||
CASE
|
||||
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_cards, 0)
|
||||
ELSE COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0)
|
||||
END AS totalCards,
|
||||
CASE
|
||||
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_tokens_seen, 0)
|
||||
ELSE COALESCE(SUM(${wordsExpr}), 0)
|
||||
END AS totalTokensSeen,
|
||||
CASE
|
||||
WHEN lm.video_id IS NOT NULL THEN COALESCE(lm.total_lines_seen, 0)
|
||||
ELSE COALESCE(SUM(COALESCE(asm.linesSeen, s.lines_seen, 0)), 0)
|
||||
END AS totalLinesSeen,
|
||||
COALESCE(SUM(COALESCE(asm.lookupCount, s.lookup_count, 0)), 0) AS totalLookupCount,
|
||||
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
|
||||
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
|
||||
@@ -271,11 +287,13 @@ export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRo
|
||||
yv.uploader_url AS uploaderUrl,
|
||||
yv.description AS description
|
||||
FROM imm_videos v
|
||||
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||
LEFT JOIN imm_sessions s ON s.video_id = v.video_id
|
||||
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
|
||||
LEFT JOIN session_word_counts swc ON swc.sessionId = s.session_id
|
||||
WHERE v.video_id = ?
|
||||
AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)
|
||||
GROUP BY v.video_id
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -302,6 +302,86 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: createFakeImmersionTracker({
|
||||
markActiveVideoWatched: async () => {
|
||||
calls.push('mark');
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
runAnilistPostWatchUpdateOnManualMark: async () => {
|
||||
calls.push('anilist');
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const result = await handlers.handle.get(IPC_CHANNELS.command.markActiveVideoWatched)?.({});
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(calls, ['mark', 'anilist']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers isolates AniList update failures after manual mark watched succeeds', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = () => undefined;
|
||||
|
||||
try {
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: createFakeImmersionTracker({
|
||||
markActiveVideoWatched: async () => {
|
||||
calls.push('mark');
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
runAnilistPostWatchUpdateOnManualMark: async () => {
|
||||
calls.push('anilist');
|
||||
throw new Error('post-watch failed');
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const result = await handlers.handle.get(IPC_CHANNELS.command.markActiveVideoWatched)?.({});
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(calls, ['mark', 'anilist']);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test('registerIpcHandlers skips AniList update when manual mark watched has no active session', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: string[] = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: createFakeImmersionTracker({
|
||||
markActiveVideoWatched: async () => {
|
||||
calls.push('mark');
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
runAnilistPostWatchUpdateOnManualMark: async () => {
|
||||
calls.push('anilist');
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const result = await handlers.handle.get(IPC_CHANNELS.command.markActiveVideoWatched)?.({});
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(calls, ['mark']);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, unknown[]]> = [];
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface IpcServiceDeps {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
@@ -213,6 +214,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
openAnilistSetup: () => void;
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
runAnilistPostWatchUpdateOnManualMark?: () => Promise<void>;
|
||||
getCharacterDictionarySelection?: () => Promise<unknown>;
|
||||
setCharacterDictionarySelection?: (mediaId: number) => Promise<unknown>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
@@ -288,6 +290,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
openAnilistSetup: options.openAnilistSetup,
|
||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||
runAnilistPostWatchUpdateOnManualMark: options.runAnilistPostWatchUpdateOnManualMark,
|
||||
getCharacterDictionarySelection:
|
||||
options.getCharacterDictionarySelection ??
|
||||
(async () => ({
|
||||
@@ -385,7 +388,18 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.command.markActiveVideoWatched, async () => {
|
||||
return (await deps.immersionTracker?.markActiveVideoWatched()) ?? false;
|
||||
const marked = (await deps.immersionTracker?.markActiveVideoWatched()) ?? false;
|
||||
if (marked) {
|
||||
try {
|
||||
await deps.runAnilistPostWatchUpdateOnManualMark?.();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to run AniList post-watch update after manual watched mark:',
|
||||
(error as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
return marked;
|
||||
});
|
||||
|
||||
ipc.on(IPC_CHANNELS.command.quitApp, () => {
|
||||
|
||||
@@ -59,6 +59,7 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'sub-ass-override',
|
||||
'sub-use-margins',
|
||||
'pause',
|
||||
'fullscreen',
|
||||
'duration',
|
||||
'media-title',
|
||||
'secondary-sub-visibility',
|
||||
|
||||
@@ -93,6 +93,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
emitTimePosChange: () => {},
|
||||
emitDurationChange: () => {},
|
||||
emitPauseChange: () => {},
|
||||
emitFullscreenChange: (payload) => state.events.push(payload),
|
||||
autoLoadSecondarySubTrack: () => {},
|
||||
setCurrentVideoPath: () => {},
|
||||
emitSecondarySubtitleVisibility: (payload) => state.events.push(payload),
|
||||
@@ -160,6 +161,17 @@ test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay sup
|
||||
]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits fullscreen changes', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'fullscreen', data: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ fullscreen: true }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay is hidden', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
isVisibleOverlayVisible: () => false,
|
||||
@@ -269,6 +281,25 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentTimePos = 0;
|
||||
const { deps } = createDeps({
|
||||
setCurrentTimePos: (time) => {
|
||||
currentTimePos = time;
|
||||
calls.push(`set:${time}`);
|
||||
},
|
||||
getCurrentTimePos: () => currentTimePos,
|
||||
emitTimePosChange: ({ time }) => {
|
||||
calls.push(`emit:${time}:current=${currentTimePos}`);
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'time-pos', data: 90 }, deps);
|
||||
|
||||
assert.deepEqual(calls, ['set:90', 'emit:90:current=90']);
|
||||
});
|
||||
|
||||
test('splitMpvMessagesFromBuffer parses complete lines and preserves partial buffer', () => {
|
||||
const parsed = splitMpvMessagesFromBuffer(
|
||||
'{"event":"shutdown"}\n{"event":"property-change","name":"media-title","data":"x"}\n{"partial"',
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitTimePosChange: (payload: { time: number }) => void;
|
||||
emitDurationChange: (payload: { duration: number }) => void;
|
||||
emitPauseChange: (payload: { paused: boolean }) => void;
|
||||
emitFullscreenChange: (payload: { fullscreen: boolean }) => void;
|
||||
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
|
||||
setCurrentSecondarySubText: (text: string) => void;
|
||||
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
|
||||
@@ -275,8 +276,9 @@ export async function dispatchMpvProtocolMessage(
|
||||
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
|
||||
deps.syncCurrentAudioStreamIndex();
|
||||
} else if (msg.name === 'time-pos') {
|
||||
deps.emitTimePosChange({ time: (msg.data as number) || 0 });
|
||||
deps.setCurrentTimePos((msg.data as number) || 0);
|
||||
const timePos = (msg.data as number) || 0;
|
||||
deps.setCurrentTimePos(timePos);
|
||||
deps.emitTimePosChange({ time: timePos });
|
||||
if (
|
||||
deps.getPauseAtTime() !== null &&
|
||||
deps.getCurrentTimePos() >= (deps.getPauseAtTime() as number)
|
||||
@@ -291,6 +293,8 @@ export async function dispatchMpvProtocolMessage(
|
||||
}
|
||||
} else if (msg.name === 'pause') {
|
||||
deps.emitPauseChange({ paused: asBoolean(msg.data, false) });
|
||||
} else if (msg.name === 'fullscreen') {
|
||||
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
|
||||
} else if (msg.name === 'media-title') {
|
||||
deps.emitMediaTitleChange({
|
||||
title: typeof msg.data === 'string' ? msg.data.trim() : null,
|
||||
|
||||
@@ -57,6 +57,22 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub
|
||||
assert.equal(events[0]!.isOverlayVisible, false);
|
||||
});
|
||||
|
||||
test('MpvIpcClient emits fullscreen property changes', async () => {
|
||||
const events: Array<{ fullscreen: boolean }> = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
client.on('fullscreen-change', (payload) => {
|
||||
events.push(payload);
|
||||
});
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'fullscreen',
|
||||
data: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [{ fullscreen: true }]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient clears cached media title when media path changes', async () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
|
||||
@@ -473,7 +489,7 @@ test('MpvIpcClient updates current audio stream index from track list', async ()
|
||||
assert.equal(client.currentAudioStreamIndex, 11);
|
||||
});
|
||||
|
||||
test('MpvIpcClient playNextSubtitle preserves a manual paused state', async () => {
|
||||
test('MpvIpcClient playNextSubtitle starts playback from paused state and auto-pauses at end', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).send = (payload: unknown) => {
|
||||
@@ -491,9 +507,29 @@ test('MpvIpcClient playNextSubtitle preserves a manual paused state', async () =
|
||||
|
||||
client.playNextSubtitle();
|
||||
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, false);
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, true);
|
||||
assert.equal((client as any).pauseAtTime, null);
|
||||
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
|
||||
assert.deepEqual(commands, [
|
||||
{ command: ['sub-seek', 1] },
|
||||
{ command: ['set_property', 'pause', false] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient playNextSubtitle starts playback when pause state is unknown', () => {
|
||||
const commands: unknown[] = [];
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
(client as any).send = (payload: unknown) => {
|
||||
commands.push(payload);
|
||||
return true;
|
||||
};
|
||||
|
||||
client.playNextSubtitle();
|
||||
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, true);
|
||||
assert.deepEqual(commands, [
|
||||
{ command: ['sub-seek', 1] },
|
||||
{ command: ['set_property', 'pause', false] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playing', async () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ export interface MpvIpcClientEventMap {
|
||||
'time-pos-change': { time: number };
|
||||
'duration-change': { duration: number };
|
||||
'pause-change': { paused: boolean };
|
||||
'fullscreen-change': { fullscreen: boolean };
|
||||
'secondary-subtitle-change': { text: string };
|
||||
'subtitle-track-change': { sid: number | null };
|
||||
'subtitle-track-list-change': { trackList: unknown[] | null };
|
||||
@@ -330,6 +331,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.playbackPaused = payload.paused;
|
||||
this.emit('pause-change', payload);
|
||||
},
|
||||
emitFullscreenChange: (payload) => {
|
||||
this.emit('fullscreen-change', payload);
|
||||
},
|
||||
emitSecondarySubtitleChange: (payload) => {
|
||||
this.emit('secondary-subtitle-change', payload);
|
||||
},
|
||||
@@ -518,14 +522,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
}
|
||||
|
||||
playNextSubtitle(): void {
|
||||
if (this.playbackPaused === true) {
|
||||
this.pendingPauseAtSubEnd = false;
|
||||
this.pauseAtTime = null;
|
||||
this.send({ command: ['sub-seek', 1] });
|
||||
return;
|
||||
}
|
||||
this.pendingPauseAtSubEnd = true;
|
||||
this.pauseAtTime = null;
|
||||
this.send({ command: ['sub-seek', 1] });
|
||||
if (this.playbackPaused !== false) {
|
||||
this.send({ command: ['set_property', 'pause', false] });
|
||||
}
|
||||
}
|
||||
|
||||
restorePreviousSecondarySubVisibility(): void {
|
||||
|
||||
@@ -77,6 +77,7 @@ test('overlay manager applies bounds for main and modal windows', () => {
|
||||
const visibleCalls: Electron.Rectangle[] = [];
|
||||
const visibleWindow = {
|
||||
isDestroyed: () => false,
|
||||
getTitle: () => 'SubMiner Overlay',
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
visibleCalls.push(bounds);
|
||||
},
|
||||
@@ -84,6 +85,7 @@ test('overlay manager applies bounds for main and modal windows', () => {
|
||||
const modalCalls: Electron.Rectangle[] = [];
|
||||
const modalWindow = {
|
||||
isDestroyed: () => false,
|
||||
getTitle: () => 'SubMiner Overlay Modal',
|
||||
setBounds: (bounds: Electron.Rectangle) => {
|
||||
modalCalls.push(bounds);
|
||||
},
|
||||
|
||||
@@ -883,7 +883,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS tracked visible overlay stays click-through without passively stealing focus', () => {
|
||||
test('macOS tracked visible overlay stays interactive without passively stealing focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
@@ -915,11 +915,158 @@ test('macOS tracked visible overlay stays click-through without passively steali
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('macOS keeps active mpv overlay visible and interactive during tracker refresh', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {
|
||||
calls.push('tracker-warning');
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.deepEqual(osdMessages, []);
|
||||
});
|
||||
|
||||
test('macOS tracked overlay releases topmost level when mpv loses foreground', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('always-on-top:false'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('ensure-level'));
|
||||
assert.ok(!calls.includes('enforce-order'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
});
|
||||
|
||||
test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => false,
|
||||
getGeometry: () => null,
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
calls.push(`tracker-warning:${shown}`);
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.deepEqual(osdMessages, []);
|
||||
});
|
||||
|
||||
test('forced mouse passthrough keeps macOS tracked overlay passive while visible', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -1192,6 +1339,65 @@ test('macOS keeps visible overlay hidden while tracker is not initialized yet',
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS preserves visible overlay during transient tracker loss with retained geometry', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
let trackerWarning = false;
|
||||
let tracking = true;
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => tracking,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => true,
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: trackerWarning,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
trackerWarning = shown;
|
||||
},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
showOverlayLoadingOsd: (message: string) => {
|
||||
osdMessages.push(message);
|
||||
},
|
||||
} as never);
|
||||
|
||||
run();
|
||||
calls.length = 0;
|
||||
tracking = false;
|
||||
|
||||
run();
|
||||
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.deepEqual(osdMessages, []);
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('macOS suppresses immediate repeat loading OSD after tracker recovery until cooldown expires', () => {
|
||||
const { window } = createMainWindowRecorder();
|
||||
const osdMessages: string[] = [];
|
||||
|
||||
@@ -89,13 +89,22 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const showPassiveVisibleOverlay = (): boolean => {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
const wasVisible = mainWindow.isVisible();
|
||||
const shouldDefaultToPassthrough =
|
||||
args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough;
|
||||
const isVisibleOverlayFocused =
|
||||
typeof mainWindow.isFocused === 'function' && mainWindow.isFocused();
|
||||
const isTrackedMacOSTargetFocused =
|
||||
!args.isMacOSPlatform || !args.windowTracker
|
||||
? true
|
||||
: (args.windowTracker.isTargetWindowFocused?.() ?? true);
|
||||
const shouldReleaseMacOSOverlayLevel =
|
||||
args.isMacOSPlatform &&
|
||||
!!args.windowTracker &&
|
||||
!isVisibleOverlayFocused &&
|
||||
!isTrackedMacOSTargetFocused;
|
||||
const shouldDefaultToPassthrough =
|
||||
args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel;
|
||||
const windowsForegroundProcessName =
|
||||
args.lastKnownWindowsForegroundProcessName?.trim().toLowerCase() ?? null;
|
||||
const windowsOverlayProcessName = args.windowsOverlayProcessName?.trim().toLowerCase() ?? null;
|
||||
@@ -138,7 +147,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// On Windows, z-order is enforced by the OS via the owner window mechanism
|
||||
// (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv
|
||||
// without any manual z-order management.
|
||||
} else if (!forceMousePassthrough) {
|
||||
} else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) {
|
||||
args.ensureOverlayWindowLevel(mainWindow);
|
||||
} else {
|
||||
mainWindow.setAlwaysOnTop(false);
|
||||
@@ -187,6 +196,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
return !shouldReleaseMacOSOverlayLevel;
|
||||
};
|
||||
|
||||
const maybeShowOverlayLoadingOsd = (): void => {
|
||||
@@ -230,8 +241,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
if (!args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
args.enforceOverlayLayerOrder();
|
||||
}
|
||||
args.syncOverlayShortcuts();
|
||||
@@ -260,11 +271,19 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null;
|
||||
const hasActiveMacOSTargetSignal =
|
||||
args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false);
|
||||
const shouldPreserveTransientTrackedOverlay =
|
||||
(args.isMacOSPlatform &&
|
||||
(hasRetainedTrackedGeometry || (mainWindow.isVisible() && hasActiveMacOSTargetSignal))) ||
|
||||
(args.isWindowsPlatform &&
|
||||
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
!args.windowTracker.isTargetWindowMinimized());
|
||||
|
||||
if (
|
||||
args.isWindowsPlatform &&
|
||||
typeof args.windowTracker.isTargetWindowMinimized === 'function' &&
|
||||
!args.windowTracker.isTargetWindowMinimized() &&
|
||||
(mainWindow.isVisible() || args.windowTracker.getGeometry() !== null)
|
||||
shouldPreserveTransientTrackedOverlay &&
|
||||
(mainWindow.isVisible() || hasRetainedTrackedGeometry)
|
||||
) {
|
||||
args.setTrackerNotReadyWarningShown(false);
|
||||
const geometry = args.windowTracker.getGeometry();
|
||||
@@ -272,7 +291,10 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
args.updateVisibleOverlayBounds(geometry);
|
||||
}
|
||||
args.syncPrimaryOverlayWindowLayer('visible');
|
||||
showPassiveVisibleOverlay();
|
||||
const shouldEnforceLayerOrder = showPassiveVisibleOverlay();
|
||||
if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) {
|
||||
args.enforceOverlayLayerOrder();
|
||||
}
|
||||
args.syncOverlayShortcuts();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ test('overlay window config explicitly disables renderer sandbox for preload com
|
||||
yomitanSession: null,
|
||||
});
|
||||
|
||||
assert.equal(options.title, 'SubMiner Overlay');
|
||||
assert.equal(options.backgroundColor, '#00000000');
|
||||
assert.equal(options.webPreferences?.sandbox, false);
|
||||
assert.equal(options.webPreferences?.backgroundThrottling, false);
|
||||
|
||||
@@ -69,10 +69,14 @@ export function handleOverlayWindowBlurred(options: {
|
||||
onWindowsVisibleOverlayBlur?: () => void;
|
||||
platform?: NodeJS.Platform;
|
||||
}): boolean {
|
||||
if ((options.platform ?? process.platform) === 'win32' && options.kind === 'visible') {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform === 'win32' && options.kind === 'visible') {
|
||||
options.onWindowsVisibleOverlayBlur?.();
|
||||
return false;
|
||||
}
|
||||
if (platform === 'darwin' && options.kind === 'visible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.kind === 'visible' && !options.isOverlayVisible(options.kind)) {
|
||||
return false;
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { BrowserWindowConstructorOptions, Session } from 'electron';
|
||||
import * as path from 'path';
|
||||
import type { OverlayWindowKind } from './overlay-window-input';
|
||||
|
||||
export const OVERLAY_WINDOW_TITLES: Record<OverlayWindowKind, string> = {
|
||||
visible: 'SubMiner Overlay',
|
||||
modal: 'SubMiner Overlay Modal',
|
||||
};
|
||||
|
||||
export function buildOverlayWindowOptions(
|
||||
kind: OverlayWindowKind,
|
||||
options: {
|
||||
@@ -14,6 +19,7 @@ export function buildOverlayWindowOptions(
|
||||
|
||||
return {
|
||||
show: false,
|
||||
title: OVERLAY_WINDOW_TITLES[kind],
|
||||
width: 800,
|
||||
height: 600,
|
||||
x: 0,
|
||||
|
||||
@@ -146,6 +146,49 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback
|
||||
assert.deepEqual(calls, ['windows-visible-blur']);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = handleOverlayWindowBlurred({
|
||||
kind: 'visible',
|
||||
windowVisible: true,
|
||||
isOverlayVisible: () => true,
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
moveWindowTop: () => {
|
||||
calls.push('move-top');
|
||||
},
|
||||
onWindowsVisibleOverlayBlur: () => {
|
||||
calls.push('windows-visible-blur');
|
||||
},
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.equal(handled, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, screen, type Session } from 'electron';
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow, Session } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { WindowGeometry } from '../../types';
|
||||
import { createLogger } from '../../logger';
|
||||
@@ -8,12 +9,14 @@ import {
|
||||
handleOverlayWindowBlurred,
|
||||
type OverlayWindowKind,
|
||||
} from './overlay-window-input';
|
||||
import { buildOverlayWindowOptions } from './overlay-window-options';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement';
|
||||
import { buildOverlayWindowOptions, OVERLAY_WINDOW_TITLES } from './overlay-window-options';
|
||||
import { normalizeOverlayWindowBoundsForPlatform } from './overlay-window-bounds';
|
||||
import { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
export { OVERLAY_WINDOW_CONTENT_READY_FLAG } from './overlay-window-flags';
|
||||
|
||||
const logger = createLogger('main:overlay-window');
|
||||
const { BrowserWindow: ElectronBrowserWindow, screen } = electron;
|
||||
const overlayWindowLayerByInstance = new WeakMap<BrowserWindow, OverlayWindowKind>();
|
||||
const overlayWindowContentReady = new WeakSet<BrowserWindow>();
|
||||
|
||||
@@ -50,7 +53,9 @@ export function updateOverlayWindowBounds(
|
||||
window: BrowserWindow | null,
|
||||
): void {
|
||||
if (!geometry || !window || window.isDestroyed()) return;
|
||||
window.setBounds(normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen));
|
||||
const bounds = normalizeOverlayWindowBoundsForPlatform(geometry, process.platform, screen);
|
||||
window.setBounds(bounds);
|
||||
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle(), bounds });
|
||||
}
|
||||
|
||||
export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
@@ -67,6 +72,9 @@ export function ensureOverlayWindowLevel(window: BrowserWindow): void {
|
||||
return;
|
||||
}
|
||||
window.setAlwaysOnTop(true);
|
||||
window.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
ensureHyprlandWindowFloatingByTitle({ title: window.getTitle() });
|
||||
window.moveTop();
|
||||
}
|
||||
|
||||
export function enforceOverlayLayerOrder(options: {
|
||||
@@ -97,7 +105,7 @@ export function createOverlayWindow(
|
||||
yomitanSession?: Session | null;
|
||||
},
|
||||
): BrowserWindow {
|
||||
const window = new BrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||
const window = new ElectronBrowserWindow(buildOverlayWindowOptions(kind, options));
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||
OVERLAY_WINDOW_CONTENT_READY_FLAG
|
||||
] = false;
|
||||
@@ -112,9 +120,15 @@ export function createOverlayWindow(
|
||||
});
|
||||
|
||||
window.webContents.on('did-finish-load', () => {
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
options.onRuntimeOptionsChanged();
|
||||
});
|
||||
|
||||
window.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
window.setTitle(OVERLAY_WINDOW_TITLES[kind]);
|
||||
});
|
||||
|
||||
window.once('ready-to-show', () => {
|
||||
overlayWindowContentReady.add(window);
|
||||
(window as BrowserWindow & { [OVERLAY_WINDOW_CONTENT_READY_FLAG]?: boolean })[
|
||||
|
||||
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { Keybinding } from '../../types';
|
||||
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
|
||||
import { SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
|
||||
import { resolveConfiguredShortcuts } from '../utils/shortcut-config';
|
||||
import { compileSessionBindings } from './session-bindings';
|
||||
|
||||
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
|
||||
@@ -179,6 +180,35 @@ test('compileSessionBindings drops conflicting bindings that canonicalize to the
|
||||
]);
|
||||
});
|
||||
|
||||
test('compileSessionBindings keeps default replay and next subtitle session actions on Linux', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
|
||||
keybindings: DEFAULT_KEYBINDINGS,
|
||||
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
|
||||
platform: 'linux',
|
||||
rawConfig: DEFAULT_CONFIG,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.warnings.filter((warning) => warning.kind === 'conflict'),
|
||||
[],
|
||||
);
|
||||
const bySignature = new Map(
|
||||
result.bindings.map((binding) => [
|
||||
`${binding.key.modifiers.join('+')}+${binding.key.code}`,
|
||||
binding,
|
||||
]),
|
||||
);
|
||||
|
||||
const replay = bySignature.get('ctrl+shift+KeyH');
|
||||
assert.equal(replay?.actionType, 'session-action');
|
||||
assert.equal(replay?.actionId, 'replayCurrentSubtitle');
|
||||
|
||||
const next = bySignature.get('ctrl+shift+KeyL');
|
||||
assert.equal(next?.actionType, 'session-action');
|
||||
assert.equal(next?.actionId, 'playNextSubtitle');
|
||||
});
|
||||
|
||||
test('compileSessionBindings omits disabled bindings', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
|
||||
@@ -3,10 +3,13 @@ import type { WindowGeometry } from '../../types';
|
||||
|
||||
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
||||
const DEFAULT_STATS_WINDOW_HEIGHT = 700;
|
||||
export const STATS_WINDOW_TITLE = 'SubMiner Stats';
|
||||
|
||||
type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTop'> &
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
|
||||
function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean {
|
||||
return (
|
||||
input.type === 'keyDown' &&
|
||||
@@ -30,12 +33,13 @@ export function buildStatsWindowOptions(options: {
|
||||
bounds?: WindowGeometry | null;
|
||||
}): BrowserWindowConstructorOptions {
|
||||
return {
|
||||
title: STATS_WINDOW_TITLE,
|
||||
x: options.bounds?.x,
|
||||
y: options.bounds?.y,
|
||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
transparent: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
skipTaskbar: true,
|
||||
@@ -43,7 +47,7 @@ export function buildStatsWindowOptions(options: {
|
||||
focusable: true,
|
||||
acceptFirstMouse: true,
|
||||
fullscreenable: false,
|
||||
backgroundColor: '#1e1e2e',
|
||||
backgroundColor: '#24273a',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
@@ -54,6 +58,30 @@ export function buildStatsWindowOptions(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStatsWindowOuterBoundsForContent(
|
||||
window: StatsWindowBoundsController,
|
||||
target: WindowGeometry,
|
||||
): WindowGeometry {
|
||||
const outer = window.getBounds();
|
||||
const content = window.getContentBounds();
|
||||
const leftInset = content.x - outer.x;
|
||||
const topInset = content.y - outer.y;
|
||||
const rightInset = outer.x + outer.width - (content.x + content.width);
|
||||
const bottomInset = outer.y + outer.height - (content.y + content.height);
|
||||
const insets = [leftInset, topInset, rightInset, bottomInset];
|
||||
|
||||
if (insets.some((inset) => !Number.isFinite(inset) || inset < 0)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
return {
|
||||
x: target.x - leftInset,
|
||||
y: target.y - topInset,
|
||||
width: target.width + leftInset + rightInset,
|
||||
height: target.height + topInset + bottomInset,
|
||||
};
|
||||
}
|
||||
|
||||
export function promoteStatsWindowLevel(
|
||||
window: StatsWindowLevelController,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
shouldHideStatsWindowForInput,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
@@ -18,12 +19,14 @@ test('buildStatsWindowOptions uses tracked overlay bounds and preload-friendly w
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(options.title, 'SubMiner Stats');
|
||||
assert.equal(options.x, 120);
|
||||
assert.equal(options.y, 80);
|
||||
assert.equal(options.width, 1440);
|
||||
assert.equal(options.height, 900);
|
||||
assert.equal(options.frame, false);
|
||||
assert.equal(options.transparent, true);
|
||||
assert.equal(options.transparent, false);
|
||||
assert.equal(options.backgroundColor, '#24273a');
|
||||
assert.equal(options.resizable, false);
|
||||
assert.equal(options.webPreferences?.preload, '/tmp/preload-stats.js');
|
||||
assert.equal(options.webPreferences?.contextIsolation, true);
|
||||
@@ -151,6 +154,33 @@ test('buildStatsWindowLoadFileOptions includes provided stats API base URL', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveStatsWindowOuterBoundsForContent compensates for Wayland content insets', () => {
|
||||
assert.deepEqual(
|
||||
resolveStatsWindowOuterBoundsForContent(
|
||||
{
|
||||
getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }),
|
||||
getContentBounds: () => ({ x: 0, y: 14, width: 3440, height: 1426 }),
|
||||
},
|
||||
{ x: 0, y: 0, width: 3440, height: 1440 },
|
||||
),
|
||||
{ x: 0, y: -14, width: 3440, height: 1454 },
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveStatsWindowOuterBoundsForContent ignores invalid inset geometry', () => {
|
||||
const target = { x: 0, y: 0, width: 3440, height: 1440 };
|
||||
assert.deepEqual(
|
||||
resolveStatsWindowOuterBoundsForContent(
|
||||
{
|
||||
getBounds: () => ({ x: 0, y: 0, width: 3440, height: 1440 }),
|
||||
getContentBounds: () => ({ x: -1, y: 0, width: 3440, height: 1440 }),
|
||||
},
|
||||
target,
|
||||
),
|
||||
target,
|
||||
);
|
||||
});
|
||||
|
||||
test('promoteStatsWindowLevel raises stats above overlay level on macOS', () => {
|
||||
const calls: string[] = [];
|
||||
promoteStatsWindowLevel(
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
shouldHideStatsWindowForInput,
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let toggleRegistered = false;
|
||||
@@ -27,20 +30,32 @@ export interface StatsWindowOptions {
|
||||
onVisibilityChanged?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
function syncStatsWindowBounds(window: BrowserWindow, bounds: WindowGeometry | null): void {
|
||||
if (!bounds || window.isDestroyed()) return;
|
||||
function syncStatsWindowBounds(
|
||||
window: BrowserWindow,
|
||||
bounds: WindowGeometry | null,
|
||||
): WindowGeometry | null {
|
||||
if (!bounds || window.isDestroyed()) return null;
|
||||
const outerBounds = resolveStatsWindowOuterBoundsForContent(window, bounds);
|
||||
window.setBounds({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
x: outerBounds.x,
|
||||
y: outerBounds.y,
|
||||
width: outerBounds.width,
|
||||
height: outerBounds.height,
|
||||
});
|
||||
return outerBounds;
|
||||
}
|
||||
|
||||
function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): void {
|
||||
syncStatsWindowBounds(window, options.resolveBounds());
|
||||
const bounds = options.resolveBounds();
|
||||
let placementBounds = syncStatsWindowBounds(window, bounds);
|
||||
promoteStatsWindowLevel(window);
|
||||
window.show();
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
if (
|
||||
!ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds })
|
||||
) {
|
||||
placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds;
|
||||
}
|
||||
window.focus();
|
||||
options.onVisibilityChanged?.(true);
|
||||
promoteStatsWindowLevel(window);
|
||||
@@ -59,6 +74,12 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
}),
|
||||
);
|
||||
|
||||
statsWindow.setTitle(STATS_WINDOW_TITLE);
|
||||
statsWindow.webContents.on('page-title-updated', (event) => {
|
||||
event.preventDefault();
|
||||
statsWindow?.setTitle(STATS_WINDOW_TITLE);
|
||||
});
|
||||
|
||||
const indexPath = path.join(options.staticDir, 'index.html');
|
||||
statsWindow.loadFile(indexPath, buildStatsWindowLoadFileOptions(options.getApiBaseUrl?.()));
|
||||
|
||||
@@ -74,7 +95,6 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
options.onVisibilityChanged?.(false);
|
||||
}
|
||||
});
|
||||
|
||||
statsWindow.once('ready-to-show', () => {
|
||||
if (!statsWindow) return;
|
||||
showStatsWindow(statsWindow, options);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,7 @@ interface TokenizerAnnotationOptions {
|
||||
minSentenceWordsForNPlusOne: number | undefined;
|
||||
pos1Exclusions: ReadonlySet<string>;
|
||||
pos2Exclusions: ReadonlySet<string>;
|
||||
sourceText?: string;
|
||||
}
|
||||
|
||||
let parserEnrichmentWorkerRuntimeModulePromise: Promise<
|
||||
@@ -159,7 +160,7 @@ async function applyAnnotationStage(
|
||||
options: TokenizerAnnotationOptions,
|
||||
): Promise<MergedToken[]> {
|
||||
if (!hasAnyAnnotationEnabled(options)) {
|
||||
return tokens;
|
||||
return stripSubtitleAnnotationMetadata(tokens, options);
|
||||
}
|
||||
|
||||
if (!annotationStageModulePromise) {
|
||||
@@ -178,7 +179,10 @@ async function applyAnnotationStage(
|
||||
);
|
||||
}
|
||||
|
||||
async function stripSubtitleAnnotationMetadata(tokens: MergedToken[]): Promise<MergedToken[]> {
|
||||
async function stripSubtitleAnnotationMetadata(
|
||||
tokens: MergedToken[],
|
||||
options: TokenizerAnnotationOptions,
|
||||
): Promise<MergedToken[]> {
|
||||
if (tokens.length === 0) {
|
||||
return tokens;
|
||||
}
|
||||
@@ -188,7 +192,7 @@ async function stripSubtitleAnnotationMetadata(tokens: MergedToken[]): Promise<M
|
||||
}
|
||||
|
||||
const annotationStage = await annotationStageModulePromise;
|
||||
return tokens.map((token) => annotationStage.stripSubtitleAnnotationMetadata(token));
|
||||
return tokens.map((token) => annotationStage.stripSubtitleAnnotationMetadata(token, options));
|
||||
}
|
||||
|
||||
export function createTokenizerDepsRuntime(
|
||||
@@ -333,6 +337,66 @@ function normalizeSelectedYomitanTokens(tokens: MergedToken[]): MergedToken[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeYomitanWordClasses(wordClasses: unknown): string[] {
|
||||
if (!Array.isArray(wordClasses)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized: string[] = [];
|
||||
for (const wordClass of wordClasses) {
|
||||
if (typeof wordClass !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const trimmed = wordClass.trim();
|
||||
if (trimmed && !normalized.includes(trimmed)) {
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolvePartOfSpeechFromYomitanWordClasses(wordClasses: string[]): {
|
||||
partOfSpeech: PartOfSpeech;
|
||||
pos1?: string;
|
||||
} {
|
||||
if (wordClasses.includes('prt')) {
|
||||
return { partOfSpeech: PartOfSpeech.particle, pos1: '助詞' };
|
||||
}
|
||||
if (wordClasses.some((wordClass) => wordClass === 'aux' || wordClass.startsWith('aux-'))) {
|
||||
return { partOfSpeech: PartOfSpeech.bound_auxiliary, pos1: '助動詞' };
|
||||
}
|
||||
if (wordClasses.some((wordClass) => wordClass.startsWith('v'))) {
|
||||
return { partOfSpeech: PartOfSpeech.verb, pos1: '動詞' };
|
||||
}
|
||||
if (wordClasses.includes('adj-i') || wordClasses.includes('adj-ix')) {
|
||||
return { partOfSpeech: PartOfSpeech.i_adjective, pos1: '形容詞' };
|
||||
}
|
||||
if (wordClasses.includes('adj-na')) {
|
||||
return { partOfSpeech: PartOfSpeech.na_adjective, pos1: '名詞' };
|
||||
}
|
||||
if (
|
||||
wordClasses.some(
|
||||
(wordClass) =>
|
||||
wordClass === 'n' ||
|
||||
wordClass === 'num' ||
|
||||
wordClass === 'ctr' ||
|
||||
wordClass === 'pn' ||
|
||||
wordClass.startsWith('n-'),
|
||||
)
|
||||
) {
|
||||
return { partOfSpeech: PartOfSpeech.noun, pos1: '名詞' };
|
||||
}
|
||||
|
||||
return { partOfSpeech: PartOfSpeech.other };
|
||||
}
|
||||
|
||||
function getYomitanWordClassPosMetadata(wordClasses: unknown): {
|
||||
partOfSpeech: PartOfSpeech;
|
||||
pos1?: string;
|
||||
} {
|
||||
return resolvePartOfSpeechFromYomitanWordClasses(normalizeYomitanWordClasses(wordClasses));
|
||||
}
|
||||
|
||||
function resolveFrequencyLookupText(
|
||||
token: MergedToken,
|
||||
matchMode: FrequencyDictionaryMatchMode,
|
||||
@@ -622,21 +686,23 @@ async function parseWithYomitanInternalParser(
|
||||
return null;
|
||||
}
|
||||
const normalizedSelectedTokens = normalizeSelectedYomitanTokens(
|
||||
selectedTokens.map(
|
||||
(token): MergedToken => ({
|
||||
selectedTokens.map((token): MergedToken => {
|
||||
const posMetadata = getYomitanWordClassPosMetadata(token.wordClasses);
|
||||
return {
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
partOfSpeech: posMetadata.partOfSpeech,
|
||||
pos1: posMetadata.pos1,
|
||||
isMerged: true,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: token.isNameMatch ?? false,
|
||||
frequencyRank: token.frequencyRank,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
if (deps.getYomitanGroupDebugEnabled?.() === true) {
|
||||
@@ -716,12 +782,11 @@ export async function tokenizeSubtitle(
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const annotationOptions = getAnnotationOptions(deps);
|
||||
annotationOptions.sourceText = tokenizeText;
|
||||
|
||||
const yomitanTokens = await parseWithYomitanInternalParser(tokenizeText, deps, annotationOptions);
|
||||
if (yomitanTokens && yomitanTokens.length > 0) {
|
||||
const annotatedTokens = await stripSubtitleAnnotationMetadata(
|
||||
await applyAnnotationStage(yomitanTokens, deps, annotationOptions),
|
||||
);
|
||||
const annotatedTokens = await applyAnnotationStage(yomitanTokens, deps, annotationOptions);
|
||||
return {
|
||||
text: displayText,
|
||||
tokens: annotatedTokens.length > 0 ? annotatedTokens : null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,57 +18,6 @@ const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
const JLPT_LEVEL_LOOKUP_CACHE_LIMIT = 2048;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
|
||||
'ああ',
|
||||
'ええ',
|
||||
'うう',
|
||||
'おお',
|
||||
'はあ',
|
||||
'はは',
|
||||
'へえ',
|
||||
'ふう',
|
||||
'ほう',
|
||||
]);
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES = ['ん', 'の', 'なん', 'なの'];
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES = [
|
||||
'だ',
|
||||
'です',
|
||||
'でした',
|
||||
'だった',
|
||||
'では',
|
||||
'じゃ',
|
||||
'でしょう',
|
||||
'だろう',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
|
||||
'',
|
||||
'か',
|
||||
'ね',
|
||||
'よ',
|
||||
'な',
|
||||
'けど',
|
||||
'よね',
|
||||
'かな',
|
||||
'かね',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES.map(
|
||||
(particle) => `${prefix}${core}${particle}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
|
||||
'って',
|
||||
'ってよ',
|
||||
'ってね',
|
||||
'ってな',
|
||||
'ってさ',
|
||||
'ってか',
|
||||
'ってば',
|
||||
]);
|
||||
|
||||
const jlptLevelLookupCaches = new WeakMap<
|
||||
(text: string) => JlptLevel | null,
|
||||
@@ -89,6 +38,7 @@ export interface AnnotationStageOptions {
|
||||
minSentenceWordsForNPlusOne?: number;
|
||||
pos1Exclusions?: ReadonlySet<string>;
|
||||
pos2Exclusions?: ReadonlySet<string>;
|
||||
sourceText?: string;
|
||||
}
|
||||
|
||||
function resolveKnownWordText(
|
||||
@@ -103,10 +53,6 @@ function normalizePos1Tag(pos1: string | undefined): string {
|
||||
return typeof pos1 === 'string' ? pos1.trim() : '';
|
||||
}
|
||||
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_POS1 = new Set(['感動詞']);
|
||||
const SUBTITLE_ANNOTATION_GRAMMAR_ONLY_POS1 = new Set(['助詞', '助動詞', '連体詞']);
|
||||
const AUXILIARY_STEM_GRAMMAR_TAIL_POS1 = new Set(['名詞', '助動詞', '助詞']);
|
||||
|
||||
function splitNormalizedTagParts(normalizedTag: string): string[] {
|
||||
if (!normalizedTag) {
|
||||
return [];
|
||||
@@ -128,57 +74,6 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
||||
return parts.some((part) => exclusions.has(part));
|
||||
}
|
||||
|
||||
function isExcludedFromSubtitleAnnotationsByPos1(normalizedPos1: string): boolean {
|
||||
const parts = splitNormalizedTagParts(normalizedPos1);
|
||||
if (parts.some((part) => SUBTITLE_ANNOTATION_EXCLUDED_POS1.has(part))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return parts.length > 0 && parts.every((part) => SUBTITLE_ANNOTATION_GRAMMAR_ONLY_POS1.has(part));
|
||||
}
|
||||
|
||||
function isExcludedTrailingParticleMergedToken(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeJlptTextForExclusion(token.surface);
|
||||
const normalizedHeadword = normalizeJlptTextForExclusion(token.headword);
|
||||
if (
|
||||
!normalizedSurface ||
|
||||
!normalizedHeadword ||
|
||||
!normalizedSurface.startsWith(normalizedHeadword)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const suffix = normalizedSurface.slice(normalizedHeadword.length);
|
||||
if (!SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES.has(suffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
|
||||
if (pos1Parts.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [leadingPos1, ...trailingPos1] = pos1Parts;
|
||||
if (!leadingPos1 || SUBTITLE_ANNOTATION_GRAMMAR_ONLY_POS1.has(leadingPos1)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return trailingPos1.length > 0 && trailingPos1.every((part) => part === '助詞');
|
||||
}
|
||||
|
||||
function isAuxiliaryStemGrammarTailToken(token: MergedToken): boolean {
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
|
||||
if (
|
||||
pos1Parts.length === 0 ||
|
||||
!pos1Parts.every((part) => AUXILIARY_STEM_GRAMMAR_TAIL_POS1.has(part))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos3Parts = splitNormalizedTagParts(normalizePos2Tag(token.pos3));
|
||||
return pos3Parts.includes('助動詞語幹');
|
||||
}
|
||||
|
||||
function resolvePos1Exclusions(options: AnnotationStageOptions): ReadonlySet<string> {
|
||||
if (options.pos1Exclusions) {
|
||||
return options.pos1Exclusions;
|
||||
@@ -254,6 +149,45 @@ function shouldAllowContentLedMergedTokenFrequency(
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldAllowOrdinalPrefixNounFrequency(token: MergedToken): boolean {
|
||||
const normalizedSurface = token.surface.trim();
|
||||
const normalizedHeadword = token.headword.trim();
|
||||
if (!normalizedSurface.startsWith('第') && !normalizedHeadword.startsWith('第')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePos2Tag(token.pos2));
|
||||
return (
|
||||
pos1Parts.length >= 2 &&
|
||||
pos1Parts[0] === '接頭詞' &&
|
||||
pos1Parts.slice(1).some((part) => part === '名詞') &&
|
||||
pos2Parts[0] === '数接続' &&
|
||||
pos2Parts.slice(1).some((part) => part === '数')
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAllowHonorificPrefixNounFrequency(token: MergedToken): boolean {
|
||||
const normalizedSurface = token.surface.trim();
|
||||
const normalizedHeadword = token.headword.trim();
|
||||
if (
|
||||
!['お', 'ご', '御'].some(
|
||||
(prefix) => normalizedSurface.startsWith(prefix) || normalizedHeadword.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePos1Tag(token.pos1));
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePos2Tag(token.pos2));
|
||||
return (
|
||||
pos1Parts.length >= 2 &&
|
||||
pos1Parts[0] === '接頭詞' &&
|
||||
pos1Parts.slice(1).some((part) => part === '名詞') &&
|
||||
pos2Parts[0] === '名詞接続'
|
||||
);
|
||||
}
|
||||
|
||||
function isFrequencyExcludedByPos(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
@@ -273,12 +207,24 @@ function isFrequencyExcludedByPos(
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
);
|
||||
const allowOrdinalPrefixNounToken = shouldAllowOrdinalPrefixNounFrequency(token);
|
||||
const allowHonorificPrefixNounToken = shouldAllowHonorificPrefixNounFrequency(token);
|
||||
|
||||
if (isExcludedByTagSet(normalizedPos1, pos1Exclusions) && !allowContentLedMergedToken) {
|
||||
if (
|
||||
isExcludedByTagSet(normalizedPos1, pos1Exclusions) &&
|
||||
!allowContentLedMergedToken &&
|
||||
!allowOrdinalPrefixNounToken &&
|
||||
!allowHonorificPrefixNounToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExcludedByTagSet(normalizedPos2, pos2Exclusions) && !allowContentLedMergedToken) {
|
||||
if (
|
||||
isExcludedByTagSet(normalizedPos2, pos2Exclusions) &&
|
||||
!allowContentLedMergedToken &&
|
||||
!allowOrdinalPrefixNounToken &&
|
||||
!allowHonorificPrefixNounToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -608,50 +554,15 @@ function isJlptEligibleToken(token: MergedToken): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isExcludedFromSubtitleAnnotationsByTerm(token: MergedToken): boolean {
|
||||
const candidates = [token.surface, token.reading, resolveJlptLookupText(token)].filter(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const trimmedCandidate = candidate.trim();
|
||||
if (!trimmedCandidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedCandidate = normalizeJlptTextForExclusion(trimmedCandidate);
|
||||
if (!normalizedCandidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmedCandidate) ||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalizedCandidate) ||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS.has(trimmedCandidate) ||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS.has(normalizedCandidate)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
isTrailingSmallTsuKanaSfx(trimmedCandidate) ||
|
||||
isTrailingSmallTsuKanaSfx(normalizedCandidate) ||
|
||||
isReduplicatedKanaSfxWithOptionalTrailingTo(trimmedCandidate) ||
|
||||
isReduplicatedKanaSfxWithOptionalTrailingTo(normalizedCandidate)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldExcludeTokenFromSubtitleAnnotations(token: MergedToken): boolean {
|
||||
return sharedShouldExcludeTokenFromSubtitleAnnotations(token);
|
||||
}
|
||||
|
||||
export function stripSubtitleAnnotationMetadata(token: MergedToken): MergedToken {
|
||||
return sharedStripSubtitleAnnotationMetadata(token);
|
||||
export function stripSubtitleAnnotationMetadata(
|
||||
token: MergedToken,
|
||||
options: AnnotationStageOptions = {},
|
||||
): MergedToken {
|
||||
return sharedStripSubtitleAnnotationMetadata(token, options);
|
||||
}
|
||||
|
||||
function computeTokenKnownStatus(
|
||||
@@ -734,10 +645,14 @@ export function annotateTokens(
|
||||
pos2Exclusions,
|
||||
})
|
||||
) {
|
||||
return sharedStripSubtitleAnnotationMetadata(token, {
|
||||
const strippedToken = sharedStripSubtitleAnnotationMetadata(token, {
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
});
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: false,
|
||||
};
|
||||
}
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
@@ -781,6 +696,7 @@ export function annotateTokens(
|
||||
sanitizedMinSentenceWordsForNPlusOne,
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
options.sourceText,
|
||||
);
|
||||
|
||||
if (!nameMatchEnabled) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
|
||||
const SENTENCE_FINAL_PARTICLE_SUFFIXES = ['', 'か', 'ね', 'よ', 'な', 'わ'] as const;
|
||||
const EXPLANATORY_ENDING_PREFIXES = ['ん', 'の', 'なん', 'なの'] as const;
|
||||
const EXPLANATORY_ENDING_CORES = [
|
||||
'だ',
|
||||
'です',
|
||||
'でした',
|
||||
'だった',
|
||||
'では',
|
||||
'じゃ',
|
||||
'でしょう',
|
||||
'だろう',
|
||||
] as const;
|
||||
const EXPLANATORY_ENDING_TRAILING_PARTICLES = [
|
||||
'',
|
||||
'か',
|
||||
'ね',
|
||||
'よ',
|
||||
'な',
|
||||
'けど',
|
||||
'よね',
|
||||
'かな',
|
||||
'かね',
|
||||
] as const;
|
||||
const EXPLANATORY_ENDING_THOUGHT_SUFFIXES = ['か', 'かな', 'かね'] as const;
|
||||
const NEGATIVE_COPULA_PREFIXES = ['じゃ', 'では'] as const;
|
||||
|
||||
export function normalizeGrammarEndingText(text: string): string {
|
||||
const raw = text.trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let normalized = '';
|
||||
for (const char of raw) {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code >= KATAKANA_CODEPOINT_START && code <= KATAKANA_CODEPOINT_END) {
|
||||
normalized += String.fromCodePoint(code - KATAKANA_TO_HIRAGANA_OFFSET);
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized += char;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function matchesSuffix(text: string, suffixes: readonly string[]): boolean {
|
||||
return suffixes.some((suffix) => text === suffix);
|
||||
}
|
||||
|
||||
function matchesPoliteCopulaEnding(text: string): boolean {
|
||||
if (!text.startsWith('です')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchesSuffix(text.slice('です'.length), SENTENCE_FINAL_PARTICLE_SUFFIXES);
|
||||
}
|
||||
|
||||
function matchesNegativeCopulaEnding(text: string): boolean {
|
||||
for (const prefix of NEGATIVE_COPULA_PREFIXES) {
|
||||
const negativeStem = `${prefix}ない`;
|
||||
if (!text.startsWith(negativeStem)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = text.slice(negativeStem.length);
|
||||
return (
|
||||
matchesSuffix(suffix, SENTENCE_FINAL_PARTICLE_SUFFIXES) || matchesPoliteCopulaEnding(suffix)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function matchesExplanatoryEnding(text: string): boolean {
|
||||
for (const prefix of EXPLANATORY_ENDING_PREFIXES) {
|
||||
if (EXPLANATORY_ENDING_THOUGHT_SUFFIXES.some((suffix) => text === `${prefix}${suffix}`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!text.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = text.slice(prefix.length);
|
||||
for (const core of EXPLANATORY_ENDING_CORES) {
|
||||
if (!suffix.startsWith(core)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesSuffix(suffix.slice(core.length), EXPLANATORY_ENDING_TRAILING_PARTICLES)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isStandaloneGrammarEndingText(text: string): boolean {
|
||||
const normalized = normalizeGrammarEndingText(text);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return matchesPoliteCopulaEnding(normalized) || matchesNegativeCopulaEnding(normalized);
|
||||
}
|
||||
|
||||
export function isSubtitleGrammarEndingText(text: string): boolean {
|
||||
const normalized = normalizeGrammarEndingText(text);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isStandaloneGrammarEndingText(normalized) || matchesExplanatoryEnding(normalized);
|
||||
}
|
||||
@@ -39,6 +39,33 @@ test('enrichTokensWithMecabPos1 fills missing pos1 using surface-sequence fallba
|
||||
assert.equal(enriched[0]?.pos1, '助詞');
|
||||
});
|
||||
|
||||
test('enrichTokensWithMecabPos1 backfills blank pos2 and pos3 fields', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'は',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
pos1: '助詞',
|
||||
pos2: '',
|
||||
pos3: ' ',
|
||||
}),
|
||||
];
|
||||
const mecabTokens = [
|
||||
makeToken({
|
||||
surface: 'は',
|
||||
startPos: 0,
|
||||
endPos: 1,
|
||||
pos1: '助詞',
|
||||
pos2: '係助詞',
|
||||
pos3: '一般',
|
||||
}),
|
||||
];
|
||||
|
||||
const enriched = enrichTokensWithMecabPos1(tokens, mecabTokens);
|
||||
assert.equal(enriched[0]?.pos2, '係助詞');
|
||||
assert.equal(enriched[0]?.pos3, '一般');
|
||||
});
|
||||
|
||||
test('enrichTokensWithMecabPos1 keeps partOfSpeech unchanged and only enriches POS tags', () => {
|
||||
const tokens = [makeToken({ surface: 'これは', startPos: 0, endPos: 3 })];
|
||||
const mecabTokens = [
|
||||
|
||||
@@ -120,6 +120,13 @@ function lowerBoundByIndex(candidates: IndexedMecabToken[], targetIndex: number)
|
||||
return low;
|
||||
}
|
||||
|
||||
function coalesceMissingPosField(
|
||||
current: string | undefined,
|
||||
fallback: string | undefined,
|
||||
): string | undefined {
|
||||
return typeof current === 'string' && current.trim().length > 0 ? current : fallback;
|
||||
}
|
||||
|
||||
function joinUniqueTags(values: Array<string | undefined>): string | undefined {
|
||||
const unique: string[] = [];
|
||||
for (const value of values) {
|
||||
@@ -303,7 +310,8 @@ function fillMissingPos1BySurfaceSequence(
|
||||
|
||||
let cursor = 0;
|
||||
return tokens.map((token) => {
|
||||
if (token.pos1 && token.pos1.trim().length > 0) {
|
||||
const hasCompletePosMetadata = token.pos1?.trim() && token.pos2?.trim() && token.pos3?.trim();
|
||||
if (hasCompletePosMetadata) {
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -327,9 +335,9 @@ function fillMissingPos1BySurfaceSequence(
|
||||
cursor = best.index + 1;
|
||||
return {
|
||||
...token,
|
||||
pos1: best.pos1,
|
||||
pos2: best.pos2,
|
||||
pos3: best.pos3,
|
||||
pos1: coalesceMissingPosField(token.pos1, best.pos1),
|
||||
pos2: coalesceMissingPosField(token.pos2, best.pos2),
|
||||
pos3: coalesceMissingPosField(token.pos3, best.pos3),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -382,7 +390,7 @@ export function enrichTokensWithMecabPos1(
|
||||
const metadataByTokenIndex = new Map<number, MecabPosMetadata>();
|
||||
|
||||
for (const [index, token] of tokens.entries()) {
|
||||
if (token.pos1) {
|
||||
if (token.pos1?.trim() && token.pos2?.trim() && token.pos3?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -410,9 +418,9 @@ export function enrichTokensWithMecabPos1(
|
||||
|
||||
return {
|
||||
...token,
|
||||
pos1: metadata.pos1,
|
||||
pos2: metadata.pos2,
|
||||
pos3: metadata.pos3,
|
||||
pos1: coalesceMissingPosField(token.pos1, metadata.pos1),
|
||||
pos2: coalesceMissingPosField(token.pos2, metadata.pos2),
|
||||
pos3: coalesceMissingPosField(token.pos3, metadata.pos3),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ test('prefers the longest dictionary headword across merged segments', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps the first headword when later segments are standalone words', () => {
|
||||
test('splits trailing grammar endings when later segments are standalone words', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[
|
||||
@@ -174,10 +174,111 @@ test('keeps the first headword when later segments are standalone words', () =>
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: '猫です',
|
||||
reading: 'ねこです',
|
||||
surface: '猫',
|
||||
reading: 'ねこ',
|
||||
headword: '猫',
|
||||
},
|
||||
{
|
||||
surface: 'です',
|
||||
reading: 'です',
|
||||
headword: 'です',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps preceding reading when standalone grammar ending has empty reading', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[
|
||||
{ text: '猫', reading: 'ねこ', headword: '猫' },
|
||||
{ text: 'です', reading: '', headword: 'です' },
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: '猫',
|
||||
reading: 'ねこ',
|
||||
headword: '猫',
|
||||
},
|
||||
{
|
||||
surface: 'です',
|
||||
reading: '',
|
||||
headword: 'です',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('splits trailing ja-nai grammar endings from preceding content', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[
|
||||
{ text: 'いる', reading: 'いる', headword: 'いる' },
|
||||
{ text: 'じゃない', reading: 'じゃない', headword: 'じゃない' },
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: 'いる',
|
||||
reading: 'いる',
|
||||
headword: 'いる',
|
||||
},
|
||||
{
|
||||
surface: 'じゃない',
|
||||
reading: 'じゃない',
|
||||
headword: 'じゃない',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('splits trailing negative-copula grammar endings by pattern', () => {
|
||||
const parseResults = [
|
||||
makeParseItem('scanning-parser', [
|
||||
[
|
||||
{ text: '問題', reading: 'もんだい', headword: '問題' },
|
||||
{ text: 'ではないですか', reading: 'ではないですか', headword: 'ない' },
|
||||
],
|
||||
]),
|
||||
];
|
||||
|
||||
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
assert.deepEqual(
|
||||
tokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
})),
|
||||
[
|
||||
{
|
||||
surface: '問題',
|
||||
reading: 'もんだい',
|
||||
headword: '問題',
|
||||
},
|
||||
{
|
||||
surface: 'ではないですか',
|
||||
reading: 'ではないですか',
|
||||
headword: 'ない',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MergedToken, NPlusOneMatchMode, PartOfSpeech } from '../../../types';
|
||||
import { isStandaloneGrammarEndingText } from './grammar-ending';
|
||||
|
||||
interface YomitanParseHeadword {
|
||||
term?: unknown;
|
||||
@@ -141,6 +142,15 @@ function isKanaOnlyText(text: string): boolean {
|
||||
return text.length > 0 && Array.from(text).every((char) => isKanaChar(char));
|
||||
}
|
||||
|
||||
function isStandaloneGrammarEndingSegment(segment: YomitanParseSegment): boolean {
|
||||
const surface = segment.text?.trim() ?? '';
|
||||
const headword = extractYomitanHeadword(segment).trim();
|
||||
return (
|
||||
headword.length > 0 &&
|
||||
(isStandaloneGrammarEndingText(surface) || isStandaloneGrammarEndingText(headword))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldMergeKanaContinuation(
|
||||
previousToken: MergedToken | undefined,
|
||||
continuationSurface: string,
|
||||
@@ -186,20 +196,97 @@ export function mapYomitanParseResultItemToMergedTokens(
|
||||
|
||||
let combinedSurface = '';
|
||||
let combinedReading = '';
|
||||
let combinedStart = charOffset;
|
||||
let firstHeadword = '';
|
||||
const expandedHeadwords: string[] = [];
|
||||
|
||||
const pushToken = (
|
||||
surface: string,
|
||||
reading: string,
|
||||
headword: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): void => {
|
||||
tokens.push({
|
||||
surface,
|
||||
reading,
|
||||
headword,
|
||||
startPos: start,
|
||||
endPos: end,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '',
|
||||
isMerged: true,
|
||||
isNPlusOneTarget: false,
|
||||
isKnown: (() => {
|
||||
const matchText = resolveKnownWordText(surface, headword, knownWordMatchMode);
|
||||
return matchText ? isKnownWord(matchText) : false;
|
||||
})(),
|
||||
});
|
||||
};
|
||||
|
||||
const flushCombinedToken = (end: number): void => {
|
||||
if (!combinedSurface) {
|
||||
combinedStart = end;
|
||||
return;
|
||||
}
|
||||
|
||||
const combinedHeadword = selectMergedHeadword(
|
||||
firstHeadword,
|
||||
expandedHeadwords,
|
||||
combinedSurface,
|
||||
);
|
||||
if (!combinedHeadword) {
|
||||
const previousToken = tokens[tokens.length - 1];
|
||||
if (shouldMergeKanaContinuation(previousToken, combinedSurface)) {
|
||||
previousToken.surface += combinedSurface;
|
||||
previousToken.reading += combinedReading;
|
||||
previousToken.endPos = end;
|
||||
}
|
||||
} else {
|
||||
hasDictionaryMatch = true;
|
||||
pushToken(combinedSurface, combinedReading, combinedHeadword, combinedStart, end);
|
||||
}
|
||||
|
||||
combinedSurface = '';
|
||||
combinedReading = '';
|
||||
firstHeadword = '';
|
||||
expandedHeadwords.length = 0;
|
||||
combinedStart = end;
|
||||
};
|
||||
|
||||
for (const segment of line) {
|
||||
const segmentText = segment.text;
|
||||
if (!segmentText || segmentText.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const segmentStart = charOffset;
|
||||
const segmentEnd = segmentStart + segmentText.length;
|
||||
charOffset = segmentEnd;
|
||||
combinedSurface += segmentText;
|
||||
if (typeof segment.reading === 'string') {
|
||||
combinedReading += segment.reading;
|
||||
}
|
||||
const segmentHeadword = extractYomitanHeadword(segment);
|
||||
if (isStandaloneGrammarEndingSegment(segment)) {
|
||||
combinedSurface = combinedSurface.slice(0, -segmentText.length);
|
||||
if (typeof segment.reading === 'string' && segment.reading.length > 0) {
|
||||
combinedReading = combinedReading.slice(0, -segment.reading.length);
|
||||
}
|
||||
flushCombinedToken(segmentStart);
|
||||
const grammarHeadword = segmentHeadword || segmentText;
|
||||
hasDictionaryMatch = true;
|
||||
pushToken(
|
||||
segmentText,
|
||||
typeof segment.reading === 'string' ? segment.reading : '',
|
||||
grammarHeadword,
|
||||
segmentStart,
|
||||
segmentEnd,
|
||||
);
|
||||
combinedStart = segmentEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segmentHeadword) {
|
||||
if (!firstHeadword) {
|
||||
firstHeadword = segmentHeadword;
|
||||
@@ -210,49 +297,7 @@ export function mapYomitanParseResultItemToMergedTokens(
|
||||
}
|
||||
}
|
||||
|
||||
if (!combinedSurface) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = charOffset;
|
||||
const end = start + combinedSurface.length;
|
||||
charOffset = end;
|
||||
const combinedHeadword = selectMergedHeadword(
|
||||
firstHeadword,
|
||||
expandedHeadwords,
|
||||
combinedSurface,
|
||||
);
|
||||
if (!combinedHeadword) {
|
||||
const previousToken = tokens[tokens.length - 1];
|
||||
if (shouldMergeKanaContinuation(previousToken, combinedSurface)) {
|
||||
previousToken.surface += combinedSurface;
|
||||
previousToken.reading += combinedReading;
|
||||
previousToken.endPos = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No dictionary-backed headword for this merged unit; skip it entirely so
|
||||
// downstream keyboard/frequency/JLPT flows only operate on lookup-backed tokens.
|
||||
continue;
|
||||
}
|
||||
hasDictionaryMatch = true;
|
||||
const headword = combinedHeadword;
|
||||
|
||||
tokens.push({
|
||||
surface: combinedSurface,
|
||||
reading: combinedReading,
|
||||
headword,
|
||||
startPos: start,
|
||||
endPos: end,
|
||||
partOfSpeech: PartOfSpeech.other,
|
||||
pos1: '',
|
||||
isMerged: true,
|
||||
isNPlusOneTarget: false,
|
||||
isKnown: (() => {
|
||||
const matchText = resolveKnownWordText(combinedSurface, headword, knownWordMatchMode);
|
||||
return matchText ? isKnownWord(matchText) : false;
|
||||
})(),
|
||||
});
|
||||
flushCombinedToken(charOffset);
|
||||
}
|
||||
|
||||
if (validLineCount === 0 || tokens.length === 0 || !hasDictionaryMatch) {
|
||||
|
||||
@@ -8,14 +8,21 @@ import {
|
||||
} from '../../../token-pos2-exclusions';
|
||||
import { MergedToken, PartOfSpeech } from '../../../types';
|
||||
import { shouldIgnoreJlptByTerm } from '../jlpt-token-filter';
|
||||
import { isSubtitleGrammarEndingText } from './grammar-ending';
|
||||
|
||||
const KATAKANA_TO_HIRAGANA_OFFSET = 0x60;
|
||||
const KATAKANA_CODEPOINT_START = 0x30a1;
|
||||
const KATAKANA_CODEPOINT_END = 0x30f6;
|
||||
|
||||
const STANDALONE_GRAMMAR_PARTICLE_PHRASES = ['たって', 'だって'] as const;
|
||||
const STANDALONE_GRAMMAR_PARTICLE_PHRASES_SET: ReadonlySet<string> = new Set(
|
||||
STANDALONE_GRAMMAR_PARTICLE_PHRASES,
|
||||
);
|
||||
|
||||
export const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
|
||||
'あ',
|
||||
'ああ',
|
||||
'ある',
|
||||
'あなた',
|
||||
'あんた',
|
||||
'ええ',
|
||||
@@ -25,6 +32,7 @@ export const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
|
||||
'お前',
|
||||
'こいつ',
|
||||
'こっち',
|
||||
'くれ',
|
||||
'じゃない',
|
||||
'そうだ',
|
||||
'たち',
|
||||
@@ -32,58 +40,27 @@ export const SUBTITLE_ANNOTATION_EXCLUDED_TERMS = new Set([
|
||||
'どこか',
|
||||
'なんか',
|
||||
'べき',
|
||||
'って',
|
||||
'はあ',
|
||||
'はぁ',
|
||||
'はは',
|
||||
'へえ',
|
||||
'ふう',
|
||||
'ほう',
|
||||
'やはり',
|
||||
'って',
|
||||
'何か',
|
||||
'何だ',
|
||||
'何も',
|
||||
'如何した',
|
||||
'有る',
|
||||
'在る',
|
||||
'様',
|
||||
'確かに',
|
||||
'誰も',
|
||||
'貴方',
|
||||
'もんか',
|
||||
'ものか',
|
||||
]);
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES = ['ん', 'の', 'なん', 'なの'];
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES = [
|
||||
'だ',
|
||||
'です',
|
||||
'でした',
|
||||
'だった',
|
||||
'では',
|
||||
'じゃ',
|
||||
'でしょう',
|
||||
'だろう',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
|
||||
'',
|
||||
'か',
|
||||
'ね',
|
||||
'よ',
|
||||
'な',
|
||||
'けど',
|
||||
'よね',
|
||||
'かな',
|
||||
'かね',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = [
|
||||
'か',
|
||||
'かな',
|
||||
'かね',
|
||||
] as const;
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES.map(
|
||||
(particle) => `${prefix}${core}${particle}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
|
||||
'って',
|
||||
'ってよ',
|
||||
@@ -95,7 +72,28 @@ const SUBTITLE_ANNOTATION_EXCLUDED_TRAILING_PARTICLE_SUFFIXES = new Set([
|
||||
]);
|
||||
const AUXILIARY_STEM_GRAMMAR_TAIL_POS1 = new Set(['名詞', '助動詞', '助詞']);
|
||||
const NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1 = new Set(['助詞', '助動詞']);
|
||||
|
||||
const AUXILIARY_INFLECTION_TRAILING_POS1 = new Set(['助動詞']);
|
||||
const AUXILIARY_HELPER_SPAN_POS1 = new Set(['助詞', '助動詞', '動詞']);
|
||||
const LEXICAL_VERB_POS2 = new Set(['自立']);
|
||||
const STANDALONE_GRAMMAR_PARTICLE_SURFACES = new Set([
|
||||
'か',
|
||||
'が',
|
||||
'さ',
|
||||
'し',
|
||||
'ぞ',
|
||||
'ぜ',
|
||||
'と',
|
||||
'な',
|
||||
'に',
|
||||
'ね',
|
||||
'の',
|
||||
'は',
|
||||
'へ',
|
||||
'も',
|
||||
'や',
|
||||
'よ',
|
||||
'を',
|
||||
]);
|
||||
export interface SubtitleAnnotationFilterOptions {
|
||||
pos1Exclusions?: ReadonlySet<string>;
|
||||
pos2Exclusions?: ReadonlySet<string>;
|
||||
@@ -301,6 +299,99 @@ function isKanaOnlyNonIndependentNounHelperMerge(token: MergedToken): boolean {
|
||||
return pos1Parts.slice(1).every((part) => NON_INDEPENDENT_NOUN_HELPER_TAIL_POS1.has(part));
|
||||
}
|
||||
|
||||
function isKanaOnlyText(text: string): boolean {
|
||||
const normalized = normalizeKana(text);
|
||||
return normalized.length > 0 && [...normalized].every(isKanaChar);
|
||||
}
|
||||
|
||||
function isLexicalKureruVerb(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
|
||||
return (
|
||||
normalizedSurface === 'くれ' &&
|
||||
normalizedHeadword === 'くれる' &&
|
||||
pos1Parts.length === 1 &&
|
||||
pos1Parts[0] === '動詞' &&
|
||||
pos2Parts.length === 1 &&
|
||||
pos2Parts[0] === '自立'
|
||||
);
|
||||
}
|
||||
|
||||
function isStandaloneAuxiliaryInflectionFragment(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
if (!isKanaOnlyText(normalizedSurface)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
if (pos1Parts.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pos1Parts.every((part) => part === '助動詞')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
|
||||
return (
|
||||
pos1Parts[0] === '動詞' &&
|
||||
pos2Parts[0] === '接尾' &&
|
||||
pos1Parts.slice(1).every((part) => AUXILIARY_INFLECTION_TRAILING_POS1.has(part))
|
||||
);
|
||||
}
|
||||
|
||||
function isAuxiliaryOnlyHelperSpan(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
if (!isKanaOnlyText(normalizedSurface) || !isKanaOnlyText(normalizedHeadword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
if (
|
||||
pos1Parts.length === 0 ||
|
||||
!pos1Parts.every((part) => AUXILIARY_HELPER_SPAN_POS1.has(part)) ||
|
||||
!pos1Parts.includes('助詞') ||
|
||||
!pos1Parts.includes('動詞')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos2Parts = splitNormalizedTagParts(normalizePosTag(token.pos2));
|
||||
return !pos2Parts.some((part) => LEXICAL_VERB_POS2.has(part));
|
||||
}
|
||||
|
||||
function isStandaloneSuruTeGrammarHelper(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
if (!normalizedSurface.startsWith('して') || normalizedHeadword !== 'する') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos1Parts = splitNormalizedTagParts(normalizePosTag(token.pos1));
|
||||
return (
|
||||
isKanaOnlyText(normalizedSurface) && (pos1Parts.length === 0 || pos1Parts.includes('動詞'))
|
||||
);
|
||||
}
|
||||
|
||||
function isStandaloneGrammarParticle(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const normalizedHeadword = normalizeKana(token.headword);
|
||||
return (
|
||||
normalizedSurface === normalizedHeadword &&
|
||||
(STANDALONE_GRAMMAR_PARTICLE_SURFACES.has(normalizedSurface) ||
|
||||
STANDALONE_GRAMMAR_PARTICLE_PHRASES_SET.has(normalizedSurface))
|
||||
);
|
||||
}
|
||||
|
||||
function isSingleKanaSurfaceFragment(token: MergedToken): boolean {
|
||||
const normalizedSurface = normalizeKana(token.surface);
|
||||
const chars = [...normalizedSurface];
|
||||
return chars.length === 1 && chars.every(isKanaChar);
|
||||
}
|
||||
|
||||
function isExcludedByTerm(token: MergedToken): boolean {
|
||||
const candidates = [token.surface, token.reading, token.headword].filter(
|
||||
(candidate): candidate is string => typeof candidate === 'string' && candidate.length > 0,
|
||||
@@ -317,21 +408,11 @@ function isExcludedByTerm(token: MergedToken): boolean {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.some((prefix) =>
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES.some(
|
||||
(suffix) => normalized === `${prefix}${suffix}`,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmed) ||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalized) ||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS.has(trimmed) ||
|
||||
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS.has(normalized) ||
|
||||
isSubtitleGrammarEndingText(trimmed) ||
|
||||
isSubtitleGrammarEndingText(normalized) ||
|
||||
shouldIgnoreJlptByTerm(trimmed) ||
|
||||
shouldIgnoreJlptByTerm(normalized)
|
||||
) {
|
||||
@@ -388,10 +469,34 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isStandaloneAuxiliaryInflectionFragment(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isAuxiliaryOnlyHelperSpan(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isStandaloneSuruTeGrammarHelper(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isStandaloneGrammarParticle(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSingleKanaSurfaceFragment(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isExcludedTrailingParticleMergedToken(token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isLexicalKureruVerb(token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isExcludedByTerm(token);
|
||||
}
|
||||
|
||||
@@ -405,7 +510,6 @@ export function stripSubtitleAnnotationMetadata(
|
||||
|
||||
return {
|
||||
...token,
|
||||
isKnown: false,
|
||||
isNPlusOneTarget: false,
|
||||
isNameMatch: false,
|
||||
jlptLevel: undefined,
|
||||
|
||||
@@ -533,7 +533,7 @@ test('requestYomitanTermFrequencies caches repeated term+reading lookups', async
|
||||
assert.equal(frequencyCalls, 1);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of parseText', async () => {
|
||||
test('requestYomitanScanTokens prefers parseText tokenization over termsFind fragments', async () => {
|
||||
const scripts: string[] = [];
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
@@ -549,6 +549,138 @@ test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of
|
||||
],
|
||||
};
|
||||
}
|
||||
if (script.includes('parseText')) {
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: '取り組んで',
|
||||
reading: 'とりくんで',
|
||||
headwords: [[{ term: '取り組む' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
surface: '取り',
|
||||
reading: 'とり',
|
||||
headword: '取る',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
},
|
||||
{
|
||||
surface: '組んで',
|
||||
reading: 'くんで',
|
||||
headword: '組む',
|
||||
startPos: 2,
|
||||
endPos: 5,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await requestYomitanScanTokens('取り組んで', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: '取り組んで',
|
||||
reading: 'とりくんで',
|
||||
headword: '取り組む',
|
||||
startPos: 0,
|
||||
endPos: 5,
|
||||
},
|
||||
]);
|
||||
assert.ok(scripts.some((script) => script.includes('parseText')));
|
||||
assert.ok(scripts.some((script) => script.includes('termsFind')));
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => {
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (script.includes('parseText')) {
|
||||
return [
|
||||
{
|
||||
source: 'scanning-parser',
|
||||
index: 0,
|
||||
content: [
|
||||
[
|
||||
{
|
||||
text: 'アクア',
|
||||
reading: 'あくあ',
|
||||
headwords: [[{ term: 'アクア' }]],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
surface: 'アクア',
|
||||
reading: 'あくあ',
|
||||
headword: 'アクア',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
isNameMatch: true,
|
||||
wordClasses: ['n'],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await requestYomitanScanTokens('アクア', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
surface: 'アクア',
|
||||
reading: 'あくあ',
|
||||
headword: 'アクア',
|
||||
startPos: 0,
|
||||
endPos: 3,
|
||||
isNameMatch: true,
|
||||
wordClasses: ['n'],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens falls back to left-to-right termsFind scanning', async () => {
|
||||
const scripts: string[] = [];
|
||||
const deps = createDeps(async (script) => {
|
||||
scripts.push(script);
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (script.includes('parseText')) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
surface: 'カズマ',
|
||||
@@ -573,6 +705,7 @@ test('requestYomitanScanTokens uses left-to-right termsFind scanning instead of
|
||||
endPos: 3,
|
||||
},
|
||||
]);
|
||||
assert.ok(scripts.some((script) => script.includes('parseText')));
|
||||
const scannerScript = scripts.find((script) => script.includes('termsFind'));
|
||||
assert.ok(scannerScript, 'expected termsFind scanning request script');
|
||||
assert.doesNotMatch(scannerScript ?? '', /parseText/);
|
||||
@@ -891,6 +1024,105 @@ test('requestYomitanScanTokens can use frequency from later exact secondary-matc
|
||||
]);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens uses exact frequency entry when selected reading differs', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
return [];
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profileIndex: 0,
|
||||
scanLength: 40,
|
||||
dictionaries: ['JPDBv2㋕', 'Jiten', 'CC100'],
|
||||
dictionaryPriorityByName: {
|
||||
'JPDBv2㋕': 0,
|
||||
Jiten: 1,
|
||||
CC100: 2,
|
||||
},
|
||||
dictionaryFrequencyModeByName: {
|
||||
'JPDBv2㋕': 'rank-based',
|
||||
Jiten: 'rank-based',
|
||||
CC100: 'rank-based',
|
||||
},
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
dictionaries: [
|
||||
{ name: 'JPDBv2㋕', enabled: true, id: 0 },
|
||||
{ name: 'Jiten', enabled: true, id: 1 },
|
||||
{ name: 'CC100', enabled: true, id: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await requestYomitanScanTokens('第二走者', deps, {
|
||||
error: () => undefined,
|
||||
});
|
||||
|
||||
const result = (await runInjectedYomitanScript(scannerScript, (action, params) => {
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
|
||||
const text = (params as { text?: string } | undefined)?.text ?? '';
|
||||
if (!text.startsWith('第二')) {
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
originalTextLength: 2,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '第二',
|
||||
reading: 'だいに',
|
||||
sources: [{ originalText: '第二', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
frequencies: [],
|
||||
},
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: '第二',
|
||||
reading: '',
|
||||
sources: [{ originalText: '第二', isPrimary: false, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
frequencies: [
|
||||
{
|
||||
headwordIndex: 0,
|
||||
dictionary: 'JPDBv2㋕',
|
||||
frequency: 189513,
|
||||
displayValue: '1820,189513句',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
})) as Array<Record<string, unknown>>;
|
||||
|
||||
assert.deepEqual(result?.[0], {
|
||||
surface: '第二',
|
||||
reading: 'だいに',
|
||||
headword: '第二',
|
||||
startPos: 0,
|
||||
endPos: 2,
|
||||
isNameMatch: false,
|
||||
frequencyRank: 1820,
|
||||
});
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens marks tokens backed by SubMiner character dictionary entries', async () => {
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('optionsGetFull')) {
|
||||
@@ -1049,6 +1281,60 @@ test('requestYomitanScanTokens marks grouped entries when SubMiner dictionary al
|
||||
assert.equal((result as Array<{ isNameMatch?: boolean }>)[0]?.isNameMatch, true);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens preserves matched headword word classes', async () => {
|
||||
let scannerScript = '';
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('termsFind')) {
|
||||
scannerScript = script;
|
||||
return [];
|
||||
}
|
||||
if (script.includes('optionsGetFull')) {
|
||||
return {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
scanning: { length: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
await requestYomitanScanTokens('は', deps, { error: () => undefined });
|
||||
|
||||
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
|
||||
if (action !== 'termsFind') {
|
||||
throw new Error(`unexpected action: ${action}`);
|
||||
}
|
||||
|
||||
const text = (params as { text?: string } | undefined)?.text;
|
||||
if (text !== 'は') {
|
||||
return { originalTextLength: 0, dictionaryEntries: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
originalTextLength: 1,
|
||||
dictionaryEntries: [
|
||||
{
|
||||
headwords: [
|
||||
{
|
||||
term: 'は',
|
||||
reading: 'は',
|
||||
wordClasses: ['prt'],
|
||||
sources: [{ originalText: 'は', isPrimary: true, matchType: 'exact' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
assert.deepEqual((result as Array<{ wordClasses?: string[] }>)[0]?.wordClasses, ['prt']);
|
||||
});
|
||||
|
||||
test('requestYomitanScanTokens skips fallback fragments without exact primary source matches', async () => {
|
||||
const deps = createDeps(async (script) => {
|
||||
if (script.includes('optionsGetFull')) {
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface YomitanScanToken {
|
||||
endPos: number;
|
||||
isNameMatch?: boolean;
|
||||
frequencyRank?: number;
|
||||
wordClasses?: string[];
|
||||
}
|
||||
|
||||
interface YomitanProfileMetadata {
|
||||
@@ -91,11 +92,30 @@ function isScanTokenArray(value: unknown): value is YomitanScanToken[] {
|
||||
typeof entry.startPos === 'number' &&
|
||||
typeof entry.endPos === 'number' &&
|
||||
(entry.isNameMatch === undefined || typeof entry.isNameMatch === 'boolean') &&
|
||||
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number'),
|
||||
(entry.frequencyRank === undefined || typeof entry.frequencyRank === 'number') &&
|
||||
(entry.wordClasses === undefined ||
|
||||
(Array.isArray(entry.wordClasses) &&
|
||||
entry.wordClasses.every((wordClass) => typeof wordClass === 'string'))),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function hasSameTokenSpans(left: YomitanScanToken[], right: YomitanScanToken[]): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return left.every((token, index) => {
|
||||
const other = right[index];
|
||||
return (
|
||||
other !== undefined &&
|
||||
token.surface === other.surface &&
|
||||
token.startPos === other.startPos &&
|
||||
token.endPos === other.endPos
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function makeTermReadingCacheKey(term: string, reading: string | null): string {
|
||||
return `${term}\u0000${reading ?? ''}`;
|
||||
}
|
||||
@@ -956,6 +976,9 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
const matchReading = typeof match.headword?.reading === 'string' ? match.headword.reading : '';
|
||||
const preferredReading =
|
||||
typeof preferredMatch.headword?.reading === 'string' ? preferredMatch.headword.reading : '';
|
||||
if (!matchReading || !preferredReading) {
|
||||
return true;
|
||||
}
|
||||
return matchReading === preferredReading;
|
||||
}
|
||||
function getBestFrequencyRankForMatches(matches, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
@@ -975,6 +998,11 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
return best;
|
||||
}
|
||||
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
|
||||
function normalizeWordClasses(headword) {
|
||||
if (!Array.isArray(headword?.wordClasses)) { return undefined; }
|
||||
const classes = headword.wordClasses.filter((wordClass) => typeof wordClass === "string" && wordClass.trim().length > 0);
|
||||
return classes.length > 0 ? classes : undefined;
|
||||
}
|
||||
function appendDictionaryNames(target, value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
@@ -1033,6 +1061,7 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
|
||||
return {
|
||||
term: preferredMatch.headword.term,
|
||||
reading: preferredMatch.headword.reading,
|
||||
wordClasses: normalizeWordClasses(preferredMatch.headword),
|
||||
isNameMatch: matchedNameDictionary || isNameDictionaryEntry(preferredMatch.dictionaryEntry),
|
||||
frequencyRank: getBestFrequencyRankForMatches(
|
||||
exactFrequencyMatches.length > 0 ? exactFrequencyMatches : exactPrimaryMatches,
|
||||
@@ -1099,7 +1128,7 @@ ${YOMITAN_SCANNING_HELPERS}
|
||||
if (preferredHeadword && typeof preferredHeadword.term === "string") {
|
||||
const reading = typeof preferredHeadword.reading === "string" ? preferredHeadword.reading : "";
|
||||
const segments = distributeFuriganaInflected(preferredHeadword.term, reading, source);
|
||||
tokens.push({
|
||||
const tokenPayload = {
|
||||
surface: segments.map((segment) => segment.text).join("") || source,
|
||||
reading: segments.map((segment) => typeof segment.reading === "string" ? segment.reading : "").join(""),
|
||||
headword: preferredHeadword.term,
|
||||
@@ -1110,7 +1139,11 @@ ${YOMITAN_SCANNING_HELPERS}
|
||||
typeof preferredHeadword.frequencyRank === "number" && Number.isFinite(preferredHeadword.frequencyRank)
|
||||
? Math.max(1, Math.floor(preferredHeadword.frequencyRank))
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
if (Array.isArray(preferredHeadword.wordClasses) && preferredHeadword.wordClasses.length > 0) {
|
||||
tokenPayload.wordClasses = preferredHeadword.wordClasses;
|
||||
}
|
||||
tokens.push(tokenPayload);
|
||||
i += originalTextLength;
|
||||
continue;
|
||||
}
|
||||
@@ -1235,6 +1268,17 @@ export async function requestYomitanScanTokens(
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseResults = await requestYomitanParseResults(text, deps, logger);
|
||||
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
|
||||
const parseScanTokens =
|
||||
selectedParseTokens?.map((token) => ({
|
||||
surface: token.surface,
|
||||
reading: token.reading,
|
||||
headword: token.headword,
|
||||
startPos: token.startPos,
|
||||
endPos: token.endPos,
|
||||
})) ?? null;
|
||||
|
||||
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
|
||||
const profileIndex = metadata?.profileIndex ?? 0;
|
||||
const scanLength = metadata?.scanLength ?? DEFAULT_YOMITAN_SCAN_LENGTH;
|
||||
@@ -1252,6 +1296,9 @@ export async function requestYomitanScanTokens(
|
||||
true,
|
||||
);
|
||||
if (isScanTokenArray(rawResult)) {
|
||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||
return hasSameTokenSpans(parseScanTokens, rawResult) ? rawResult : parseScanTokens;
|
||||
}
|
||||
return rawResult;
|
||||
}
|
||||
if (Array.isArray(rawResult)) {
|
||||
@@ -1266,8 +1313,14 @@ export async function requestYomitanScanTokens(
|
||||
})) ?? null
|
||||
);
|
||||
}
|
||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||
return parseScanTokens;
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (parseScanTokens && parseScanTokens.length > 0) {
|
||||
return parseScanTokens;
|
||||
}
|
||||
logger.error('Yomitan scanner request failed:', (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
|
||||
+60
-3
@@ -33,6 +33,11 @@ import {
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
updateLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
} from './main/runtime/linux-mpv-fullscreen-overlay-refresh';
|
||||
import { mergeAiConfig } from './ai/config';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
@@ -1402,6 +1407,8 @@ const subtitleProcessingController = createSubtitleProcessingController(
|
||||
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
|
||||
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastObservedTimePos = 0;
|
||||
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
|
||||
null;
|
||||
const SEEK_THRESHOLD_SECONDS = 3;
|
||||
|
||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
@@ -1411,6 +1418,11 @@ function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function cancelPendingLinuxMpvFullscreenOverlayRefreshBurst(): void {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst?.();
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
|
||||
}
|
||||
|
||||
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||
getCurrentService: () => subtitlePrefetchService,
|
||||
setCurrentService: (service) => {
|
||||
@@ -3136,6 +3148,10 @@ const {
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = null;
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
},
|
||||
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
||||
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
||||
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
||||
@@ -3422,6 +3438,9 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
};
|
||||
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
||||
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||
};
|
||||
let hasAttemptedImmersionTrackerStartup = false;
|
||||
const ensureImmersionTrackerStarted = (): void => {
|
||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||
@@ -3840,6 +3859,20 @@ const {
|
||||
}
|
||||
lastObservedTimePos = time;
|
||||
},
|
||||
onFullscreenChange: (fullscreen) => {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
fullscreen,
|
||||
{
|
||||
overlayManager: {
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
},
|
||||
overlayVisibilityRuntime,
|
||||
ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window),
|
||||
},
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
);
|
||||
},
|
||||
onSubtitleTrackChange: (sid) => {
|
||||
scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||
@@ -4080,10 +4113,18 @@ const buildUpdateVisibleOverlayBoundsMainDepsHandler =
|
||||
createBuildUpdateVisibleOverlayBoundsMainDepsHandler({
|
||||
setOverlayWindowBounds: (geometry) => applyOverlayRegions(geometry),
|
||||
afterSetOverlayWindowBounds: () => {
|
||||
if (process.platform !== 'win32' || !overlayManager.getVisibleOverlayVisible()) {
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||
if (process.platform === 'win32') {
|
||||
scheduleWindowsVisibleOverlayZOrderSyncBurst();
|
||||
return;
|
||||
}
|
||||
const mainWindow = overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
ensureOverlayWindowLevel(mainWindow);
|
||||
},
|
||||
});
|
||||
const updateVisibleOverlayBoundsMainDeps = buildUpdateVisibleOverlayBoundsMainDepsHandler();
|
||||
@@ -4228,6 +4269,9 @@ function destroyTray(): void {
|
||||
function initializeOverlayRuntime(): void {
|
||||
initializeOverlayRuntimeHandler();
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
@@ -4906,6 +4950,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
openAnilistSetup: () => openAnilistSetupWindow(),
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
runAnilistPostWatchUpdateOnManualMark: () => maybeRunAnilistPostWatchUpdate({ force: true }),
|
||||
getCharacterDictionarySelection: () =>
|
||||
characterDictionaryRuntime.getManualSelectionSnapshot(),
|
||||
setCharacterDictionarySelection: async (mediaId: number) =>
|
||||
@@ -4934,6 +4979,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
setAnkiIntegration: (integration: AnkiIntegration | null) => {
|
||||
appState.ankiIntegration = integration;
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
},
|
||||
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
showDesktopNotification,
|
||||
@@ -5159,6 +5207,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
|
||||
onWindowContentReady: () => overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
|
||||
onWindowClosed: (windowKind) => {
|
||||
if (windowKind === 'visible') {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
overlayManager.setMainWindow(null);
|
||||
} else {
|
||||
overlayManager.setModalWindow(null);
|
||||
@@ -5433,6 +5482,9 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
@@ -5442,13 +5494,18 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!overlayManager.getVisibleOverlayVisible()) {
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
toggleVisibleOverlayHandler();
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
openAnilistSetup: IpcDepsRuntimeOptions['openAnilistSetup'];
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
runAnilistPostWatchUpdateOnManualMark?: IpcDepsRuntimeOptions['runAnilistPostWatchUpdateOnManualMark'];
|
||||
getCharacterDictionarySelection?: IpcDepsRuntimeOptions['getCharacterDictionarySelection'];
|
||||
setCharacterDictionarySelection?: IpcDepsRuntimeOptions['setCharacterDictionarySelection'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
@@ -263,6 +264,7 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
openAnilistSetup: params.openAnilistSetup,
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
runAnilistPostWatchUpdateOnManualMark: params.runAnilistPostWatchUpdateOnManualMark,
|
||||
getCharacterDictionarySelection: params.getCharacterDictionarySelection,
|
||||
setCharacterDictionarySelection: params.setCharacterDictionarySelection,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
|
||||
@@ -77,6 +77,107 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
|
||||
assert.ok(calls.includes('inflight:false'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => false,
|
||||
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => false,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 0,
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
return 1000;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
await handler({ force: true });
|
||||
|
||||
assert.equal(calls.includes('probe'), false);
|
||||
assert.ok(calls.includes('update'));
|
||||
assert.ok(calls.includes('remember'));
|
||||
assert.ok(calls.includes('osd:updated ok'));
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
|
||||
const calls: string[] = [];
|
||||
let inFlight = false;
|
||||
let resolveDuration!: (duration: number) => void;
|
||||
const durationPromise = new Promise<number>((resolve) => {
|
||||
resolveDuration = resolve;
|
||||
});
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
getInFlight: () => inFlight,
|
||||
setInFlight: (value) => {
|
||||
inFlight = value;
|
||||
calls.push(`inflight:${value}`);
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
isAnilistTrackingEnabled: () => true,
|
||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
||||
hasMpvClient: () => true,
|
||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
||||
resetTrackedMedia: () => {},
|
||||
getWatchedSeconds: () => 1000,
|
||||
maybeProbeAnilistDuration: async () => {
|
||||
calls.push('probe');
|
||||
return await durationPromise;
|
||||
},
|
||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
|
||||
hasAttemptedUpdateKey: () => false,
|
||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
||||
refreshAnilistClientSecretState: async () => 'token',
|
||||
enqueueRetry: () => calls.push('enqueue'),
|
||||
markRetryFailure: () => calls.push('mark-failure'),
|
||||
markRetrySuccess: () => calls.push('mark-success'),
|
||||
refreshRetryQueueState: () => calls.push('refresh'),
|
||||
updateAnilistPostWatchProgress: async () => {
|
||||
calls.push('update');
|
||||
return { status: 'updated', message: 'updated ok' };
|
||||
},
|
||||
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
minWatchSeconds: 600,
|
||||
minWatchRatio: 0.85,
|
||||
});
|
||||
|
||||
const firstRun = handler();
|
||||
assert.deepEqual(calls, ['inflight:true', 'probe']);
|
||||
|
||||
await handler();
|
||||
assert.deepEqual(calls, ['inflight:true', 'probe']);
|
||||
|
||||
resolveDuration(1000);
|
||||
await firstRun;
|
||||
|
||||
assert.equal(calls.filter((call) => call === 'update').length, 1);
|
||||
assert.equal(calls.at(-1), 'inflight:false');
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||
|
||||
@@ -16,6 +16,10 @@ type RetryQueueItem = {
|
||||
episode: number;
|
||||
};
|
||||
|
||||
type AnilistPostWatchRunOptions = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
|
||||
return `${mediaKey}::${episode}`;
|
||||
}
|
||||
@@ -118,10 +122,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
minWatchSeconds: number;
|
||||
minWatchRatio: number;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
return async (options: AnilistPostWatchRunOptions = {}): Promise<void> => {
|
||||
if (deps.getInFlight()) {
|
||||
return;
|
||||
}
|
||||
const force = options.force === true;
|
||||
|
||||
const resolved = deps.getResolvedConfig();
|
||||
if (!deps.isAnilistTrackingEnabled(resolved)) {
|
||||
@@ -129,7 +134,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
}
|
||||
|
||||
const mediaKey = deps.getCurrentMediaKey();
|
||||
if (!mediaKey || !deps.hasMpvClient()) {
|
||||
if (!mediaKey || (!force && !deps.hasMpvClient())) {
|
||||
return;
|
||||
}
|
||||
if (isYoutubeMediaPath(mediaKey)) {
|
||||
@@ -139,31 +144,36 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
deps.resetTrackedMedia(mediaKey);
|
||||
}
|
||||
|
||||
const watchedSeconds = deps.getWatchedSeconds();
|
||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
||||
if (!duration || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
if (watchedSeconds / duration < deps.minWatchRatio) {
|
||||
return;
|
||||
}
|
||||
|
||||
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
|
||||
if (!guess?.title || !guess.episode || guess.episode <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
return;
|
||||
let watchedSeconds = 0;
|
||||
if (!force) {
|
||||
watchedSeconds = deps.getWatchedSeconds();
|
||||
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
deps.setInFlight(true);
|
||||
try {
|
||||
if (!force) {
|
||||
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
|
||||
if (!duration || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
if (watchedSeconds / duration < deps.minWatchRatio) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
|
||||
if (!guess?.title || !guess.episode || guess.episode <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deps.processNextAnilistRetryUpdate();
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
return;
|
||||
|
||||
@@ -27,7 +27,10 @@ test('consume anilist setup token main deps builder maps callbacks', () => {
|
||||
const calls: string[] = [];
|
||||
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
|
||||
consumeAnilistSetupCallbackUrl: () => true,
|
||||
saveToken: () => calls.push('save'),
|
||||
saveToken: () => {
|
||||
calls.push('save');
|
||||
return true;
|
||||
},
|
||||
setCachedToken: () => calls.push('cache'),
|
||||
setResolvedState: () => calls.push('resolved'),
|
||||
setSetupPageOpened: () => calls.push('opened'),
|
||||
@@ -38,7 +41,7 @@ test('consume anilist setup token main deps builder maps callbacks', () => {
|
||||
assert.equal(
|
||||
deps.consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup',
|
||||
saveToken: () => {},
|
||||
saveToken: () => true,
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
|
||||
@@ -22,7 +22,7 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
|
||||
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
|
||||
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
|
||||
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
|
||||
saveToken: () => {},
|
||||
saveToken: () => true,
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export type ConsumeAnilistSetupTokenDeps = {
|
||||
consumeAnilistSetupCallbackUrl: (input: {
|
||||
rawUrl: string;
|
||||
saveToken: (token: string) => void;
|
||||
saveToken: (token: string) => boolean;
|
||||
setCachedToken: (token: string) => void;
|
||||
setResolvedState: (resolvedAt: number) => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
closeWindow: () => void;
|
||||
}) => boolean;
|
||||
saveToken: (token: string) => void;
|
||||
saveToken: (token: string) => boolean;
|
||||
setCachedToken: (token: string) => void;
|
||||
setResolvedState: (resolvedAt: number) => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
|
||||
@@ -90,7 +90,10 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
saveToken: (value: string) => {
|
||||
events.push(`save:${value}`);
|
||||
return true;
|
||||
},
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
@@ -120,7 +123,10 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
|
||||
Date.now = () => 120_000;
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => events.push(`save:${value}`),
|
||||
saveToken: (value: string) => {
|
||||
events.push(`save:${value}`);
|
||||
return true;
|
||||
},
|
||||
setCachedToken: (value: string) => events.push(`cache:${value}`),
|
||||
setResolvedState: (timestampMs: number) =>
|
||||
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
|
||||
@@ -143,11 +149,33 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
|
||||
}
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl keeps setup open when token persistence fails', () => {
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
|
||||
saveToken: (value: string) => {
|
||||
events.push(`save:${value}`);
|
||||
return false;
|
||||
},
|
||||
setCachedToken: () => events.push('cache'),
|
||||
setResolvedState: () => events.push('state'),
|
||||
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
|
||||
onSuccess: () => events.push('success'),
|
||||
closeWindow: () => events.push('close'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(events, ['save:saved-token', 'opened:true']);
|
||||
});
|
||||
|
||||
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
|
||||
const events: string[] = [];
|
||||
const handled = consumeAnilistSetupCallbackUrl({
|
||||
rawUrl: 'https://anilist.co/settings/developer',
|
||||
saveToken: () => events.push('save'),
|
||||
saveToken: () => {
|
||||
events.push('save');
|
||||
return true;
|
||||
},
|
||||
setCachedToken: () => events.push('cache'),
|
||||
setResolvedState: () => events.push('state'),
|
||||
setSetupPageOpened: () => events.push('opened'),
|
||||
|
||||
@@ -10,7 +10,7 @@ export type BuildAnilistSetupUrlDeps = {
|
||||
|
||||
export type ConsumeAnilistSetupCallbackUrlDeps = {
|
||||
rawUrl: string;
|
||||
saveToken: (token: string) => void;
|
||||
saveToken: (token: string) => boolean;
|
||||
setCachedToken: (token: string) => void;
|
||||
setResolvedState: (resolvedAt: number) => void;
|
||||
setSetupPageOpened: (opened: boolean) => void;
|
||||
@@ -71,8 +71,12 @@ export function consumeAnilistSetupCallbackUrl(deps: ConsumeAnilistSetupCallback
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!deps.saveToken(token)) {
|
||||
deps.setSetupPageOpened(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const resolvedAt = Date.now();
|
||||
deps.saveToken(token);
|
||||
deps.setCachedToken(token);
|
||||
deps.setResolvedState(resolvedAt);
|
||||
deps.setSetupPageOpened(false);
|
||||
|
||||
@@ -18,6 +18,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-poll'),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
|
||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
@@ -42,10 +44,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 29);
|
||||
assert.equal(calls.length, 30);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
||||
destroyMainOverlayWindow: () => void;
|
||||
destroyModalOverlayWindow: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
@@ -38,6 +39,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop();
|
||||
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
|
||||
@@ -20,6 +20,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
@@ -88,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.equal(reconnectTimer, null);
|
||||
assert.equal(immersionTracker, null);
|
||||
});
|
||||
@@ -103,6 +106,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
|
||||
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
|
||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||
clearMainOverlayWindow: () => void;
|
||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||
@@ -67,6 +68,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () =>
|
||||
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
|
||||
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(),
|
||||
destroyMainOverlayWindow: () => {
|
||||
const window = deps.getMainOverlayWindow();
|
||||
if (!window) return;
|
||||
|
||||
@@ -15,7 +15,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
|
||||
},
|
||||
consumeTokenDeps: {
|
||||
consumeAnilistSetupCallbackUrl: () => false,
|
||||
saveToken: () => {},
|
||||
saveToken: () => true,
|
||||
setCachedToken: () => {},
|
||||
setResolvedState: () => {},
|
||||
setSetupPageOpened: () => {},
|
||||
|
||||
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
|
||||
updateLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
|
||||
} from './linux-mpv-fullscreen-overlay-refresh';
|
||||
|
||||
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 200;
|
||||
while (!calls.includes('updateVisibleOverlayVisibility') && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
|
||||
assert.ok(calls.includes('hide'));
|
||||
assert.ok(calls.includes('showInactive'));
|
||||
assert.ok(calls.includes('ensureOverlayWindowLevel'));
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('linux mpv fullscreen overlay refresh update cancels burst when fullscreen exits', async () => {
|
||||
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'linux',
|
||||
});
|
||||
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
const deps = {
|
||||
overlayManager: {
|
||||
getMainWindow: () =>
|
||||
({
|
||||
hide: () => calls.push('hide'),
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
showInactive: () => calls.push('showInactive'),
|
||||
}) as never,
|
||||
getVisibleOverlayVisible: () => true,
|
||||
},
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
|
||||
},
|
||||
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
|
||||
};
|
||||
|
||||
const cancel = updateLinuxMpvFullscreenOverlayRefreshBurst(true, deps, null);
|
||||
const nextCancel = updateLinuxMpvFullscreenOverlayRefreshBurst(false, deps, cancel);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
|
||||
assert.equal(nextCancel, null);
|
||||
assert.deepEqual(calls, []);
|
||||
} finally {
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
type LinuxMpvFullscreenOverlayWindow = {
|
||||
hide: () => void;
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
showInactive: () => void;
|
||||
};
|
||||
|
||||
export type LinuxMpvFullscreenOverlayRefreshDeps = {
|
||||
overlayManager: {
|
||||
getMainWindow: () => LinuxMpvFullscreenOverlayWindow | null;
|
||||
getVisibleOverlayVisible: () => boolean;
|
||||
};
|
||||
overlayVisibilityRuntime: {
|
||||
updateVisibleOverlayVisibility: () => void;
|
||||
};
|
||||
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
|
||||
};
|
||||
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
|
||||
|
||||
const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const;
|
||||
let linuxMpvFullscreenOverlayRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
|
||||
function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
|
||||
for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = [];
|
||||
}
|
||||
|
||||
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): void {
|
||||
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
|
||||
const mainWindow = deps.overlayManager.getMainWindow();
|
||||
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.hide();
|
||||
mainWindow.showInactive();
|
||||
deps.ensureOverlayWindowLevel(mainWindow);
|
||||
}
|
||||
|
||||
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
|
||||
if (process.platform !== 'linux') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
|
||||
for (const delayMs of LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
|
||||
(timeout) => timeout !== refreshTimeout,
|
||||
);
|
||||
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
|
||||
}, delayMs);
|
||||
refreshTimeout.unref?.();
|
||||
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
|
||||
}
|
||||
return clearLinuxMpvFullscreenOverlayRefreshTimeouts;
|
||||
}
|
||||
|
||||
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
isFullscreen: boolean,
|
||||
deps: LinuxMpvFullscreenOverlayRefreshDeps,
|
||||
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
|
||||
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
|
||||
cancelCurrentBurst?.();
|
||||
if (!isFullscreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
|
||||
}
|
||||
|
||||
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
|
||||
@@ -128,6 +128,7 @@ test('mpv event bindings register all expected events', () => {
|
||||
onTimePosChange: () => {},
|
||||
onDurationChange: () => {},
|
||||
onPauseChange: () => {},
|
||||
onFullscreenChange: () => {},
|
||||
onSubtitleMetricsChange: () => {},
|
||||
onSecondarySubtitleVisibility: () => {},
|
||||
});
|
||||
@@ -151,6 +152,7 @@ test('mpv event bindings register all expected events', () => {
|
||||
'time-pos-change',
|
||||
'duration-change',
|
||||
'pause-change',
|
||||
'fullscreen-change',
|
||||
'subtitle-metrics-change',
|
||||
'secondary-subtitle-visibility',
|
||||
]);
|
||||
|
||||
@@ -11,6 +11,7 @@ type MpvBindingEventName =
|
||||
| 'time-pos-change'
|
||||
| 'duration-change'
|
||||
| 'pause-change'
|
||||
| 'fullscreen-change'
|
||||
| 'subtitle-metrics-change'
|
||||
| 'secondary-subtitle-visibility';
|
||||
|
||||
@@ -83,6 +84,7 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
onTimePosChange: (payload: { time: number }) => void;
|
||||
onDurationChange: (payload: { duration: number }) => void;
|
||||
onPauseChange: (payload: { paused: boolean }) => void;
|
||||
onFullscreenChange: (payload: { fullscreen: boolean }) => void;
|
||||
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
|
||||
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
}) {
|
||||
@@ -99,6 +101,7 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
mpvClient.on('time-pos-change', deps.onTimePosChange);
|
||||
mpvClient.on('duration-change', deps.onDurationChange);
|
||||
mpvClient.on('pause-change', deps.onPauseChange);
|
||||
mpvClient.on('fullscreen-change', deps.onFullscreenChange);
|
||||
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
|
||||
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
|
||||
};
|
||||
|
||||
@@ -49,8 +49,37 @@ test('subtitle change handler broadcasts cached annotated payload immediately wh
|
||||
assert.deepEqual(calls, [
|
||||
'set:line',
|
||||
'lookup:line',
|
||||
'broadcast:annotated',
|
||||
'process:line',
|
||||
'broadcast:annotated',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleChangeHandler({
|
||||
setCurrentSubText: (text) => calls.push(`set:${text}`),
|
||||
getImmediateSubtitlePayload: (text) => {
|
||||
calls.push(`lookup:${text}`);
|
||||
return { text, tokens: [] };
|
||||
},
|
||||
emitImmediateSubtitle: (payload) => {
|
||||
calls.push(`emit:${payload.tokens === null ? 'plain' : 'annotated'}`);
|
||||
},
|
||||
broadcastSubtitle: (payload) => {
|
||||
calls.push(`broadcast:${payload.tokens === null ? 'plain' : 'annotated'}`);
|
||||
},
|
||||
onSubtitleChange: (text) => calls.push(`process:${text}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ text: 'line' });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'set:line',
|
||||
'lookup:line',
|
||||
'process:line',
|
||||
'emit:annotated',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
@@ -170,6 +199,10 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
},
|
||||
logError: () => calls.push('post-watch-error'),
|
||||
});
|
||||
const pauseHandler = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||
@@ -183,12 +216,48 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
'time:12.5',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'post-watch',
|
||||
'pause:yes',
|
||||
'progress:force',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
|
||||
const calls: string[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {
|
||||
calls.push('post-watch');
|
||||
throw new Error('boom');
|
||||
},
|
||||
logError: (message, error) => calls.push(`error:${message}:${(error as Error).message}`),
|
||||
});
|
||||
const pauseHandler = createHandleMpvPauseChangeHandler({
|
||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
timeHandler({ time: 12.5 });
|
||||
pauseHandler({ paused: true });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'time:12.5',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'post-watch',
|
||||
'pause:yes',
|
||||
'progress:force',
|
||||
'presence',
|
||||
'error:AniList post-watch update failed unexpectedly:boom',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitle metrics change handler forwards patch payload', () => {
|
||||
let received: Record<string, unknown> | null = null;
|
||||
const handler = createHandleMpvSubtitleMetricsChangeHandler({
|
||||
|
||||
@@ -12,14 +12,15 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
deps.setCurrentSubText(text);
|
||||
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
|
||||
if (immediatePayload) {
|
||||
deps.onSubtitleChange(text);
|
||||
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
|
||||
} else {
|
||||
deps.broadcastSubtitle({
|
||||
text,
|
||||
tokens: null,
|
||||
});
|
||||
deps.onSubtitleChange(text);
|
||||
}
|
||||
deps.onSubtitleChange(text);
|
||||
deps.refreshDiscordPresence();
|
||||
};
|
||||
}
|
||||
@@ -104,12 +105,17 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
recordPlaybackPosition: (time: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
return ({ time }: { time: number }): void => {
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.refreshDiscordPresence();
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
|
||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||
});
|
||||
deps.onTimePosUpdate?.(time);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
recordMediaDuration: (durationSec: number) => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
onFullscreenChange?: (fullscreen: boolean) => void;
|
||||
recordPauseState: (paused: boolean) => void;
|
||||
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
@@ -148,6 +149,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
|
||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
||||
});
|
||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||
@@ -177,6 +180,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
onTimePosChange: handleMpvTimePosChange,
|
||||
onDurationChange: ({ duration }) => deps.recordMediaDuration(duration),
|
||||
onPauseChange: handleMpvPauseChange,
|
||||
onFullscreenChange: ({ fullscreen }) => deps.onFullscreenChange?.(fullscreen),
|
||||
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
|
||||
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
|
||||
})(mpvClient);
|
||||
|
||||
@@ -57,6 +57,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
|
||||
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
|
||||
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
|
||||
onFullscreenChange: (fullscreen) => calls.push(`fullscreen:${fullscreen}`),
|
||||
updateSubtitleRenderMetrics: () => calls.push('metrics'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
})();
|
||||
@@ -95,6 +96,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.notifyImmersionTitleUpdate('title');
|
||||
deps.recordPlaybackPosition(10);
|
||||
deps.reportJellyfinRemoteProgress(true);
|
||||
deps.onFullscreenChange?.(true);
|
||||
deps.recordPauseState(true);
|
||||
deps.updateSubtitleRenderMetrics({});
|
||||
deps.setPreviousSecondarySubVisibility(true);
|
||||
@@ -112,6 +114,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
assert.ok(calls.includes('metrics'));
|
||||
assert.ok(calls.includes('fullscreen:true'));
|
||||
assert.ok(calls.includes('presence-refresh'));
|
||||
assert.ok(calls.includes('restore-mpv-sub'));
|
||||
assert.ok(calls.includes('reset-sidebar-layout'));
|
||||
@@ -159,6 +162,48 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
|
||||
assert.equal(typeof deps.setCurrentSubText, 'function');
|
||||
});
|
||||
|
||||
test('mpv main event main deps treat managed playback as quit-on-disconnect', () => {
|
||||
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: { managedPlayback: true },
|
||||
overlayRuntimeInitialized: false,
|
||||
mpvClient: null,
|
||||
immersionTracker: null,
|
||||
subtitleTimingTracker: null,
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
|
||||
assert.equal(deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(), true);
|
||||
});
|
||||
|
||||
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
|
||||
const recorded: number[] = [];
|
||||
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { MergedToken, SubtitleData } from '../../types';
|
||||
|
||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
|
||||
initialArgs?: {
|
||||
jellyfinPlay?: unknown;
|
||||
managedPlayback?: unknown;
|
||||
youtubePlay?: unknown;
|
||||
} | null;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient: {
|
||||
connected?: boolean;
|
||||
@@ -60,6 +64,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
resetAnilistMediaGuessState: () => void;
|
||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
onFullscreenChange?: (fullscreen: boolean) => void;
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
ensureImmersionTrackerInitialized: () => void;
|
||||
@@ -73,15 +78,19 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
|
||||
};
|
||||
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
|
||||
Boolean(
|
||||
deps.appState.initialArgs?.managedPlayback ||
|
||||
deps.appState.initialArgs?.jellyfinPlay ||
|
||||
deps.appState.initialArgs?.youtubePlay,
|
||||
);
|
||||
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||
Boolean(deps.appState.initialArgs?.jellyfinPlay || deps.appState.initialArgs?.youtubePlay),
|
||||
hasInitialPlaybackQuitOnDisconnectArg,
|
||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
Boolean(deps.appState.initialArgs?.youtubePlay),
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
|
||||
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
@@ -176,6 +185,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
onTimePosUpdate: deps.onTimePosUpdate
|
||||
? (time: number) => deps.onTimePosUpdate!(time)
|
||||
: undefined,
|
||||
onFullscreenChange: deps.onFullscreenChange
|
||||
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
|
||||
: undefined,
|
||||
recordPauseState: (paused: boolean) => {
|
||||
deps.appState.playbackPaused = paused;
|
||||
deps.ensureImmersionTrackerInitialized();
|
||||
|
||||
@@ -50,7 +50,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
|
||||
assert.equal(showedWarningDialog, process.platform === 'darwin');
|
||||
assert.ok(calls.some((entry) => entry.includes('actual=10 fallback=250')));
|
||||
assert.ok(calls.includes('hotReload:start'));
|
||||
assert.deepEqual(refreshCalls, [{ force: true }]);
|
||||
assert.deepEqual(refreshCalls, [{ force: true, allowSetupPrompt: false }]);
|
||||
});
|
||||
|
||||
test('createReloadConfigHandler fails startup for parse errors', () => {
|
||||
|
||||
@@ -27,7 +27,10 @@ export type ReloadConfigRuntimeDeps = {
|
||||
logWarning: (message: string) => void;
|
||||
showDesktopNotification: (title: string, options: { body: string }) => void;
|
||||
startConfigHotReload: () => void;
|
||||
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
|
||||
refreshAnilistClientSecretState: (options: {
|
||||
force: boolean;
|
||||
allowSetupPrompt?: boolean;
|
||||
}) => Promise<unknown>;
|
||||
failHandlers: {
|
||||
logError: (details: string) => void;
|
||||
showErrorBox: (title: string, details: string) => void;
|
||||
@@ -72,7 +75,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
|
||||
}
|
||||
|
||||
deps.startConfigHotReload();
|
||||
void deps.refreshAnilistClientSecretState({ force: true });
|
||||
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ test('stats server routing defers to a live background daemon from another proce
|
||||
processAlive: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'foreign' });
|
||||
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
|
||||
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ function formatStatsServerUrl(port: number): string {
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
export type EnsureStatsServerUrlResult =
|
||||
| { url: string; source: 'foreign' }
|
||||
| { url: string; source: 'local' };
|
||||
export type EnsureStatsServerUrlResult = { url: string; source: 'background' | 'local' };
|
||||
|
||||
export function createEnsureStatsServerUrlHandler(
|
||||
deps: EnsureStatsServerUrlDeps,
|
||||
@@ -30,7 +28,7 @@ export function createEnsureStatsServerUrlHandler(
|
||||
} else if (!deps.isProcessAlive(state.pid)) {
|
||||
deps.removeBackgroundState();
|
||||
} else if (state.pid !== deps.currentPid) {
|
||||
return { url: formatStatsServerUrl(state.port), source: 'foreign' };
|
||||
return { url: formatStatsServerUrl(state.port), source: 'background' };
|
||||
}
|
||||
|
||||
if (!deps.hasLocalStatsServer()) {
|
||||
|
||||
@@ -1202,6 +1202,32 @@ test('session binding: copy subtitle multiple captures follow-up digit locally',
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: mine sentence multiple captures modified follow-up digit locally', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.mineSentenceMultiple',
|
||||
originalKey: 'Ctrl+Shift+S',
|
||||
key: { code: 'KeyS', modifiers: ['ctrl', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'mineSentenceMultiple',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'S', code: 'KeyS', ctrlKey: true, shiftKey: true });
|
||||
testGlobals.dispatchKeydown({ key: '#', code: 'Digit3', ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'mineSentenceMultiple', payload: { count: 3 } },
|
||||
]);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -176,13 +176,17 @@ export function createKeyboardHandlers(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!/^[1-9]$/.test(e.key) || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
|
||||
const digit = /^[1-9]$/.test(e.key)
|
||||
? e.key
|
||||
: (e.code.match(/^(?:Digit|Numpad)([1-9])$/)?.[1] ?? null);
|
||||
|
||||
if (!digit) {
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const count = Number(e.key);
|
||||
const count = Number(digit);
|
||||
const actionId = pendingNumericSelection.actionId;
|
||||
cancelPendingNumericSelection(false);
|
||||
void window.electronAPI.dispatchSessionAction(actionId, { count });
|
||||
|
||||
@@ -1315,6 +1315,75 @@ test('window resize ignores synthetic subtitle enter until the pointer moves aga
|
||||
}
|
||||
});
|
||||
|
||||
test('window resize allows primary hover pause from a real mouseenter over subtitles', async () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
const mpvCommands: Array<(string | number)[]> = [];
|
||||
const windowListeners = new Map<string, Array<() => void>>();
|
||||
ctx.platform.shouldToggleMouseIgnore = true;
|
||||
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
electronAPI: {
|
||||
setIgnoreMouseEvents: () => {},
|
||||
},
|
||||
addEventListener: (type: string, listener: () => void) => {
|
||||
const bucket = windowListeners.get(type) ?? [];
|
||||
bucket.push(listener);
|
||||
windowListeners.set(type, bucket);
|
||||
},
|
||||
getComputedStyle: () => ({
|
||||
visibility: 'hidden',
|
||||
display: 'none',
|
||||
opacity: '0',
|
||||
}),
|
||||
focus: () => {},
|
||||
innerHeight: 1000,
|
||||
},
|
||||
});
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: () => {},
|
||||
elementFromPoint: (x: number, y: number) =>
|
||||
x === 120 && y === 240 ? ctx.dom.subtitleContainer : null,
|
||||
querySelectorAll: () => [],
|
||||
body: {},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const handlers = createMouseHandlers(ctx as never, {
|
||||
modalStateReader: {
|
||||
isAnySettingsModalOpen: () => false,
|
||||
isAnyModalOpen: () => false,
|
||||
},
|
||||
applyYPercent: () => {},
|
||||
getCurrentYPercent: () => 10,
|
||||
persistSubtitlePositionPatch: () => {},
|
||||
getSubtitleHoverAutoPauseEnabled: () => true,
|
||||
getYomitanPopupAutoPauseEnabled: () => false,
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command) => {
|
||||
mpvCommands.push(command);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.setupResizeHandler();
|
||||
for (const listener of windowListeners.get('resize') ?? []) {
|
||||
listener();
|
||||
}
|
||||
|
||||
await handlers.handlePrimaryMouseEnter({ clientX: 120, clientY: 240 } as MouseEvent);
|
||||
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||
}
|
||||
});
|
||||
|
||||
test('visibility recovery keeps overlay click-through when pointer is not over subtitles', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -1428,7 +1497,8 @@ test('pointer tracking enables overlay interaction as soon as the cursor reaches
|
||||
bucket.push(listener);
|
||||
documentListeners.set(type, bucket);
|
||||
},
|
||||
elementFromPoint: () => ctx.dom.subtitleContainer,
|
||||
elementFromPoint: (x: number, y: number) =>
|
||||
x === 120 && y === 240 ? ctx.dom.subtitleContainer : null,
|
||||
querySelectorAll: () => [],
|
||||
body: {},
|
||||
},
|
||||
|
||||
@@ -300,12 +300,15 @@ export function createMouseHandlers(
|
||||
}
|
||||
|
||||
async function handleMouseEnter(
|
||||
_event?: MouseEvent,
|
||||
event?: MouseEvent,
|
||||
showSecondaryHover = false,
|
||||
source: 'direct' | 'tracked-pointer' = 'direct',
|
||||
): Promise<void> {
|
||||
if (source === 'direct' && suppressDirectHoverEnterSource !== null) {
|
||||
return;
|
||||
if (!event || !syncHoverStateFromPoint(event.clientX, event.clientY).isOverSubtitle) {
|
||||
return;
|
||||
}
|
||||
suppressDirectHoverEnterSource = null;
|
||||
}
|
||||
|
||||
ctx.state.isOverSubtitle = true;
|
||||
|
||||
@@ -28,6 +28,18 @@ test('renderer stylesheet no longer contains invisible-layer selectors', () => {
|
||||
assert.doesNotMatch(cssSource, /body\.layer-invisible/);
|
||||
});
|
||||
|
||||
test('renderer stylesheet only hides visible focus chrome on top-level overlay focus targets', () => {
|
||||
const cssSource = readWorkspaceFile('src/renderer/style.css');
|
||||
assert.match(
|
||||
cssSource,
|
||||
/html:focus-visible,\s*body:focus-visible,\s*#overlay:focus-visible\s*\{[^}]*outline:\s*none;/s,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
cssSource,
|
||||
/html:focus,\s*body:focus,\s*#overlay:focus\s*\{[^}]*outline:\s*none;/s,
|
||||
);
|
||||
});
|
||||
|
||||
test('top-level readme avoids stale overlay-layers wording', () => {
|
||||
const readmeSource = readWorkspaceFile('README.md');
|
||||
assert.doesNotMatch(readmeSource, /overlay layers/i);
|
||||
|
||||
+101
-25
@@ -40,6 +40,12 @@ body {
|
||||
'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Arial Unicode MS', Arial, sans-serif;
|
||||
}
|
||||
|
||||
html:focus-visible,
|
||||
body:focus-visible,
|
||||
#overlay:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--subtitle-sidebar-reserved-width: 0px;
|
||||
|
||||
@@ -794,11 +800,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n1-color, #ed8796);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1[data-jlpt-level]::after {
|
||||
@@ -806,11 +809,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n2-color, #f5a97f);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2[data-jlpt-level]::after {
|
||||
@@ -818,11 +818,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n3-color, #f9e2af);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3[data-jlpt-level]::after {
|
||||
@@ -830,11 +827,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n4-color, #a6e3a1);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4[data-jlpt-level]::after {
|
||||
@@ -842,11 +836,8 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5 {
|
||||
text-decoration-line: underline;
|
||||
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.12em;
|
||||
text-decoration-skip-ink: none;
|
||||
text-decoration-line: none;
|
||||
border-bottom: 2px solid var(--subtitle-jlpt-n5-color, #8aadf4);
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5[data-jlpt-level]::after {
|
||||
@@ -997,6 +988,91 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
|
||||
-webkit-text-fill-color: var(--subtitle-frequency-band-5-color, #8aadf4) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n1.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-name-match,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n1.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n1:hover,
|
||||
#subtitleRoot .word.word-jlpt-n1 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n1::selection,
|
||||
#subtitleRoot .word.word-jlpt-n1 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n1-color, #ed8796) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n2.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-name-match,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n2.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n2:hover,
|
||||
#subtitleRoot .word.word-jlpt-n2 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n2::selection,
|
||||
#subtitleRoot .word.word-jlpt-n2 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n2-color, #f5a97f) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n3.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-name-match,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n3.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n3:hover,
|
||||
#subtitleRoot .word.word-jlpt-n3 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n3::selection,
|
||||
#subtitleRoot .word.word-jlpt-n3 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n3-color, #f9e2af) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n4.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-name-match,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n4.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n4:hover,
|
||||
#subtitleRoot .word.word-jlpt-n4 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n4::selection,
|
||||
#subtitleRoot .word.word-jlpt-n4 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n4-color, #a6e3a1) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot .word.word-jlpt-n5.word-known,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-n-plus-one,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-name-match,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-single,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-1,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-2,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-3,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-4,
|
||||
#subtitleRoot .word.word-jlpt-n5.word-frequency-band-5,
|
||||
#subtitleRoot .word.word-jlpt-n5:hover,
|
||||
#subtitleRoot .word.word-jlpt-n5 .c:hover,
|
||||
#subtitleRoot .word.word-jlpt-n5::selection,
|
||||
#subtitleRoot .word.word-jlpt-n5 .c::selection {
|
||||
text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4) !important;
|
||||
-webkit-text-decoration-color: var(--subtitle-jlpt-n5-color, #8aadf4) !important;
|
||||
}
|
||||
|
||||
#subtitleRoot
|
||||
.word:is(.word-jlpt-n1, .word-jlpt-n2, .word-jlpt-n3, .word-jlpt-n4, .word-jlpt-n5):not(
|
||||
.word-known
|
||||
|
||||
@@ -220,8 +220,20 @@ function normalizeCssSelector(selector: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildJlptUnderlineSelector(level: number): string {
|
||||
return `#subtitleRoot .word.word-jlpt-n${level}`;
|
||||
function buildJlptColorSelector(level: number): string {
|
||||
const higherPriorityClasses = [
|
||||
'.word-known',
|
||||
'.word-n-plus-one',
|
||||
'.word-name-match',
|
||||
'.word-frequency-single',
|
||||
'.word-frequency-band-1',
|
||||
'.word-frequency-band-2',
|
||||
'.word-frequency-band-3',
|
||||
'.word-frequency-band-4',
|
||||
'.word-frequency-band-5',
|
||||
].join(', ');
|
||||
|
||||
return `#subtitleRoot .word.word-jlpt-n${level}:not(:is(${higherPriorityClasses}))`;
|
||||
}
|
||||
|
||||
test('computeWordClass preserves known and n+1 classes while adding JLPT classes', () => {
|
||||
@@ -887,20 +899,32 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
const cssText = fs.readFileSync(cssPath, 'utf-8');
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const block = extractClassBlock(cssText, buildJlptUnderlineSelector(level));
|
||||
assert.ok(block.length > 0, `word-jlpt-n${level} class should exist`);
|
||||
assert.doesNotMatch(block, /(?:^|\n)\s*color\s*:/m);
|
||||
assert.doesNotMatch(block, /-webkit-text-fill-color\s*:/);
|
||||
assert.match(block, /text-decoration-line:\s*underline;/);
|
||||
const plainJlptBlock = extractClassBlock(cssText, `#subtitleRoot .word.word-jlpt-n${level}`);
|
||||
// JLPT tagging must never recolor the token text — other annotations own
|
||||
// text color. JLPT also must not use `text-decoration: underline`,
|
||||
// because Chromium repaints text-decoration during ::selection and the
|
||||
// underline would adopt the other annotation's color during a Yomitan
|
||||
// lookup. The underline is drawn by `border-bottom`, which is unaffected
|
||||
// by ::selection and stays locked on the JLPT level color regardless of
|
||||
// popup/selection state.
|
||||
assert.doesNotMatch(plainJlptBlock, /(?:^|\n)\s*color\s*:/m);
|
||||
assert.doesNotMatch(plainJlptBlock, /text-decoration-line:\s*underline;/);
|
||||
assert.doesNotMatch(plainJlptBlock, /text-decoration\s*:[^;]*\bunderline\b/i);
|
||||
assert.match(
|
||||
block,
|
||||
new RegExp(`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,`),
|
||||
plainJlptBlock,
|
||||
new RegExp(`border-bottom:\\s*2px\\s+solid\\s+var\\(--subtitle-jlpt-n${level}-color,`),
|
||||
`JLPT level must paint a permanent 2px border-bottom in the level color`,
|
||||
);
|
||||
|
||||
// JLPT tagging must communicate level *only* via the underline; it must
|
||||
// never recolor the token text. Other annotations (known, n+1, frequency,
|
||||
// name match) are responsible for token text color.
|
||||
const jlptOnlyColorBlock = extractClassBlock(cssText, buildJlptColorSelector(level));
|
||||
assert.equal(
|
||||
jlptOnlyColorBlock,
|
||||
'',
|
||||
`word-jlpt-n${level} (without other annotations) must not set text color — JLPT only paints the underline`,
|
||||
);
|
||||
assert.doesNotMatch(block, /border-bottom\s*:/);
|
||||
assert.doesNotMatch(block, /padding-bottom\s*:/);
|
||||
assert.doesNotMatch(block, /box-decoration-break\s*:/);
|
||||
assert.doesNotMatch(block, /-webkit-box-decoration-break\s*:/);
|
||||
assert.doesNotMatch(block, /text-shadow\s*:/);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
@@ -1064,6 +1088,55 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
|
||||
/-webkit-text-fill-color:\s*var\(--subtitle-hover-token-color,\s*#f4dbd6\)\s*!important;/,
|
||||
);
|
||||
|
||||
for (let level = 1; level <= 5; level += 1) {
|
||||
const jlptSelectionLockBlock = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level}::selection`,
|
||||
);
|
||||
assert.ok(jlptSelectionLockBlock.length > 0, `word-jlpt-n${level} selection lock should exist`);
|
||||
assert.match(
|
||||
jlptSelectionLockBlock,
|
||||
new RegExp(
|
||||
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
|
||||
'i',
|
||||
),
|
||||
);
|
||||
|
||||
for (const annotationClass of [
|
||||
'word-known',
|
||||
'word-n-plus-one',
|
||||
'word-name-match',
|
||||
'word-frequency-single',
|
||||
'word-frequency-band-2',
|
||||
]) {
|
||||
const combinedAnnotationBlock = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level}.${annotationClass}`,
|
||||
);
|
||||
assert.match(
|
||||
combinedAnnotationBlock,
|
||||
new RegExp(
|
||||
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
|
||||
'i',
|
||||
),
|
||||
`combined JLPT ${annotationClass} selector should lock underline color`,
|
||||
);
|
||||
}
|
||||
|
||||
const jlptCharHoverBlock = extractClassBlock(
|
||||
cssText,
|
||||
`#subtitleRoot .word.word-jlpt-n${level} .c:hover`,
|
||||
);
|
||||
assert.match(
|
||||
jlptCharHoverBlock,
|
||||
new RegExp(
|
||||
`text-decoration-color:\\s*var\\(--subtitle-jlpt-n${level}-color,\\s*#[0-9a-f]{6}\\)\\s*!important;`,
|
||||
'i',
|
||||
),
|
||||
'JLPT character hover selector should lock underline color',
|
||||
);
|
||||
}
|
||||
|
||||
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
|
||||
assert.match(
|
||||
selectionBlock,
|
||||
|
||||
+85
-4
@@ -177,8 +177,7 @@ export function mergeTokens(
|
||||
}
|
||||
|
||||
const result: MergedToken[] = [];
|
||||
const normalizedSourceText =
|
||||
typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : null;
|
||||
const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText);
|
||||
let charOffset = 0;
|
||||
let sourceCursor = 0;
|
||||
let lastStandaloneToken: Token | null = null;
|
||||
@@ -191,7 +190,9 @@ export function mergeTokens(
|
||||
|
||||
for (const token of tokens) {
|
||||
const matchedStart =
|
||||
normalizedSourceText !== null ? normalizedSourceText.indexOf(token.word, sourceCursor) : -1;
|
||||
typeof normalizedSourceText === 'string'
|
||||
? normalizedSourceText.indexOf(token.word, sourceCursor)
|
||||
: -1;
|
||||
const start = matchedStart >= sourceCursor ? matchedStart : charOffset;
|
||||
const end = start + token.word.length;
|
||||
charOffset = end;
|
||||
@@ -282,6 +283,49 @@ function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet<strin
|
||||
return parts.every((part) => exclusions.has(part));
|
||||
}
|
||||
|
||||
function isKanaChar(char: string): boolean {
|
||||
const code = char.codePointAt(0);
|
||||
if (code === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(code >= 0x3041 && code <= 0x3096) ||
|
||||
(code >= 0x309b && code <= 0x309f) ||
|
||||
code === 0x30fc ||
|
||||
(code >= 0x30a0 && code <= 0x30fa) ||
|
||||
(code >= 0x30fd && code <= 0x30ff)
|
||||
);
|
||||
}
|
||||
|
||||
function isKanaCandidateIgnorableChar(char: string): boolean {
|
||||
return /^[\s.,!?;:()[\]{}"'`、。!?…‥・「」『』()[]{}〈〉《》【】―-]$/u.test(char);
|
||||
}
|
||||
|
||||
function isKanaOnlyText(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let hasKana = false;
|
||||
for (const char of normalized) {
|
||||
if (isKanaChar(char)) {
|
||||
hasKana = true;
|
||||
continue;
|
||||
}
|
||||
if (!isKanaCandidateIgnorableChar(char)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return hasKana;
|
||||
}
|
||||
|
||||
function normalizeSourceTextForTokenOffsets(sourceText: string | undefined): string | undefined {
|
||||
return typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : undefined;
|
||||
}
|
||||
|
||||
export function isNPlusOneCandidateToken(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
|
||||
@@ -290,6 +334,9 @@ export function isNPlusOneCandidateToken(
|
||||
if (token.isKnown) {
|
||||
return false;
|
||||
}
|
||||
if (isKanaOnlyText(token.surface)) {
|
||||
return false;
|
||||
}
|
||||
return isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions);
|
||||
}
|
||||
|
||||
@@ -339,6 +386,18 @@ function isNPlusOneWordCountToken(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNPlusOneSentenceLengthToken(
|
||||
token: MergedToken,
|
||||
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
|
||||
pos2Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS2,
|
||||
): boolean {
|
||||
if (!isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return token.isKnown || isNPlusOneCandidateToken(token, pos1Exclusions, pos2Exclusions);
|
||||
}
|
||||
|
||||
function isSentenceBoundaryToken(token: MergedToken): boolean {
|
||||
if (token.partOfSpeech !== PartOfSpeech.symbol) {
|
||||
return false;
|
||||
@@ -347,22 +406,39 @@ function isSentenceBoundaryToken(token: MergedToken): boolean {
|
||||
return SENTENCE_BOUNDARY_SURFACES.has(token.surface);
|
||||
}
|
||||
|
||||
function hasSentenceBoundaryInSourceGap(
|
||||
sourceText: string | undefined,
|
||||
previousEnd: number | null,
|
||||
nextStart: number,
|
||||
): boolean {
|
||||
if (typeof sourceText !== 'string' || previousEnd === null || nextStart <= previousEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gap = sourceText.slice(previousEnd, nextStart);
|
||||
return [...gap].some((char) => SENTENCE_BOUNDARY_SURFACES.has(char));
|
||||
}
|
||||
|
||||
export function markNPlusOneTargets(
|
||||
tokens: MergedToken[],
|
||||
minSentenceWords = 3,
|
||||
pos1Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS1,
|
||||
pos2Exclusions: ReadonlySet<string> = N_PLUS_ONE_IGNORED_POS2,
|
||||
sourceText?: string,
|
||||
): MergedToken[] {
|
||||
if (tokens.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText);
|
||||
|
||||
const markedTokens = tokens.map((token) => ({
|
||||
...token,
|
||||
isNPlusOneTarget: false,
|
||||
}));
|
||||
|
||||
let sentenceStart = 0;
|
||||
let previousTokenEnd: number | null = null;
|
||||
const minimumSentenceWords = Number.isInteger(minSentenceWords)
|
||||
? Math.max(1, minSentenceWords)
|
||||
: 3;
|
||||
@@ -373,7 +449,7 @@ export function markNPlusOneTargets(
|
||||
for (let i = start; i < endExclusive; i++) {
|
||||
const token = markedTokens[i];
|
||||
if (!token) continue;
|
||||
if (isNPlusOneWordCountToken(token, pos1Exclusions, pos2Exclusions)) {
|
||||
if (isNPlusOneSentenceLengthToken(token, pos1Exclusions, pos2Exclusions)) {
|
||||
sentenceWordCount += 1;
|
||||
}
|
||||
|
||||
@@ -393,10 +469,15 @@ export function markNPlusOneTargets(
|
||||
for (let i = 0; i < markedTokens.length; i++) {
|
||||
const token = markedTokens[i];
|
||||
if (!token) continue;
|
||||
if (hasSentenceBoundaryInSourceGap(normalizedSourceText, previousTokenEnd, token.startPos)) {
|
||||
markSentence(sentenceStart, i);
|
||||
sentenceStart = i;
|
||||
}
|
||||
if (isSentenceBoundaryToken(token)) {
|
||||
markSentence(sentenceStart, i);
|
||||
sentenceStart = i + 1;
|
||||
}
|
||||
previousTokenEnd = token.endPos;
|
||||
}
|
||||
|
||||
if (sentenceStart < markedTokens.length) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isHyprlandGeometryEvent,
|
||||
parseHyprctlClients,
|
||||
parseHyprctlMonitors,
|
||||
resolveHyprlandWindowGeometry,
|
||||
selectHyprlandMpvWindow,
|
||||
type HyprlandClient,
|
||||
type HyprlandMonitor,
|
||||
} from './hyprland-tracker';
|
||||
|
||||
function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
|
||||
@@ -19,6 +23,17 @@ function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMonitor(overrides: Partial<HyprlandMonitor> = {}): HyprlandMonitor {
|
||||
return {
|
||||
id: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('selectHyprlandMpvWindow ignores hidden and unmapped mpv clients', () => {
|
||||
const selected = selectHyprlandMpvWindow(
|
||||
[
|
||||
@@ -106,3 +121,59 @@ test('parseHyprctlClients tolerates non-json prefix output', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseHyprctlMonitors returns null for malformed JSON output', () => {
|
||||
assert.equal(parseHyprctlMonitors('not-json'), null);
|
||||
assert.equal(parseHyprctlMonitors('[{"id":0,"x":0,"y":0,"width":1920'), null);
|
||||
});
|
||||
|
||||
test('isHyprlandGeometryEvent treats geometry events as geometry-changing only', () => {
|
||||
assert.equal(isHyprlandGeometryEvent('fullscreenv2'), true);
|
||||
assert.equal(isHyprlandGeometryEvent('workspacev2'), true);
|
||||
assert.equal(isHyprlandGeometryEvent('windowtitle'), false);
|
||||
assert.equal(isHyprlandGeometryEvent('windowtitlev2'), false);
|
||||
assert.equal(isHyprlandGeometryEvent('activewindowv2'), false);
|
||||
});
|
||||
|
||||
test('resolveHyprlandWindowGeometry uses monitor bounds for fullscreen clients', () => {
|
||||
const geometry = resolveHyprlandWindowGeometry(
|
||||
makeClient({
|
||||
at: [60, 80],
|
||||
size: [1280, 720],
|
||||
monitor: 1,
|
||||
fullscreen: 2,
|
||||
fullscreenClient: 2,
|
||||
}),
|
||||
[
|
||||
makeMonitor({ id: 0, x: 0, y: 0, width: 1920, height: 1080 }),
|
||||
makeMonitor({ id: 1, x: 1920, y: 0, width: 2560, height: 1440 }),
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(geometry, {
|
||||
x: 1920,
|
||||
y: 0,
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveHyprlandWindowGeometry uses monitor bounds for client-requested fullscreen', () => {
|
||||
const geometry = resolveHyprlandWindowGeometry(
|
||||
makeClient({
|
||||
at: [0, 28],
|
||||
size: [1920, 1052],
|
||||
monitor: 0,
|
||||
fullscreen: 0,
|
||||
fullscreenClient: 2,
|
||||
}),
|
||||
[makeMonitor({ id: 0, x: 0, y: 0, width: 1920, height: 1080 })],
|
||||
);
|
||||
|
||||
assert.deepEqual(geometry, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import * as net from 'net';
|
||||
import { execSync } from 'child_process';
|
||||
import { BaseWindowTracker } from './base-tracker';
|
||||
import { createLogger } from '../logger';
|
||||
import type { WindowGeometry } from '../types';
|
||||
|
||||
const log = createLogger('tracker').child('hyprland');
|
||||
|
||||
@@ -29,11 +30,22 @@ export interface HyprlandClient {
|
||||
initialClass?: string;
|
||||
at: [number, number];
|
||||
size: [number, number];
|
||||
monitor?: number;
|
||||
fullscreen?: number;
|
||||
fullscreenClient?: number;
|
||||
pid?: number;
|
||||
mapped?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface HyprlandMonitor {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface SelectHyprlandMpvWindowOptions {
|
||||
targetMpvSocketPath: string | null;
|
||||
activeWindowAddress: string | null;
|
||||
@@ -124,7 +136,12 @@ export function parseHyprctlClients(output: string): HyprlandClient[] | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(jsonPayload) as unknown;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonPayload) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
@@ -132,8 +149,76 @@ export function parseHyprctlClients(output: string): HyprlandClient[] | null {
|
||||
return parsed as HyprlandClient[];
|
||||
}
|
||||
|
||||
export function parseHyprctlMonitors(output: string): HyprlandMonitor[] | null {
|
||||
const jsonPayload = extractHyprctlJsonPayload(output);
|
||||
if (!jsonPayload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(jsonPayload) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as HyprlandMonitor[];
|
||||
}
|
||||
|
||||
function isHyprlandFullscreenClient(client: HyprlandClient): boolean {
|
||||
return (client.fullscreen ?? 0) > 0 || (client.fullscreenClient ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function resolveHyprlandWindowGeometry(
|
||||
client: HyprlandClient,
|
||||
monitors: HyprlandMonitor[] | null,
|
||||
): WindowGeometry {
|
||||
if (isHyprlandFullscreenClient(client) && typeof client.monitor === 'number') {
|
||||
const monitor = monitors?.find((candidate) => candidate.id === client.monitor);
|
||||
if (monitor) {
|
||||
return {
|
||||
x: monitor.x,
|
||||
y: monitor.y,
|
||||
width: monitor.width,
|
||||
height: monitor.height,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: client.at[0],
|
||||
y: client.at[1],
|
||||
width: client.size[0],
|
||||
height: client.size[1],
|
||||
};
|
||||
}
|
||||
|
||||
export function isHyprlandGeometryEvent(name: string): boolean {
|
||||
return (
|
||||
name === 'movewindow' ||
|
||||
name === 'movewindowv2' ||
|
||||
name === 'resizewindow' ||
|
||||
name === 'resizewindowv2' ||
|
||||
name === 'openwindow' ||
|
||||
name === 'closewindow' ||
|
||||
name === 'fullscreen' ||
|
||||
name === 'fullscreenv2' ||
|
||||
name === 'changefloatingmode' ||
|
||||
name === 'workspace' ||
|
||||
name === 'workspacev2' ||
|
||||
name === 'focusedmon' ||
|
||||
name === 'monitoradded' ||
|
||||
name === 'monitoraddedv2' ||
|
||||
name === 'monitorremoved'
|
||||
);
|
||||
}
|
||||
|
||||
export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private pollTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
private eventSocket: net.Socket | null = null;
|
||||
private readonly targetMpvSocketPath: string | null;
|
||||
private activeWindowAddress: string | null = null;
|
||||
@@ -154,6 +239,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
for (const timeout of this.pollTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.pollTimeouts = [];
|
||||
if (this.eventSocket) {
|
||||
this.eventSocket.destroy();
|
||||
this.eventSocket = null;
|
||||
@@ -200,6 +289,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
}
|
||||
|
||||
const [name, rawData = ''] = trimmedEvent.split('>>', 2);
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const data = rawData.trim();
|
||||
|
||||
if (name === 'activewindowv2') {
|
||||
@@ -212,17 +304,24 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
this.activeWindowAddress = null;
|
||||
}
|
||||
|
||||
if (
|
||||
name === 'movewindow' ||
|
||||
name === 'movewindowv2' ||
|
||||
name === 'windowtitle' ||
|
||||
name === 'windowtitlev2' ||
|
||||
name === 'openwindow' ||
|
||||
name === 'closewindow' ||
|
||||
name === 'fullscreen' ||
|
||||
name === 'changefloatingmode'
|
||||
) {
|
||||
this.pollGeometry();
|
||||
if (isHyprlandGeometryEvent(name)) {
|
||||
this.scheduleGeometryPollBurst();
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleGeometryPollBurst(): void {
|
||||
for (const timeout of this.pollTimeouts) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => {
|
||||
const pollTimeout = setTimeout(() => {
|
||||
this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout);
|
||||
this.pollGeometry();
|
||||
}, delayMs);
|
||||
return pollTimeout;
|
||||
});
|
||||
for (const pollTimeout of this.pollTimeouts) {
|
||||
pollTimeout.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,12 +336,9 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
const mpvWindow = this.findTargetWindow(clients);
|
||||
|
||||
if (mpvWindow) {
|
||||
this.updateGeometry({
|
||||
x: mpvWindow.at[0],
|
||||
y: mpvWindow.at[1],
|
||||
width: mpvWindow.size[0],
|
||||
height: mpvWindow.size[1],
|
||||
});
|
||||
this.updateGeometry(
|
||||
resolveHyprlandWindowGeometry(mpvWindow, this.getHyprlandMonitors(mpvWindow)),
|
||||
);
|
||||
} else {
|
||||
this.updateGeometry(null);
|
||||
}
|
||||
@@ -259,6 +355,19 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||
});
|
||||
}
|
||||
|
||||
private getHyprlandMonitors(client: HyprlandClient): HyprlandMonitor[] | null {
|
||||
if (!isHyprlandFullscreenClient(client)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execSync('hyprctl -j monitors', { encoding: 'utf-8' });
|
||||
return parseHyprctlMonitors(output);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private getWindowCommandLine(pid: number): string | null {
|
||||
const commandLine = execSync(`ps -p ${pid} -o args=`, {
|
||||
encoding: 'utf-8',
|
||||
|
||||
Reference in New Issue
Block a user