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
+1 -1
View File
@@ -9,7 +9,7 @@ breaking: true
- Routed startup tokenization, subtitle annotation, and character dictionary status through queued overlay notifications for `overlay`/`both` instead of falling back to mpv OSD while the overlay loads; queued loading cards are shown before their ready update when both happen before the overlay is ready, and the bundled mpv plugin now only emits startup OSD messages for `osd` and `osd-system`.
- Preserved character dictionary checking/building/importing/ready phases in overlay notification history and sent those phases to system notifications when `notificationType` is `both`.
- Initialized the tray and visible overlay shell before deferred tokenization warmups finish on visible-overlay startup, while keeping playback paused until SubMiner reports autoplay readiness.
- Kept playback feedback such as subtitle visibility, subtitle track, and subtitle delay text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates.
- Kept playback feedback such as subtitle visibility, subtitle track, subtitle delay, and AniSkip prompt/skip text on overlay/OSD surfaces only; desktop/system notifications are reserved for real notifications like mined cards, errors, and updates.
- Reused the active primary/secondary subtitle mode overlay notification while cycling modes so rapid toggles update one card instead of stacking duplicate feedback.
- Updated repeated progress notifications such as subsync syncing in place so their spinner stays live instead of flickering on every tick.
- Stabilized overlay startup notifications so queued progress updates do not replay the card entrance animation or trigger macOS pass-through hover flicker after the loading OSD hands off to overlay notifications.
+1 -1
View File
@@ -234,7 +234,7 @@ Configure where overlay notification cards appear:
Every overlay notification shown during a session is also recorded in a notification history panel. Press `Ctrl/Cmd+N` (configurable via [`shortcuts.toggleNotificationHistory`](#shortcuts-configuration)) to toggle the panel; the binding works whether the overlay or mpv has focus. The panel slides in from the same edge the notifications use — left when `overlayPosition` is `"top-left"`, and right for `"top-right"` or `"top"` (centered). Character dictionary sync uses one live card but records each distinct phase in history. Each entry can be removed individually, or use **Clear** to empty the history. History is session-only and is not persisted across restarts.
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`.
Startup tokenization, subtitle annotation, and character dictionary status follow the configured notification surface. When the surface is `"overlay"` or `"both"`, SubMiner queues those startup notifications until the overlay renderer is ready instead of falling back to mpv OSD. If loading and ready states both finish before the overlay can paint, the loading card is delivered first and then updates to ready shortly after. With `"both"`, character dictionary checking/building/importing/ready status also goes to system notifications; building and importing are only emitted when that work is actually needed. The bundled mpv plugin only shows its startup OSD messages when `ankiConnect.behavior.notificationType` is set to `"osd"` or `"osd-system"` in `config.jsonc`; AniSkip prompts and skip result messages are playback feedback and still route to overlay notifications when configured.
### Auto-Start Overlay
+25
View File
@@ -428,6 +428,9 @@ function M.create(ctx)
table.insert(args, "--texthooker")
end
end
if action == "playback-feedback" and type(overrides.message) == "string" and overrides.message ~= "" then
table.insert(args, overrides.message)
end
return args
end
@@ -515,6 +518,27 @@ function M.create(ctx)
end)
end
local function notify_playback_feedback(message, fallback)
if type(message) ~= "string" or message == "" then
return
end
if resolve_osd_messages_enabled() then
show_osd(message)
return
end
if not binary.ensure_binary_available() then
if fallback then
fallback()
end
return
end
run_control_command_async("playback-feedback", { message = message }, function(ok)
if not ok and fallback then
fallback()
end
end)
end
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
attempt = attempt or 1
run_control_command_async("app-ping", nil, function(_ok, result)
@@ -934,6 +958,7 @@ function M.create(ctx)
describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async,
notify_playback_feedback = notify_playback_feedback,
record_visible_overlay_visibility = record_visible_overlay_visibility,
run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides,
+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,