feat(aniskip): route skip prompts and results through playback feedback

- Add --playback-feedback CLI flag; overlay/both modes show AniSkip hint and skip result on overlay instead of raw OSD
- Wire notify_playback_feedback through process.lua and the full deps chain to showPlaybackFeedback
- Fallback to OSD when binary unavailable or osd_messages opt enabled
This commit is contained in:
2026-06-09 22:23:16 -07:00
parent 2a4fdb74e4
commit 50b6226a7b
15 changed files with 119 additions and 10 deletions
+9
View File
@@ -131,6 +131,15 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true);
});
test('parseArgs captures internal playback feedback command', () => {
const args = parseArgs(['--playback-feedback', 'You can skip by pressing TAB']);
assert.equal(args.playbackFeedback, 'You can skip by pressing TAB');
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
assert.equal(commandNeedsOverlayRuntime(args), true);
});
test('parseArgs ignores non-positive numeric session action counts', () => {
const args = parseArgs(['--copy-subtitle-count=0', '--mine-sentence-count', '-1']);
+14 -1
View File
@@ -43,6 +43,7 @@ export interface CliArgs {
playNextSubtitle: boolean;
shiftSubDelayPrevLine: boolean;
shiftSubDelayNextLine: boolean;
playbackFeedback?: string;
cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest;
@@ -150,6 +151,7 @@ export function parseArgs(argv: string[]): CliArgs {
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined,
anilistStatus: false,
anilistLogout: false,
anilistSetup: false,
@@ -296,7 +298,13 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
else if (arg === '--shift-sub-delay-prev-line') args.shiftSubDelayPrevLine = true;
else if (arg === '--shift-sub-delay-next-line') args.shiftSubDelayNextLine = true;
else if (arg.startsWith('--cycle-runtime-option=')) {
else if (arg.startsWith('--playback-feedback=')) {
const value = arg.slice('--playback-feedback='.length).trim();
if (value) args.playbackFeedback = value;
} else if (arg === '--playback-feedback') {
const value = readValue(argv[i + 1])?.trim();
if (value) args.playbackFeedback = value;
} else if (arg.startsWith('--cycle-runtime-option=')) {
const parsed = parseCycleRuntimeOption(arg.split('=', 2)[1]);
if (parsed) {
args.cycleRuntimeOptionId = parsed.id;
@@ -556,6 +564,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
@@ -631,6 +640,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined &&
@@ -697,6 +707,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
@@ -757,6 +768,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.playNextSubtitle &&
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.playbackFeedback === undefined &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined &&
@@ -822,6 +834,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.playNextSubtitle ||
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.playbackFeedback !== undefined ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
+13
View File
@@ -51,6 +51,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
shiftSubDelayNextLine: false,
playbackFeedback: undefined,
cycleRuntimeOptionId: undefined,
cycleRuntimeOptionDirection: undefined,
anilistStatus: false,
@@ -252,6 +253,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
showMpvOsd: (text) => {
osd.push(text);
},
showPlaybackFeedback: (text) => {
calls.push(`feedback:${text}`);
},
log: (message) => {
calls.push(`log:${message}`);
},
@@ -493,6 +497,15 @@ test('handleCliCommand reports async mine errors to OSD', async () => {
assert.ok(osd.some((value) => value.includes('Mine sentence failed: boom')));
});
test('handleCliCommand routes playback feedback through configured feedback surface', () => {
const { deps, calls, osd } = createDeps();
handleCliCommand(makeArgs({ playbackFeedback: 'You can skip by pressing TAB' }), 'initial', deps);
assert.deepEqual(calls, ['initializeOverlayRuntime', 'feedback:You can skip by pressing TAB']);
assert.deepEqual(osd, []);
});
test('handleCliCommand applies socket path and connects on start', () => {
const { deps, calls } = createDeps();
+6
View File
@@ -106,6 +106,7 @@ export interface CliCommandServiceDeps {
hasMainWindow: () => boolean;
getMultiCopyTimeoutMs: () => number;
showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
log: (message: string) => void;
logDebug: (message: string) => void;
warn: (message: string) => void;
@@ -128,6 +129,7 @@ interface MpvCliRuntime {
setSocketPath: (socketPath: string) => void;
getClient: () => MpvClientLike | null;
showOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
}
interface TexthookerCliRuntime {
@@ -295,6 +297,7 @@ export function createCliCommandDepsRuntime(
hasMainWindow: options.app.hasMainWindow,
getMultiCopyTimeoutMs: options.getMultiCopyTimeoutMs,
showMpvOsd: options.mpv.showOsd,
showPlaybackFeedback: options.mpv.showPlaybackFeedback,
log: options.log,
logDebug: options.logDebug,
warn: options.warn,
@@ -546,6 +549,9 @@ export function handleCliCommand(
'shiftSubDelayNextLine',
'Shift subtitle delay failed',
);
} else if (args.playbackFeedback) {
const showFeedback = deps.showPlaybackFeedback ?? deps.showMpvOsd;
showFeedback(args.playbackFeedback);
} else if (args.cycleRuntimeOptionId !== undefined) {
dispatchCliSessionAction(
{
+2
View File
@@ -5748,6 +5748,7 @@ const aniSkipRuntime = createAniSkipRuntime({
showMpvOsd: (text, durationMs) => {
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
},
showPlaybackFeedback: (text) => showConfiguredPlaybackFeedback(text),
logInfo: (message) => logger.info(message),
logWarn: (message, error) => logger.warn(message, error),
logDebug: (message) => logger.debug(message),
@@ -7366,6 +7367,7 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
showMpvOsd: (text: string) => showConfiguredStatusNotification(text),
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
togglePrimarySubtitleBar: () => togglePrimarySubtitleBar(),
+2
View File
@@ -11,6 +11,7 @@ export interface CliCommandRuntimeServiceContext {
setSocketPath: (socketPath: string) => void;
getClient: CliCommandRuntimeServiceDepsParams['mpv']['getClient'];
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
showPlaybackFeedback?: CliCommandRuntimeServiceDepsParams['mpv']['showPlaybackFeedback'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
@@ -74,6 +75,7 @@ function createCliCommandDepsFromContext(
setSocketPath: context.setSocketPath,
getClient: context.getClient,
showOsd: context.showOsd,
showPlaybackFeedback: context.showPlaybackFeedback,
},
texthooker: {
service: context.texthookerService,
+2
View File
@@ -149,6 +149,7 @@ export interface CliCommandRuntimeServiceDepsParams {
setSocketPath: CliCommandDepsRuntimeOptions['mpv']['setSocketPath'];
getClient: CliCommandDepsRuntimeOptions['mpv']['getClient'];
showOsd: CliCommandDepsRuntimeOptions['mpv']['showOsd'];
showPlaybackFeedback?: CliCommandDepsRuntimeOptions['mpv']['showPlaybackFeedback'];
};
texthooker: {
service: CliCommandDepsRuntimeOptions['texthooker']['service'];
@@ -342,6 +343,7 @@ export function createCliCommandRuntimeServiceDeps(
setSocketPath: params.mpv.setSocketPath,
getClient: params.mpv.getClient,
showOsd: params.mpv.showOsd,
showPlaybackFeedback: params.mpv.showPlaybackFeedback,
},
texthooker: {
service: params.texthooker.service,
+24 -2
View File
@@ -22,19 +22,21 @@ function createHarness(options?: {
buttonKey?: string;
metadata?: AniSkipMetadata | (() => Promise<AniSkipMetadata>);
chapterList?: unknown;
playbackFeedback?: boolean;
}) {
const state = {
enabled: options?.enabled ?? true,
buttonKey: options?.buttonKey ?? 'TAB',
commands: [] as unknown[][],
osd: [] as string[],
feedback: [] as string[],
resolveCalls: [] as string[],
connected: true,
timePos: 0,
chapterList: options?.chapterList ?? [],
};
const deps: AniSkipRuntimeDeps = {
const deps = {
getAniSkipConfig: () => ({
aniskipEnabled: state.enabled,
aniskipButtonKey: state.buttonKey,
@@ -57,10 +59,17 @@ function createHarness(options?: {
showMpvOsd: (text) => {
state.osd.push(text);
},
...(options?.playbackFeedback
? {
showPlaybackFeedback: (text: string) => {
state.feedback.push(text);
},
}
: {}),
logInfo: () => {},
logWarn: () => {},
logDebug: () => {},
};
} satisfies AniSkipRuntimeDeps & { showPlaybackFeedback?: (text: string) => void };
return { runtime: createAniSkipRuntime(deps), state };
}
@@ -152,6 +161,19 @@ test('time-pos prompt shows once near intro start', async () => {
assert.deepEqual(state.osd, ['You can skip by pressing TAB']);
});
test('prompt and skip messages use playback feedback when configured', async () => {
const { runtime, state } = createHarness({ buttonKey: 'TAB', playbackFeedback: true });
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
await flushAsync();
runtime.handleTimePosChange({ time: 10.5 });
state.timePos = 30;
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
assert.deepEqual(state.feedback, ['You can skip by pressing TAB', 'Skipped intro']);
assert.deepEqual(state.osd, []);
});
test('connection change binds skip key and legacy fallback for custom keys', () => {
const { runtime, state } = createHarness({ buttonKey: 'F6' });
runtime.handleConnectionChange({ connected: true });
+14 -5
View File
@@ -22,6 +22,7 @@ export interface AniSkipRuntimeDeps {
isMpvConnected: () => boolean;
getCurrentTimePos: () => number;
showMpvOsd: (text: string, durationMs: number) => void;
showPlaybackFeedback?: (text: string) => void;
logInfo: (message: string) => void;
logWarn: (message: string, error?: unknown) => void;
logDebug: (message: string) => void;
@@ -53,6 +54,14 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
return key || DEFAULT_ANISKIP_BUTTON_KEY;
}
function showPlaybackFeedback(text: string, durationMs = PROMPT_OSD_DURATION_MS): void {
if (deps.showPlaybackFeedback) {
deps.showPlaybackFeedback(text);
return;
}
deps.showMpvOsd(text, durationMs);
}
function bindSkipKeys(): void {
if (!deps.isMpvConnected()) return;
const enabled = deps.getAniSkipConfig().aniskipEnabled;
@@ -204,23 +213,23 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
function skipIntroNow(): void {
if (!deps.getAniSkipConfig().aniskipEnabled) return;
if (!introWindow) {
deps.showMpvOsd('Intro skip unavailable', PROMPT_OSD_DURATION_MS);
showPlaybackFeedback('Intro skip unavailable');
return;
}
const now = deps.getCurrentTimePos();
if (!Number.isFinite(now)) {
deps.showMpvOsd('Skip unavailable', PROMPT_OSD_DURATION_MS);
showPlaybackFeedback('Skip unavailable');
return;
}
if (
now < introWindow.start - SKIP_WINDOW_EPSILON_SECONDS ||
now > introWindow.end + SKIP_WINDOW_EPSILON_SECONDS
) {
deps.showMpvOsd('Skip intro only during intro', PROMPT_OSD_DURATION_MS);
showPlaybackFeedback('Skip intro only during intro');
return;
}
deps.sendMpvCommand(['set_property', 'time-pos', introWindow.end]);
deps.showMpvOsd('Skipped intro', PROMPT_OSD_DURATION_MS);
showPlaybackFeedback('Skipped intro');
}
function handleTimePosChange({ time }: { time: number }): void {
@@ -229,7 +238,7 @@ export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
const promptWindowEnd = Math.min(introWindow.start + PROMPT_WINDOW_SECONDS, introWindow.end);
if (time >= introWindow.start && time < promptWindowEnd) {
promptShown = true;
deps.showMpvOsd(`You can skip by pressing ${resolveButtonKey()}`, PROMPT_OSD_DURATION_MS);
showPlaybackFeedback(`You can skip by pressing ${resolveButtonKey()}`);
}
}
@@ -7,6 +7,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
setSocketPath: (socketPath: string) => void;
getMpvClient: CliCommandContextFactoryDeps['getMpvClient'];
showOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
@@ -63,6 +64,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
setSocketPath: deps.setSocketPath,
getMpvClient: deps.getMpvClient,
showOsd: deps.showOsd,
showPlaybackFeedback: deps.showPlaybackFeedback,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
@@ -25,6 +25,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
initializeOverlayRuntime: () => void;
toggleVisibleOverlay: () => void;
@@ -83,6 +84,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
},
getMpvClient: () => deps.appState.mpvClient,
showOsd: (text: string) => deps.showMpvOsd(text),
showPlaybackFeedback: deps.showPlaybackFeedback,
texthookerService: deps.texthookerService,
getTexthookerPort: () => deps.appState.texthookerPort,
setTexthookerPort: (port: number) => {
+2
View File
@@ -12,6 +12,7 @@ export type CliCommandContextFactoryDeps = {
setSocketPath: (socketPath: string) => void;
getMpvClient: () => MpvClientLike;
showOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
@@ -72,6 +73,7 @@ export function createCliCommandContext(
setSocketPath: deps.setSocketPath,
getClient: deps.getMpvClient,
showOsd: deps.showOsd,
showPlaybackFeedback: deps.showPlaybackFeedback,
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,