From 11e9c721c6e1b7246a1d79cad923ec5cbff17fd6 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 2 Mar 2026 01:12:26 -0800 Subject: [PATCH] feat(subtitles): add no-jump subtitle-delay shift commands --- ...hift-to-adjacent-cue-without-seek-jumps.md | 51 +++++ docs/configuration.md | 6 +- docs/shortcuts.md | 2 + src/config/definitions/shared.ts | 7 + src/core/services/index.ts | 1 + src/core/services/ipc-command.test.ts | 20 ++ src/core/services/ipc-command.ts | 20 ++ .../services/subtitle-delay-shift.test.ts | 122 +++++++++++ src/core/services/subtitle-delay-shift.ts | 201 ++++++++++++++++++ src/main.ts | 45 +++- src/main/dependencies.ts | 2 + src/main/ipc-mpv-command.ts | 3 + .../composers/ipc-runtime-composer.test.ts | 1 + .../ipc-bridge-actions-main-deps.test.ts | 1 + src/main/runtime/ipc-bridge-actions.test.ts | 1 + .../runtime/ipc-mpv-command-main-deps.test.ts | 5 + src/main/runtime/ipc-mpv-command-main-deps.ts | 2 + 17 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 backlog/tasks/task-83 - Jellyfin-subtitle-delay-shift-to-adjacent-cue-without-seek-jumps.md create mode 100644 src/core/services/subtitle-delay-shift.test.ts create mode 100644 src/core/services/subtitle-delay-shift.ts diff --git a/backlog/tasks/task-83 - Jellyfin-subtitle-delay-shift-to-adjacent-cue-without-seek-jumps.md b/backlog/tasks/task-83 - Jellyfin-subtitle-delay-shift-to-adjacent-cue-without-seek-jumps.md new file mode 100644 index 0000000..9da9d9b --- /dev/null +++ b/backlog/tasks/task-83 - Jellyfin-subtitle-delay-shift-to-adjacent-cue-without-seek-jumps.md @@ -0,0 +1,51 @@ +--- +id: TASK-83 +title: 'Jellyfin subtitle delay: shift to adjacent cue without seek jumps' +status: Done +assignee: [] +created_date: '2026-03-02 00:06' +updated_date: '2026-03-02 00:06' +labels: [] +dependencies: [] +priority: high +ordinal: 9003 +--- + +## Description + + + +Add keybinding-friendly special commands that shift `sub-delay` to align current subtitle start with next/previous cue start, without `sub-seek` probing (avoid playback jump). + +Scope: +- add special commands for next/previous line alignment; +- compute delta from active subtitle cue timeline (external subtitle file/URL, including Jellyfin-delivered URLs); +- apply `add sub-delay ` and show OSD value; +- keep existing proxy OSD behavior for direct `sub-delay` keybinding commands. + + + +## Acceptance Criteria + + + +- [x] #1 New special commands exist for subtitle-delay shift to next/previous cue boundary. +- [x] #2 Shift logic parses active external subtitle source timings (SRT/VTT/ASS) and computes delta from current `sub-start`. +- [x] #3 Runtime applies delay shift without `sub-seek` and shows OSD feedback. +- [x] #4 Direct `sub-delay` proxy commands also show OSD current value. +- [x] #5 Tests added for cue parsing/shift behavior and IPC dispatch wiring. + + + +## Final Summary + + + +Implemented no-jump subtitle-delay alignment commands: +- added `__sub-delay-next-line` and `__sub-delay-prev-line` special commands; +- added `createShiftSubtitleDelayToAdjacentCueHandler` to parse cue start times from active external subtitle source and apply `add sub-delay` delta from current `sub-start`; +- wired command handling through IPC runtime deps into main runtime; +- retained/extended OSD proxy feedback for `sub-delay` keybindings; +- updated configuration docs and added regression tests for subtitle-delay shift and IPC command routing. + + diff --git a/docs/configuration.md b/docs/configuration.md index dcea8da..162e862 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -375,6 +375,8 @@ See `config.example.jsonc` for detailed configuration options and more examples. | `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | | `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | | `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | +| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue | +| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue | | `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | | `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | | `KeyQ` | `["quit"]` | Quit mpv | @@ -402,11 +404,11 @@ See `config.example.jsonc` for detailed configuration options and more examples. { "key": "Space", "command": null } ``` -**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. +**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) -For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`), SubMiner also shows an mpv OSD notification after the command runs. +For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs. **See `config.example.jsonc`** for more keybinding examples and configuration options. diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 12a0233..9f22b11 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -46,6 +46,8 @@ These control playback and subtitle display. They require overlay window focus. | `ArrowDown` | Seek backward 60 seconds | | `Shift+H` | Jump to previous subtitle | | `Shift+L` | Jump to next subtitle | +| `Shift+[` | Shift subtitle delay to previous subtitle cue | +| `Shift+]` | Shift subtitle delay to next subtitle cue | | `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | | `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | | `Q` | Quit mpv | diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts index 045cdaa..07b37da 100644 --- a/src/config/definitions/shared.ts +++ b/src/config/definitions/shared.ts @@ -44,6 +44,8 @@ export const SPECIAL_COMMANDS = { RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', REPLAY_SUBTITLE: '__replay-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle', + SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', + SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', } as const; export const DEFAULT_KEYBINDINGS: NonNullable = [ @@ -56,6 +58,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable = [ { key: 'ArrowDown', command: ['seek', -60] }, { key: 'Shift+KeyH', command: ['sub-seek', -1] }, { key: 'Shift+KeyL', command: ['sub-seek', 1] }, + { key: 'Shift+BracketRight', command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START] }, + { + key: 'Shift+BracketLeft', + command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], + }, { key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'KeyQ', command: ['quit'] }, diff --git a/src/core/services/index.ts b/src/core/services/index.ts index 778858c..2084850 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -10,6 +10,7 @@ export { unregisterOverlayShortcutsRuntime, } from './overlay-shortcut'; export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler'; +export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift'; export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command'; export { copyCurrentSubtitle, diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts index ff54d15..3995851 100644 --- a/src/core/services/ipc-command.test.ts +++ b/src/core/services/ipc-command.test.ts @@ -13,6 +13,8 @@ function createOptions(overrides: Partial { calls.push('subsync'); @@ -30,6 +32,9 @@ function createOptions(overrides: Partial { calls.push('next'); }, + shiftSubDelayToAdjacentSubtitle: async (direction) => { + calls.push(`shift:${direction}`); + }, mpvSendCommand: (command) => { sentCommands.push(command); }, @@ -68,6 +73,21 @@ test('handleMpvCommandFromIpc emits osd for secondary subtitle track keybinding assert.deepEqual(osd, ['Secondary subtitle track: ${secondary-sid}']); }); +test('handleMpvCommandFromIpc emits osd for subtitle delay keybinding proxies', () => { + const { options, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options); + assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]); + assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']); +}); + +test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => { + const { options, calls, sentCommands, osd } = createOptions(); + handleMpvCommandFromIpc(['__sub-delay-next-line'], options); + assert.deepEqual(calls, ['shift:next']); + assert.deepEqual(sentCommands, []); + assert.deepEqual(osd, []); +}); + test('handleMpvCommandFromIpc does not forward commands while disconnected', () => { const { options, sentCommands, osd } = createOptions({ isMpvConnected: () => false, diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts index 05dca5e..db6b6f5 100644 --- a/src/core/services/ipc-command.ts +++ b/src/core/services/ipc-command.ts @@ -12,6 +12,8 @@ export interface HandleMpvCommandFromIpcOptions { RUNTIME_OPTION_CYCLE_PREFIX: string; REPLAY_SUBTITLE: string; PLAY_NEXT_SUBTITLE: string; + SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string; + SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string; }; triggerSubsyncFromConfig: () => void; openRuntimeOptionsPalette: () => void; @@ -19,6 +21,7 @@ export interface HandleMpvCommandFromIpcOptions { showMpvOsd: (text: string) => void; mpvReplaySubtitle: () => void; mpvPlayNextSubtitle: () => void; + shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; mpvSendCommand: (command: (string | number)[]) => void; isMpvConnected: () => boolean; hasRuntimeOptionsManager: () => boolean; @@ -46,6 +49,9 @@ function resolveProxyCommandOsd(command: (string | number)[]): string | null { if (property === 'secondary-sid') { return 'Secondary subtitle track: ${secondary-sid}'; } + if (property === 'sub-delay') { + return 'Subtitle delay: ${sub-delay}'; + } return null; } @@ -64,6 +70,20 @@ export function handleMpvCommandFromIpc( return; } + if ( + first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START || + first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START + ) { + const direction = + first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START + ? 'next' + : 'previous'; + options.shiftSubDelayToAdjacentSubtitle(direction).catch((error) => { + options.showMpvOsd(`Subtitle delay shift failed: ${(error as Error).message}`); + }); + return; + } + if (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) { if (!options.hasRuntimeOptionsManager()) return; const [, idToken, directionToken] = first.split(':'); diff --git a/src/core/services/subtitle-delay-shift.test.ts b/src/core/services/subtitle-delay-shift.test.ts new file mode 100644 index 0000000..242742c --- /dev/null +++ b/src/core/services/subtitle-delay-shift.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift'; + +function createMpvClient(props: Record) { + return { + connected: true, + requestProperty: async (name: string) => props[name], + }; +} + +test('shift subtitle delay to next cue using active external srt track', async () => { + const commands: Array> = []; + const osd: string[] = []; + let loadCount = 0; + const handler = createShiftSubtitleDelayToAdjacentCueHandler({ + getMpvClient: () => + createMpvClient({ + 'track-list': [ + { + type: 'sub', + id: 2, + external: true, + 'external-filename': '/tmp/subs.srt', + }, + ], + sid: 2, + 'sub-start': 3.0, + }), + loadSubtitleSourceText: async () => { + loadCount += 1; + return `1 +00:00:01,000 --> 00:00:02,000 +line-1 + +2 +00:00:03,000 --> 00:00:04,000 +line-2 + +3 +00:00:05,000 --> 00:00:06,000 +line-3`; + }, + sendMpvCommand: (command) => commands.push(command), + showMpvOsd: (text) => osd.push(text), + }); + + await handler('next'); + await handler('next'); + + assert.equal(loadCount, 1); + assert.equal(commands.length, 2); + const delta = commands[0]?.[2]; + assert.equal(commands[0]?.[0], 'add'); + assert.equal(commands[0]?.[1], 'sub-delay'); + assert.equal(typeof delta, 'number'); + assert.equal(Math.abs((delta as number) - 2) < 0.0001, true); + assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}', 'Subtitle delay: ${sub-delay}']); +}); + +test('shift subtitle delay to previous cue using active external ass track', async () => { + const commands: Array> = []; + const handler = createShiftSubtitleDelayToAdjacentCueHandler({ + getMpvClient: () => + createMpvClient({ + 'track-list': [ + { + type: 'sub', + id: 4, + external: true, + 'external-filename': '/tmp/subs.ass', + }, + ], + sid: 4, + 'sub-start': 2.0, + }), + loadSubtitleSourceText: async () => `[Events] +Dialogue: 0,0:00:00.50,0:00:01.50,Default,,0,0,0,,line-1 +Dialogue: 0,0:00:02.00,0:00:03.00,Default,,0,0,0,,line-2 +Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`, + sendMpvCommand: (command) => commands.push(command), + showMpvOsd: () => {}, + }); + + await handler('previous'); + + const delta = commands[0]?.[2]; + assert.equal(typeof delta, 'number'); + assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true); +}); + +test('shift subtitle delay throws when no next cue exists', async () => { + const handler = createShiftSubtitleDelayToAdjacentCueHandler({ + getMpvClient: () => + createMpvClient({ + 'track-list': [ + { + type: 'sub', + id: 1, + external: true, + 'external-filename': '/tmp/subs.vtt', + }, + ], + sid: 1, + 'sub-start': 5.0, + }), + loadSubtitleSourceText: async () => `WEBVTT + +00:00:01.000 --> 00:00:02.000 +line-1 + +00:00:03.000 --> 00:00:04.000 +line-2 + +00:00:05.000 --> 00:00:06.000 +line-3`, + sendMpvCommand: () => {}, + showMpvOsd: () => {}, + }); + + await assert.rejects(() => handler('next'), /No next subtitle cue found/); +}); diff --git a/src/core/services/subtitle-delay-shift.ts b/src/core/services/subtitle-delay-shift.ts new file mode 100644 index 0000000..f648daa --- /dev/null +++ b/src/core/services/subtitle-delay-shift.ts @@ -0,0 +1,201 @@ +type SubtitleDelayShiftDirection = 'next' | 'previous'; + +type MpvClientLike = { + connected: boolean; + requestProperty: (name: string) => Promise; +}; + +type MpvSubtitleTrackLike = { + type?: unknown; + id?: unknown; + external?: unknown; + 'external-filename'?: unknown; +}; + +type SubtitleCueCacheEntry = { + starts: number[]; +}; + +type SubtitleDelayShiftDeps = { + getMpvClient: () => MpvClientLike | null; + loadSubtitleSourceText: (source: string) => Promise; + sendMpvCommand: (command: Array) => void; + showMpvOsd: (text: string) => void; +}; + +function asTrackId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value.trim()); + if (Number.isInteger(parsed)) return parsed; + } + return null; +} + +function parseSrtOrVttStartTimes(content: string): number[] { + const starts: number[] = []; + const lines = content.split(/\r?\n/); + for (const line of lines) { + const match = line.match( + /^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/, + ); + if (!match) continue; + const hours = Number(match[1] || 0); + const minutes = Number(match[2] || 0); + const seconds = Number(match[3] || 0); + const millis = Number(String(match[4]).padEnd(3, '0')); + starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000); + } + return starts; +} + +function parseAssStartTimes(content: string): number[] { + const starts: number[] = []; + const lines = content.split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/); + if (!match) continue; + const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':'); + if (secondsRaw === undefined) continue; + const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.'); + const hours = Number(hoursRaw); + const minutes = Number(minutesRaw); + const wholeSeconds = Number(wholeSecondsRaw); + const fraction = Number(`0.${fractionRaw}`); + starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction); + } + return starts; +} + +function normalizeCueStarts(starts: number[]): number[] { + const sorted = starts + .filter((value) => Number.isFinite(value) && value >= 0) + .sort((a, b) => a - b); + if (sorted.length === 0) return []; + + const deduped: number[] = [sorted[0]!]; + for (let i = 1; i < sorted.length; i += 1) { + const current = sorted[i]!; + const previous = deduped[deduped.length - 1]!; + if (Math.abs(current - previous) > 0.0005) { + deduped.push(current); + } + } + return deduped; +} + +function parseCueStarts(content: string, source: string): number[] { + const normalizedSource = source.toLowerCase().split('?')[0] || ''; + const parseSrtLike = () => parseSrtOrVttStartTimes(content); + const parseAssLike = () => parseAssStartTimes(content); + + let starts: number[] = []; + if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) { + starts = parseAssLike(); + if (starts.length === 0) { + starts = parseSrtLike(); + } + } else { + starts = parseSrtLike(); + if (starts.length === 0) { + starts = parseAssLike(); + } + } + + const normalized = normalizeCueStarts(starts); + if (normalized.length === 0) { + throw new Error('Could not parse subtitle cue timings from active subtitle source.'); + } + return normalized; +} + +function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string { + const sid = asTrackId(sidRaw); + if (sid === null) { + throw new Error('No active subtitle track selected.'); + } + if (!Array.isArray(trackListRaw)) { + throw new Error('Could not inspect subtitle track list.'); + } + + const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => { + if (!entry || typeof entry !== 'object') return false; + const track = entry as MpvSubtitleTrackLike; + return track.type === 'sub' && asTrackId(track.id) === sid; + }); + + if (!activeTrack) { + throw new Error('No active subtitle track found in mpv track list.'); + } + if (activeTrack.external !== true) { + throw new Error('Active subtitle track is internal and has no direct subtitle file source.'); + } + + const source = + typeof activeTrack['external-filename'] === 'string' + ? activeTrack['external-filename'].trim() + : ''; + if (!source) { + throw new Error('Active subtitle track has no external subtitle source path.'); + } + return source; +} + +function findAdjacentCueStart( + starts: number[], + currentStart: number, + direction: SubtitleDelayShiftDirection, +): number { + const epsilon = 0.0005; + if (direction === 'next') { + const target = starts.find((value) => value > currentStart + epsilon); + if (target === undefined) { + throw new Error('No next subtitle cue found for active subtitle source.'); + } + return target; + } + + for (let index = starts.length - 1; index >= 0; index -= 1) { + const value = starts[index]!; + if (value < currentStart - epsilon) { + return value; + } + } + throw new Error('No previous subtitle cue found for active subtitle source.'); +} + +export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) { + const cueCache = new Map(); + + return async (direction: SubtitleDelayShiftDirection): Promise => { + const client = deps.getMpvClient(); + if (!client || !client.connected) { + throw new Error('MPV not connected.'); + } + + const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([ + client.requestProperty('track-list'), + client.requestProperty('sid'), + client.requestProperty('sub-start'), + ]); + + const currentStart = + typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null; + if (currentStart === null) { + throw new Error('Current subtitle start time is unavailable.'); + } + + const source = getActiveSubtitleSource(trackListRaw, sidRaw); + let cueStarts = cueCache.get(source)?.starts; + if (!cueStarts) { + const content = await deps.loadSubtitleSourceText(source); + cueStarts = parseCueStarts(content, source); + cueCache.set(source, { starts: cueStarts }); + } + + const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction); + const delta = targetStart - currentStart; + deps.sendMpvCommand(['add', 'sub-delay', delta]); + deps.showMpvOsd('Subtitle delay: ${sub-delay}'); + }; +} diff --git a/src/main.ts b/src/main.ts index 89ef363..b48bdb1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -331,6 +331,7 @@ import { copyCurrentSubtitle as copyCurrentSubtitleCore, createConfigHotReloadRuntime, createDiscordPresenceService, + createShiftSubtitleDelayToAdjacentCueHandler, createFieldGroupingOverlayRuntime, createOverlayContentMeasurementStore, createOverlayManager, @@ -1353,6 +1354,20 @@ function getRuntimeBooleanOption( return typeof value === 'boolean' ? value : fallback; } +function shouldInitializeMecabForAnnotations(): boolean { + const config = getResolvedConfig(); + const nPlusOneEnabled = getRuntimeBooleanOption( + 'subtitle.annotation.nPlusOne', + config.ankiConnect.nPlusOne.highlightEnabled, + ); + const jlptEnabled = getRuntimeBooleanOption('subtitle.annotation.jlpt', config.subtitleStyle.enableJlpt); + const frequencyEnabled = getRuntimeBooleanOption( + 'subtitle.annotation.frequency', + config.subtitleStyle.frequencyDictionary.enabled, + ); + return nPlusOneEnabled || jlptEnabled || frequencyEnabled; +} + const { getResolvedJellyfinConfig, getJellyfinClientInfo, @@ -2469,7 +2484,10 @@ const { if (startupWarmups.lowPowerMode) { return false; } - return startupWarmups.mecab; + if (!startupWarmups.mecab) { + return false; + } + return shouldInitializeMecabForAnnotations(); }, shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension, shouldWarmupSubtitleDictionaries: () => { @@ -2925,6 +2943,30 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand appendClipboardVideoToQueueMainDeps, ); +const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({ + getMpvClient: () => appState.mpvClient, + loadSubtitleSourceText: async (source) => { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source; + return fs.promises.readFile(filePath, 'utf8'); + }, + sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), + showMpvOsd: (text) => showMpvOsd(text), +}); + const { handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler, runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler, @@ -2945,6 +2987,7 @@ const { showMpvOsd: (text: string) => showMpvOsd(text), replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient), playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient), + shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubtitleDelayToAdjacentCueHandler(direction), sendMpvCommand: (rawCommand: (string | number)[]) => sendMpvCommandRuntime(appState.mpvClient, rawCommand), isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected), diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 2d4d9fe..8a04adf 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -180,6 +180,7 @@ export interface MpvCommandRuntimeServiceDepsParams { showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; + shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle']; mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand']; isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected']; hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager']; @@ -328,6 +329,7 @@ export function createMpvCommandRuntimeServiceDeps( showMpvOsd: params.showMpvOsd, mpvReplaySubtitle: params.mpvReplaySubtitle, mpvPlayNextSubtitle: params.mpvPlayNextSubtitle, + shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle, mpvSendCommand: params.mpvSendCommand, isMpvConnected: params.isMpvConnected, hasRuntimeOptionsManager: params.hasRuntimeOptionsManager, diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts index 2829567..46ae564 100644 --- a/src/main/ipc-mpv-command.ts +++ b/src/main/ipc-mpv-command.ts @@ -10,6 +10,7 @@ export interface MpvCommandFromIpcRuntimeDeps { showMpvOsd: (text: string) => void; replayCurrentSubtitle: () => void; playNextSubtitle: () => void; + shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise; sendMpvCommand: (command: (string | number)[]) => void; isMpvConnected: () => boolean; hasRuntimeOptionsManager: () => boolean; @@ -29,6 +30,8 @@ export function handleMpvCommandFromIpcRuntime( showMpvOsd: deps.showMpvOsd, mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvPlayNextSubtitle: deps.playNextSubtitle, + shiftSubDelayToAdjacentSubtitle: (direction) => + deps.shiftSubDelayToAdjacentSubtitle(direction), mpvSendCommand: deps.sendMpvCommand, isMpvConnected: deps.isMpvConnected, hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager, diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts index b664824..e36230d 100644 --- a/src/main/runtime/composers/ipc-runtime-composer.test.ts +++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts @@ -14,6 +14,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, playNextSubtitle: () => {}, + shiftSubDelayToAdjacentSubtitle: async () => {}, sendMpvCommand: () => {}, isMpvConnected: () => false, hasRuntimeOptionsManager: () => true, diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts index 20615fe..63c994e 100644 --- a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts +++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts @@ -17,6 +17,7 @@ test('ipc bridge action main deps builders map callbacks', async () => { showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, playNextSubtitle: () => {}, + shiftSubDelayToAdjacentSubtitle: async () => {}, sendMpvCommand: () => {}, isMpvConnected: () => true, hasRuntimeOptionsManager: () => true, diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts index 4c2b82f..d073d84 100644 --- a/src/main/runtime/ipc-bridge-actions.test.ts +++ b/src/main/runtime/ipc-bridge-actions.test.ts @@ -14,6 +14,7 @@ test('handle mpv command handler forwards command and built deps', () => { showMpvOsd: () => {}, replayCurrentSubtitle: () => {}, playNextSubtitle: () => {}, + shiftSubDelayToAdjacentSubtitle: async () => {}, sendMpvCommand: () => {}, isMpvConnected: () => true, hasRuntimeOptionsManager: () => true, diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts index 7a670d6..10f102e 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts @@ -11,6 +11,9 @@ test('ipc mpv command main deps builder maps callbacks', () => { showMpvOsd: (text) => calls.push(`osd:${text}`), replayCurrentSubtitle: () => calls.push('replay'), playNextSubtitle: () => calls.push('next'), + shiftSubDelayToAdjacentSubtitle: async (direction) => { + calls.push(`shift:${direction}`); + }, sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`), isMpvConnected: () => true, hasRuntimeOptionsManager: () => false, @@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { deps.showMpvOsd('hello'); deps.replayCurrentSubtitle(); deps.playNextSubtitle(); + void deps.shiftSubDelayToAdjacentSubtitle('next'); deps.sendMpvCommand(['show-text', 'ok']); assert.equal(deps.isMpvConnected(), true); assert.equal(deps.hasRuntimeOptionsManager(), false); @@ -31,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => { 'osd:hello', 'replay', 'next', + 'shift:next', 'cmd:show-text:ok', ]); }); diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts index 8c62bf8..26db27b 100644 --- a/src/main/runtime/ipc-mpv-command-main-deps.ts +++ b/src/main/runtime/ipc-mpv-command-main-deps.ts @@ -10,6 +10,8 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler( showMpvOsd: (text: string) => deps.showMpvOsd(text), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), playNextSubtitle: () => deps.playNextSubtitle(), + shiftSubDelayToAdjacentSubtitle: (direction) => + deps.shiftSubDelayToAdjacentSubtitle(direction), sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command), isMpvConnected: () => deps.isMpvConnected(), hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),