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
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Reused an already-running background SubMiner app for launcher-opened videos, preserving warmups and keeping the tray app alive after playback closes.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Wired configured session shortcuts, including `stats.markWatchedKey`, through mpv so custom add/remove changes work while mpv has focus.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: config
- Config: Preserved N+1 subtitle highlighting for existing configs that already enabled known-word highlighting, while keeping N+1 disabled by default for new configs unless `ankiConnect.nPlusOne.enabled` is set.
+34 -33
View File
@@ -20,15 +20,16 @@ N+1 highlighting identifies sentences where you know every word except one, maki
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
| Option | Default | Description |
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. |
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
::: tip
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
@@ -46,10 +47,10 @@ Character-name matches are built from the active merged SubMiner character dicti
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
| Option | Default | Description |
| -------------------------------- | --------- | ---------------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
@@ -66,15 +67,15 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
| Option | Default | Description |
| ------------------------------------------------ | ------------ | ---------------------------------------- |
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
@@ -96,22 +97,22 @@ SubMiner loads offline `term_meta_bank_*.json` files from `vendor/yomitan-jlpt-v
**Default colors:**
| Level | Color | Preview |
| --- | --- | --- |
| N1 | `#ed8796` | Red |
| N2 | `#f5a97f` | Peach |
| N3 | `#f9e2af` | Yellow |
| N4 | `#a6e3a1` | Green |
| N5 | `#8aadf4` | Blue |
| Level | Color | Preview |
| ----- | --------- | ------- |
| N1 | `#ed8796` | Red |
| N2 | `#f5a97f` | Peach |
| N3 | `#f9e2af` | Yellow |
| N4 | `#a6e3a1` | Green |
| N5 | `#8aadf4` | Blue |
All colors are customizable via the `subtitleStyle.jlptColors` object.
**Key settings:**
| Option | Default | Description |
| --- | --- | --- |
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
| Option | Default | Description |
| ---------------------------------- | --------- | ----------------------------- |
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
| `subtitleStyle.jlptColors.N1``N5` | see above | Per-level underline colors |
## Runtime Toggles
+42
View File
@@ -655,6 +655,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
}
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+25 -1
View File
@@ -1004,6 +1004,7 @@ export async function startOverlay(
): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
@@ -1015,7 +1016,16 @@ export async function startOverlay(
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
if (appAlreadyRunning) {
log(
'debug',
args.logLevel,
'SubMiner app is already running; launcher will not stop it after playback',
);
clearOverlayManagedByLauncher();
} else {
markOverlayManagedByLauncher(appPath);
}
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -1042,6 +1052,20 @@ export function markOverlayManagedByLauncher(appPath?: string): void {
state.overlayManagedByLauncher = true;
}
function clearOverlayManagedByLauncher(): void {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
if (result.error) {
log('debug', logLevel, `App ping failed before overlay start: ${result.error.message}`);
return false;
}
return result.status === 0;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
+46
View File
@@ -133,6 +133,9 @@ if (entry.argv.includes('--start')) {
if (entry.argv.includes('--stop')) {
fs.appendFileSync(stopPath, JSON.stringify(entry) + '\\n');
}
if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
}
process.exit(0);
`,
@@ -347,6 +350,49 @@ test(
},
);
test(
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const env = {
...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1',
};
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-borrow-background',
);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.ok(
appEntries.some(
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
),
);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0);
});
},
);
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
+2
View File
@@ -151,6 +151,8 @@ function M.create(ctx)
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "markWatched" then
return { "--mark-watched" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
+9
View File
@@ -220,6 +220,14 @@ local ctx = {
actionType = "mpv-command",
command = { "quit" },
},
{
key = {
code = "KeyW",
modifiers = {},
},
actionType = "session-action",
actionId = "markWatched",
},
{
key = {
code = "KeyA",
@@ -307,6 +315,7 @@ local expected_cli_bindings = {
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
{ keys = "w", flag = "--mark-watched" },
}
for _, expected in ipairs(expected_cli_bindings) do
+8
View File
@@ -94,6 +94,7 @@ test('parseArgs captures youtube startup forwarding flags', () => {
test('parseArgs captures session action forwarding flags', () => {
const args = parseArgs([
'--toggle-stats-overlay',
'--mark-watched',
'--open-jimaku',
'--open-youtube-picker',
'--open-playlist-browser',
@@ -110,6 +111,7 @@ test('parseArgs captures session action forwarding flags', () => {
]);
assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true);
assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true);
@@ -285,6 +287,12 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
const markWatched = parseArgs(['--mark-watched']);
assert.equal(markWatched.markWatched, true);
assert.equal(hasExplicitCommand(markWatched), true);
assert.equal(shouldStartApp(markWatched), true);
assert.equal(commandNeedsOverlayRuntime(markWatched), true);
const dictionary = parseArgs(['--dictionary']);
assert.equal(dictionary.dictionary, true);
assert.equal(hasExplicitCommand(dictionary), true);
+8
View File
@@ -28,6 +28,7 @@ export interface CliArgs {
triggerSubsync: boolean;
markAudioCard: boolean;
toggleStatsOverlay: boolean;
markWatched: boolean;
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
@@ -134,6 +135,7 @@ export function parseArgs(argv: string[]): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -255,6 +257,7 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--trigger-subsync') args.triggerSubsync = true;
else if (arg === '--mark-audio-card') args.markAudioCard = true;
else if (arg === '--toggle-stats-overlay') args.toggleStatsOverlay = true;
else if (arg === '--mark-watched') args.markWatched = true;
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true;
@@ -509,6 +512,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -583,6 +587,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -648,6 +653,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
@@ -707,6 +713,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
!args.triggerSubsync &&
!args.markAudioCard &&
!args.toggleStatsOverlay &&
!args.markWatched &&
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
@@ -768,6 +775,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.updateLastCardFromClipboard ||
args.toggleSecondarySub ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.triggerFieldGrouping ||
args.triggerSubsync ||
+1
View File
@@ -23,6 +23,7 @@ test('printHelp includes configured texthooker port', () => {
assert.doesNotMatch(output, /--refresh-known-words/);
assert.match(output, /--setup\s+Open first-run setup window/);
assert.match(output, /--config\s+Open configuration window/);
assert.match(output, /--mark-watched\s+Mark current video watched and advance playlist/);
assert.match(output, /--anilist-status/);
assert.match(output, /--anilist-retry-queue/);
assert.match(output, /--dictionary/);
+1
View File
@@ -39,6 +39,7 @@ ${B}Mining${R}
--trigger-field-grouping Run Kiku field grouping
--trigger-subsync Run subtitle sync
--toggle-secondary-sub Cycle secondary subtitle mode
--mark-watched Mark current video watched and advance playlist
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
+2
View File
@@ -2131,6 +2131,7 @@ test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', (
};
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.deepEqual(config.ankiConnect.knownWords.decks, {
@@ -2208,6 +2209,7 @@ test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
const warnings = service.getWarnings();
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
assert.ok(
+23
View File
@@ -84,6 +84,29 @@ test('accepts knownWords.addMinedWordsImmediately boolean override', () => {
);
});
test('enables n+1 for existing configs with known-word highlighting enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, true);
});
test('keeps explicit n+1 disabled when known-word highlighting is enabled', () => {
const { context } = makeContext({
knownWords: { highlightEnabled: true },
nPlusOne: { enabled: false },
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.knownWords.highlightEnabled, true);
assert.equal(context.resolved.ankiConnect.nPlusOne.enabled, false);
});
test('converts legacy knownWords.decks array to object with default fields', () => {
const { context, warnings } = makeContext({
knownWords: { decks: ['Core Deck'] },
+2
View File
@@ -758,6 +758,8 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
'Expected boolean.',
);
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
} else if (context.resolved.ankiConnect.knownWords.highlightEnabled === true) {
context.resolved.ankiConnect.nPlusOne.enabled = true;
} else {
context.resolved.ankiConnect.nPlusOne.enabled = DEFAULT_CONFIG.ankiConnect.nPlusOne.enabled;
}
+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,
+25
View File
@@ -76,3 +76,28 @@ test('normalizes fallback shortcuts when AnkiConnect flag is unset', () => {
assert.equal(resolved.openRuntimeOptions, '9');
assert.equal(resolved.openCharacterDictionary, 'Ctrl+Shift+A');
});
test('preserves null shortcut overrides so defaults can be disabled', () => {
const config: Config = {
shortcuts: {
copySubtitle: null,
openJimaku: null,
toggleSubtitleSidebar: null,
},
};
const defaults: Config = {
shortcuts: {
copySubtitle: 'Ctrl+KeyC',
openJimaku: 'Ctrl+Shift+KeyJ',
toggleSubtitleSidebar: 'Backslash',
openRuntimeOptions: 'Digit9',
},
};
const resolved = resolveConfiguredShortcuts(config, defaults);
assert.equal(resolved.copySubtitle, null);
assert.equal(resolved.openJimaku, null);
assert.equal(resolved.toggleSubtitleSidebar, null);
assert.equal(resolved.openRuntimeOptions, '9');
});
+24 -57
View File
@@ -26,77 +26,44 @@ export function resolveConfiguredShortcuts(
defaultConfig: Config,
): ConfiguredShortcuts {
const isAnkiConnectDisabled = config.ankiConnect?.enabled === false;
type ShortcutKey = keyof Omit<ConfiguredShortcuts, 'multiCopyTimeoutMs'> &
keyof NonNullable<Config['shortcuts']>;
const normalizeShortcut = (value: string | null | undefined): string | null | undefined => {
if (typeof value !== 'string') return value;
return value.replace(/\bKey([A-Z])\b/g, '$1').replace(/\bDigit([0-9])\b/g, '$1');
};
const shortcutValue = (key: ShortcutKey): string | null | undefined =>
Object.prototype.hasOwnProperty.call(config.shortcuts ?? {}, key)
? config.shortcuts?.[key]
: defaultConfig.shortcuts?.[key];
return {
toggleVisibleOverlayGlobal: normalizeShortcut(
config.shortcuts?.toggleVisibleOverlayGlobal ??
defaultConfig.shortcuts?.toggleVisibleOverlayGlobal,
),
copySubtitle: normalizeShortcut(
config.shortcuts?.copySubtitle ?? defaultConfig.shortcuts?.copySubtitle,
),
copySubtitleMultiple: normalizeShortcut(
config.shortcuts?.copySubtitleMultiple ?? defaultConfig.shortcuts?.copySubtitleMultiple,
),
toggleVisibleOverlayGlobal: normalizeShortcut(shortcutValue('toggleVisibleOverlayGlobal')),
copySubtitle: normalizeShortcut(shortcutValue('copySubtitle')),
copySubtitleMultiple: normalizeShortcut(shortcutValue('copySubtitleMultiple')),
updateLastCardFromClipboard: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.updateLastCardFromClipboard ??
defaultConfig.shortcuts?.updateLastCardFromClipboard),
isAnkiConnectDisabled ? null : shortcutValue('updateLastCardFromClipboard'),
),
triggerFieldGrouping: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.triggerFieldGrouping ?? defaultConfig.shortcuts?.triggerFieldGrouping),
),
triggerSubsync: normalizeShortcut(
config.shortcuts?.triggerSubsync ?? defaultConfig.shortcuts?.triggerSubsync,
),
mineSentence: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.mineSentence ?? defaultConfig.shortcuts?.mineSentence),
isAnkiConnectDisabled ? null : shortcutValue('triggerFieldGrouping'),
),
triggerSubsync: normalizeShortcut(shortcutValue('triggerSubsync')),
mineSentence: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('mineSentence')),
mineSentenceMultiple: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.mineSentenceMultiple ?? defaultConfig.shortcuts?.mineSentenceMultiple),
isAnkiConnectDisabled ? null : shortcutValue('mineSentenceMultiple'),
),
multiCopyTimeoutMs:
config.shortcuts?.multiCopyTimeoutMs ?? defaultConfig.shortcuts?.multiCopyTimeoutMs ?? 5000,
toggleSecondarySub: normalizeShortcut(
config.shortcuts?.toggleSecondarySub ?? defaultConfig.shortcuts?.toggleSecondarySub,
),
markAudioCard: normalizeShortcut(
isAnkiConnectDisabled
? null
: (config.shortcuts?.markAudioCard ?? defaultConfig.shortcuts?.markAudioCard),
),
openCharacterDictionary: normalizeShortcut(
config.shortcuts?.openCharacterDictionary ?? defaultConfig.shortcuts?.openCharacterDictionary,
),
openRuntimeOptions: normalizeShortcut(
config.shortcuts?.openRuntimeOptions ?? defaultConfig.shortcuts?.openRuntimeOptions,
),
openJimaku: normalizeShortcut(
config.shortcuts?.openJimaku ?? defaultConfig.shortcuts?.openJimaku,
),
openSessionHelp: normalizeShortcut(
config.shortcuts?.openSessionHelp ?? defaultConfig.shortcuts?.openSessionHelp,
),
openControllerSelect: normalizeShortcut(
config.shortcuts?.openControllerSelect ?? defaultConfig.shortcuts?.openControllerSelect,
),
openControllerDebug: normalizeShortcut(
config.shortcuts?.openControllerDebug ?? defaultConfig.shortcuts?.openControllerDebug,
),
toggleSubtitleSidebar: normalizeShortcut(
config.shortcuts?.toggleSubtitleSidebar ?? defaultConfig.shortcuts?.toggleSubtitleSidebar,
),
toggleSecondarySub: normalizeShortcut(shortcutValue('toggleSecondarySub')),
markAudioCard: normalizeShortcut(isAnkiConnectDisabled ? null : shortcutValue('markAudioCard')),
openCharacterDictionary: normalizeShortcut(shortcutValue('openCharacterDictionary')),
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
};
}
+34
View File
@@ -67,6 +67,40 @@ test('normalizeStartupArgv uses transported AppImage args instead of raw Electro
);
});
test('normalizeStartupArgv defaults empty transported AppImage args to background startup', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
SUBMINER_APP_ARGC: '0',
}),
['SubMiner.AppImage', '--start', '--background'],
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('normalizeStartupArgv defaults passive-only transported AppImage args to background startup', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage'], {
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--password-store',
SUBMINER_APP_ARG_1: 'gnome-libsecret',
}),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true);
assert.equal(hasTransportedStartupArgs({}), false);
+6
View File
@@ -117,6 +117,12 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
const transportedArgs = readTransportedStartupArgs(env);
if (transportedArgs) {
if (removePassiveStartupArgs(transportedArgs).length === 0) {
if (process.platform === 'win32') {
return [argv[0] ?? APP_NAME, ...transportedArgs, START_ARG];
}
return [argv[0] ?? APP_NAME, ...transportedArgs, START_ARG, BACKGROUND_ARG];
}
return [argv[0] ?? APP_NAME, ...transportedArgs];
}
+14
View File
@@ -4655,6 +4655,7 @@ function compileCurrentSessionBindings(): {
keybindings: appState.keybindings,
shortcuts: getConfiguredShortcuts(),
statsToggleKey: getResolvedConfig().stats.toggleKey,
statsMarkWatchedKey: getResolvedConfig().stats.markWatchedKey,
platform: resolveSessionBindingPlatform(),
rawConfig: getResolvedConfig(),
});
@@ -5141,6 +5142,17 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
toggleSecondarySub: () => handleCycleSecondarySubMode(),
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
markActiveVideoWatched: async () => {
const marked = (await appState.immersionTracker?.markActiveVideoWatched()) ?? false;
if (marked) {
try {
await maybeRunAnilistPostWatchUpdate({ force: true });
} catch (error) {
logger.warn('Failed to run AniList post-watch update after manual watched mark:', error);
}
}
return marked;
},
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
@@ -5162,6 +5174,8 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
(text) => showMpvOsd(text),
);
},
playNextPlaylistItem: () =>
sendMpvCommandRuntime(appState.mpvClient, ['playlist-next', 'force']),
showMpvOsd: (text) => showMpvOsd(text),
});
}
@@ -149,6 +149,48 @@ test('buildConfigHotReloadPayload includes independent primary subtitle mode', (
assert.equal(payload.secondarySubMode, 'hidden');
});
test('buildConfigHotReloadPayload reflects added, removed, and remapped session bindings', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.stats.markWatchedKey = 'Ctrl+Shift+KeyW';
config.shortcuts.openJimaku = null;
config.keybindings = [
{ key: 'KeyF', command: null },
{ key: 'Ctrl+Alt+KeyM', command: ['show-text', 'custom'] },
];
const payload = buildConfigHotReloadPayload(config);
assert.equal(
payload.sessionBindings.some(
(binding) =>
binding.sourcePath === 'stats.markWatchedKey' &&
binding.originalKey === 'Ctrl+Shift+KeyW' &&
binding.actionType === 'session-action' &&
binding.actionId === 'markWatched',
),
true,
);
assert.equal(
payload.sessionBindings.some(
(binding) =>
binding.originalKey === 'Ctrl+Alt+KeyM' &&
binding.actionType === 'mpv-command' &&
binding.command.join(' ') === 'show-text custom',
),
true,
);
assert.equal(
payload.sessionBindings.some((binding) => binding.originalKey === 'KeyF'),
false,
);
assert.equal(
payload.sessionBindings.some(
(binding) => binding.actionType === 'session-action' && binding.actionId === 'openJimaku',
),
false,
);
});
test('createConfigHotReloadAppliedHandler skips optional effects when no hot fields', () => {
const config = deepCloneConfig(DEFAULT_CONFIG);
const calls: string[] = [];
@@ -47,6 +47,7 @@ export function buildConfigHotReloadPayload(config: ResolvedConfig): ConfigHotRe
keybindings,
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
statsToggleKey: config.stats.toggleKey,
statsMarkWatchedKey: config.stats.markWatchedKey,
platform:
process.platform === 'darwin' ? 'darwin' : process.platform === 'win32' ? 'win32' : 'linux',
rawConfig: config,
@@ -47,6 +47,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
triggerSubsync: false,
markAudioCard: false,
toggleStatsOverlay: false,
markWatched: false,
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
@@ -155,6 +156,7 @@ test('shouldAutoOpenFirstRunSetup treats session and stats startup commands as e
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, openControllerDebug: true })),
false,
);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, markWatched: true })), false);
assert.equal(shouldAutoOpenFirstRunSetup(makeArgs({ start: true, stats: true })), false);
assert.equal(
shouldAutoOpenFirstRunSetup(makeArgs({ background: true, jellyfinSubtitleUrlsOnly: true })),
@@ -90,6 +90,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.triggerSubsync ||
args.markAudioCard ||
args.toggleStatsOverlay ||
args.markWatched ||
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
+28 -1
View File
@@ -469,7 +469,10 @@ function createKeyboardHandlerHarness() {
}
test('renderer installs keyboard forwarding before startup subtitle IPC awaits', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'), 'utf8');
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'renderer.ts'),
'utf8',
);
const keyboardSetupIndex = source.indexOf('await keyboardHandlers.setupMpvInputForwarding();');
const subtitleRequestIndex = source.indexOf('await window.electronAPI.getCurrentSubtitle();');
@@ -1381,6 +1384,30 @@ test('session binding: Ctrl+Shift+O dispatches runtime options locally', async (
}
});
test('session binding: remapped mark watched dispatches locally with modifiers', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
try {
await handlers.setupMpvInputForwarding();
handlers.updateSessionBindings([
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'Ctrl+Shift+KeyW',
key: { code: 'KeyW', modifiers: ['ctrl', 'shift'] },
actionType: 'session-action',
actionId: 'markWatched',
},
] as never);
testGlobals.dispatchKeydown({ key: 'W', code: 'KeyW', ctrlKey: true, shiftKey: true });
assert.deepEqual(testGlobals.sessionActions, [{ actionId: 'markWatched', payload: undefined }]);
assert.equal(testGlobals.markActiveVideoWatchedCalls(), 0);
} finally {
testGlobals.restore();
}
});
test('session binding: copy subtitle multiple captures follow-up digit locally', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
@@ -0,0 +1,81 @@
import type { SessionHelpSection } from './session-help-sections';
export type SessionHelpSubtitleStyle = {
knownWordColor?: unknown;
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
N1?: unknown;
N2?: unknown;
N3?: unknown;
N4?: unknown;
N5?: unknown;
};
};
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: 'Character names',
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
},
{
shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: 'JLPT N2',
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: 'JLPT N3',
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: 'JLPT N4',
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: 'JLPT N5',
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
@@ -0,0 +1,78 @@
import type { SessionHelpItem, SessionHelpSection } from './session-help-sections';
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-item';
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement('div');
left.className = 'session-help-item-left';
const shortcut = document.createElement('span');
shortcut.className = 'session-help-key';
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement('div');
right.className = 'session-help-item-right';
const action = document.createElement('span');
action.className = 'session-help-action';
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement('span');
dot.className = 'session-help-color-dot';
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
button.appendChild(left);
button.appendChild(right);
return button;
}
const SECTION_ICON: Record<string, string> = {
'Playback and navigation': '▶',
'Visual feedback': '◉',
'Subtitle sync': '⟲',
'Mining and capture': '✦',
'Stats and progress': '◉',
'Overlay controls': '◈',
'Modals and tools': '▣',
'Runtime settings': '⚙',
'System actions': '◆',
'Other shortcuts': '…',
'Fixed overlay controls': '◇',
'Y chords': '⌘',
'Global shortcuts': '◆',
'Color legend': '◈',
};
export function createSessionHelpSectionNode(
section: SessionHelpSection,
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement('section');
sectionNode.className = 'session-help-section';
const title = document.createElement('h3');
title.className = 'session-help-section-title';
const icon = SECTION_ICON[section.title] ?? '•';
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement('div');
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
sectionNode.appendChild(list);
return sectionNode;
}
@@ -0,0 +1,469 @@
import type {
CompiledSessionBinding,
SessionActionId,
SessionKeyModifier,
SessionKeySpec,
} from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { buildColorSection, type SessionHelpSubtitleStyle } from './session-help-colors';
export type SessionHelpItem = {
shortcut: string;
action: string;
color?: string;
};
export type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
export type SessionHelpTabId = 'essentials' | 'playback' | 'mining' | 'tools' | 'reference';
export type SessionHelpTab = {
id: SessionHelpTabId;
label: string;
};
export const SESSION_HELP_TABS: SessionHelpTab[] = [
{ id: 'essentials', label: 'Essentials' },
{ id: 'playback', label: 'Playback' },
{ id: 'mining', label: 'Mining' },
{ id: 'tools', label: 'Tools' },
{ id: 'reference', label: 'Reference' },
];
const KEY_NAME_MAP: Record<string, string> = {
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
Slash: '/',
Backslash: '\\',
Backquote: '`',
BracketLeft: '[',
BracketRight: ']',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Cmd: 'Cmd',
Shift: 'Shift',
Alt: 'Alt',
Super: 'Meta',
Meta: 'Meta',
Backspace: 'Backspace',
};
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith('Key')) return token.slice(3);
if (token.startsWith('Digit')) return token.slice(5);
if (token.startsWith('Numpad')) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split('+');
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
return normalized.join(' + ');
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Unknown action';
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
if (first === 'seek' && typeof command[1] === 'number') {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
if (command[1] > 0) return 'Jump to next subtitle';
if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${
rawDirection === 'prev' ? 'previous' : 'next'
}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
export {
describeCommand as describeSessionHelpCommand,
formatKeybinding as formatSessionHelpKeybinding,
};
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Other shortcuts';
if (
first === 'cycle' ||
first === 'seek' ||
first === 'sub-seek' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return 'Playback and navigation';
}
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
return 'Visual feedback';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return 'Subtitle sync';
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';
}
if (first === 'quit') return 'System actions';
return 'Other shortcuts';
}
const MODIFIER_LABELS: Record<SessionKeyModifier, string> = {
ctrl: 'Ctrl',
alt: 'Alt',
shift: 'Shift',
meta: 'Meta',
};
function formatSessionKeySpec(key: SessionKeySpec): string {
return [
...key.modifiers.map((modifier) => MODIFIER_LABELS[modifier]),
normalizeKeyToken(key.code),
]
.filter(Boolean)
.join(' + ');
}
function describeSessionAction(
actionId: SessionActionId,
payload?: { runtimeOptionId?: string; direction?: 1 | -1 },
): string {
switch (actionId) {
case 'toggleStatsOverlay':
return 'Toggle stats overlay';
case 'toggleVisibleOverlay':
return 'Show/hide visible overlay';
case 'copySubtitle':
return 'Copy subtitle';
case 'copySubtitleMultiple':
return 'Copy subtitle (multi)';
case 'updateLastCardFromClipboard':
return 'Update last card from clipboard';
case 'triggerFieldGrouping':
return 'Trigger field grouping';
case 'triggerSubsync':
return 'Open subtitle sync controls';
case 'mineSentence':
return 'Mine sentence';
case 'mineSentenceMultiple':
return 'Mine sentence (multi)';
case 'toggleSecondarySub':
return 'Toggle secondary subtitle mode';
case 'toggleSubtitleSidebar':
return 'Toggle subtitle sidebar';
case 'markAudioCard':
return 'Mark audio card';
case 'markWatched':
return 'Mark video watched';
case 'openRuntimeOptions':
return 'Open runtime options';
case 'openSessionHelp':
return 'Open session help';
case 'openCharacterDictionary':
return 'Open character dictionary anime selector';
case 'openControllerSelect':
return 'Open controller select';
case 'openControllerDebug':
return 'Open controller debug';
case 'openJimaku':
return 'Open jimaku';
case 'openYoutubePicker':
return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser':
return 'Open playlist browser';
case 'replayCurrentSubtitle':
return 'Replay current subtitle';
case 'playNextSubtitle':
return 'Play next subtitle';
case 'shiftSubDelayPrevLine':
return 'Shift subtitle delay to previous cue';
case 'shiftSubDelayNextLine':
return 'Shift subtitle delay to next cue';
case 'cycleRuntimeOption':
return `Cycle runtime option ${payload?.runtimeOptionId ?? 'option'} ${
payload?.direction === -1 ? 'previous' : 'next'
}`;
}
}
function sectionForSessionBinding(binding: CompiledSessionBinding): string {
if (binding.actionType === 'mpv-command') return sectionForCommand(binding.command);
switch (binding.actionId) {
case 'copySubtitle':
case 'copySubtitleMultiple':
case 'updateLastCardFromClipboard':
case 'triggerFieldGrouping':
case 'mineSentence':
case 'mineSentenceMultiple':
case 'markAudioCard':
return 'Mining and capture';
case 'toggleStatsOverlay':
case 'markWatched':
return 'Stats and progress';
case 'toggleVisibleOverlay':
case 'toggleSecondarySub':
case 'toggleSubtitleSidebar':
return 'Overlay controls';
case 'triggerSubsync':
return 'Subtitle sync';
case 'openRuntimeOptions':
case 'openJimaku':
case 'openCharacterDictionary':
case 'openControllerSelect':
case 'openControllerDebug':
case 'openYoutubePicker':
case 'openPlaylistBrowser':
case 'openSessionHelp':
return 'Modals and tools';
case 'replayCurrentSubtitle':
case 'playNextSubtitle':
case 'shiftSubDelayPrevLine':
case 'shiftSubDelayNextLine':
return 'Playback and navigation';
case 'cycleRuntimeOption':
return 'Runtime settings';
}
}
function buildSessionBindingSections(
sessionBindings: CompiledSessionBinding[],
): SessionHelpSection[] {
const grouped = new Map<string, SessionHelpItem[]>();
for (const binding of sessionBindings) {
const section = sectionForSessionBinding(binding);
const row: SessionHelpItem = {
shortcut: formatSessionKeySpec(binding.key),
action:
binding.actionType === 'mpv-command'
? describeCommand(binding.command)
: describeSessionAction(binding.actionId, binding.payload),
};
grouped.set(section, [...(grouped.get(section) ?? []), row]);
}
const sectionOrder = [
'Playback and navigation',
'Mining and capture',
'Stats and progress',
'Overlay controls',
'Subtitle sync',
'Runtime settings',
'Modals and tools',
'Visual feedback',
'System actions',
'Other shortcuts',
];
return Array.from(grouped.entries())
.sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
const bIdx = sectionOrder.indexOf(b[0]);
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
})
.map(([title, rows]) => ({ title, rows }));
}
function buildConfiguredOverlaySections(input: {
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
}): SessionHelpSection[] {
const statsRows: SessionHelpItem[] = [];
if (input.markWatchedKey) {
statsRows.push({
shortcut: formatKeybinding(input.markWatchedKey),
action: 'Mark video watched',
});
}
const overlayRows: SessionHelpItem[] = [];
if (input.subtitleSidebarToggleKey) {
overlayRows.push({
shortcut: formatKeybinding(input.subtitleSidebarToggleKey),
action: 'Toggle subtitle sidebar',
});
}
return [
...(statsRows.length > 0 ? [{ title: 'Stats and progress', rows: statsRows }] : []),
...(overlayRows.length > 0 ? [{ title: 'Overlay controls', rows: overlayRows }] : []),
];
}
function buildFixedOverlaySections(): SessionHelpSection[] {
return [
{
title: 'Fixed overlay controls',
rows: [
{ shortcut: 'V', action: 'Toggle primary subtitle bar visibility' },
{ shortcut: 'Ctrl/Cmd + A', action: 'Append clipboard video path to playlist' },
{ shortcut: 'Right-click', action: 'Toggle playback outside subtitle area' },
{ shortcut: 'Right-click + drag', action: 'Reposition subtitles on subtitle area' },
],
},
{
title: 'Y chords',
rows: [
{ shortcut: 'Y then Y', action: 'Open SubMiner menu' },
{ shortcut: 'Y then S', action: 'Start overlay' },
{ shortcut: 'Y then Shift + S', action: 'Stop overlay' },
{ shortcut: 'Y then T', action: 'Toggle visible overlay' },
{ shortcut: 'Y then O', action: 'Open Yomitan settings' },
{ shortcut: 'Y then R', action: 'Restart overlay' },
{ shortcut: 'Y then C', action: 'Check overlay status' },
{ shortcut: 'Y then H/K', action: 'Open session help' },
{ shortcut: 'Y then D', action: 'Toggle DevTools' },
],
},
{
title: 'Global shortcuts',
rows: [{ shortcut: 'Alt + Shift + Y', action: 'Open Yomitan settings' }],
},
];
}
function mergeSectionsByTitle(sections: SessionHelpSection[]): SessionHelpSection[] {
const merged: SessionHelpSection[] = [];
const byTitle = new Map<string, SessionHelpSection>();
for (const section of sections) {
const existing = byTitle.get(section.title);
if (existing) {
existing.rows.push(...section.rows);
continue;
}
const next = { title: section.title, rows: [...section.rows] };
byTitle.set(section.title, next);
merged.push(next);
}
return merged;
}
export function buildSessionHelpSections(input: {
sessionBindings: CompiledSessionBinding[];
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
}): SessionHelpSection[] {
const sessionBindings = input.sessionBindings.filter((binding) => {
if (binding.actionType !== 'session-action') return true;
if (input.markWatchedKey && binding.actionId === 'markWatched') return false;
if (input.subtitleSidebarToggleKey && binding.actionId === 'toggleSubtitleSidebar') {
return false;
}
return true;
});
return mergeSectionsByTitle([
...buildSessionBindingSections(sessionBindings),
...buildConfiguredOverlaySections({
markWatchedKey: input.markWatchedKey,
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
}),
...buildFixedOverlaySections(),
buildColorSection(input.subtitleStyle ?? {}),
]);
}
export function getSessionHelpSectionTabId(section: SessionHelpSection): SessionHelpTabId {
switch (section.title) {
case 'Stats and progress':
case 'Overlay controls':
case 'Fixed overlay controls':
case 'Global shortcuts':
return 'essentials';
case 'Playback and navigation':
case 'Subtitle sync':
case 'Visual feedback':
case 'System actions':
return 'playback';
case 'Mining and capture':
return 'mining';
case 'Modals and tools':
case 'Runtime settings':
return 'tools';
case 'Y chords':
case 'Color legend':
case 'Other shortcuts':
default:
return 'reference';
}
}
export function filterSessionHelpSections(
sections: SessionHelpSection[],
query: string,
): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, 'ctrl')
.replace(/cmd\/ctrl/gu, 'ctrl')
.replace(/[\s+\-_/]/gu, '');
const normalized = normalize(query);
if (!normalized) return sections;
return sections
.map((section) => {
if (normalize(section.title).includes(normalized)) {
return section;
}
const rows = section.rows.filter(
(row) =>
normalize(row.shortcut).includes(normalized) ||
normalize(row.action).includes(normalized),
);
if (rows.length === 0) return null;
return { ...section, rows };
})
.filter((section): section is SessionHelpSection => section !== null)
.filter((section) => section.rows.length > 0);
}
+50
View File
@@ -0,0 +1,50 @@
import {
filterSessionHelpSections,
getSessionHelpSectionTabId,
SESSION_HELP_TABS,
type SessionHelpSection,
type SessionHelpTabId,
} from './session-help-sections';
function countRows(sections: SessionHelpSection[]): number {
return sections.reduce((count, section) => count + section.rows.length, 0);
}
function sectionMatchesTab(section: SessionHelpSection, tabId: SessionHelpTabId): boolean {
return getSessionHelpSectionTabId(section) === tabId;
}
export function buildVisibleSessionHelpSections(
sections: SessionHelpSection[],
tabId: SessionHelpTabId,
query: string,
): SessionHelpSection[] {
if (query.trim()) return filterSessionHelpSections(sections, query);
return sections.filter((section) => sectionMatchesTab(section, tabId));
}
export function createSessionHelpTabBar(
sections: SessionHelpSection[],
activeTabId: SessionHelpTabId,
onSelect: (tabId: SessionHelpTabId) => void,
): HTMLElement {
const tabBar = document.createElement('div');
tabBar.className = 'session-help-tabs';
for (const tab of SESSION_HELP_TABS) {
const tabSections = sections.filter((section) => sectionMatchesTab(section, tab.id));
if (tabSections.length === 0) continue;
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-tab';
button.dataset.sessionHelpTab = tab.id;
button.setAttribute('aria-pressed', String(tab.id === activeTabId));
if (tab.id === activeTabId) button.classList.add('active');
button.textContent = `${tab.label} ${countRows(tabSections)}`;
button.addEventListener('click', () => onSelect(tab.id));
tabBar.appendChild(button);
}
return tabBar;
}
+68 -5
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import { createRendererState } from '../state.js';
import {
buildSessionHelpSections,
createSessionHelpModal,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
@@ -34,7 +35,7 @@ test('session help formats bracket keybindings as physical keys', () => {
test('session help imports browser-safe special command constants', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help.ts'),
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help-sections.ts'),
'utf8',
);
@@ -42,6 +43,67 @@ test('session help imports browser-safe special command constants', () => {
assert.doesNotMatch(source, /from ['"]\.\.\/\.\.\/config\/definitions['"]/);
});
test('session help builds rows from canonical session bindings and fixed overlay affordances', () => {
const sections = buildSessionHelpSections({
sessionBindings: [
{
sourcePath: 'stats.toggleKey',
originalKey: 'Backquote',
key: { code: 'Backquote', modifiers: [] },
actionType: 'session-action',
actionId: 'toggleStatsOverlay',
},
{
sourcePath: 'shortcuts.openSessionHelp',
originalKey: 'CommandOrControl+Slash',
key: { code: 'Slash', modifiers: ['ctrl'] },
actionType: 'session-action',
actionId: 'openSessionHelp',
},
{
sourcePath: 'shortcuts.toggleSubtitleSidebar',
originalKey: 'Backslash',
key: { code: 'Backslash', modifiers: [] },
actionType: 'session-action',
actionId: 'toggleSubtitleSidebar',
},
{
sourcePath: 'stats.markWatchedKey',
originalKey: 'KeyW',
key: { code: 'KeyW', modifiers: [] },
actionType: 'session-action',
actionId: 'markWatched',
},
{
sourcePath: 'keybindings[0].key',
originalKey: 'Space',
key: { code: 'Space', modifiers: [] },
actionType: 'mpv-command',
command: ['cycle', 'pause'],
},
],
markWatchedKey: 'KeyW',
subtitleSidebarToggleKey: 'KeyB',
subtitleStyle: {},
});
const rows = sections.flatMap((section) => section.rows);
assert.ok(rows.some((row) => row.shortcut === '`' && row.action === 'Toggle stats overlay'));
assert.ok(rows.some((row) => row.shortcut === 'W' && row.action === 'Mark video watched'));
assert.equal(rows.filter((row) => row.action === 'Mark video watched').length, 1);
assert.equal(sections.filter((section) => section.title === 'Stats and progress').length, 1);
assert.ok(rows.some((row) => row.shortcut === 'B' && row.action === 'Toggle subtitle sidebar'));
assert.equal(rows.filter((row) => row.action === 'Toggle subtitle sidebar').length, 1);
assert.ok(rows.some((row) => row.shortcut === 'Ctrl + /' && row.action === 'Open session help'));
assert.ok(rows.some((row) => row.shortcut === 'Space' && row.action === 'Toggle playback'));
assert.ok(
rows.some(
(row) => row.shortcut === 'V' && row.action === 'Toggle primary subtitle bar visibility',
),
);
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
@@ -108,11 +170,12 @@ test('modal-layer session help does not focus hidden main overlay and still clos
notifyOverlayModalClosed: (modal: string) => {
notifications.push(modal);
},
getKeybindings: async () => {
throw new Error('mpv unavailable');
},
getSessionBindings: async () => [],
getSubtitleStyle: async () => ({}),
getConfiguredShortcuts: async () => ({}),
getMarkWatchedKey: async () => 'KeyW',
getSubtitleSidebarSnapshot: async () => ({
config: { toggleKey: 'Backslash' },
}),
},
focus: () => {},
addEventListener: () => {},
+42 -408
View File
@@ -1,7 +1,17 @@
import type { Keybinding } from '../../types';
import type { ShortcutsConfig } from '../../types';
import { SPECIAL_COMMANDS } from '../../config/definitions/shared';
import type { ModalStateReader, RendererContext } from '../context';
import {
buildSessionHelpSections,
type SessionHelpSection,
type SessionHelpTabId,
} from './session-help-sections';
import { createSessionHelpSectionNode } from './session-help-render';
import { buildVisibleSessionHelpSections, createSessionHelpTabBar } from './session-help-tabs';
export {
buildSessionHelpSections,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
} from './session-help-sections';
type SessionHelpBindingInfo = {
bindingKey: 'KeyH' | 'KeyK';
@@ -9,314 +19,6 @@ type SessionHelpBindingInfo = {
fallbackUnavailable: boolean;
};
type SessionHelpItem = {
shortcut: string;
action: string;
color?: string;
};
type SessionHelpSection = {
title: string;
rows: SessionHelpItem[];
};
type RuntimeShortcutConfig = Omit<Required<ShortcutsConfig>, 'multiCopyTimeoutMs'>;
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
// Fallbacks mirror the session overlay's default subtitle/word color scheme.
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
jlptN2Color: '#f5a97f',
jlptN3Color: '#f9e2af',
jlptN4Color: '#a6e3a1',
jlptN5Color: '#8aadf4',
};
const KEY_NAME_MAP: Record<string, string> = {
Space: 'Space',
ArrowUp: '↑',
ArrowDown: '↓',
ArrowLeft: '←',
ArrowRight: '→',
Escape: 'Esc',
Tab: 'Tab',
Enter: 'Enter',
BracketLeft: '[',
BracketRight: ']',
CommandOrControl: 'Cmd/Ctrl',
Ctrl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Cmd: 'Cmd',
Shift: 'Shift',
Alt: 'Alt',
Super: 'Meta',
Meta: 'Meta',
Backspace: 'Backspace',
};
function normalizeColor(value: unknown, fallback: string): string {
if (typeof value !== 'string') return fallback;
const next = value.trim();
return HEX_COLOR_RE.test(next) ? next : fallback;
}
function normalizeKeyToken(token: string): string {
if (KEY_NAME_MAP[token]) return KEY_NAME_MAP[token];
if (token.startsWith('Key')) return token.slice(3);
if (token.startsWith('Digit')) return token.slice(5);
if (token.startsWith('Numpad')) return token.slice(6);
return token;
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split('+');
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
return normalized.join(' + ');
}
const OVERLAY_SHORTCUTS: Array<{
key: keyof RuntimeShortcutConfig;
label: string;
}> = [
{ key: 'copySubtitle', label: 'Copy subtitle' },
{ key: 'copySubtitleMultiple', label: 'Copy subtitle (multi)' },
{
key: 'updateLastCardFromClipboard',
label: 'Update last card from clipboard',
},
{ key: 'triggerFieldGrouping', label: 'Trigger field grouping' },
{ key: 'triggerSubsync', label: 'Open subtitle sync controls' },
{ key: 'mineSentence', label: 'Mine sentence' },
{ key: 'mineSentenceMultiple', label: 'Mine sentence (multi)' },
{ key: 'toggleSecondarySub', label: 'Toggle secondary subtitle mode' },
{ key: 'markAudioCard', label: 'Mark audio card' },
{ key: 'openCharacterDictionary', label: 'Open character dictionary anime selector' },
{ key: 'openRuntimeOptions', label: 'Open runtime options' },
{ key: 'openJimaku', label: 'Open jimaku' },
{ key: 'openSessionHelp', label: 'Open session help' },
{ key: 'openControllerSelect', label: 'Open controller select' },
{ key: 'openControllerDebug', label: 'Open controller debug' },
{ key: 'toggleSubtitleSidebar', label: 'Toggle subtitle sidebar' },
{ key: 'toggleVisibleOverlayGlobal', label: 'Show/hide visible overlay' },
];
function buildOverlayShortcutSections(shortcuts: RuntimeShortcutConfig): SessionHelpSection[] {
const rows: SessionHelpItem[] = [];
for (const shortcut of OVERLAY_SHORTCUTS) {
const keybind = shortcuts[shortcut.key];
rows.push({
shortcut:
typeof keybind === 'string' && keybind.trim().length > 0
? formatKeybinding(keybind)
: 'Unbound',
action: shortcut.label,
});
}
if (rows.length === 0) return [];
return [{ title: 'Overlay shortcuts', rows }];
}
function describeCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Unknown action';
if (first === 'cycle' && command[1] === 'pause') return 'Toggle playback';
if (first === 'seek' && typeof command[1] === 'number') {
return `Seek ${command[1] > 0 ? '+' : ''}${command[1]} second(s)`;
}
if (first === 'sub-seek' && typeof command[1] === 'number') {
if (command[1] > 0) return 'Jump to next subtitle';
if (command[1] < 0) return 'Jump to previous subtitle';
return 'Reload current subtitle timing';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
return 'Shift subtitle delay to next cue';
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
return 'Shift subtitle delay to previous cue';
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
const [, rawId, rawDirection] = first.split(':');
return `Cycle runtime option ${rawId || 'option'} ${rawDirection === 'prev' ? 'previous' : 'next'}`;
}
return `MPV command: ${command.map((entry) => String(entry)).join(' ')}`;
}
export {
describeCommand as describeSessionHelpCommand,
formatKeybinding as formatSessionHelpKeybinding,
};
function sectionForCommand(command: (string | number)[]): string {
const first = command[0];
if (typeof first !== 'string') return 'Other shortcuts';
if (
first === 'cycle' ||
first === 'seek' ||
first === 'sub-seek' ||
first === SPECIAL_COMMANDS.REPLAY_SUBTITLE ||
first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE
) {
return 'Playback and navigation';
}
if (first === 'show-text' || first === 'show-progress' || first.startsWith('osd')) {
return 'Visual feedback';
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) {
return 'Subtitle sync';
}
if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) {
return 'Runtime settings';
}
if (first === 'quit') return 'System actions';
return 'Other shortcuts';
}
function buildBindingSections(keybindings: Keybinding[]): SessionHelpSection[] {
const grouped = new Map<string, SessionHelpItem[]>();
for (const binding of keybindings) {
const section = sectionForCommand(binding.command ?? []);
const row: SessionHelpItem = {
shortcut: formatKeybinding(binding.key),
action: describeCommand(binding.command ?? []),
};
grouped.set(section, [...(grouped.get(section) ?? []), row]);
}
const sectionOrder = [
'Playback and navigation',
'Visual feedback',
'Subtitle sync',
'Runtime settings',
'System actions',
'Other shortcuts',
];
const sectionEntries = Array.from(grouped.entries()).sort((a, b) => {
const aIdx = sectionOrder.indexOf(a[0]);
const bIdx = sectionOrder.indexOf(b[0]);
if (aIdx === -1 && bIdx === -1) return a[0].localeCompare(b[0]);
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
return sectionEntries.map(([title, rows]) => ({ title, rows }));
}
function buildColorSection(style: {
knownWordColor?: unknown;
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
N1?: unknown;
N2?: unknown;
N3?: unknown;
N4?: unknown;
N5?: unknown;
};
}): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
color: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
},
{
shortcut: 'Character names',
action: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
color: normalizeColor(style.nameMatchColor, FALLBACK_COLORS.nameMatchColor),
},
{
shortcut: 'JLPT N1',
action: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
color: normalizeColor(style.jlptColors?.N1, FALLBACK_COLORS.jlptN1Color),
},
{
shortcut: 'JLPT N2',
action: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
color: normalizeColor(style.jlptColors?.N2, FALLBACK_COLORS.jlptN2Color),
},
{
shortcut: 'JLPT N3',
action: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
color: normalizeColor(style.jlptColors?.N3, FALLBACK_COLORS.jlptN3Color),
},
{
shortcut: 'JLPT N4',
action: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
color: normalizeColor(style.jlptColors?.N4, FALLBACK_COLORS.jlptN4Color),
},
{
shortcut: 'JLPT N5',
action: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
color: normalizeColor(style.jlptColors?.N5, FALLBACK_COLORS.jlptN5Color),
},
],
};
}
function filterSections(sections: SessionHelpSection[], query: string): SessionHelpSection[] {
const normalize = (value: string): string =>
value
.toLowerCase()
.replace(/commandorcontrol/gu, 'ctrl')
.replace(/cmd\/ctrl/gu, 'ctrl')
.replace(/[\s+\-_/]/gu, '');
const normalized = normalize(query);
if (!normalized) return sections;
return sections
.map((section) => {
if (normalize(section.title).includes(normalized)) {
return section;
}
const rows = section.rows.filter(
(row) =>
normalize(row.shortcut).includes(normalized) ||
normalize(row.action).includes(normalized),
);
if (rows.length === 0) return null;
return { ...section, rows };
})
.filter((section): section is SessionHelpSection => section !== null)
.filter((section) => section.rows.length > 0);
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
@@ -324,79 +26,6 @@ function formatBindingHint(info: SessionHelpBindingInfo): string {
return 'Y-H';
}
function createShortcutRow(row: SessionHelpItem, globalIndex: number): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.className = 'session-help-item';
button.tabIndex = -1;
button.dataset.sessionHelpIndex = String(globalIndex);
const left = document.createElement('div');
left.className = 'session-help-item-left';
const shortcut = document.createElement('span');
shortcut.className = 'session-help-key';
shortcut.textContent = row.shortcut;
left.appendChild(shortcut);
const right = document.createElement('div');
right.className = 'session-help-item-right';
const action = document.createElement('span');
action.className = 'session-help-action';
action.textContent = row.action;
right.appendChild(action);
if (row.color) {
const dot = document.createElement('span');
dot.className = 'session-help-color-dot';
dot.style.backgroundColor = row.color;
right.insertBefore(dot, action);
}
button.appendChild(left);
button.appendChild(right);
return button;
}
const SECTION_ICON: Record<string, string> = {
'MPV shortcuts': '⚙',
'Playback and navigation': '▶',
'Visual feedback': '◉',
'Subtitle sync': '⟲',
'Runtime settings': '⚙',
'System actions': '◆',
'Other shortcuts': '…',
'Overlay shortcuts (configurable)': '✦',
'Overlay shortcuts': '✦',
'Color legend': '◈',
};
function createSectionNode(
section: SessionHelpSection,
sectionIndex: number,
globalIndexMap: number[],
): HTMLElement {
const sectionNode = document.createElement('section');
sectionNode.className = 'session-help-section';
const title = document.createElement('h3');
title.className = 'session-help-section-title';
const icon = SECTION_ICON[section.title] ?? '•';
title.textContent = `${icon} ${section.title}`;
sectionNode.appendChild(title);
const list = document.createElement('div');
list.className = 'session-help-item-list';
section.rows.forEach((row, rowIndex) => {
const globalIndex = (globalIndexMap[sectionIndex] ?? 0) + rowIndex;
const button = createShortcutRow(row, globalIndex);
list.appendChild(button);
});
sectionNode.appendChild(list);
return sectionNode;
}
export function createSessionHelpModal(
ctx: RendererContext,
options: {
@@ -412,6 +41,7 @@ export function createSessionHelpModal(
};
let helpFilterValue = '';
let helpSections: SessionHelpSection[] = [];
let activeTabId: SessionHelpTabId = 'essentials';
let focusGuard: ((event: FocusEvent) => void) | null = null;
let windowFocusGuard: (() => void) | null = null;
let modalPointerFocusGuard: ((event: Event) => void) | null = null;
@@ -497,7 +127,7 @@ export function createSessionHelpModal(
}
function applyFilterAndRender(): void {
const sections = filterSections(helpSections, helpFilterValue);
const sections = buildVisibleSessionHelpSections(helpSections, activeTabId, helpFilterValue);
const indexOffsets: number[] = [];
let running = 0;
for (const section of sections) {
@@ -506,8 +136,16 @@ export function createSessionHelpModal(
}
ctx.dom.sessionHelpContent.innerHTML = '';
if (!helpFilterValue.trim()) {
ctx.dom.sessionHelpContent.appendChild(
createSessionHelpTabBar(helpSections, activeTabId, (tabId) => {
activeTabId = tabId;
applyFilterAndRender();
}),
);
}
sections.forEach((section, sectionIndex) => {
const sectionNode = createSectionNode(section, sectionIndex, indexOffsets);
const sectionNode = createSessionHelpSectionNode(section, sectionIndex, indexOffsets);
ctx.dom.sessionHelpContent.appendChild(sectionNode);
});
@@ -515,7 +153,7 @@ export function createSessionHelpModal(
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = helpFilterValue
? 'No matching shortcuts found.'
: 'No active session shortcuts found.';
: 'No active shortcuts in this tab.';
ctx.state.sessionHelpSelectedIndex = 0;
return;
}
@@ -572,6 +210,7 @@ export function createSessionHelpModal(
function showRenderError(message: string): void {
helpSections = [];
helpFilterValue = '';
activeTabId = 'essentials';
ctx.dom.sessionHelpFilter.value = '';
ctx.dom.sessionHelpContent.classList.add('session-help-content-no-results');
ctx.dom.sessionHelpContent.textContent = message;
@@ -580,28 +219,23 @@ export function createSessionHelpModal(
async function render(): Promise<boolean> {
try {
const [keybindings, styleConfig, shortcuts] = await Promise.all([
window.electronAPI.getKeybindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getConfiguredShortcuts(),
]);
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
]);
const bindingSections = buildBindingSections(keybindings);
if (bindingSections.length > 0) {
const playback = bindingSections.find(
(section) => section.title === 'Playback and navigation',
);
if (playback) {
playback.title = 'MPV shortcuts';
}
}
const shortcutSections = buildOverlayShortcutSections(shortcuts);
if (shortcutSections.length > 0) {
shortcutSections[0]!.title = 'Overlay shortcuts (configurable)';
}
const colorSection = buildColorSection(styleConfig ?? {});
helpSections = [...bindingSections, ...shortcutSections, colorSection];
helpSections = buildSessionHelpSections({
sessionBindings,
markWatchedKey,
subtitleSidebarToggleKey,
subtitleStyle: styleConfig ?? {},
});
applyFilterAndRender();
return true;
} catch (error) {
+46
View File
@@ -2129,6 +2129,48 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
padding-right: 4px;
}
.session-help-tabs {
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
padding: 4px 0 6px;
background: linear-gradient(180deg, rgba(30, 32, 48, 0.98), rgba(30, 32, 48, 0.82));
backdrop-filter: blur(10px);
}
.session-help-tab {
min-width: 0;
min-height: 34px;
padding: 7px 8px;
border-radius: 7px;
border: 1px solid rgba(110, 115, 141, 0.22);
background: rgba(49, 50, 68, 0.76);
color: var(--ctp-subtext1);
font-size: 12px;
font-weight: 700;
line-height: 1.15;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.session-help-tab:hover,
.session-help-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48);
color: var(--ctp-text);
outline: none;
}
.session-help-tab.active {
border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16);
color: var(--ctp-yellow);
}
.session-help-filter {
width: 100%;
min-height: 32px;
@@ -2276,6 +2318,10 @@ body.subtitle-sidebar-embedded-open #subtitleSidebarContent {
max-height: calc(84vh - 190px);
}
.session-help-tabs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.session-help-item {
flex-direction: column;
align-items: flex-start;
+1
View File
@@ -2,6 +2,7 @@ export type SessionKeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta';
export type SessionActionId =
| 'toggleStatsOverlay'
| 'markWatched'
| 'toggleVisibleOverlay'
| 'copySubtitle'
| 'copySubtitleMultiple'