diff --git a/changes/background-launcher-reuse.md b/changes/background-launcher-reuse.md new file mode 100644 index 00000000..14938080 --- /dev/null +++ b/changes/background-launcher-reuse.md @@ -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. diff --git a/changes/mpv-session-shortcuts.md b/changes/mpv-session-shortcuts.md new file mode 100644 index 00000000..8fbb6013 --- /dev/null +++ b/changes/mpv-session-shortcuts.md @@ -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. diff --git a/changes/nplusone-known-word-compat.md b/changes/nplusone-known-word-compat.md new file mode 100644 index 00000000..1dc5c555 --- /dev/null +++ b/changes/nplusone-known-word-compat.md @@ -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. diff --git a/docs-site/subtitle-annotations.md b/docs-site/subtitle-annotations.md index db1eb744..b9e81991 100644 --- a/docs-site/subtitle-annotations.md +++ b/docs-site/subtitle-annotations.md @@ -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 diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 615ee638..c5da5507 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -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'); diff --git a/launcher/mpv.ts b/launcher/mpv.ts index f68a0fb1..10280ccc 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -1004,6 +1004,7 @@ export async function startOverlay( ): Promise { 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' diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 260bcd8c..6aee89c6 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -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 }, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index fee7ec14..47de9f81 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -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 diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index 713e1411..69d800e4 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -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 diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 840aed74..07575a0c 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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); diff --git a/src/cli/args.ts b/src/cli/args.ts index d8c790c3..0a1977c3 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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 || diff --git a/src/cli/help.test.ts b/src/cli/help.test.ts index 8a875343..b169c714 100644 --- a/src/cli/help.test.ts +++ b/src/cli/help.test.ts @@ -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/); diff --git a/src/cli/help.ts b/src/cli/help.ts index 83e5a477..7eca51ff 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 0c4a708f..d35e966f 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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( diff --git a/src/config/resolve/anki-connect.test.ts b/src/config/resolve/anki-connect.test.ts index 0755fe85..12c0bef2 100644 --- a/src/config/resolve/anki-connect.test.ts +++ b/src/config/resolve/anki-connect.test.ts @@ -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'] }, diff --git a/src/config/resolve/anki-connect.ts b/src/config/resolve/anki-connect.ts index 2db4a7c6..dfefe321 100644 --- a/src/config/resolve/anki-connect.ts +++ b/src/config/resolve/anki-connect.ts @@ -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; } diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index f2e1c4d6..e7aca271 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -32,6 +32,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + markWatched: false, toggleSubtitleSidebar: false, openRuntimeOptions: false, openSessionHelp: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 06c943b3..c11583b2 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -32,6 +32,7 @@ function makeArgs(overrides: Partial = {}): 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); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 2a9b5c63..2ce23238 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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' }, diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts new file mode 100644 index 00000000..4ee78ea8 --- /dev/null +++ b/src/core/services/session-actions.test.ts @@ -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 = {}) { + 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']); +}); diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index ba1031c2..819e797b 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -15,6 +15,7 @@ export interface SessionActionExecutorDeps { toggleSecondarySub: () => void; toggleSubtitleSidebar: () => void; markLastCardAsAudioCard: () => Promise; + markActiveVideoWatched: () => Promise; openRuntimeOptionsPalette: () => void; openSessionHelp: () => void; openCharacterDictionary: () => void; @@ -27,6 +28,7 @@ export interface SessionActionExecutorDeps { playNextSubtitle: () => void; shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; 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; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 2b19192e..6c5e92d7 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -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> = [ + '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(), + ); +}); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index 855e0e75..c22a621d 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -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 | 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); diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index 02b1fb67..a8dd8e8a 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -32,6 +32,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { triggerSubsync: false, markAudioCard: false, toggleStatsOverlay: false, + markWatched: false, toggleSubtitleSidebar: false, openRuntimeOptions: false, openSessionHelp: false, diff --git a/src/core/utils/shortcut-config.test.ts b/src/core/utils/shortcut-config.test.ts index f3f3119d..c9ed98a6 100644 --- a/src/core/utils/shortcut-config.test.ts +++ b/src/core/utils/shortcut-config.test.ts @@ -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'); +}); diff --git a/src/core/utils/shortcut-config.ts b/src/core/utils/shortcut-config.ts index 0c9e3c01..dc090cd9 100644 --- a/src/core/utils/shortcut-config.ts +++ b/src/core/utils/shortcut-config.ts @@ -26,77 +26,44 @@ export function resolveConfiguredShortcuts( defaultConfig: Config, ): ConfiguredShortcuts { const isAnkiConnectDisabled = config.ankiConnect?.enabled === false; + type ShortcutKey = keyof Omit & + keyof NonNullable; 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')), }; } diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index 2864355f..9bd4b9f2 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -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); diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index 671bd3ff..51695af2 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -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]; } diff --git a/src/main.ts b/src/main.ts index b5e773c6..cad31457 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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), }); } diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index 33d1d2df..cf7af824 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -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[] = []; diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 91472a02..be0e5677 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -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, diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index cbf0b3dd..d9c1cc0a 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -47,6 +47,7 @@ function makeArgs(overrides: Partial = {}): 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 })), diff --git a/src/main/runtime/first-run-setup-service.ts b/src/main/runtime/first-run-setup-service.ts index 6ea578ce..6e28beaa 100644 --- a/src/main/runtime/first-run-setup-service.ts +++ b/src/main/runtime/first-run-setup-service.ts @@ -90,6 +90,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean { args.triggerSubsync || args.markAudioCard || args.toggleStatsOverlay || + args.markWatched || args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts index bed69f66..650eb8b0 100644 --- a/src/renderer/handlers/keyboard.test.ts +++ b/src/renderer/handlers/keyboard.test.ts @@ -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(); diff --git a/src/renderer/modals/session-help-colors.ts b/src/renderer/modals/session-help-colors.ts new file mode 100644 index 00000000..969e76ce --- /dev/null +++ b/src/renderer/modals/session-help-colors.ts @@ -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), + }, + ], + }; +} diff --git a/src/renderer/modals/session-help-render.ts b/src/renderer/modals/session-help-render.ts new file mode 100644 index 00000000..ea61a843 --- /dev/null +++ b/src/renderer/modals/session-help-render.ts @@ -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 = { + '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; +} diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts new file mode 100644 index 00000000..b1c68e73 --- /dev/null +++ b/src/renderer/modals/session-help-sections.ts @@ -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 = { + 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 = { + 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(); + + 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(); + + 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); +} diff --git a/src/renderer/modals/session-help-tabs.ts b/src/renderer/modals/session-help-tabs.ts new file mode 100644 index 00000000..65f0748d --- /dev/null +++ b/src/renderer/modals/session-help-tabs.ts @@ -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; +} diff --git a/src/renderer/modals/session-help.test.ts b/src/renderer/modals/session-help.test.ts index c06f1289..e9f9e632 100644 --- a/src/renderer/modals/session-help.test.ts +++ b/src/renderer/modals/session-help.test.ts @@ -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: () => {}, diff --git a/src/renderer/modals/session-help.ts b/src/renderer/modals/session-help.ts index 41935f88..ed226bcb 100644 --- a/src/renderer/modals/session-help.ts +++ b/src/renderer/modals/session-help.ts @@ -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, '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 = { - 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(); - - 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 = { - '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 { 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) { diff --git a/src/renderer/style.css b/src/renderer/style.css index 1e4782e8..22f6e1be 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts index df0b150d..144286b5 100644 --- a/src/types/session-bindings.ts +++ b/src/types/session-bindings.ts @@ -2,6 +2,7 @@ export type SessionKeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'; export type SessionActionId = | 'toggleStatsOverlay' + | 'markWatched' | 'toggleVisibleOverlay' | 'copySubtitle' | 'copySubtitleMultiple'