fix: address PR review follow-ups

This commit is contained in:
2026-04-25 20:34:18 -07:00
parent ba48db6255
commit ac93a5bd2e
32 changed files with 345 additions and 40 deletions

View File

@@ -0,0 +1,51 @@
---
id: TASK-300
title: Fix transparent subtitle hover background config
status: Done
assignee: []
created_date: '2026-04-26 03:23'
updated_date: '2026-04-26 03:26'
labels:
- bug
- overlay
- config
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
User reports setting subtitleStyle.hoverTokenBackgroundColor to transparent still renders default hover background in overlay subtitles.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Transparent hoverTokenBackgroundColor is accepted by config resolution.
- [x] #2 Renderer applies transparent hover token background instead of falling back to default.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce config alias behavior with a failing config test.
2. Map subtitleStyle.hoverBackground to hoverTokenBackgroundColor in config resolution while keeping canonical key precedence.
3. Add renderer regression for transparent hover token background CSS variable.
4. Update docs and changelog fragment; run focused verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Local user config used subtitleStyle.hoverBackground, which was ignored because only subtitleStyle.hoverTokenBackgroundColor was recognized. Canonical key still takes precedence when both are present.
Verification passed: bun run test:config:src; bun test src/renderer/subtitle-render.test.ts; bun run changelog:lint; bun run docs:test; bun run docs:build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented config compatibility for transparent hover token backgrounds. `subtitleStyle.hoverBackground` now maps to the canonical `subtitleStyle.hoverTokenBackgroundColor` during resolution, preserving canonical key precedence. Added regression coverage for the alias and renderer handling of `transparent`, documented the alias, and added a changelog fragment.
Verification: `bun run test:config:src`; `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`; `bun run docs:test`; `bun run docs:build`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,53 @@
---
id: TASK-301
title: Fix launcher-managed video close leaving background app alive
status: Done
assignee:
- Codex
created_date: '2026-04-26 03:29'
updated_date: '2026-04-26 03:33'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Launcher/plugin-managed video playback should not leave the SubMiner background app or tray icon running after the video closes unless the user explicitly launched SubMiner in background mode via --background or by starting with no app arguments. This is a regression after crash-avoidance work that added background startup for launcher-managed playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Closing a launcher-managed video exits the launcher-started SubMiner app/tray instead of leaving it alive.
- [x] #2 Explicit background launches still keep SubMiner alive after windows close.
- [x] #3 No-argument app startup behavior remains unchanged.
- [x] #4 Regression coverage exercises the launcher-managed playback shutdown lifecycle.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression coverage first: plugin auto-start should tag launcher-managed playback, and app mpv shutdown handling should quit only when started in that managed playback mode.
2. Add a narrow CLI flag/state field for launcher-managed playback, separate from explicit persistent background mode.
3. Have plugin pass the new flag with its background start command.
4. On mpv shutdown/disconnect, request app quit only when managed playback mode is active; preserve explicit --background and no-arg startup persistence.
5. Run focused plugin/app tests, then relevant launcher/core gates if feasible.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented managed playback shutdown by adding a `--managed-playback` app flag that the mpv plugin passes only for launcher-managed starts. The main mpv shutdown path now quits the app when initial args indicate managed playback, while explicit background/no-arg startup remains persistent. Added plugin start-gate and mpv protocol regression coverage.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Launcher-managed video playback now exits the SubMiner background app/tray when mpv shuts down, avoiding a leftover background process after closing a launcher-started video. Explicit `--background` and no-argument app startup remain persistent because the quit path is gated on the new `--managed-playback` flag. The mpv plugin passes that flag for auto-started playback, and mpv protocol shutdown requests app quit only in that managed mode.
Verification: plugin start-gate regression coverage, mpv protocol shutdown regression coverage, CLI managed-playback parse coverage, plus broader local gates run before handoff.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,4 @@
type: fixed
area: config
- Accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`, so setting it to `transparent` removes hover token backgrounds.

View File

@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Launcher-managed playback now exits the background SubMiner app when the video closes, while explicit background launches stay persistent.

View File

@@ -310,7 +310,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | | `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) | | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) | | `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |

View File

@@ -187,6 +187,7 @@ function M.create(ctx)
if action == "start" then if action == "start" then
table.insert(args, "--background") table.insert(args, "--background")
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend) local backend = resolve_backend(overrides.backend)
if backend and backend ~= "" then if backend and backend ~= "" then

View File

@@ -769,6 +769,10 @@ do
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true(call_has_arg(start_call, "--background"), "auto-start should launch SubMiner in background mode") assert_true(call_has_arg(start_call, "--background"), "auto-start should launch SubMiner in background mode")
assert_true(
call_has_arg(start_call, "--managed-playback"),
"auto-start should mark SubMiner as launcher-managed playback"
)
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled") assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command") assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
assert_true( assert_true(

View File

@@ -261,6 +261,7 @@ export class CardCreationService {
); );
for (const audioField of audioFields) { for (const audioField of audioFields) {
const existingAudio = noteInfo.fields[audioField]?.value || ''; const existingAudio = noteInfo.fields[audioField]?.value || '';
// Manual clipboard updates intentionally replace old captured audio.
updatedFields[audioField] = this.deps.mergeFieldValue( updatedFields[audioField] = this.deps.mergeFieldValue(
existingAudio, existingAudio,
audioValue, audioValue,

View File

@@ -214,7 +214,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(anilistRetryQueue), true); assert.equal(hasExplicitCommand(anilistRetryQueue), true);
assert.equal(shouldStartApp(anilistRetryQueue), false); assert.equal(shouldStartApp(anilistRetryQueue), false);
const dictionaryCandidates = parseArgs(['--dictionary-candidates', '--dictionary-target', '/tmp/a.mkv']); const dictionaryCandidates = parseArgs([
'--dictionary-candidates',
'--dictionary-target',
'/tmp/a.mkv',
]);
assert.equal(dictionaryCandidates.dictionaryCandidates, true); assert.equal(dictionaryCandidates.dictionaryCandidates, true);
assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv'); assert.equal(dictionaryCandidates.dictionaryTarget, '/tmp/a.mkv');
assert.equal(hasExplicitCommand(dictionaryCandidates), true); assert.equal(hasExplicitCommand(dictionaryCandidates), true);
@@ -334,6 +338,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(hasExplicitCommand(background), true); assert.equal(hasExplicitCommand(background), true);
assert.equal(shouldStartApp(background), true); assert.equal(shouldStartApp(background), true);
const managedPlayback = parseArgs(['--background', '--managed-playback']);
assert.equal(managedPlayback.background, true);
assert.equal(managedPlayback.managedPlayback, true);
assert.equal(hasExplicitCommand(managedPlayback), true);
assert.equal(shouldStartApp(managedPlayback), true);
const setup = parseArgs(['--setup']); const setup = parseArgs(['--setup']);
assert.equal((setup as typeof setup & { setup?: boolean }).setup, true); assert.equal((setup as typeof setup & { setup?: boolean }).setup, true);
assert.equal(hasExplicitCommand(setup), true); assert.equal(hasExplicitCommand(setup), true);

View File

@@ -1,5 +1,6 @@
export interface CliArgs { export interface CliArgs {
background: boolean; background: boolean;
managedPlayback: boolean;
start: boolean; start: boolean;
launchMpv: boolean; launchMpv: boolean;
launchMpvTargets: string[]; launchMpvTargets: string[];
@@ -99,6 +100,7 @@ export type CliCommandSource = 'initial' | 'second-instance';
export function parseArgs(argv: string[]): CliArgs { export function parseArgs(argv: string[]): CliArgs {
const args: CliArgs = { const args: CliArgs = {
background: false, background: false,
managedPlayback: false,
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],
@@ -201,6 +203,7 @@ export function parseArgs(argv: string[]): CliArgs {
if (!arg || !arg.startsWith('--')) continue; if (!arg || !arg.startsWith('--')) continue;
if (arg === '--background') args.background = true; if (arg === '--background') args.background = true;
else if (arg === '--managed-playback') args.managedPlayback = true;
else if (arg === '--start') args.start = true; else if (arg === '--start') args.start = true;
else if (arg.startsWith('--youtube-play=')) { else if (arg.startsWith('--youtube-play=')) {
const value = arg.split('=', 2)[1]; const value = arg.split('=', 2)[1];

View File

@@ -437,6 +437,22 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values
); );
}); });
test('parses subtitleStyle.hoverBackground as a hoverTokenBackgroundColor alias', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"subtitleStyle": {
"hoverBackground": "transparent"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().subtitleStyle.hoverTokenBackgroundColor, 'transparent');
});
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => { test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
const validDir = makeTempDir(); const validDir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(

View File

@@ -260,20 +260,21 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
); );
} }
const hoverTokenBackgroundColor = asString( const subtitleStyleSource = src.subtitleStyle as {
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor, hoverBackground?: unknown;
); hoverTokenBackgroundColor?: unknown;
};
const rawHoverTokenBackgroundColor =
subtitleStyleSource.hoverTokenBackgroundColor ?? subtitleStyleSource.hoverBackground;
const hoverTokenBackgroundColor = asString(rawHoverTokenBackgroundColor);
if (hoverTokenBackgroundColor !== undefined) { if (hoverTokenBackgroundColor !== undefined) {
resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor; resolved.subtitleStyle.hoverTokenBackgroundColor = hoverTokenBackgroundColor;
} else if ( } else if (rawHoverTokenBackgroundColor !== undefined) {
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor !==
undefined
) {
resolved.subtitleStyle.hoverTokenBackgroundColor = resolved.subtitleStyle.hoverTokenBackgroundColor =
fallbackSubtitleStyleHoverTokenBackgroundColor; fallbackSubtitleStyleHoverTokenBackgroundColor;
warn( warn(
'subtitleStyle.hoverTokenBackgroundColor', 'subtitleStyle.hoverTokenBackgroundColor',
(src.subtitleStyle as { hoverTokenBackgroundColor?: unknown }).hoverTokenBackgroundColor, rawHoverTokenBackgroundColor,
resolved.subtitleStyle.hoverTokenBackgroundColor, resolved.subtitleStyle.hoverTokenBackgroundColor,
'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).', 'Expected a CSS color value (hex, rgba/hsl/hsla, named color, or var()).',
); );

View File

@@ -6,6 +6,7 @@ import { AppLifecycleServiceDeps, startAppLifecycle } from './app-lifecycle';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs { function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return { return {
background: false, background: false,
managedPlayback: false,
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],

View File

@@ -6,6 +6,7 @@ import { CliCommandServiceDeps, handleCliCommand } from './cli-command';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs { function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return { return {
background: false, background: false,
managedPlayback: false,
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],
@@ -717,6 +718,34 @@ test('handleCliCommand sets character dictionary manual AniList selection', asyn
); );
}); });
test('handleCliCommand does not log character dictionary selection success when result is not ok', async () => {
const { calls, deps } = createDeps({
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: 'test',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
});
handleCliCommand(
makeArgs({
dictionarySelect: true,
dictionaryAnilistId: 21355,
dictionaryTarget: '/tmp/re-zero.mkv',
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.ok(calls.includes('warn:Character dictionary override was not saved.'));
assert.equal(
calls.some((call) => call.startsWith('log:Character dictionary override saved:')),
false,
);
});
test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => { test('handleCliCommand does not dispatch runJellyfinCommand for non-Jellyfin commands', () => {
const nonJellyfinArgs: Array<Partial<CliArgs>> = [ const nonJellyfinArgs: Array<Partial<CliArgs>> = [
{ start: true }, { start: true },

View File

@@ -645,6 +645,10 @@ export function handleCliCommand(
mediaId: args.dictionaryAnilistId, mediaId: args.dictionaryAnilistId,
}) })
.then((result) => { .then((result) => {
if (!result.ok) {
deps.warn('Character dictionary override was not saved.');
return;
}
deps.log( deps.log(
`Character dictionary override saved: ${result.seriesKey} -> ${result.selected.id} - ${result.selected.title}`, `Character dictionary override saved: ${result.seriesKey} -> ${result.selected.id} - ${result.selected.title}`,
); );

View File

@@ -19,6 +19,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
commands: unknown[]; commands: unknown[];
mediaPath: string; mediaPath: string;
restored: number; restored: number;
quitRequested: number;
}; };
} { } {
const state = { const state = {
@@ -28,6 +29,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
commands: [] as unknown[], commands: [] as unknown[],
mediaPath: '', mediaPath: '',
restored: 0, restored: 0,
quitRequested: 0,
}; };
const metrics: MpvSubtitleRenderMetrics = { const metrics: MpvSubtitleRenderMetrics = {
subPos: 100, subPos: 100,
@@ -102,6 +104,10 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
restorePreviousSecondarySubVisibility: () => { restorePreviousSecondarySubVisibility: () => {
state.restored += 1; state.restored += 1;
}, },
shouldQuitOnMpvShutdown: () => false,
requestAppQuit: () => {
state.quitRequested += 1;
},
setPreviousSecondarySubVisibility: () => { setPreviousSecondarySubVisibility: () => {
// intentionally not tracked in this unit test // intentionally not tracked in this unit test
}, },
@@ -223,6 +229,18 @@ test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', asy
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps); await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1); assert.equal(state.restored, 1);
assert.equal(state.quitRequested, 0);
});
test('dispatchMpvProtocolMessage quits app on managed playback shutdown', async () => {
const { deps, state } = createDeps({
shouldQuitOnMpvShutdown: () => true,
});
await dispatchMpvProtocolMessage({ event: 'shutdown' }, deps);
assert.equal(state.restored, 1);
assert.equal(state.quitRequested, 1);
}); });
test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => { test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is set', async () => {

View File

@@ -91,6 +91,8 @@ export interface MpvProtocolHandleMessageDeps {
) => void; ) => void;
sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean; sendCommand: (payload: { command: unknown[]; request_id?: number }) => boolean;
restorePreviousSecondarySubVisibility: () => void; restorePreviousSecondarySubVisibility: () => void;
shouldQuitOnMpvShutdown: () => boolean;
requestAppQuit: () => void;
} }
type SubtitleTrackCandidate = { type SubtitleTrackCandidate = {
@@ -360,6 +362,9 @@ export async function dispatchMpvProtocolMessage(
} }
} else if (msg.event === 'shutdown') { } else if (msg.event === 'shutdown') {
deps.restorePreviousSecondarySubVisibility(); deps.restorePreviousSecondarySubVisibility();
if (deps.shouldQuitOnMpvShutdown()) {
deps.requestAppQuit();
}
} else if (msg.request_id) { } else if (msg.request_id) {
if (deps.resolvePendingRequest(msg.request_id, msg)) { if (deps.resolvePendingRequest(msg.request_id, msg)) {
return; return;

View File

@@ -105,6 +105,8 @@ export interface MpvIpcClientProtocolDeps {
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null; getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
} }
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
@@ -399,6 +401,8 @@ export class MpvIpcClient implements MpvClient {
restorePreviousSecondarySubVisibility: () => { restorePreviousSecondarySubVisibility: () => {
this.restorePreviousSecondarySubVisibility(); this.restorePreviousSecondarySubVisibility();
}, },
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => this.deps.requestAppQuit?.(),
}; };
} }

View File

@@ -6,6 +6,7 @@ import { CliArgs } from '../../cli/args';
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs { function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return { return {
background: false, background: false,
managedPlayback: false,
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],

View File

@@ -4091,8 +4091,7 @@ test('tokenizeSubtitle clears annotations for ことに while preserving lexical
text === '違う' ? 900 : text === '事' ? 81 : text === '気付く' ? 1500 : null, text === '違う' ? 900 : text === '事' ? 81 : text === '気付く' ? 1500 : null,
getJlptLevel: (text) => getJlptLevel: (text) =>
text === '違う' ? 'N4' : text === '事' ? 'N4' : text === '気付く' ? 'N3' : null, text === '違う' ? 'N4' : text === '事' ? 'N4' : text === '気付く' ? 'N3' : null,
isKnownWord: (text) => isKnownWord: (text) => ['さっき', 'の', '俺', 'と', '気付く', 'かい', ''].includes(text),
['さっき', 'の', '俺', 'と', '気付く', 'かい', ''].includes(text),
getMinSentenceWordsForNPlusOne: () => 1, getMinSentenceWordsForNPlusOne: () => 1,
tokenizeWithMecab: async () => [ tokenizeWithMecab: async () => [
{ {

View File

@@ -3823,6 +3823,8 @@ const {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => { setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer; appState.reconnectTimer = timer;
}, },
shouldQuitOnMpvShutdown: () => appState.initialArgs?.managedPlayback === true,
requestAppQuit: () => requestAppQuit(),
}, },
updateMpvSubtitleRenderMetricsMainDeps: { updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics, getCurrentMetrics: () => appState.mpvSubtitleRenderMetrics,

View File

@@ -175,7 +175,9 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}; };
}; };
const resolveGuessInput = (targetPath?: string): { mediaPath: string | null; mediaTitle: string | null } => { const resolveGuessInput = (
targetPath?: string,
): { mediaPath: string | null; mediaTitle: string | null } => {
const dictionaryTarget = targetPath?.trim() || ''; const dictionaryTarget = targetPath?.trim() || '';
return dictionaryTarget.length > 0 return dictionaryTarget.length > 0
? resolveDictionaryGuessInputs(dictionaryTarget) ? resolveDictionaryGuessInputs(dictionaryTarget)
@@ -324,13 +326,13 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
`[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`, `[dictionary] stored snapshot for AniList ${mediaId}: ${snapshot.entryCount} terms`,
); );
return { return {
mediaId: snapshot.mediaId, mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle, mediaTitle: snapshot.mediaTitle,
entryCount: snapshot.entryCount, entryCount: snapshot.entryCount,
fromCache: false, fromCache: false,
updatedAt: snapshot.updatedAt, updatedAt: snapshot.updatedAt,
}; };
}; };
return { return {

View File

@@ -4,9 +4,10 @@ import test from 'node:test';
import { searchAniListMediaCandidates } from './fetch'; import { searchAniListMediaCandidates } from './fetch';
test('searchAniListMediaCandidates trims fallback candidate titles', async () => { test('searchAniListMediaCandidates trims fallback candidate titles', async () => {
const previousFetch = globalThis.fetch; const previousFetchDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch');
Object.defineProperty(globalThis, 'fetch', { Object.defineProperty(globalThis, 'fetch', {
configurable: true, configurable: true,
writable: true,
value: async () => value: async () =>
new Response( new Response(
JSON.stringify({ JSON.stringify({
@@ -24,6 +25,10 @@ test('searchAniListMediaCandidates trims fallback candidate titles', async () =>
assert.equal(candidates[0]?.title, 'Re:ZERO'); assert.equal(candidates[0]?.title, 'Re:ZERO');
} finally { } finally {
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: previousFetch }); if (previousFetchDescriptor) {
Object.defineProperty(globalThis, 'fetch', previousFetchDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'fetch');
}
} }
}); });

View File

@@ -98,11 +98,7 @@ export function buildCharacterDictionarySeriesKey(input: {
} }
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) { export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
const filePath = path.join( const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
deps.userDataPath,
'character-dictionaries',
'anilist-overrides.json',
);
return { return {
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => { getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {

View File

@@ -467,10 +467,7 @@ test('auto sync removes stale manual-selection media ids when applying corrected
path.join(dictionariesDir, 'auto-sync-state.json'), path.join(dictionariesDir, 'auto-sync-state.json'),
JSON.stringify( JSON.stringify(
{ {
activeMediaIds: [ activeMediaIds: ['10607 - Rerere no Tensai Bakabon', '130298 - The Eminence in Shadow'],
'10607 - Rerere no Tensai Bakabon',
'130298 - The Eminence in Shadow',
],
mergedRevision: 'old', mergedRevision: 'old',
mergedDictionaryTitle: 'SubMiner Character Dictionary', mergedDictionaryTitle: 'SubMiner Character Dictionary',
}, },

View File

@@ -20,6 +20,7 @@ function withTempDir(fn: (dir: string) => Promise<void> | void): Promise<void> |
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs { function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
return { return {
background: false, background: false,
managedPlayback: false,
start: false, start: false,
launchMpv: false, launchMpv: false,
launchMpvTargets: [], launchMpvTargets: [],

View File

@@ -11,6 +11,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null; getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
bindEventHandlers: (client: TClient) => void; bindEventHandlers: (client: TClient) => void;
}) { }) {
return () => ({ return () => ({
@@ -24,6 +26,8 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
getReconnectTimer: () => deps.getReconnectTimer(), getReconnectTimer: () => deps.getReconnectTimer(),
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
deps.setReconnectTimer(timer), deps.setReconnectTimer(timer),
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => deps.requestAppQuit?.(),
}, },
bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client), bindEventHandlers: (client: TClient) => deps.bindEventHandlers(client),
}); });

View File

@@ -7,6 +7,8 @@ export type MpvClientRuntimeServiceOptions = {
isVisibleOverlayVisible: () => boolean; isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null; getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void; setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
}; };
type MpvClientLike = { type MpvClientLike = {

View File

@@ -362,14 +362,7 @@ export function createKeyboardHandlers(
} }
function isPrimarySubtitleVisibilityToggle(e: KeyboardEvent): boolean { function isPrimarySubtitleVisibilityToggle(e: KeyboardEvent): boolean {
return ( return e.code === 'KeyV' && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey && !e.repeat;
e.code === 'KeyV' &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
!e.shiftKey &&
!e.repeat
);
} }
function togglePrimarySubtitleBarVisibility(): void { function togglePrimarySubtitleBarVisibility(): void {

View File

@@ -153,3 +153,63 @@ test('character dictionary modal loads candidates and applies selected override'
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument }); Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
} }
}); });
test('character dictionary modal shows refresh errors without rejecting open', async () => {
const previousWindow = globalThis.window;
const overlay = createNodeStub();
const modalNode = createNodeStub(true);
const status = createNodeStub();
const state = createRendererState();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
getCharacterDictionarySelection: async () => {
throw new Error('candidate lookup failed');
},
setCharacterDictionarySelection: async () => ({
ok: false,
seriesKey: 'test',
selected: { id: 0, title: '', episodes: null },
staleMediaIds: [],
}),
notifyOverlayModalClosed: () => {},
} satisfies Pick<
ElectronAPI,
| 'getCharacterDictionarySelection'
| 'setCharacterDictionarySelection'
| 'notifyOverlayModalClosed'
>,
},
});
try {
const modal = createCharacterDictionaryModal(
{
state,
dom: {
overlay,
characterDictionaryModal: modalNode,
characterDictionaryClose: createNodeStub(),
characterDictionarySummary: createNodeStub(),
characterDictionaryCurrent: createNodeStub(),
characterDictionaryCandidates: createNodeStub(),
characterDictionaryStatus: status,
},
} as never,
{
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
},
);
await modal.openCharacterDictionaryModal();
assert.equal(state.characterDictionaryModalOpen, true);
assert.equal(status.textContent, 'candidate lookup failed');
assert.equal(status.classList.contains('error'), true);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
}
});

View File

@@ -162,7 +162,11 @@ export function createCharacterDictionaryModal(
} else { } else {
setStatus('Refreshing AniList candidates...'); setStatus('Refreshing AniList candidates...');
} }
await refreshSelection(); try {
await refreshSelection();
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), true);
}
} }
function closeCharacterDictionaryModal(): void { function closeCharacterDictionaryModal(): void {

View File

@@ -658,6 +658,36 @@ test('sanitizeSubtitleHoverTokenColor keeps non-black color values', () => {
assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6'); assert.equal(sanitizeSubtitleHoverTokenColor(undefined), '#f4dbd6');
}); });
test('applySubtitleStyle keeps transparent hover token background', () => {
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({
hoverTokenBackgroundColor: 'transparent',
} as never);
const rootStyleValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
.values;
assert.equal(rootStyleValues?.get('--subtitle-hover-token-background-color'), 'transparent');
} finally {
restoreDocument();
}
});
test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => { test('alignTokensToSourceText preserves newline separators between adjacent token surfaces', () => {
const tokens = [ const tokens = [
createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }), createToken({ surface: 'キリキリと', reading: 'きりきりと', headword: 'キリキリと' }),