diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index 50f524c7..84b7b28d 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -3086,6 +3086,27 @@ test('tokenizeSubtitle uses Yomitan word classes to classify standalone particle assert.equal(result.tokens?.[0]?.jlptLevel, undefined); }); +test('tokenizeSubtitle uses Yomitan word classes to classify auxiliary subclasses', async () => { + const result = await tokenizeSubtitle( + 'です', + makeDepsFromYomitanTokens( + [{ surface: 'です', reading: 'です', headword: 'です', wordClasses: ['aux-v'] }], + { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: () => 10, + getJlptLevel: () => 'N5', + tokenizeWithMecab: async () => null, + }, + ), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.partOfSpeech, PartOfSpeech.bound_auxiliary); + assert.equal(result.tokens?.[0]?.pos1, '助動詞'); + assert.equal(result.tokens?.[0]?.frequencyRank, undefined); + assert.equal(result.tokens?.[0]?.jlptLevel, undefined); +}); + test('tokenizeSubtitle fills detailed MeCab POS when Yomitan word class supplies coarse POS', async () => { const result = await tokenizeSubtitle( 'は', diff --git a/src/core/services/tokenizer.ts b/src/core/services/tokenizer.ts index dda46d8c..90a06f4c 100644 --- a/src/core/services/tokenizer.ts +++ b/src/core/services/tokenizer.ts @@ -359,7 +359,7 @@ function resolvePartOfSpeechFromYomitanWordClasses(wordClasses: string[]): { if (wordClasses.includes('prt')) { return { partOfSpeech: PartOfSpeech.particle, pos1: '助詞' }; } - if (wordClasses.includes('aux')) { + if (wordClasses.some((wordClass) => wordClass === 'aux' || wordClass.startsWith('aux-'))) { return { partOfSpeech: PartOfSpeech.bound_auxiliary, pos1: '助動詞' }; } if (wordClasses.some((wordClass) => wordClass.startsWith('v'))) { diff --git a/src/core/services/tokenizer/annotation-stage.test.ts b/src/core/services/tokenizer/annotation-stage.test.ts index 9df047e8..c098e646 100644 --- a/src/core/services/tokenizer/annotation-stage.test.ts +++ b/src/core/services/tokenizer/annotation-stage.test.ts @@ -713,6 +713,57 @@ test('annotateTokens N+1 sentence word count respects source punctuation gaps om assert.equal(result[3]?.isNPlusOneTarget, false); }); +test('annotateTokens N+1 sentence word count normalizes line breaks before gap detection', () => { + const tokens = [ + makeToken({ + surface: '私', + headword: '私', + pos1: '名詞', + startPos: 0, + endPos: 1, + }), + makeToken({ + surface: '猫', + headword: '猫', + pos1: '名詞', + startPos: 2, + endPos: 3, + }), + makeToken({ + surface: '犬', + headword: '犬', + pos1: '名詞', + startPos: 3, + endPos: 4, + }), + makeToken({ + surface: 'ふざけん', + headword: 'ふざける', + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞', + pos2: '自立', + startPos: 5, + endPos: 9, + }), + ]; + + const result = annotateTokens( + tokens, + makeDeps({ + isKnownWord: (text) => text === '私' || text === '猫' || text === '犬', + }), + { + minSentenceWordsForNPlusOne: 3, + sourceText: '私\r\n猫犬!ふざけんなよ!', + }, + ); + + assert.equal(result[0]?.isNPlusOneTarget, false); + assert.equal(result[1]?.isNPlusOneTarget, false); + assert.equal(result[2]?.isNPlusOneTarget, false); + assert.equal(result[3]?.isNPlusOneTarget, false); +}); + test('annotateTokens applies configured pos1 exclusions to both frequency and N+1', () => { const tokens = [ makeToken({ diff --git a/src/main.ts b/src/main.ts index 61a4e63f..20045373 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,10 @@ 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 { + clearLinuxMpvFullscreenOverlayRefreshTimeouts, + scheduleLinuxVisibleOverlayFullscreenRefreshBurst, +} from './main/runtime/linux-mpv-fullscreen-overlay-refresh'; import { mergeAiConfig } from './ai/config'; function getPasswordStoreArg(argv: string[]): string | null { @@ -1911,7 +1915,6 @@ const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as cons const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const; const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75; const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200; -const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const; let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; @@ -1919,7 +1922,6 @@ let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; -let linuxMpvFullscreenOverlayRefreshTimeouts: Array> = []; function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { @@ -1935,48 +1937,6 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { windowsVisibleOverlayZOrderRetryTimeouts = []; } -function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void { - for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) { - clearTimeout(timeout); - } - linuxMpvFullscreenOverlayRefreshTimeouts = []; -} - -function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(): void { - if (process.platform !== 'linux' || !overlayManager.getVisibleOverlayVisible()) { - return; - } - - overlayVisibilityRuntime.updateVisibleOverlayVisibility(); - - const mainWindow = overlayManager.getMainWindow(); - if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) { - return; - } - - mainWindow.hide(); - mainWindow.showInactive(); - ensureOverlayWindowLevel(mainWindow); -} - -function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(): void { - 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(); - }, delayMs); - refreshTimeout.unref?.(); - linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout); - } -} - function getWindowsNativeWindowHandle(window: BrowserWindow): string { const handle = window.getNativeWindowHandle(); return handle.length >= 8 @@ -3146,6 +3106,8 @@ const { stopTexthookerService: () => texthookerService.stop(), clearWindowsVisibleOverlayForegroundPollLoop: () => clearWindowsVisibleOverlayForegroundPollLoop(), + clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => + clearLinuxMpvFullscreenOverlayRefreshTimeouts(), getMainOverlayWindow: () => overlayManager.getMainWindow(), clearMainOverlayWindow: () => overlayManager.setMainWindow(null), getModalOverlayWindow: () => overlayManager.getModalWindow(), @@ -3851,7 +3813,14 @@ const { lastObservedTimePos = time; }, onFullscreenChange: () => { - scheduleLinuxVisibleOverlayFullscreenRefreshBurst(); + scheduleLinuxVisibleOverlayFullscreenRefreshBurst({ + overlayManager: { + getMainWindow: () => overlayManager.getMainWindow(), + getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), + }, + overlayVisibilityRuntime, + ensureOverlayWindowLevel: (window) => ensureOverlayWindowLevel(window), + }); }, onSubtitleTrackChange: (sid) => { scheduleSubtitlePrefetchRefresh(); diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 8d5727bd..9e858daf 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -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')); }); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 5e90c0e7..cf9eb634 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -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(); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index e2bd0b3e..e9bd557a 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -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'), diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index 6d4bbb9a..4ab2bd70 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -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; diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index f3ddad99..96fbb369 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler stopSubtitleWebsocket: () => {}, stopTexthookerService: () => {}, clearWindowsVisibleOverlayForegroundPollLoop: () => {}, + clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {}, getMainOverlayWindow: () => null, clearMainOverlayWindow: () => {}, getModalOverlayWindow: () => null, diff --git a/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts new file mode 100644 index 00000000..48a8215f --- /dev/null +++ b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + clearLinuxMpvFullscreenOverlayRefreshTimeouts, + 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'), + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + 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); + } + } +}); diff --git a/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts new file mode 100644 index 00000000..a0287fc3 --- /dev/null +++ b/src/main/runtime/linux-mpv-fullscreen-overlay-refresh.ts @@ -0,0 +1,68 @@ +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; +}; + +const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const; +let linuxMpvFullscreenOverlayRefreshTimeouts: Array> = []; + +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, +): void { + 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); + } +} + +export { clearLinuxMpvFullscreenOverlayRefreshTimeouts }; diff --git a/src/token-merger.ts b/src/token-merger.ts index f7037ac1..f0a65e5b 100644 --- a/src/token-merger.ts +++ b/src/token-merger.ts @@ -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; @@ -302,6 +303,10 @@ function isKanaOnlyText(text: string): boolean { return normalized.length > 0 && Array.from(normalized).every((char) => isKanaChar(char)); } +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 = N_PLUS_ONE_IGNORED_POS1, @@ -394,8 +399,7 @@ export function markNPlusOneTargets( return []; } - const normalizedSourceText = - typeof sourceText === 'string' ? sourceText.replace(/\r?\n/g, ' ').trim() : undefined; + const normalizedSourceText = normalizeSourceTextForTokenOffsets(sourceText); const markedTokens = tokens.map((token) => ({ ...token, diff --git a/src/window-trackers/hyprland-tracker.ts b/src/window-trackers/hyprland-tracker.ts index 4c86e889..86207fc8 100644 --- a/src/window-trackers/hyprland-tracker.ts +++ b/src/window-trackers/hyprland-tracker.ts @@ -302,11 +302,10 @@ export class HyprlandWindowTracker extends BaseWindowTracker { } private scheduleGeometryPollBurst(): void { - this.pollGeometry(); for (const timeout of this.pollTimeouts) { clearTimeout(timeout); } - this.pollTimeouts = [50, 150, 300].map((delayMs) => { + this.pollTimeouts = [0, 50, 150, 300].map((delayMs) => { const pollTimeout = setTimeout(() => { this.pollTimeouts = this.pollTimeouts.filter((timeout) => timeout !== pollTimeout); this.pollGeometry();