diff --git a/changes/overlay-notifications.md b/changes/overlay-notifications.md index e8159642..f4ff50fb 100644 --- a/changes/overlay-notifications.md +++ b/changes/overlay-notifications.md @@ -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. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 975d5652..94de7988 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -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 diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index af05dbee..9a334615 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -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, diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 26535dbc..a71a1f61 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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']); diff --git a/src/cli/args.ts b/src/cli/args.ts index 5855327b..2f078d72 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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 || diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index f1d8e316..850eca0a 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -51,6 +51,7 @@ function makeArgs(overrides: Partial = {}): CliArgs { playNextSubtitle: false, shiftSubDelayPrevLine: false, shiftSubDelayNextLine: false, + playbackFeedback: undefined, cycleRuntimeOptionId: undefined, cycleRuntimeOptionDirection: undefined, anilistStatus: false, @@ -252,6 +253,9 @@ function createDeps(overrides: Partial = {}) { 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(); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index 3dad5ec7..86902ba5 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -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( { diff --git a/src/main.ts b/src/main.ts index 6e821955..505e14f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(), diff --git a/src/main/cli-runtime.ts b/src/main/cli-runtime.ts index 771c2c52..694888f9 100644 --- a/src/main/cli-runtime.ts +++ b/src/main/cli-runtime.ts @@ -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, diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index edd3ff1f..edc2f125 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -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, diff --git a/src/main/runtime/aniskip-runtime.test.ts b/src/main/runtime/aniskip-runtime.test.ts index 0934f309..6d58ae8d 100644 --- a/src/main/runtime/aniskip-runtime.test.ts +++ b/src/main/runtime/aniskip-runtime.test.ts @@ -22,19 +22,21 @@ function createHarness(options?: { buttonKey?: string; metadata?: AniSkipMetadata | (() => Promise); 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 }); diff --git a/src/main/runtime/aniskip-runtime.ts b/src/main/runtime/aniskip-runtime.ts index 03fe41b5..edfca633 100644 --- a/src/main/runtime/aniskip-runtime.ts +++ b/src/main/runtime/aniskip-runtime.ts @@ -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()}`); } } diff --git a/src/main/runtime/cli-command-context-deps.ts b/src/main/runtime/cli-command-context-deps.ts index 4ebd0a38..d921c869 100644 --- a/src/main/runtime/cli-command-context-deps.ts +++ b/src/main/runtime/cli-command-context-deps.ts @@ -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, diff --git a/src/main/runtime/cli-command-context-main-deps.ts b/src/main/runtime/cli-command-context-main-deps.ts index d50bf87f..19de1f26 100644 --- a/src/main/runtime/cli-command-context-main-deps.ts +++ b/src/main/runtime/cli-command-context-main-deps.ts @@ -25,6 +25,7 @@ export function createBuildCliCommandContextMainDepsHandler(deps: { openExternal: (url: string) => Promise; 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) => { diff --git a/src/main/runtime/cli-command-context.ts b/src/main/runtime/cli-command-context.ts index e622cf25..e1a36cd3 100644 --- a/src/main/runtime/cli-command-context.ts +++ b/src/main/runtime/cli-command-context.ts @@ -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,