feat: add mark-watched action, background app reuse, and N+1 compat

- Add `--mark-watched` CLI flag + mpv session binding; marks video watched, shows OSD, advances playlist
- Launcher detects running background app via `--app-ping` and borrows it instead of owning its lifecycle
- Preserve N+1 highlighting for existing configs with `knownWords.highlightEnabled` set
- Fix `resolveConfiguredShortcuts` to respect explicit `null` overrides (disabling defaults)
- Split session-help modal into focused modules (colors, render, sections, tabs)
This commit is contained in:
2026-05-19 01:30:49 -07:00
parent 24b95eda9d
commit f4845513f3
42 changed files with 1429 additions and 505 deletions
+1
View File
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
+18
View File
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
refreshKnownWords: false,
openRuntimeOptions: false,
@@ -607,6 +608,7 @@ test('handleCliCommand handles visibility and utility command dispatches', () =>
{ args: { toggleSecondarySub: true }, expected: 'cycleSecondarySubMode' },
{ args: { togglePrimarySubtitleBar: true }, expected: 'togglePrimarySubtitleBar' },
{ args: { toggleStatsOverlay: true }, expected: 'dispatchSessionAction' },
{ args: { markWatched: true }, expected: 'dispatchSessionAction' },
{
args: { openRuntimeOptions: true },
expected: 'openRuntimeOptionsPalette',
@@ -653,6 +655,22 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ markWatched: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'markWatched',
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+2
View File
@@ -469,6 +469,8 @@ export function handleCliCommand(
'toggleStatsOverlay',
'Stats toggle failed',
);
} else if (args.markWatched) {
dispatchCliSessionAction({ actionId: 'markWatched' }, 'markWatched', 'Mark watched failed');
} else if (args.toggleSubtitleSidebar) {
dispatchCliSessionAction(
{ actionId: 'toggleSubtitleSidebar' },
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { dispatchSessionAction, type SessionActionExecutorDeps } from './session-actions';
function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
const calls: string[] = [];
const deps: SessionActionExecutorDeps = {
toggleStatsOverlay: () => calls.push('stats'),
toggleVisibleOverlay: () => calls.push('visible'),
copyCurrentSubtitle: () => calls.push('copy'),
copySubtitleCount: (count) => calls.push(`copy:${count}`),
updateLastCardFromClipboard: async () => {
calls.push('update');
},
triggerFieldGrouping: async () => {
calls.push('field-grouping');
},
triggerSubsyncFromConfig: async () => {
calls.push('subsync');
},
mineSentenceCard: async () => {
calls.push('mine');
},
mineSentenceCount: (count) => calls.push(`mine:${count}`),
toggleSecondarySub: () => calls.push('secondary'),
toggleSubtitleSidebar: () => calls.push('sidebar'),
markLastCardAsAudioCard: async () => {
calls.push('audio');
},
markActiveVideoWatched: async () => {
calls.push('mark-watched');
return true;
},
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openSessionHelp: () => calls.push('session-help'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
},
openPlaylistBrowser: () => {
calls.push('playlist');
},
replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('play-next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
cycleRuntimeOption: () => ({ ok: true }),
playNextPlaylistItem: () => calls.push('playlist-next'),
showMpvOsd: (text) => calls.push(`osd:${text}`),
...overrides,
};
return { calls, deps };
}
test('dispatchSessionAction marks watched and advances playlist after success', async () => {
const { calls, deps } = createDeps();
await dispatchSessionAction({ actionId: 'markWatched' }, deps);
assert.deepEqual(calls, ['mark-watched', 'osd:Marked as watched', 'playlist-next']);
});
test('dispatchSessionAction does not advance playlist when mark watched no-ops', async () => {
const { calls, deps } = createDeps({
markActiveVideoWatched: async () => {
calls.push('mark-watched');
return false;
},
});
await dispatchSessionAction({ actionId: 'markWatched' }, deps);
assert.deepEqual(calls, ['mark-watched']);
});
+10
View File
@@ -15,6 +15,7 @@ export interface SessionActionExecutorDeps {
toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
markLastCardAsAudioCard: () => Promise<void>;
markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openCharacterDictionary: () => void;
@@ -27,6 +28,7 @@ export interface SessionActionExecutorDeps {
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void;
showMpvOsd: (text: string) => void;
}
@@ -80,6 +82,14 @@ export async function dispatchSessionAction(
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
case 'markWatched': {
const marked = await deps.markActiveVideoWatched();
if (marked) {
deps.showMpvOsd('Marked as watched');
deps.playNextPlaylistItem();
}
return;
}
case 'openRuntimeOptions':
deps.openRuntimeOptionsPalette();
return;
@@ -375,3 +375,64 @@ test('compileSessionBindings includes stats toggle in the shared session binding
},
]);
});
test('compileSessionBindings includes mark-watched in the shared session binding artifact', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [],
statsMarkWatchedKey: 'Ctrl+Shift+KeyW',
platform: 'darwin',
});
assert.equal(result.warnings.length, 0);
assert.deepEqual(result.bindings, [
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'Ctrl+Shift+KeyW',
key: {
code: 'KeyW',
modifiers: ['ctrl', 'shift'],
},
actionType: 'session-action',
actionId: 'markWatched',
},
]);
});
test('compileSessionBindings wires every configured shortcut key into the shared artifact', () => {
const shortcutKeys: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'>> = [
'toggleVisibleOverlayGlobal',
'copySubtitle',
'copySubtitleMultiple',
'updateLastCardFromClipboard',
'triggerFieldGrouping',
'triggerSubsync',
'mineSentence',
'mineSentenceMultiple',
'toggleSecondarySub',
'markAudioCard',
'openCharacterDictionary',
'openRuntimeOptions',
'openJimaku',
'openSessionHelp',
'openControllerSelect',
'openControllerDebug',
'toggleSubtitleSidebar',
];
const shortcuts = createShortcuts();
shortcutKeys.forEach((key, index) => {
shortcuts[key] = `Ctrl+Alt+F${index + 1}`;
});
const result = compileSessionBindings({
shortcuts,
keybindings: [],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(
result.bindings.map((binding) => binding.sourcePath).sort(),
shortcutKeys.map((key) => `shortcuts.${key}`).sort(),
);
});
+30
View File
@@ -18,6 +18,7 @@ type CompileSessionBindingsInput = {
keybindings: Keybinding[];
shortcuts: ConfiguredShortcuts;
statsToggleKey?: string | null;
statsMarkWatchedKey?: string | null;
platform: PlatformKeyModel;
rawConfig?: ResolvedConfig | null;
};
@@ -353,6 +354,8 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
input.rawConfig?.shortcuts as Record<string, unknown> | undefined
)?.toggleVisibleOverlayGlobal;
const statsToggleKey = input.statsToggleKey ?? input.rawConfig?.stats?.toggleKey ?? null;
const statsMarkWatchedKey =
input.statsMarkWatchedKey ?? input.rawConfig?.stats?.markWatchedKey ?? null;
if (legacyToggleVisibleOverlayGlobal !== undefined) {
warnings.push({
@@ -419,6 +422,33 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
}
}
if (statsMarkWatchedKey) {
const parsed = parseDomKeyString(statsMarkWatchedKey, input.platform);
if (!parsed.key) {
warnings.push({
kind: 'unsupported',
path: 'stats.markWatchedKey',
value: statsMarkWatchedKey,
message: parsed.message ?? 'Unsupported stats mark-watched key syntax.',
});
} else {
const binding: CompiledSessionActionBinding = {
sourcePath: 'stats.markWatchedKey',
originalKey: statsMarkWatchedKey,
key: parsed.key,
actionType: 'session-action',
actionId: 'markWatched',
};
const signature = getSessionKeySpecSignature(parsed.key);
const draft = candidates.get(signature) ?? [];
draft.push({
binding,
actionFingerprint: getBindingFingerprint(binding),
});
candidates.set(signature, draft);
}
}
input.keybindings.forEach((binding, index) => {
if (!binding.command) return;
const parsed = parseDomKeyString(binding.key, input.platform);
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,