diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 666962f..14be87d 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -71,7 +71,7 @@ auto_start = maybe auto_start_visible_overlay = no auto_start_pause_until_ready = off `); - assert.equal(parsed.autoStart, true); + assert.equal(parsed.autoStart, false); assert.equal(parsed.autoStartVisibleOverlay, false); assert.equal(parsed.autoStartPauseUntilReady, false); }); diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index d57470d..1ee0a53 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -15,7 +15,10 @@ export function getPluginConfigCandidates(): string[] { ); } -export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig { +export function parsePluginRuntimeConfigContent( + content: string, + logLevel: LogLevel = 'warn', +): PluginRuntimeConfig { const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH, autoStart: true, @@ -23,11 +26,12 @@ export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeC autoStartPauseUntilReady: true, }; - const parseBooleanValue = (value: string, fallback: boolean): boolean => { + const parseBooleanValue = (key: string, value: string): boolean => { const normalized = value.trim().toLowerCase(); if (['yes', 'true', '1', 'on'].includes(normalized)) return true; if (['no', 'false', '0', 'off'].includes(normalized)) return false; - return fallback; + log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`); + return false; }; for (const line of content.split(/\r?\n/)) { @@ -44,20 +48,17 @@ export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeC continue; } if (key === 'auto_start') { - runtimeConfig.autoStart = parseBooleanValue(value, runtimeConfig.autoStart); + runtimeConfig.autoStart = parseBooleanValue('auto_start', value); continue; } if (key === 'auto_start_visible_overlay') { - runtimeConfig.autoStartVisibleOverlay = parseBooleanValue( - value, - runtimeConfig.autoStartVisibleOverlay, - ); + runtimeConfig.autoStartVisibleOverlay = parseBooleanValue('auto_start_visible_overlay', value); continue; } if (key === 'auto_start_pause_until_ready') { runtimeConfig.autoStartPauseUntilReady = parseBooleanValue( + 'auto_start_pause_until_ready', value, - runtimeConfig.autoStartPauseUntilReady, ); } } diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 622e56a..778858c 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -28,6 +28,7 @@ export { } from './startup'; export { openYomitanSettingsWindow } from './yomitan-settings'; export { createTokenizerDepsRuntime, tokenizeSubtitle } from './tokenizer'; +export { clearYomitanParserCachesForWindow } from './tokenizer/yomitan-parser-runtime'; export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime'; export { createSubtitleProcessingController } from './subtitle-processing-controller'; export { createFrequencyDictionaryLookup } from './frequency-dictionary'; diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index 9079931..6b77670 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -62,6 +62,9 @@ function clearWindowCaches(window: BrowserWindow): void { yomitanProfileMetadataByWindow.delete(window); yomitanFrequencyCacheByWindow.delete(window); } +export function clearYomitanParserCachesForWindow(window: BrowserWindow): void { + clearWindowCaches(window); +} function asPositiveInteger(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index ae86c1b..a927b75 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -7,6 +7,7 @@ export interface OpenYomitanSettingsWindowOptions { yomitanExt: Extension | null; getExistingWindow: () => BrowserWindow | null; setWindow: (window: BrowserWindow | null) => void; + onWindowClosed?: () => void; } export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOptions): void { @@ -81,6 +82,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti }, 500); settingsWindow.on('closed', () => { + options.onWindowClosed?.(); options.setWindow(null); }); } diff --git a/src/main.ts b/src/main.ts index 46b9369..bac358d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -354,6 +354,7 @@ import { resolveJellyfinPlaybackPlanRuntime, runStartupBootstrapRuntime, saveSubtitlePosition as saveSubtitlePositionCore, + clearYomitanParserCachesForWindow, syncYomitanDefaultAnkiServer as syncYomitanDefaultAnkiServerCore, sendMpvCommandRuntime, setMpvSubVisibilityRuntime, @@ -845,6 +846,7 @@ const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMa const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); let autoPlayReadySignalMediaPath: string | null = null; +let autoPlayReadySignalGeneration = 0; function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { if (!payload.text.trim()) { @@ -858,8 +860,32 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { return; } autoPlayReadySignalMediaPath = mediaPath; + const playbackGeneration = ++autoPlayReadySignalGeneration; logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`); sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']); + const isPlaybackPaused = async (client: { + requestProperty: (property: string) => Promise; + }): Promise => { + try { + const pauseProperty = await client.requestProperty('pause'); + if (typeof pauseProperty === 'boolean') { + return pauseProperty; + } + if (typeof pauseProperty === 'string') { + return pauseProperty.toLowerCase() !== 'no' && pauseProperty !== '0'; + } + if (typeof pauseProperty === 'number') { + return pauseProperty !== 0; + } + logger.debug(`[autoplay-ready] unrecognized pause property for media ${mediaPath}: ${String(pauseProperty)}`); + } catch (error) { + logger.debug( + `[autoplay-ready] failed to read pause property for media ${mediaPath}: ${(error as Error).message}`, + ); + } + return true; + }; + // Fallback: unpause directly in case plugin readiness handler is unavailable/outdated. void (async () => { const mpvClient = appState.mpvClient; @@ -868,20 +894,8 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { return; } - let shouldUnpause = appState.playbackPaused !== false; - try { - const pauseProperty = await mpvClient.requestProperty('pause'); - if (typeof pauseProperty === 'boolean') { - shouldUnpause = pauseProperty; - } else if (typeof pauseProperty === 'string') { - shouldUnpause = pauseProperty.toLowerCase() !== 'no' && pauseProperty !== '0'; - } - logger.debug(`[autoplay-ready] mpv pause property before fallback: ${String(pauseProperty)}`); - } catch (error) { - logger.debug( - `[autoplay-ready] failed to read pause property before fallback: ${(error as Error).message}`, - ); - } + const shouldUnpause = await isPlaybackPaused(mpvClient); + logger.debug(`[autoplay-ready] mpv paused before fallback for ${mediaPath}: ${shouldUnpause}`); if (!shouldUnpause) { logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed'); @@ -890,10 +904,25 @@ function maybeSignalPluginAutoplayReady(payload: SubtitleData): void { mpvClient.send({ command: ['set_property', 'pause', false] }); setTimeout(() => { - const followupClient = appState.mpvClient; - if (followupClient?.connected) { + void (async () => { + if ( + autoPlayReadySignalMediaPath !== mediaPath || + playbackGeneration !== autoPlayReadySignalGeneration + ) { + return; + } + + const followupClient = appState.mpvClient; + if (!followupClient?.connected) { + return; + } + + const shouldUnpauseFollowup = await isPlaybackPaused(followupClient); + if (!shouldUnpauseFollowup) { + return; + } followupClient.send({ command: ['set_property', 'pause', false] }); - } + })(); }, 500); logger.debug('[autoplay-ready] issued direct mpv unpause fallback'); })(); @@ -3177,6 +3206,11 @@ const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSetting yomitanExt: yomitanExt as Extension, getExistingWindow: () => getExistingWindow() as BrowserWindow | null, setWindow: (window) => setWindow(window as BrowserWindow | null), + onWindowClosed: () => { + if (appState.yomitanParserWindow) { + clearYomitanParserCachesForWindow(appState.yomitanParserWindow); + } + }, }); }, getExistingWindow: () => appState.yomitanSettingsWindow, diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts index 278de88..feeed18 100644 --- a/src/main/runtime/app-runtime-main-deps.ts +++ b/src/main/runtime/app-runtime-main-deps.ts @@ -66,6 +66,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler TWindow | null; setWindow: (window: TWindow | null) => void; + onWindowClosed?: () => void; }) => void; getExistingWindow: () => TWindow | null; setWindow: (window: TWindow | null) => void; @@ -78,6 +79,7 @@ export function createBuildOpenYomitanSettingsMainDepsHandler TWindow | null; setWindow: (window: TWindow | null) => void; + onWindowClosed?: () => void; }) => deps.openYomitanSettingsWindow(params), getExistingWindow: () => deps.getExistingWindow(), setWindow: (window: TWindow | null) => deps.setWindow(window), diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts index 7d8b88a..85339b6 100644 --- a/src/main/runtime/yomitan-settings-opener.ts +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -7,6 +7,7 @@ export function createOpenYomitanSettingsHandler(deps: { yomitanExt: YomitanExtensionLike; getExistingWindow: () => BrowserWindowLike | null; setWindow: (window: BrowserWindowLike | null) => void; + onWindowClosed?: () => void; }) => void; getExistingWindow: () => BrowserWindowLike | null; setWindow: (window: BrowserWindowLike | null) => void; diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index 56d2c10..fe59a14 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -85,7 +85,7 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena }); await handlers.handleMouseEnter(); - handlers.handleMouseLeave(); + await handlers.handleMouseLeave(); assert.deepEqual(mpvCommands, [ ['set_property', 'pause', 'yes'], @@ -93,9 +93,10 @@ test('auto-pause on subtitle hover pauses on enter and resumes on leave when ena ]); }); -test('auto-pause on subtitle hover does not unpause when playback was already paused', async () => { +test('auto-pause on subtitle hover does not unpause when playback becomes paused on leave', async () => { const ctx = createMouseTestContext(); const mpvCommands: Array<(string | number)[]> = []; + const playbackPausedStates = [false, true]; const handlers = createMouseHandlers(ctx as never, { modalStateReader: { @@ -106,16 +107,16 @@ test('auto-pause on subtitle hover does not unpause when playback was already pa getCurrentYPercent: () => 10, persistSubtitlePositionPatch: () => {}, getSubtitleHoverAutoPauseEnabled: () => true, - getPlaybackPaused: async () => true, + getPlaybackPaused: async () => playbackPausedStates.shift() ?? true, sendMpvCommand: (command) => { mpvCommands.push(command); }, }); await handlers.handleMouseEnter(); - handlers.handleMouseLeave(); + await handlers.handleMouseLeave(); - assert.deepEqual(mpvCommands, []); + assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]); }); test('auto-pause on subtitle hover is skipped when disabled in config', async () => { @@ -138,7 +139,7 @@ test('auto-pause on subtitle hover is skipped when disabled in config', async () }); await handlers.handleMouseEnter(); - handlers.handleMouseLeave(); + await handlers.handleMouseLeave(); assert.deepEqual(mpvCommands, []); }); @@ -164,7 +165,7 @@ test('pending hover pause check is ignored when mouse leaves before pause state }); const enterPromise = handlers.handleMouseEnter(); - handlers.handleMouseLeave(); + await handlers.handleMouseLeave(); deferred.resolve(false); await enterPromise; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index a3a6a22..9da0592 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -76,12 +76,20 @@ export function createMouseHandlers( pausedBySubtitleHover = true; } - function handleMouseLeave(): void { + async function handleMouseLeave(): Promise { ctx.state.isOverSubtitle = false; hoverPauseRequestId += 1; if (pausedBySubtitleHover) { - options.sendMpvCommand(['set_property', 'pause', 'no']); pausedBySubtitleHover = false; + try { + const isPaused = await options.getPlaybackPaused(); + if (isPaused !== false) { + return; + } + } catch { + return; + } + options.sendMpvCommand(['set_property', 'pause', 'no']); } if (yomitanPopupVisible) return; disablePopupInteractionIfIdle();