Replace subtitle delay actions with native mpv keybindings (#120)

This commit is contained in:
2026-06-12 00:03:06 -07:00
committed by GitHub
parent b3b45521b6
commit 0a384a22c9
41 changed files with 395 additions and 790 deletions
-2
View File
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
-2
View File
@@ -49,8 +49,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
togglePrimarySubtitleBar: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
-12
View File
@@ -537,18 +537,6 @@ export function handleCliCommand(
'playNextSubtitle',
'Play next subtitle failed',
);
} else if (args.shiftSubDelayPrevLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayPrevLine' },
'shiftSubDelayPrevLine',
'Shift subtitle delay failed',
);
} else if (args.shiftSubDelayNextLine) {
dispatchCliSessionAction(
{ actionId: 'shiftSubDelayNextLine' },
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.playbackFeedback) {
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
showFeedback(args.playbackFeedback);
-1
View File
@@ -10,7 +10,6 @@ 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,
+15 -11
View File
@@ -15,8 +15,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
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',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
},
@@ -48,9 +46,6 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
mpvPlayNextSubtitle: () => {
calls.push('next');
},
shiftSubDelayToAdjacentSubtitle: async (direction) => {
calls.push(`shift:${direction}`);
},
mpvSendCommand: (command) => {
sentCommands.push(command);
},
@@ -111,20 +106,29 @@ test('handleMpvCommandFromIpc emits resolved feedback for secondary subtitle tra
]);
});
test('handleMpvCommandFromIpc emits feedback for subtitle delay keybinding proxies', async () => {
test('handleMpvCommandFromIpc emits mpv OSD for subtitle delay keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['add', 'sub-delay', 0.1], options);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['add', 'sub-delay', 0.1]]);
assert.deepEqual(osd, []);
assert.deepEqual(playbackFeedback, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, []);
});
test('handleMpvCommandFromIpc dispatches special subtitle-delay shift command', () => {
test('handleMpvCommandFromIpc emits mpv OSD for subtitle step keybinding proxies', async () => {
const { options, sentCommands, osd, playbackFeedback } = createOptions();
handleMpvCommandFromIpc(['sub-step', 1], options);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(sentCommands, [['sub-step', 1]]);
assert.deepEqual(osd, ['Subtitle delay: ${sub-delay}']);
assert.deepEqual(playbackFeedback, []);
});
test('handleMpvCommandFromIpc does not dispatch retired subtitle-delay shift tokens', () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__sub-delay-next-line'], options);
assert.deepEqual(calls, ['shift:next']);
assert.deepEqual(sentCommands, []);
assert.deepEqual(calls, []);
assert.deepEqual(sentCommands, [['__sub-delay-next-line']]);
assert.deepEqual(osd, []);
});
+22 -27
View File
@@ -13,8 +13,6 @@ 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;
YOUTUBE_PICKER_OPEN: string;
PLAYLIST_BROWSER_OPEN: string;
};
@@ -25,10 +23,10 @@ export interface HandleMpvCommandFromIpcOptions {
openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
showRawMpvOsd?: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
mpvReplaySubtitle: () => void;
mpvPlayNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
mpvSendCommand: (command: (string | number)[]) => void;
resolveProxyCommandOsd?: (command: (string | number)[]) => Promise<string | null>;
isMpvConnected: () => boolean;
@@ -44,21 +42,30 @@ const MPV_PROPERTY_COMMANDS = new Set([
'multiply',
]);
function resolveProxyCommandOsdTemplate(command: (string | number)[]): string | null {
interface ProxyCommandFeedback {
template: string;
rawMpvOsd: boolean;
}
function resolveProxyCommandOsdTemplate(command: (string | number)[]): ProxyCommandFeedback | null {
const operation = typeof command[0] === 'string' ? command[0] : '';
if (operation === 'sub-step') {
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
}
const property = typeof command[1] === 'string' ? command[1] : '';
if (!MPV_PROPERTY_COMMANDS.has(operation)) return null;
if (property === 'sub-pos') {
return 'Subtitle position: ${sub-pos}';
return { template: 'Subtitle position: ${sub-pos}', rawMpvOsd: false };
}
if (property === 'sid') {
return 'Subtitle track: ${sid}';
return { template: 'Subtitle track: ${sid}', rawMpvOsd: false };
}
if (property === 'secondary-sid') {
return 'Secondary subtitle track: ${secondary-sid}';
return { template: 'Secondary subtitle track: ${secondary-sid}', rawMpvOsd: false };
}
if (property === 'sub-delay') {
return 'Subtitle delay: ${sub-delay}';
return { template: 'Subtitle delay: ${sub-delay}', rawMpvOsd: true };
}
return null;
}
@@ -67,16 +74,18 @@ function showResolvedProxyCommandOsd(
command: (string | number)[],
options: HandleMpvCommandFromIpcOptions,
): void {
const template = resolveProxyCommandOsdTemplate(command);
if (!template) return;
const showFeedback = options.showPlaybackFeedback ?? options.showMpvOsd;
const feedback = resolveProxyCommandOsdTemplate(command);
if (!feedback) return;
const showFeedback = feedback.rawMpvOsd
? (options.showRawMpvOsd ?? options.showMpvOsd)
: (options.showPlaybackFeedback ?? options.showMpvOsd);
const emit = async () => {
try {
const resolved = await options.resolveProxyCommandOsd?.(command);
showFeedback(resolved || template);
showFeedback(resolved || feedback.template);
} catch {
showFeedback(template);
showFeedback(feedback.template);
}
};
@@ -118,20 +127,6 @@ 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(':');
@@ -47,9 +47,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
},
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}`),
-7
View File
@@ -27,7 +27,6 @@ export interface SessionActionExecutorDeps {
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
playNextPlaylistItem: () => void;
showMpvOsd: (text: string) => void;
@@ -124,12 +123,6 @@ export async function dispatchSessionAction(
case 'playNextSubtitle':
deps.playNextSubtitle();
return;
case 'shiftSubDelayPrevLine':
await deps.shiftSubDelayToAdjacentSubtitle('previous');
return;
case 'shiftSubDelayNextLine':
await deps.shiftSubDelayToAdjacentSubtitle('next');
return;
case 'cycleRuntimeOption': {
const runtimeOptionId = request.payload?.runtimeOptionId as RuntimeOptionId | undefined;
if (!runtimeOptionId) {
+23 -2
View File
@@ -287,8 +287,6 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
test('compileSessionBindings wires every default keybinding to an overlay or mpv action', () => {
const expectedSpecialActions: Record<string, string> = {
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START]: 'shiftSubDelayPrevLine',
[SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START]: 'shiftSubDelayNextLine',
[SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN]: 'openYoutubePicker',
[SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN]: 'openPlaylistBrowser',
[SPECIAL_COMMANDS.REPLAY_SUBTITLE]: 'replayCurrentSubtitle',
@@ -320,6 +318,29 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
}
});
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [
createKeybinding('Shift+BracketLeft', ['__sub-delay-prev-line']),
createKeybinding('Shift+BracketRight', ['__sub-delay-next-line']),
],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.deepEqual(
result.bindings.map((binding) => ({
actionType: binding.actionType,
command: binding.actionType === 'mpv-command' ? binding.command : undefined,
})),
[
{ actionType: 'mpv-command', command: ['__sub-delay-prev-line'] },
{ actionType: 'mpv-command', command: ['__sub-delay-next-line'] },
],
);
});
test('compileSessionBindings omits disabled bindings', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
-8
View File
@@ -319,14 +319,6 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'playNextSubtitle' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayPrevLine' };
}
if (first === SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'shiftSubDelayNextLine' };
}
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
if (command.length !== 1) {
return null;
@@ -43,8 +43,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
@@ -1,156 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
function createMpvClient(props: Record<string, unknown>) {
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<Array<string | number>> = [];
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<Array<string | number>> = [];
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 reports cumulative delay after adjacent cue shift', async () => {
const shiftedDelays: number[] = [];
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
getMpvClient: () =>
createMpvClient({
'track-list': [
{
type: 'sub',
id: 2,
external: true,
'external-filename': '/tmp/subs.srt',
},
],
sid: 2,
'sub-start': 3.0,
'sub-delay': 0.5,
}),
loadSubtitleSourceText: async () => `1
00:00:03,000 --> 00:00:04,000
line-1
2
00:00:05,000 --> 00:00:06,000
line-2`,
sendMpvCommand: () => {},
showMpvOsd: () => {},
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
});
await handler('next');
assert.deepEqual(shiftedDelays, [2.5]);
});
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/);
});
-210
View File
@@ -1,210 +0,0 @@
type SubtitleDelayShiftDirection = 'next' | 'previous';
type MpvClientLike = {
connected: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type MpvSubtitleTrackLike = {
type?: unknown;
id?: unknown;
external?: unknown;
'external-filename'?: unknown;
};
type SubtitleCueCacheEntry = {
starts: number[];
};
type SubtitleDelayShiftDeps = {
getMpvClient: () => MpvClientLike | null;
loadSubtitleSourceText: (source: string) => Promise<string>;
sendMpvCommand: (command: Array<string | number>) => void;
showMpvOsd: (text: string) => void;
onSubtitleDelayShifted?: (delaySeconds: number) => 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<string, SubtitleCueCacheEntry>();
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
const client = deps.getMpvClient();
if (!client || !client.connected) {
throw new Error('MPV not connected.');
}
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
client.requestProperty('track-list'),
client.requestProperty('sid'),
client.requestProperty('sub-start'),
client.requestProperty('sub-delay'),
]);
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]);
const currentDelay =
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
try {
deps.onSubtitleDelayShifted?.(currentDelay + delta);
} catch {}
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
};
}