diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index 03037775..7480a8f3 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -106,6 +106,16 @@ test('parseLauncherMpvConfig reads launch mode preference', () => { assert.equal(parsed.aniskipButtonKey, 'F8'); }); +test('parseLauncherMpvConfig ignores blank subminer binary paths', () => { + const parsed = parseLauncherMpvConfig({ + mpv: { + subminerBinaryPath: ' ', + }, + }); + + assert.equal(parsed.subminerBinaryPath, undefined); +}); + test('parseLauncherMpvConfig ignores invalid launch mode values', () => { const parsed = parseLauncherMpvConfig({ mpv: { diff --git a/launcher/config/mpv-config.ts b/launcher/config/mpv-config.ts index a57dd2ab..3a8b5cb0 100644 --- a/launcher/config/mpv-config.ts +++ b/launcher/config/mpv-config.ts @@ -37,8 +37,7 @@ export function parseLauncherMpvConfig(root: Record): LauncherM typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined, pauseUntilOverlayReady: typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined, - subminerBinaryPath: - typeof mpv.subminerBinaryPath === 'string' ? mpv.subminerBinaryPath.trim() : undefined, + subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath), aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined, aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey), }; diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index c5da5507..61339175 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -697,6 +697,47 @@ test('startOverlay borrows an already-running background app instead of owning i } }); +test('startOverlay keeps lifecycle ownership for its already-managed app', async () => { + const { dir, socketPath } = createTempSocketPath(); + const appPath = path.join(dir, 'fake-subminer.sh'); + const appInvocationsPath = path.join(dir, 'app-invocations.log'); + fs.writeFileSync( + appPath, + [ + '#!/bin/sh', + `printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`, + 'if [ "$1" = "--app-ping" ]; then exit 0; fi', + 'exit 0', + '', + ].join('\n'), + ); + fs.chmodSync(appPath, 0o755); + fs.writeFileSync(socketPath, ''); + const originalCreateConnection = net.createConnection; + try { + state.appPath = appPath; + state.overlayManagedByLauncher = true; + net.createConnection = (() => { + const socket = new EventEmitter() as net.Socket; + socket.destroy = (() => socket) as net.Socket['destroy']; + socket.setTimeout = (() => socket) as net.Socket['setTimeout']; + setTimeout(() => socket.emit('connect'), 10); + return socket; + }) as typeof net.createConnection; + + await startOverlay(appPath, makeArgs(), socketPath); + + assert.equal(state.overlayManagedByLauncher, true); + assert.equal(state.appPath, appPath); + } finally { + net.createConnection = originalCreateConnection; + state.overlayProc = null; + state.overlayManagedByLauncher = false; + state.appPath = ''; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => { const { dir } = createTempSocketPath(); const appPath = path.join(dir, 'fake-subminer.sh'); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 10280ccc..f8298bfc 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -1016,7 +1016,7 @@ export async function startOverlay( env: buildAppEnv(process.env, target.env), }); attachAppProcessLogging(state.overlayProc); - if (appAlreadyRunning) { + if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) { log( 'debug', args.logLevel, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index d35e966f..7ef2ae34 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2096,10 +2096,19 @@ test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => { subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string }; }; assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6'); - assert.equal(config.subtitleStyle.knownWordColor, '#a6da95'); assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false); }); +test('legacy migration failures are logged and rethrown', () => { + const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8'); + const catchBlock = source.match(/catch\s*\(error\)\s*\{(?[\s\S]*?)\n \}/)?.groups?.body; + + assert.ok(catchBlock); + assert.match(catchBlock, /legacy config migration failed/); + assert.match(catchBlock, /console\.error/); + assert.match(catchBlock, /throw error;/); +}); + test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => { const dir = makeTempDir(); const configPath = path.join(dir, 'config.jsonc'); diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 7a2abd47..fbf0e947 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -132,7 +132,7 @@ test('n+1 annotation color has one public config path', () => { const leaves = collectConfigLeafPaths(DEFAULT_CONFIG); assert.ok(leaves.includes('subtitleStyle.nPlusOneColor')); - assert.ok(!leaves.includes('ankiConnect.nPlusOne.nPlusOne')); + assert.ok(!leaves.includes('ankiConnect.nPlusOne.color')); }); test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => { diff --git a/src/config/service.ts b/src/config/service.ts index 2719d8ea..aa1ac28e 100644 --- a/src/config/service.ts +++ b/src/config/service.ts @@ -151,8 +151,9 @@ export class ConfigService { } fs.writeFileSync(configPath, content, 'utf-8'); return rawConfig; - } catch { - return config; + } catch (error) { + console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error); + throw error; } } } diff --git a/src/main.ts b/src/main.ts index ecf6817c..3978fa2f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4251,6 +4251,7 @@ const { appState.currentSubText = ''; appState.currentSubAssText = ''; appState.currentSubtitleData = null; + broadcastToOverlayWindows('subtitle:set', { text: '', tokens: null }); } autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); currentMediaTokenizationGate.updateCurrentMediaPath(path); diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 03edb3a6..50812fbd 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -21,6 +21,23 @@ test('manual watched session action starts immersion tracker before marking watc ); }); +test('media path changes clear rendered subtitle state', () => { + const source = readMainSource(); + const actionBlock = source.match( + /updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/, + )?.groups?.body; + + assert.ok(actionBlock); + assert.match(actionBlock, /appState\.currentSubText = '';/); + assert.match(actionBlock, /appState\.currentSubAssText = '';/); + assert.match(actionBlock, /appState\.currentSubtitleData = null;/); + assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/); + assert.ok( + actionBlock.indexOf('appState.currentSubtitleData = null;') < + actionBlock.indexOf("broadcastToOverlayWindows('subtitle:set'"), + ); +}); + test('main process uses one shared mpv plugin runtime config helper', () => { const source = readMainSource(); assert.match(source, /function getMpvPluginRuntimeConfig\(\)/); diff --git a/src/main/runtime/autoplay-subtitle-primer.test.ts b/src/main/runtime/autoplay-subtitle-primer.test.ts index dcd706c1..0219c2b1 100644 --- a/src/main/runtime/autoplay-subtitle-primer.test.ts +++ b/src/main/runtime/autoplay-subtitle-primer.test.ts @@ -30,6 +30,13 @@ test('selectAutoplayStartupCue returns the next imminent cue before playback sta ); }); +test('selectAutoplayStartupCue clamps negative current time to startup', () => { + assert.deepEqual( + selectAutoplayStartupCue([{ startTime: 0, endTime: 1, text: 'startup' }], -0.5, 0), + { startTime: 0, endTime: 1, text: 'startup' }, + ); +}); + test('selectAutoplayStartupCue does not reveal far future subtitle text', () => { assert.equal( selectAutoplayStartupCue([{ startTime: 12, endTime: 15, text: 'later' }], 0, 2), diff --git a/src/main/runtime/autoplay-subtitle-primer.ts b/src/main/runtime/autoplay-subtitle-primer.ts index fbc93314..2f96256b 100644 --- a/src/main/runtime/autoplay-subtitle-primer.ts +++ b/src/main/runtime/autoplay-subtitle-primer.ts @@ -5,7 +5,7 @@ export function selectAutoplayStartupCue( currentTimeSeconds: number, lookaheadSeconds: number, ): SubtitleCue | null { - const currentTime = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0; + const currentTime = Math.max(0, Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0); const lookahead = Math.max(0, Number.isFinite(lookaheadSeconds) ? lookaheadSeconds : 0); const latestStartTime = currentTime + lookahead; diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index b1c68e73..be9806ea 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -68,10 +68,13 @@ function normalizeKeyToken(token: string): string { } function formatKeybinding(rawBinding: string): string { - const parts = rawBinding.split('+'); + const parts = rawBinding + .split('+') + .map((part) => part.trim()) + .filter(Boolean); const key = parts.pop(); if (!key) return rawBinding; - const normalized = [...parts, normalizeKeyToken(key)]; + const normalized = [...parts.map(normalizeKeyToken), normalizeKeyToken(key)]; return normalized.join(' + '); } diff --git a/src/renderer/modals/session-help.test.ts b/src/renderer/modals/session-help.test.ts index e9f9e632..ebad232f 100644 --- a/src/renderer/modals/session-help.test.ts +++ b/src/renderer/modals/session-help.test.ts @@ -33,6 +33,10 @@ test('session help formats bracket keybindings as physical keys', () => { assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + ['); }); +test('session help normalizes configured modifier aliases', () => { + assert.equal(formatSessionHelpKeybinding('CommandOrControl+KeyS'), 'Cmd/Ctrl + S'); +}); + test('session help imports browser-safe special command constants', () => { const source = fs.readFileSync( path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help-sections.ts'), diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 5ce78dc8..98f0a638 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -45,6 +45,12 @@ class FakeStyleDeclaration { setProperty(name: string, value: string) { this.values.set(name, value); } + + removeProperty(name: string) { + const previous = this.values.get(name) ?? ''; + this.values.delete(name); + return previous; + } } class FakeElement { @@ -475,6 +481,57 @@ test('applySubtitleStyle applies primary and secondary css declaration objects', } }); +test('applySubtitleStyle removes css declarations missing from later updates', () => { + const restoreDocument = installFakeDocument(); + try { + const subtitleRoot = new FakeElement('div'); + const subtitleContainer = new FakeElement('div'); + const secondarySubRoot = new FakeElement('div'); + const secondarySubContainer = new FakeElement('div'); + const ctx = { + state: createRendererState(), + dom: { + subtitleRoot, + subtitleContainer, + secondarySubRoot, + secondarySubContainer, + }, + } as never; + + const renderer = createSubtitleRenderer(ctx); + renderer.applySubtitleStyle({ + css: { + 'font-size': '42px', + 'text-wrap': 'balance', + }, + secondary: { + css: { + 'text-transform': 'uppercase', + }, + }, + } as never); + renderer.applySubtitleStyle({ + css: { + 'font-size': '44px', + }, + secondary: { + css: {}, + }, + } as never); + + const primaryValues = (subtitleRoot.style as unknown as { values?: Map }) + .values; + const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map }) + .values; + + assert.equal(primaryValues?.get('font-size'), '44px'); + assert.equal(primaryValues?.has('text-wrap'), false); + assert.equal(secondaryValues?.has('text-transform'), false); + } finally { + restoreDocument(); + } +}); + test('annotated subtitle tokens inherit configured base subtitle typography', () => { const restoreDocument = installFakeDocument(); try { diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index e7dbf2aa..0138c842 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -158,6 +158,49 @@ function applyInlineStyleDeclarations( } } +const appliedCssKeys = new WeakMap>(); + +function inlineStyleDeclarationKeys( + declarations: Record, + excludedKeys: ReadonlySet, +): Set { + const keys = new Set(); + for (const [key, value] of Object.entries(declarations)) { + if (excludedKeys.has(key)) continue; + if (value === null || value === undefined || typeof value === 'object') continue; + keys.add(key); + } + return keys; +} + +function clearInlineStyleDeclaration(target: HTMLElement, key: string): void { + if (key.includes('-')) { + target.style.removeProperty(key); + if (key === '--webkit-text-stroke') { + target.style.removeProperty('-webkit-text-stroke'); + } + return; + } + + (target.style as unknown as Record)[key] = ''; +} + +function replaceInlineStyleDeclarations( + target: HTMLElement, + declarations: Record, + excludedKeys: ReadonlySet = new Set(), +): void { + const nextKeys = inlineStyleDeclarationKeys(declarations, excludedKeys); + const previousKeys = appliedCssKeys.get(target) ?? new Set(); + for (const key of previousKeys) { + if (!nextKeys.has(key)) { + clearInlineStyleDeclaration(target, key); + } + } + applyInlineStyleDeclarations(target, declarations, excludedKeys); + appliedCssKeys.set(target, nextKeys); +} + function normalizeCssDeclarationObject(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; @@ -177,8 +220,8 @@ function applySubtitleCssDeclarations( container: HTMLElement, declarations: Record, ): void { - applyInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS); - applyInlineStyleDeclarations( + replaceInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS); + replaceInlineStyleDeclarations( container, pickInlineStyleDeclarations(declarations, CONTAINER_STYLE_KEYS), );