mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
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:
@@ -32,6 +32,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
triggerSubsync: false,
|
||||
markAudioCard: false,
|
||||
toggleStatsOverlay: false,
|
||||
markWatched: false,
|
||||
toggleSubtitleSidebar: false,
|
||||
openRuntimeOptions: false,
|
||||
openSessionHelp: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user