mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 15:13:32 -07:00
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:
@@ -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
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user