mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-26 04:19:27 -07:00
fix: address PR review follow-ups
This commit is contained in:
@@ -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 -->
|
||||||
@@ -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 -->
|
||||||
4
changes/300-transparent-hover-background.md
Normal file
4
changes/300-transparent-hover-background.md
Normal 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.
|
||||||
4
changes/301-managed-playback-exit.md
Normal file
4
changes/301-managed-playback-exit.md
Normal 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.
|
||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()).',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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?.(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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 () => [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -162,7 +162,11 @@ export function createCharacterDictionaryModal(
|
|||||||
} else {
|
} else {
|
||||||
setStatus('Refreshing AniList candidates...');
|
setStatus('Refreshing AniList candidates...');
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await refreshSelection();
|
await refreshSelection();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCharacterDictionaryModal(): void {
|
function closeCharacterDictionaryModal(): void {
|
||||||
|
|||||||
@@ -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: 'キリキリと' }),
|
||||||
|
|||||||
Reference in New Issue
Block a user