mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18:22:42 -08:00
feat(subtitles): add no-jump subtitle-delay shift commands
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
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 <delta>` and show OSD value;
|
||||||
|
- keep existing proxy OSD behavior for direct `sub-delay` keybinding commands.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -375,6 +375,8 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next 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+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||||
@@ -402,11 +404,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
|||||||
{ "key": "Space", "command": null }
|
{ "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:<id>[: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:<id>[:next|prev]` cycles a runtime option value.
|
||||||
|
|
||||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
**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.
|
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
| `ArrowDown` | Seek backward 60 seconds |
|
| `ArrowDown` | Seek backward 60 seconds |
|
||||||
| `Shift+H` | Jump to previous subtitle |
|
| `Shift+H` | Jump to previous subtitle |
|
||||||
| `Shift+L` | Jump to next 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+H` | Replay current subtitle (play to end, then pause) |
|
||||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||||
| `Q` | Quit mpv |
|
| `Q` | Quit mpv |
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export const SPECIAL_COMMANDS = {
|
|||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-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;
|
} as const;
|
||||||
|
|
||||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||||
@@ -56,6 +58,11 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
|||||||
{ key: 'ArrowDown', command: ['seek', -60] },
|
{ key: 'ArrowDown', command: ['seek', -60] },
|
||||||
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
{ key: 'Shift+KeyH', command: ['sub-seek', -1] },
|
||||||
{ key: 'Shift+KeyL', 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+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||||
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||||
{ key: 'KeyQ', command: ['quit'] },
|
{ key: 'KeyQ', command: ['quit'] },
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export {
|
|||||||
unregisterOverlayShortcutsRuntime,
|
unregisterOverlayShortcutsRuntime,
|
||||||
} from './overlay-shortcut';
|
} from './overlay-shortcut';
|
||||||
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
export { createOverlayShortcutRuntimeHandlers } from './overlay-shortcut-handler';
|
||||||
|
export { createShiftSubtitleDelayToAdjacentCueHandler } from './subtitle-delay-shift';
|
||||||
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
export { createCliCommandDepsRuntime, handleCliCommand } from './cli-command';
|
||||||
export {
|
export {
|
||||||
copyCurrentSubtitle,
|
copyCurrentSubtitle,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||||
PLAY_NEXT_SUBTITLE: '__play-next-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',
|
||||||
},
|
},
|
||||||
triggerSubsyncFromConfig: () => {
|
triggerSubsyncFromConfig: () => {
|
||||||
calls.push('subsync');
|
calls.push('subsync');
|
||||||
@@ -30,6 +32,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
|||||||
mpvPlayNextSubtitle: () => {
|
mpvPlayNextSubtitle: () => {
|
||||||
calls.push('next');
|
calls.push('next');
|
||||||
},
|
},
|
||||||
|
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||||
|
calls.push(`shift:${direction}`);
|
||||||
|
},
|
||||||
mpvSendCommand: (command) => {
|
mpvSendCommand: (command) => {
|
||||||
sentCommands.push(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}']);
|
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', () => {
|
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
|
||||||
const { options, sentCommands, osd } = createOptions({
|
const { options, sentCommands, osd } = createOptions({
|
||||||
isMpvConnected: () => false,
|
isMpvConnected: () => false,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||||
REPLAY_SUBTITLE: string;
|
REPLAY_SUBTITLE: string;
|
||||||
PLAY_NEXT_SUBTITLE: string;
|
PLAY_NEXT_SUBTITLE: string;
|
||||||
|
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||||
|
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||||
};
|
};
|
||||||
triggerSubsyncFromConfig: () => void;
|
triggerSubsyncFromConfig: () => void;
|
||||||
openRuntimeOptionsPalette: () => void;
|
openRuntimeOptionsPalette: () => void;
|
||||||
@@ -19,6 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
|||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
mpvReplaySubtitle: () => void;
|
mpvReplaySubtitle: () => void;
|
||||||
mpvPlayNextSubtitle: () => void;
|
mpvPlayNextSubtitle: () => void;
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||||
mpvSendCommand: (command: (string | number)[]) => void;
|
mpvSendCommand: (command: (string | number)[]) => void;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
hasRuntimeOptionsManager: () => boolean;
|
hasRuntimeOptionsManager: () => boolean;
|
||||||
@@ -46,6 +49,9 @@ function resolveProxyCommandOsd(command: (string | number)[]): string | null {
|
|||||||
if (property === 'secondary-sid') {
|
if (property === 'secondary-sid') {
|
||||||
return 'Secondary subtitle track: ${secondary-sid}';
|
return 'Secondary subtitle track: ${secondary-sid}';
|
||||||
}
|
}
|
||||||
|
if (property === 'sub-delay') {
|
||||||
|
return 'Subtitle delay: ${sub-delay}';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +70,20 @@ export function handleMpvCommandFromIpc(
|
|||||||
return;
|
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 (first.startsWith(options.specialCommands.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||||
if (!options.hasRuntimeOptionsManager()) return;
|
if (!options.hasRuntimeOptionsManager()) return;
|
||||||
const [, idToken, directionToken] = first.split(':');
|
const [, idToken, directionToken] = first.split(':');
|
||||||
|
|||||||
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
122
src/core/services/subtitle-delay-shift.test.ts
Normal file
@@ -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<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 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/);
|
||||||
|
});
|
||||||
201
src/core/services/subtitle-delay-shift.ts
Normal file
201
src/core/services/subtitle-delay-shift.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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] = 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}');
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/main.ts
45
src/main.ts
@@ -331,6 +331,7 @@ import {
|
|||||||
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
copyCurrentSubtitle as copyCurrentSubtitleCore,
|
||||||
createConfigHotReloadRuntime,
|
createConfigHotReloadRuntime,
|
||||||
createDiscordPresenceService,
|
createDiscordPresenceService,
|
||||||
|
createShiftSubtitleDelayToAdjacentCueHandler,
|
||||||
createFieldGroupingOverlayRuntime,
|
createFieldGroupingOverlayRuntime,
|
||||||
createOverlayContentMeasurementStore,
|
createOverlayContentMeasurementStore,
|
||||||
createOverlayManager,
|
createOverlayManager,
|
||||||
@@ -1353,6 +1354,20 @@ function getRuntimeBooleanOption(
|
|||||||
return typeof value === 'boolean' ? value : fallback;
|
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 {
|
const {
|
||||||
getResolvedJellyfinConfig,
|
getResolvedJellyfinConfig,
|
||||||
getJellyfinClientInfo,
|
getJellyfinClientInfo,
|
||||||
@@ -2469,7 +2484,10 @@ const {
|
|||||||
if (startupWarmups.lowPowerMode) {
|
if (startupWarmups.lowPowerMode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return startupWarmups.mecab;
|
if (!startupWarmups.mecab) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return shouldInitializeMecabForAnnotations();
|
||||||
},
|
},
|
||||||
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
|
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
|
||||||
shouldWarmupSubtitleDictionaries: () => {
|
shouldWarmupSubtitleDictionaries: () => {
|
||||||
@@ -2925,6 +2943,30 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
|||||||
appendClipboardVideoToQueueMainDeps,
|
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 {
|
const {
|
||||||
handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler,
|
handleMpvCommandFromIpc: handleMpvCommandFromIpcHandler,
|
||||||
runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler,
|
runSubsyncManualFromIpc: runSubsyncManualFromIpcHandler,
|
||||||
@@ -2945,6 +2987,7 @@ const {
|
|||||||
showMpvOsd: (text: string) => showMpvOsd(text),
|
showMpvOsd: (text: string) => showMpvOsd(text),
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction) => shiftSubtitleDelayToAdjacentCueHandler(direction),
|
||||||
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
||||||
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
||||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
|||||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||||
|
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
|
||||||
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
mpvSendCommand: HandleMpvCommandFromIpcOptions['mpvSendCommand'];
|
||||||
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
isMpvConnected: HandleMpvCommandFromIpcOptions['isMpvConnected'];
|
||||||
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
|
hasRuntimeOptionsManager: HandleMpvCommandFromIpcOptions['hasRuntimeOptionsManager'];
|
||||||
@@ -328,6 +329,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
|||||||
showMpvOsd: params.showMpvOsd,
|
showMpvOsd: params.showMpvOsd,
|
||||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||||
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
|
||||||
|
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
|
||||||
mpvSendCommand: params.mpvSendCommand,
|
mpvSendCommand: params.mpvSendCommand,
|
||||||
isMpvConnected: params.isMpvConnected,
|
isMpvConnected: params.isMpvConnected,
|
||||||
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
|
hasRuntimeOptionsManager: params.hasRuntimeOptionsManager,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
|||||||
showMpvOsd: (text: string) => void;
|
showMpvOsd: (text: string) => void;
|
||||||
replayCurrentSubtitle: () => void;
|
replayCurrentSubtitle: () => void;
|
||||||
playNextSubtitle: () => void;
|
playNextSubtitle: () => void;
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
|
||||||
sendMpvCommand: (command: (string | number)[]) => void;
|
sendMpvCommand: (command: (string | number)[]) => void;
|
||||||
isMpvConnected: () => boolean;
|
isMpvConnected: () => boolean;
|
||||||
hasRuntimeOptionsManager: () => boolean;
|
hasRuntimeOptionsManager: () => boolean;
|
||||||
@@ -29,6 +30,8 @@ export function handleMpvCommandFromIpcRuntime(
|
|||||||
showMpvOsd: deps.showMpvOsd,
|
showMpvOsd: deps.showMpvOsd,
|
||||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||||
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
mpvPlayNextSubtitle: deps.playNextSubtitle,
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||||
|
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||||
mpvSendCommand: deps.sendMpvCommand,
|
mpvSendCommand: deps.sendMpvCommand,
|
||||||
isMpvConnected: deps.isMpvConnected,
|
isMpvConnected: deps.isMpvConnected,
|
||||||
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
|
hasRuntimeOptionsManager: deps.hasRuntimeOptionsManager,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
|||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
playNextSubtitle: () => {},
|
playNextSubtitle: () => {},
|
||||||
|
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
isMpvConnected: () => false,
|
isMpvConnected: () => false,
|
||||||
hasRuntimeOptionsManager: () => true,
|
hasRuntimeOptionsManager: () => true,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
|||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
playNextSubtitle: () => {},
|
playNextSubtitle: () => {},
|
||||||
|
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
isMpvConnected: () => true,
|
isMpvConnected: () => true,
|
||||||
hasRuntimeOptionsManager: () => true,
|
hasRuntimeOptionsManager: () => true,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
|||||||
showMpvOsd: () => {},
|
showMpvOsd: () => {},
|
||||||
replayCurrentSubtitle: () => {},
|
replayCurrentSubtitle: () => {},
|
||||||
playNextSubtitle: () => {},
|
playNextSubtitle: () => {},
|
||||||
|
shiftSubDelayToAdjacentSubtitle: async () => {},
|
||||||
sendMpvCommand: () => {},
|
sendMpvCommand: () => {},
|
||||||
isMpvConnected: () => true,
|
isMpvConnected: () => true,
|
||||||
hasRuntimeOptionsManager: () => true,
|
hasRuntimeOptionsManager: () => true,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||||
replayCurrentSubtitle: () => calls.push('replay'),
|
replayCurrentSubtitle: () => calls.push('replay'),
|
||||||
playNextSubtitle: () => calls.push('next'),
|
playNextSubtitle: () => calls.push('next'),
|
||||||
|
shiftSubDelayToAdjacentSubtitle: async (direction) => {
|
||||||
|
calls.push(`shift:${direction}`);
|
||||||
|
},
|
||||||
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
|
sendMpvCommand: (command) => calls.push(`cmd:${command.join(':')}`),
|
||||||
isMpvConnected: () => true,
|
isMpvConnected: () => true,
|
||||||
hasRuntimeOptionsManager: () => false,
|
hasRuntimeOptionsManager: () => false,
|
||||||
@@ -22,6 +25,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
deps.showMpvOsd('hello');
|
deps.showMpvOsd('hello');
|
||||||
deps.replayCurrentSubtitle();
|
deps.replayCurrentSubtitle();
|
||||||
deps.playNextSubtitle();
|
deps.playNextSubtitle();
|
||||||
|
void deps.shiftSubDelayToAdjacentSubtitle('next');
|
||||||
deps.sendMpvCommand(['show-text', 'ok']);
|
deps.sendMpvCommand(['show-text', 'ok']);
|
||||||
assert.equal(deps.isMpvConnected(), true);
|
assert.equal(deps.isMpvConnected(), true);
|
||||||
assert.equal(deps.hasRuntimeOptionsManager(), false);
|
assert.equal(deps.hasRuntimeOptionsManager(), false);
|
||||||
@@ -31,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
|||||||
'osd:hello',
|
'osd:hello',
|
||||||
'replay',
|
'replay',
|
||||||
'next',
|
'next',
|
||||||
|
'shift:next',
|
||||||
'cmd:show-text:ok',
|
'cmd:show-text:ok',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
|||||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||||
playNextSubtitle: () => deps.playNextSubtitle(),
|
playNextSubtitle: () => deps.playNextSubtitle(),
|
||||||
|
shiftSubDelayToAdjacentSubtitle: (direction) =>
|
||||||
|
deps.shiftSubDelayToAdjacentSubtitle(direction),
|
||||||
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
|
||||||
isMpvConnected: () => deps.isMpvConnected(),
|
isMpvConnected: () => deps.isMpvConnected(),
|
||||||
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
|
||||||
|
|||||||
Reference in New Issue
Block a user