feat(notifications): add overlay notifications with position config (#110)

This commit is contained in:
2026-06-10 22:46:52 -07:00
committed by GitHub
parent c09d009a3e
commit 7be1843c41
177 changed files with 7524 additions and 440 deletions
+3
View File
@@ -63,6 +63,7 @@ export interface AppReadyRuntimeDepsFactoryInput {
shouldRunHeadlessInitialCommand?: AppReadyRuntimeDeps['shouldRunHeadlessInitialCommand'];
shouldUseMinimalStartup?: AppReadyRuntimeDeps['shouldUseMinimalStartup'];
shouldSkipHeavyStartup?: AppReadyRuntimeDeps['shouldSkipHeavyStartup'];
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: AppReadyRuntimeDeps['shouldHandleInitialArgsBeforeDeferredOverlayWarmup'];
}
export function createAppLifecycleRuntimeDeps(
@@ -133,6 +134,8 @@ export function createAppReadyRuntimeDeps(
shouldRunHeadlessInitialCommand: params.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: params.shouldUseMinimalStartup,
shouldSkipHeavyStartup: params.shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
params.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
};
}
+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,
+11
View File
@@ -1,4 +1,5 @@
import { RuntimeOptionId, RuntimeOptionValue, SubsyncManualPayload } from '../types';
import type { OverlayNotificationPayload } from '../types/notification';
import { SubsyncResolvedConfig } from '../subsync/utils';
import type { SubsyncRuntimeDeps } from '../core/services/subsync-runner';
import type { IpcDepsRuntimeOptions } from '../core/services/ipc';
@@ -59,6 +60,7 @@ export interface MainIpcRuntimeServiceDepsParams {
onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened'];
onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged'];
onOverlayInteractiveHint?: IpcDepsRuntimeOptions['onOverlayInteractiveHint'];
handleOverlayNotificationAction?: IpcDepsRuntimeOptions['handleOverlayNotificationAction'];
onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve'];
openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings'];
quitApp: IpcDepsRuntimeOptions['quitApp'];
@@ -82,6 +84,7 @@ export interface MainIpcRuntimeServiceDepsParams {
dispatchSessionAction: IpcDepsRuntimeOptions['dispatchSessionAction'];
getStatsToggleKey: IpcDepsRuntimeOptions['getStatsToggleKey'];
getMarkWatchedKey: IpcDepsRuntimeOptions['getMarkWatchedKey'];
getOverlayNotificationPosition: IpcDepsRuntimeOptions['getOverlayNotificationPosition'];
getControllerConfig: IpcDepsRuntimeOptions['getControllerConfig'];
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
saveControllerPreference: IpcDepsRuntimeOptions['saveControllerPreference'];
@@ -124,6 +127,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
setAnkiIntegration: AnkiJimakuIpcRuntimeOptions['setAnkiIntegration'];
getKnownWordCacheStatePath: AnkiJimakuIpcRuntimeOptions['getKnownWordCacheStatePath'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
@@ -145,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'];
@@ -221,6 +226,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
showPlaybackFeedback?: HandleMpvCommandFromIpcOptions['showPlaybackFeedback'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
shiftSubDelayToAdjacentSubtitle: HandleMpvCommandFromIpcOptions['shiftSubDelayToAdjacentSubtitle'];
@@ -240,6 +246,7 @@ export function createMainIpcRuntimeServiceDeps(
onOverlayModalOpened: params.onOverlayModalOpened,
onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: params.onOverlayInteractiveHint,
handleOverlayNotificationAction: params.handleOverlayNotificationAction,
onYoutubePickerResolve: params.onYoutubePickerResolve,
openYomitanSettings: params.openYomitanSettings,
quitApp: params.quitApp,
@@ -261,6 +268,7 @@ export function createMainIpcRuntimeServiceDeps(
dispatchSessionAction: params.dispatchSessionAction,
getStatsToggleKey: params.getStatsToggleKey,
getMarkWatchedKey: params.getMarkWatchedKey,
getOverlayNotificationPosition: params.getOverlayNotificationPosition,
getControllerConfig: params.getControllerConfig,
saveControllerConfig: params.saveControllerConfig,
saveControllerPreference: params.saveControllerPreference,
@@ -309,6 +317,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
setAnkiIntegration: params.setAnkiIntegration,
getKnownWordCacheStatePath: params.getKnownWordCacheStatePath,
showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver,
@@ -334,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,
@@ -414,6 +424,7 @@ export function createMpvCommandRuntimeServiceDeps(
openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
showPlaybackFeedback: params.showPlaybackFeedback,
mpvReplaySubtitle: params.mpvReplaySubtitle,
mpvPlayNextSubtitle: params.mpvPlayNextSubtitle,
shiftSubDelayToAdjacentSubtitle: params.shiftSubDelayToAdjacentSubtitle,
+2
View File
@@ -17,6 +17,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
showPlaybackFeedback?: (text: string) => void;
replayCurrentSubtitle: () => void;
playNextSubtitle: () => void;
shiftSubDelayToAdjacentSubtitle: (direction: 'next' | 'previous') => Promise<void>;
@@ -41,6 +42,7 @@ export function handleMpvCommandFromIpcRuntime(
openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
showPlaybackFeedback: deps.showPlaybackFeedback,
mpvReplaySubtitle: deps.replayCurrentSubtitle,
mpvPlayNextSubtitle: deps.playNextSubtitle,
shiftSubDelayToAdjacentSubtitle: (direction) =>
+163 -12
View File
@@ -59,6 +59,50 @@ test('same media path updates do not reset autoplay ready fallback state', () =>
);
});
test('mpv startup signals start overlay loading OSD before readiness work', () => {
const source = readMainSource();
const connectedBlock = source.match(
/onMpvConnected:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},\n maybeRunAnilistPostWatchUpdate:/,
)?.groups?.body;
const mediaPathBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
)?.groups?.body;
const setVisibleBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(connectedBlock);
assert.ok(mediaPathBlock);
assert.ok(setVisibleBlock);
assert.match(connectedBlock, /maybeStartOverlayLoadingOsd\(\);/);
assert.match(
mediaPathBlock,
/const normalizedPath = path\.trim\(\);\s+maybeStartOverlayLoadingOsd\(normalizedPath\);/,
);
assert.match(setVisibleBlock, /if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/);
assert.match(
source,
/function toggleVisibleOverlay\(\): void \{[\s\S]*?else \{\s+maybeStartOverlayLoadingOsd\(\);/,
);
assert.match(
source,
/function setOverlayVisible\(visible: boolean\): void \{[\s\S]*?if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);/,
);
});
test('overlay loading dismiss notifies mpv plugin to stop early loading OSD', () => {
const source = readMainSource();
const dismissBlock = source.match(
/function dismissOverlayLoadingStatusNotification\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(dismissBlock);
assert.match(
dismissBlock,
/sendMpvCommandRuntime\(appState\.mpvClient, \['script-message', 'subminer-overlay-loading-ready'\]\);/,
);
});
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -68,7 +112,7 @@ test('manual visible overlay toggles only release current-media autoplay when hi
assert.ok(actionBlock);
assert.match(
actionBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
});
@@ -89,15 +133,15 @@ test('all visible overlay hide paths clear stale overlay input state', () => {
assert.ok(setOverlayBlock);
assert.match(
setVisibleBlock,
/if \(!visible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
/if \(!visible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);\s+resetVisibleOverlayInputState\(\);/,
/if \(!nextVisible\) \{[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);[\s\S]*?resetVisibleOverlayInputState\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{\s+resetVisibleOverlayInputState\(\);\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
/if \(!visible\) \{[\s\S]*?cancelVisibleOverlaySubtitleRefreshAfterFirstPaint\(\);[\s\S]*?resetVisibleOverlayInputState\(\);[\s\S]*?autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);[\s\S]*?cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
);
});
@@ -118,6 +162,23 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
);
});
test('update overlay notification action triggers install flow', () => {
const source = readMainSource();
assert.match(
source,
/handleOverlayNotificationAction:\s*\(notificationId,\s*actionId,\s*noteId\)\s*=>/,
);
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
assert.match(source, /installWhenAvailable:\s*true/);
assert.match(source, /actionId === OPEN_ANKI_CARD_ACTION_ID && noteId !== undefined/);
assert.match(source, /appState\.ankiIntegration\?\.openNoteInAnki\(noteId\)/);
assert.match(source, /appState\.runtimeOptionsManager\?\.getEffectiveAnkiConnectConfig/);
assert.match(source, /new AnkiConnectClient\(\s*effectiveAnkiConfig\.url \|\| DEFAULT_CONFIG\.ankiConnect\.url/);
assert.match(source, /fallbackClient\.openNoteInBrowser\(noteId\)/);
});
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -160,7 +221,7 @@ test('autoplay subtitle prime emits cached annotations and avoids raw fallback o
);
});
test('startup autoplay release is tied to tokenization and visible overlay measurement readiness', () => {
test('startup autoplay release is tied to visible overlay measurement readiness', () => {
const source = readMainSource();
const gateBlock = source.match(
/const autoplayReadyGate = createAutoplayReadyGate\(\{(?<body>[\s\S]*?)\n\}\);/,
@@ -171,7 +232,7 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
assert.ok(gateBlock);
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
assert.match(gateBlock, /isTokenizationWarmupReady\(\)/);
assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/);
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
@@ -180,6 +241,37 @@ test('startup autoplay release is tied to tokenization and visible overlay measu
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
});
test('visible overlay content-ready does not tokenize before first measurement', () => {
const source = readMainSource();
const contentReadyBlock = source.match(
/onWindowContentReady:\s*\(\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
const measurementBlock = source.match(
/reportOverlayContentBounds:\s*\(payload: unknown\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(contentReadyBlock);
assert.doesNotMatch(contentReadyBlock, /subtitleProcessingController\.refreshCurrentSubtitle/);
assert.match(contentReadyBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
assert.match(contentReadyBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok(
contentReadyBlock.indexOf('overlayVisibilityRuntime.updateVisibleOverlayVisibility();') <
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
assert.ok(
contentReadyBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();') <
contentReadyBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();'),
);
assert.ok(measurementBlock);
assert.match(measurementBlock, /autoplayReadyGate\.flushPendingAutoplayReadySignal\(\)/);
assert.match(measurementBlock, /scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint\(\)/);
assert.ok(
measurementBlock.indexOf('autoplayReadyGate.flushPendingAutoplayReadySignal();') <
measurementBlock.indexOf('scheduleVisibleOverlaySubtitleRefreshAfterFirstPaint();'),
);
});
test('accepted visible overlay measurement immediately refreshes Linux pointer interaction', () => {
const source = readMainSource();
const measurementBlock = source.match(
@@ -189,10 +281,15 @@ test('accepted visible overlay measurement immediately refreshes Linux pointer i
assert.ok(measurementBlock);
assert.match(measurementBlock, /overlayContentMeasurementStore\.report\(payload\)/);
assert.match(measurementBlock, /tickLinuxOverlayPointerInteractionNow\(\)/);
assert.match(measurementBlock, /primeLinuxOverlayPointerInteractionAfterFirstMeasurement\(\)/);
assert.ok(
measurementBlock.indexOf('overlayContentMeasurementStore.report(payload)') <
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();'),
);
assert.ok(
measurementBlock.indexOf('tickLinuxOverlayPointerInteractionNow();') <
measurementBlock.indexOf('primeLinuxOverlayPointerInteractionAfterFirstMeasurement();'),
);
});
test('subtitle sidebar open state is restored for replacement visible overlay windows', () => {
@@ -216,11 +313,14 @@ test('subtitle sidebar open state is restored for replacement visible overlay wi
assert.match(depsBlock, /subtitleSidebarRequestedOpen/);
});
test('warm tokenization release reuses current subtitle payload instead of synthetic readiness', () => {
test('warm tokenization release can signal readiness before the first subtitle appears', () => {
const source = readMainSource();
const warmReleaseBlock = source.match(
/signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
const signalBlock = source.match(
/function signalCurrentSubtitleAutoplayReady\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const currentPayloadBlock = source.match(
/function getCurrentAutoplaySubtitlePayload\(\): SubtitleData \| null \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
@@ -230,7 +330,12 @@ test('warm tokenization release reuses current subtitle payload instead of synth
warmReleaseBlock,
/signalAutoplayReady: \(\) => signalCurrentSubtitleAutoplayReady\(\)/,
);
assert.doesNotMatch(warmReleaseBlock, /__warm__/);
assert.ok(signalBlock);
assert.match(signalBlock, /const payload = getCurrentAutoplaySubtitlePayload\(\);/);
assert.match(signalBlock, /if \(payload\) \{/);
assert.match(signalBlock, /if \(!appState\.currentSubText\.trim\(\)\) \{/);
assert.match(signalBlock, /text: '__warm__'/);
assert.ok(currentPayloadBlock);
assert.match(currentPayloadBlock, /appState\.currentSubtitleData/);
@@ -247,7 +352,10 @@ test('stats server Yomitan note creation honors configured Anki server override
)?.groups?.body;
assert.ok(addYomitanNoteBlock);
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
assert.match(
addYomitanNoteBlock,
/const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/,
);
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
});
@@ -321,6 +429,49 @@ test('manual visible overlay changes notify mpv plugin visibility state', () =>
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
});
test('manual visible overlay hide dismisses loading OSD', () => {
const source = readMainSource();
const setBlock = source.match(
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const toggleBlock = source.match(
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const setOverlayBlock = source.match(
/function setOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(setBlock);
assert.ok(toggleBlock);
assert.ok(setOverlayBlock);
assert.match(setBlock, /if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/);
assert.match(
toggleBlock,
/if \(!nextVisible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
assert.match(
setOverlayBlock,
/if \(!visible\) \{[\s\S]*?dismissOverlayLoadingStatusNotification\(\);/,
);
});
test('configured overlay notifications require visible ready overlay window', () => {
const source = readMainSource();
const readinessBlock = source.match(
/function isVisibleOverlayContentReady\(\): boolean \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
const statusBlock = source.match(
/function showConfiguredStatusNotification\([\s\S]*?\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body;
assert.ok(readinessBlock);
assert.ok(statusBlock);
assert.match(readinessBlock, /overlayManager\.getVisibleOverlayVisible\(\)/);
assert.match(readinessBlock, /isOverlayWindowReadyForNotification\(overlayWindow\)/);
assert.doesNotMatch(readinessBlock, /isOverlayWindowContentReady\(overlayWindow\)/);
assert.match(statusBlock, /isOverlayReady: \(\) => isVisibleOverlayContentReady\(\)/);
});
test('manual visible overlay show primes current subtitle from mpv before relying on live events', () => {
const source = readMainSource();
const setBlock = source.match(
@@ -334,11 +485,11 @@ test('manual visible overlay show primes current subtitle from mpv before relyin
assert.ok(toggleBlock);
assert.match(
setBlock,
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
);
assert.match(
toggleBlock,
/else \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
/else \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);\s+void primeCurrentSubtitleForVisibleOverlay\(\);/,
);
});
@@ -357,7 +508,7 @@ test('Linux visible overlay show/reset does not leave an empty X11 window shape'
assert.doesNotMatch(source, /setShape\?\.\(\[\]\)|setShape\(\[\]\)/);
assert.match(
setBlock,
/if \(visible\) \{\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
/if \(visible\) \{\s+maybeStartOverlayLoadingOsd\(\);\s+resetLinuxVisibleOverlayStartupInputPrimer\(\);\s+restoreVisibleOverlayWindowShapeForShow\(\);\s+void ensureOverlayMpvSubtitlesHidden\(\);/,
);
});
+2
View File
@@ -30,6 +30,7 @@ export interface OverlayVisibilityRuntimeDeps {
isMacOSPlatform: () => boolean;
isWindowsPlatform: () => boolean;
showOverlayLoadingOsd: (message: string) => void;
dismissOverlayLoadingOsd?: () => void;
resolveFallbackBounds: () => WindowGeometry;
hideNonNativeOverlayWhenTargetUnfocused?: () => boolean;
}
@@ -80,6 +81,7 @@ export function createOverlayVisibilityRuntimeService(
isMacOSPlatform: deps.isMacOSPlatform(),
isWindowsPlatform: deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(),
shouldShowOverlayLoadingOsd: () =>
lastOverlayLoadingOsdAtMs === null ||
Date.now() - lastOverlayLoadingOsdAtMs >= OVERLAY_LOADING_OSD_COOLDOWN_MS,
+2 -2
View File
@@ -330,7 +330,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
assert.deepEqual(calls, []);
});
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already handled current attempt key', async () => {
const calls: string[] = [];
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
@@ -378,5 +378,5 @@ test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after ret
assert.equal(calls.includes('update'), false);
assert.equal(calls.includes('enqueue'), false);
assert.equal(calls.includes('mark-failure'), false);
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']);
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
});
+4 -1
View File
@@ -194,8 +194,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
return;
}
await deps.processNextAnilistRetryUpdate();
const retryResult = await deps.processNextAnilistRetryUpdate();
if (deps.hasAttemptedUpdateKey(attemptKey)) {
if (retryResult.ok) {
deps.showMpvOsd(retryResult.message);
}
return;
}
@@ -23,6 +23,18 @@ test('notify anilist setup main deps builder maps callbacks', () => {
assert.deepEqual(calls, ['osd:ok', 'notify:SubMiner', 'log:done']);
});
test('notify anilist setup main deps builder preserves optional notification callbacks', () => {
const deps = createBuildNotifyAnilistSetupMainDepsHandler({
hasMpvClient: () => true,
showMpvOsd: () => {},
showDesktopNotification: () => {},
logInfo: () => {},
})();
assert.equal(deps.getNotificationType, undefined);
assert.equal(deps.showOverlayNotification, undefined);
});
test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
@@ -18,8 +18,12 @@ type RegisterSubminerProtocolClientMainDeps = Parameters<
export function createBuildNotifyAnilistSetupMainDepsHandler(deps: NotifyAnilistSetupMainDeps) {
return (): NotifyAnilistSetupMainDeps => ({
getNotificationType: deps.getNotificationType ? () => deps.getNotificationType?.() : undefined,
hasMpvClient: () => deps.hasMpvClient(),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showOverlayNotification: deps.showOverlayNotification
? (payload) => deps.showOverlayNotification?.(payload)
: undefined,
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
logInfo: (message: string) => deps.logInfo(message),
@@ -19,6 +19,24 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
assert.deepEqual(calls, ['osd:AniList login success']);
});
test('createNotifyAnilistSetupHandler routes through configured notification surfaces', () => {
const calls: string[] = [];
const notify = createNotifyAnilistSetupHandler({
getNotificationType: () => 'both',
hasMpvClient: () => true,
showMpvOsd: (message) => calls.push(`osd:${message}`),
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.title}:${payload.body}:${payload.variant}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
logInfo: () => calls.push('log'),
});
notify('AniList login success');
assert.deepEqual(calls, [
'overlay:SubMiner AniList:AniList login success:success',
'notify:SubMiner AniList:AniList login success',
]);
});
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
@@ -1,3 +1,5 @@
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string;
@@ -30,12 +32,35 @@ export function createConsumeAnilistSetupTokenFromUrlHandler(deps: ConsumeAnilis
}
export function createNotifyAnilistSetupHandler(deps: {
getNotificationType?: () => NotificationType | undefined;
hasMpvClient: () => boolean;
showMpvOsd: (message: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logInfo: (message: string) => void;
}) {
return (message: string): void => {
const type = deps.getNotificationType?.();
if (type) {
if (type === 'none') {
return;
}
if (type === 'overlay' || type === 'both') {
deps.showOverlayNotification?.({
title: 'SubMiner AniList',
body: message,
variant: 'success',
});
}
if ((type === 'osd' || type === 'osd-system') && deps.hasMpvClient()) {
deps.showMpvOsd(message);
}
if (type === 'system' || type === 'both' || type === 'osd-system') {
deps.showDesktopNotification('SubMiner AniList', { body: message });
}
return;
}
if (deps.hasMpvClient()) {
deps.showMpvOsd(message);
return;
+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()}`);
}
}
@@ -48,6 +48,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
startBackgroundWarmups: () => calls.push('start-warmups'),
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => true,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
setVisibleOverlayVisible: () => calls.push('set-visible-overlay'),
initializeOverlayRuntime: () => calls.push('init-overlay'),
handleInitialArgs: () => calls.push('handle-initial-args'),
@@ -64,6 +65,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async
assert.equal(onReady.defaultTexthookerPort, 5174);
assert.equal(onReady.texthookerOnlyMode, false);
assert.equal(onReady.shouldAutoInitializeOverlayRuntimeFromConfig(), true);
assert.equal(onReady.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.(), true);
assert.equal(onReady.now?.(), 123);
onReady.loadSubtitlePosition();
onReady.resolveKeybindings();
+2
View File
@@ -45,5 +45,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
shouldRunHeadlessInitialCommand: deps.shouldRunHeadlessInitialCommand,
shouldUseMinimalStartup: deps.shouldUseMinimalStartup,
shouldSkipHeavyStartup: deps.shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup:
deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
});
}
@@ -314,6 +314,100 @@ test('autoplay ready gate defers plugin readiness until the signal target is rea
);
});
test('autoplay ready gate retries deferred readiness without an external flush event', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
let targetReady = false;
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => targetReady,
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
assert.equal(scheduled.length, 1);
targetReady = true;
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
true,
);
});
test('autoplay ready gate keeps deferred startup readiness retries active for cold starts', async () => {
const commands: Array<Array<string | boolean>> = [];
const scheduled: Array<() => void> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isSignalTargetReady: () => false,
schedule: (callback) => {
scheduled.push(callback);
return 1 as never;
},
logDebug: () => {},
});
gate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
for (let attempt = 1; attempt <= 100; attempt += 1) {
assert.equal(scheduled.length, 1, `missing deferred readiness retry ${attempt}`);
scheduled.shift()?.();
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(commands, []);
});
test('autoplay ready gate drops deferred readiness after media changes before flush', async () => {
const commands: Array<Array<string | boolean>> = [];
let targetReady = false;
+48 -6
View File
@@ -1,6 +1,9 @@
import type { SubtitleData } from '../../types';
import { resolveAutoplayReadyMaxReleaseAttempts } from './startup-autoplay-release-policy';
const PENDING_AUTOPLAY_READY_RETRY_DELAY_MS = 200;
const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 150;
type MpvClientLike = {
connected?: boolean;
requestProperty: (property: string) => Promise<unknown>;
@@ -34,12 +37,22 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
let autoPlayReadySignalMediaPath: string | null = null;
let autoPlayReadySignalGeneration = 0;
let pendingAutoplayReadySignal: AutoplayReadySignal | null = null;
let pendingAutoplayReadyRetryToken = 0;
let pendingAutoplayReadyRetryAttempts = 0;
let scheduledPendingAutoplayReadyRetryToken: number | null = null;
const now = deps.now ?? (() => Date.now());
const invalidatePendingAutoplayReadyRetry = (): void => {
pendingAutoplayReadyRetryToken += 1;
pendingAutoplayReadyRetryAttempts = 0;
scheduledPendingAutoplayReadyRetryToken = null;
};
const invalidatePendingAutoplayReadyFallbacks = (): void => {
autoPlayReadySignalMediaPath = null;
pendingAutoplayReadySignal = null;
autoPlayReadySignalGeneration += 1;
invalidatePendingAutoplayReadyRetry();
};
const isSignalTargetReady = (signal: AutoplayReadySignal): boolean =>
@@ -52,18 +65,43 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
pendingAutoplayReadySignal = null;
autoPlayReadySignalMediaPath = getSignalMediaPath();
autoPlayReadySignalGeneration += 1;
invalidatePendingAutoplayReadyRetry();
};
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
const setPendingAutoplayReadySignal = (signal: AutoplayReadySignal): boolean => {
if (
pendingAutoplayReadySignal &&
pendingAutoplayReadySignal.mediaPath === signal.mediaPath &&
pendingAutoplayReadySignal.payload.text === signal.payload.text &&
pendingAutoplayReadySignal.requestedAtMs <= signal.requestedAtMs
) {
return;
return false;
}
pendingAutoplayReadySignal = signal;
pendingAutoplayReadyRetryAttempts = 0;
return true;
};
const schedulePendingAutoplayReadyRetry = (): void => {
if (scheduledPendingAutoplayReadyRetryToken === pendingAutoplayReadyRetryToken) {
return;
}
if (pendingAutoplayReadyRetryAttempts >= MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS) {
return;
}
const retryToken = pendingAutoplayReadyRetryToken;
pendingAutoplayReadyRetryAttempts += 1;
scheduledPendingAutoplayReadyRetryToken = retryToken;
deps.schedule(() => {
if (scheduledPendingAutoplayReadyRetryToken === retryToken) {
scheduledPendingAutoplayReadyRetryToken = null;
}
if (retryToken !== pendingAutoplayReadyRetryToken || !pendingAutoplayReadySignal) {
return;
}
flushPendingAutoplayReadySignal();
}, PENDING_AUTOPLAY_READY_RETRY_DELAY_MS);
};
const releaseAutoplayReadySignal = (signal: AutoplayReadySignal): void => {
@@ -139,6 +177,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
};
pendingAutoplayReadySignal = null;
invalidatePendingAutoplayReadyRetry();
autoPlayReadySignalMediaPath = mediaPath;
const playbackGeneration = ++autoPlayReadySignalGeneration;
deps.signalPluginAutoplayReady();
@@ -152,10 +191,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
return;
}
if (!isSignalTargetReady(signal)) {
setPendingAutoplayReadySignal(signal);
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
);
const pendingSignalChanged = setPendingAutoplayReadySignal(signal);
schedulePendingAutoplayReadyRetry();
if (pendingSignalChanged) {
deps.logDebug(
`[autoplay-ready] deferred until signal target is ready for media ${signal.mediaPath}`,
);
}
return;
}
@@ -4,6 +4,7 @@ import {
notifyCharacterDictionaryAutoSyncStatus,
type CharacterDictionaryAutoSyncNotificationEvent,
} from './character-dictionary-auto-sync-notifications';
import { createStartupOsdSequencer } from './startup-osd-sequencer';
function makeEvent(
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
@@ -70,7 +71,7 @@ test('auto sync notifications send osd updates for progress phases', () => {
]);
});
test('auto sync notifications never send desktop notifications', () => {
test('auto sync notifications send overlay and desktop delivery for both', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
@@ -80,14 +81,10 @@ test('auto sync notifications never send desktop notifications', () => {
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) =>
calls.push(
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
getNotificationType: () => 'both',
@@ -96,9 +93,25 @@ test('auto sync notifications never send desktop notifications', () => {
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) =>
calls.push(
`overlay:${payload.id}:${payload.historyId}:${payload.title}:${payload.body}:${payload.persistent ? 'pin' : 'auto'}`,
),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
getNotificationType: () => 'both',
assert.deepEqual(calls, [
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-syncing:Character dictionary:syncing:pin',
'desktop:SubMiner:syncing',
'overlay:character-dictionary-auto-sync:character-dictionary-auto-sync-101291-ready:Character dictionary:ready:auto',
'desktop:SubMiner:ready',
]);
});
test('auto sync notifications fall back to desktop when overlay routing is unavailable', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('building', 'building'), {
getNotificationType: () => undefined,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
@@ -106,14 +119,30 @@ test('auto sync notifications never send desktop notifications', () => {
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
assert.deepEqual(calls, ['desktop:SubMiner:building']);
});
test('auto sync notifications fall back to desktop for long progress when osd is unavailable', () => {
test('auto sync notifications keep osd-system on legacy surfaces', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
showOverlayNotification: (payload) => calls.push(`overlay:${payload.body}`),
});
assert.deepEqual(calls, ['osd:syncing', 'desktop:SubMiner:syncing']);
});
test('auto sync notifications keep osd-system desktop delivery even when osd is unavailable', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
getNotificationType: () => 'both',
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
return false;
@@ -122,7 +151,7 @@ test('auto sync notifications fall back to desktop for long progress when osd is
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
getNotificationType: () => 'both',
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
return false;
@@ -131,14 +160,19 @@ test('auto sync notifications fall back to desktop for long progress when osd is
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:generating', 'desktop:SubMiner:generating', 'osd:ready']);
assert.deepEqual(calls, [
'osd:generating',
'desktop:SubMiner:generating',
'osd:ready',
'desktop:SubMiner:ready',
]);
});
test('auto sync notifications fall back to desktop when startup sequencer cannot show osd', () => {
test('auto sync notifications send osd-system desktop updates with startup sequencer', () => {
const calls: string[] = [];
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'both',
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
@@ -154,3 +188,29 @@ test('auto sync notifications fall back to desktop when startup sequencer cannot
assert.deepEqual(calls, ['sequencer:importing:importing', 'desktop:SubMiner:importing']);
});
test('auto sync notifications let startup sequencer own osd-system desktop delivery', () => {
const calls: string[] = [];
const startupOsdSequencer = createStartupOsdSequencer({
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body ?? ''}`);
},
});
startupOsdSequencer.markTokenizationReady();
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`direct-osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`direct-desktop:${title}:${options.body ?? ''}`),
startupOsdSequencer,
});
assert.deepEqual(calls, ['osd:importing', 'desktop:SubMiner:importing']);
});
@@ -1,11 +1,14 @@
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
export interface CharacterDictionaryAutoSyncNotificationDeps {
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
getNotificationType: () => NotificationType | undefined;
showOsd: (message: string) => boolean | void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
startupOsdSequencer?: {
notifyCharacterDictionaryStatus: (
@@ -14,39 +17,58 @@ export interface CharacterDictionaryAutoSyncNotificationDeps {
};
}
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
return type !== 'none';
function isTerminalPhase(phase: CharacterDictionaryAutoSyncNotificationEvent['phase']): boolean {
return phase === 'ready' || phase === 'failed';
}
function shouldFallbackToDesktop(
type: 'osd' | 'system' | 'both' | 'none' | undefined,
function overlayVariantForPhase(
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
): boolean {
return (
(type === 'system' || type === 'both') &&
(phase === 'generating' || phase === 'building' || phase === 'importing')
);
): OverlayNotificationPayload['variant'] {
if (phase === 'ready') return 'success';
if (phase === 'failed') return 'error';
return 'progress';
}
function historyIdForEvent(event: CharacterDictionaryAutoSyncNotificationEvent): string {
const mediaId = typeof event.mediaId === 'number' ? String(event.mediaId) : 'current';
return `character-dictionary-auto-sync-${mediaId}-${event.phase}`;
}
export function notifyCharacterDictionaryAutoSyncStatus(
event: CharacterDictionaryAutoSyncNotificationEvent,
deps: CharacterDictionaryAutoSyncNotificationDeps,
): void {
const type = deps.getNotificationType();
if (shouldShowOsd(type)) {
if (deps.startupOsdSequencer) {
const shown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
const type = deps.getNotificationType() ?? 'overlay';
if (type === 'none') return;
let startupSequencerShown = false;
if (shouldShowOverlay(type)) {
if (deps.showOverlayNotification) {
deps.showOverlayNotification({
id: 'character-dictionary-auto-sync',
historyId: historyIdForEvent(event),
title: 'Character dictionary',
body: event.message,
variant: overlayVariantForPhase(event.phase),
persistent: !isTerminalPhase(event.phase),
});
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
return;
}
const shown = deps.showOsd(event.message) !== false;
if (!shown && shouldFallbackToDesktop(type, event.phase)) {
} else if (!shouldShowDesktop(type)) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}
if (shouldShowOsd(type)) {
if (deps.startupOsdSequencer) {
startupSequencerShown = deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
phase: event.phase,
message: event.message,
});
} else {
deps.showOsd(event.message);
}
}
if (shouldShowDesktop(type) && !startupSequencerShown) {
deps.showDesktopNotification('SubMiner', { body: event.message });
}
}
@@ -18,6 +18,8 @@ function makeDeps(options: {
getNotificationType: () => options.notificationType ?? 'osd',
openManager: () => calls.push('open'),
showOsd: (message: string) => calls.push(`osd:${message}`),
showOverlayNotification: (payload: { title: string; body?: string }) =>
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`),
showDesktopNotification: (title: string, opts: { body: string }) =>
calls.push(`system:${title}:${opts.body}`),
logWarn: (message: string) => calls.push(`warn:${message}`),
@@ -39,6 +41,13 @@ test('routes disabled manager notification to configured surfaces', () => {
['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
[
'both',
[
`overlay:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
],
],
[
'osd-system',
[
`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
@@ -1,4 +1,6 @@
export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none';
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
export type CharacterDictionaryManagerNotificationType = NotificationType;
export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE =
'Enable Name Match in Settings to use the character dictionary manager.';
@@ -8,16 +10,27 @@ export interface CharacterDictionaryManagerGateDeps {
getNotificationType: () => CharacterDictionaryManagerNotificationType;
openManager: () => void;
showOsd: (message: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logWarn?: (message: string, error?: unknown) => void;
}
function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void {
const type = deps.getNotificationType();
if (type === 'osd' || type === 'both') {
if (type === 'none') {
return;
}
if (type === 'overlay' || type === 'both') {
deps.showOverlayNotification?.({
title: 'SubMiner',
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
variant: 'warning',
});
}
if (type === 'osd' || type === 'osd-system') {
deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE);
}
if (type === 'system' || type === 'both') {
if (type === 'system' || type === 'both' || type === 'osd-system') {
try {
deps.showDesktopNotification('SubMiner', {
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
@@ -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,
@@ -58,6 +58,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => ({}) as never,
saveControllerConfig: () => {},
saveControllerPreference: () => {},
@@ -265,6 +265,23 @@ test('createConfigHotReloadMessageHandler mirrors message to OSD and desktop not
assert.deepEqual(calls, ['osd:Config reload failed', 'notify:SubMiner:Config reload failed']);
});
test('createConfigHotReloadMessageHandler routes message through configured notification surfaces', () => {
const calls: string[] = [];
const handleMessage = createConfigHotReloadMessageHandler({
getNotificationType: () => 'both',
showMpvOsd: (message) => calls.push(`osd:${message}`),
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.title}:${payload.body}:${payload.variant}`),
showDesktopNotification: (title, options) => calls.push(`notify:${title}:${options.body}`),
});
handleMessage('Config reload failed');
assert.deepEqual(calls, [
'overlay:SubMiner:Config reload failed:warning',
'notify:SubMiner:Config reload failed',
]);
});
test('buildRestartRequiredConfigMessage formats changed fields', () => {
assert.equal(
buildRestartRequiredConfigMessage(['websocket', 'subtitleStyle']),
+20 -2
View File
@@ -5,6 +5,7 @@ import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config';
import type { AnkiConnectConfig } from '../../types/anki';
import type { ConfigHotReloadPayload, ResolvedConfig, SecondarySubMode } from '../../types';
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
type ConfigHotReloadAppliedDeps = {
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => void;
@@ -26,7 +27,9 @@ type ConfigHotReloadAppliedDeps = {
};
type ConfigHotReloadMessageDeps = {
getNotificationType?: () => NotificationType | undefined;
showMpvOsd: (message: string) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
};
@@ -183,8 +186,23 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
export function createConfigHotReloadMessageHandler(deps: ConfigHotReloadMessageDeps) {
return (message: string): void => {
deps.showMpvOsd(message);
deps.showDesktopNotification('SubMiner', { body: message });
const type = deps.getNotificationType?.() ?? 'osd-system';
if (type === 'none') {
return;
}
if (type === 'overlay' || type === 'both') {
deps.showOverlayNotification?.({
title: 'SubMiner',
body: message,
variant: 'warning',
});
}
if (type === 'osd' || type === 'osd-system') {
deps.showMpvOsd(message);
}
if (type === 'system' || type === 'both' || type === 'osd-system') {
deps.showDesktopNotification('SubMiner', { body: message });
}
};
}
@@ -55,7 +55,9 @@ export function createBuildConfigHotReloadMessageMainDepsHandler(
deps: ConfigHotReloadMessageMainDeps,
) {
return (): ConfigHotReloadMessageMainDeps => ({
getNotificationType: () => deps.getNotificationType?.(),
showMpvOsd: (message: string) => deps.showMpvOsd(message),
showOverlayNotification: (payload) => deps.showOverlayNotification?.(payload),
showDesktopNotification: (title: string, options: { body: string }) =>
deps.showDesktopNotification(title, options),
});
@@ -0,0 +1,439 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
getPlaybackFeedbackNotificationOptions,
notifyConfiguredStatus,
} from './configured-status-notification';
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = [];
notifyConfiguredStatus('Subsync: choose engine and source', {
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(
`overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, [
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
'desktop:SubMiner:Subsync: choose engine and source',
]);
});
test('notifyConfiguredStatus falls back to desktop for pre-overlay both status', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'both',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
});
test('notifyConfiguredStatus falls back to desktop for pre-overlay overlay-only status', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'overlay',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
});
test('notifyConfiguredStatus routes pre-overlay system status to desktop only', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'system',
isOverlayReady: () => false,
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.id ?? ''}:${payload.body ?? ''}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:Overlay loading...']);
});
test('notifyConfiguredStatus keeps osd-system on legacy surfaces', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay loading...', {
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['osd:Overlay loading...', 'desktop:SubMiner:Overlay loading...']);
});
test('notifyConfiguredStatus can suppress desktop delivery for progress ticks', () => {
const calls: string[] = [];
notifyConfiguredStatus(
'Subsync: syncing |',
{
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(
`overlay:${payload.id ?? ''}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
{
id: 'subsync-status',
title: 'Subsync',
variant: 'progress',
persistent: true,
desktop: false,
},
);
assert.deepEqual(calls, ['overlay:subsync-status:Subsync:Subsync: syncing |:progress:pin']);
});
test('notifyConfiguredStatus routes feedback through overlay without desktop delivery', () => {
const calls: string[] = [];
notifyConfiguredStatus(
'Primary subtitle: hover',
{
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) =>
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`),
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
{ delivery: 'feedback' },
);
assert.deepEqual(calls, ['overlay:SubMiner:Primary subtitle: hover']);
});
test('notifyConfiguredStatus routes osd-system feedback through osd only', () => {
const calls: string[] = [];
notifyConfiguredStatus(
'Secondary subtitle: visible',
{
getNotificationType: () => 'osd-system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
{ delivery: 'feedback' },
);
assert.deepEqual(calls, ['osd:Secondary subtitle: visible']);
});
test('notifyConfiguredStatus suppresses system-only feedback', () => {
const calls: string[] = [];
notifyConfiguredStatus(
'Primary subtitle: visible',
{
getNotificationType: () => 'system',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
},
{ delivery: 'feedback' },
);
assert.deepEqual(calls, []);
});
test('playback feedback options reuse subtitle mode notification ids', () => {
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Primary subtitle: hover'), {
id: 'primary-subtitle-mode-feedback',
});
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle: hidden'), {
id: 'secondary-subtitle-mode-feedback',
});
assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {});
});
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
const calls: string[] = [];
notifyConfiguredStatus('Overlay unavailable.', {
getNotificationType: () => 'overlay',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showDesktopNotification: (title, options) =>
calls.push(`desktop:${title}:${options.body ?? ''}`),
});
assert.deepEqual(calls, ['desktop:SubMiner:Overlay unavailable.']);
});
test('overlay notification delivery queues until an overlay window is ready', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
});
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
delivery.send({ id: 'character-dictionary-auto-sync', title: 'Dictionary', body: 'Building' });
assert.equal(delivery.getQueuedCount(), 2);
assert.deepEqual(sent, []);
ready = true;
delivery.flush();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, [
'startup-tokenization:Loading',
'character-dictionary-auto-sync:Building',
]);
});
test('overlay notification delivery upserts queued progress by notification id', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
});
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '|' });
delivery.send({ id: 'startup-subtitle-annotations', title: 'Subtitle annotations', body: '/' });
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Ready' });
ready = true;
delivery.flush();
assert.deepEqual(sent, ['startup-subtitle-annotations:/', 'startup-tokenization:Ready']);
});
test('overlay notification delivery preserves queued events with distinct history ids', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'historyId' in payload ? payload.historyId : ''}:${'body' in payload ? payload.body : ''}`,
),
});
delivery.send({
id: 'character-dictionary-auto-sync',
historyId: 'character-dictionary-auto-sync-checking',
title: 'Character dictionary',
body: 'Checking character dictionary...',
persistent: true,
});
delivery.send({
id: 'character-dictionary-auto-sync',
historyId: 'character-dictionary-auto-sync-building',
title: 'Character dictionary',
body: 'Building character dictionary...',
persistent: true,
});
ready = true;
delivery.flush();
assert.deepEqual(sent, [
'character-dictionary-auto-sync:character-dictionary-auto-sync-checking:Checking character dictionary...',
'character-dictionary-auto-sync:character-dictionary-auto-sync-building:Building character dictionary...',
]);
});
test('overlay notification delivery preserves queued startup progress before terminal update', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
),
scheduleFlushRetry: (callback) => {
scheduled.push(callback);
},
});
delivery.send({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
body: 'Loading subtitle tokenization...',
variant: 'progress',
persistent: true,
});
delivery.send({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
body: 'Subtitle tokenization ready',
variant: 'success',
persistent: false,
});
ready = true;
delivery.flush();
scheduled.shift()?.();
assert.deepEqual(sent, [
'startup-tokenization:Loading subtitle tokenization...:pin',
'startup-tokenization:Subtitle tokenization ready:auto',
]);
});
test('overlay notification delivery defers terminal update after first queued progress paint', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
const delays: number[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push(
`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}:${'persistent' in payload && payload.persistent ? 'pin' : 'auto'}`,
),
scheduleFlushRetry: (callback, delayMs) => {
scheduled.push(callback);
delays.push(delayMs);
},
terminalUpdateDelayMs: 750,
});
delivery.send({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
body: 'Loading subtitle annotations |',
variant: 'progress',
persistent: true,
});
delivery.send({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
body: 'Subtitle annotations loaded',
variant: 'success',
persistent: false,
});
ready = true;
delivery.flush();
assert.deepEqual(sent, ['startup-subtitle-annotations:Loading subtitle annotations |:pin']);
assert.equal(delivery.getQueuedCount(), 1);
assert.deepEqual(delays, [750]);
scheduled.shift()?.();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, [
'startup-subtitle-annotations:Loading subtitle annotations |:pin',
'startup-subtitle-annotations:Subtitle annotations loaded:auto',
]);
});
test('overlay notification delivery retries flush when lifecycle fires before window readiness settles', () => {
const sent: string[] = [];
const scheduled: Array<() => void> = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) => sent.push(`${payload.id ?? ''}:${'body' in payload ? payload.body : ''}`),
scheduleFlushRetry: (callback) => {
scheduled.push(callback);
},
});
delivery.send({ id: 'startup-tokenization', title: 'Subtitle tokenization', body: 'Loading' });
delivery.flush();
assert.equal(delivery.getQueuedCount(), 1);
assert.equal(scheduled.length, 1);
assert.deepEqual(sent, []);
ready = true;
scheduled.shift()?.();
assert.equal(delivery.getQueuedCount(), 0);
assert.deepEqual(sent, ['startup-tokenization:Loading']);
});
test('overlay notification delivery drops queued notification when dismissed before flush', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
});
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
delivery.send({ id: 'overlay-loading-status', dismiss: true });
ready = true;
delivery.flush();
assert.deepEqual(sent, []);
});
test('overlay notification delivery removes queued notification when dismissed at readiness', () => {
const sent: string[] = [];
let ready = false;
const delivery = createOverlayNotificationDelivery({
hasReadyOverlayWindow: () => ready,
send: (payload) =>
sent.push('dismiss' in payload ? `dismiss:${payload.id}` : `show:${payload.id ?? ''}`),
});
delivery.send({ id: 'overlay-loading-status', title: 'SubMiner', body: 'Overlay loading' });
ready = true;
delivery.send({ id: 'overlay-loading-status', dismiss: true });
delivery.flush();
assert.deepEqual(sent, ['dismiss:overlay-loading-status']);
});
@@ -0,0 +1,74 @@
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
export interface ConfiguredStatusNotificationDeps {
getNotificationType: () => NotificationType | undefined;
isOverlayReady?: () => boolean;
showOsd: (message: string) => boolean | void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification: (title: string, options: { body?: string }) => void;
}
export interface ConfiguredStatusNotificationOptions {
id?: string;
title?: string;
variant?: OverlayNotificationPayload['variant'];
persistent?: boolean;
desktop?: boolean;
delivery?: 'notification' | 'feedback';
}
export function getPlaybackFeedbackNotificationOptions(
message: string,
): ConfiguredStatusNotificationOptions {
if (/^Primary subtitle: (hidden|visible|hover)$/.test(message)) {
return { id: 'primary-subtitle-mode-feedback' };
}
if (/^Secondary subtitle: (hidden|visible|hover)$/.test(message)) {
return { id: 'secondary-subtitle-mode-feedback' };
}
return {};
}
export function notifyConfiguredStatus(
message: string,
deps: ConfiguredStatusNotificationDeps,
options: ConfiguredStatusNotificationOptions = {},
): void {
const type = deps.getNotificationType() ?? 'overlay';
const delivery = options.delivery ?? 'notification';
const showOverlay = shouldShowOverlay(type);
const showOsd = shouldShowOsd(type);
const desktopEnabled = delivery !== 'feedback' && options.desktop !== false;
if (type === 'none') {
return;
}
if (delivery === 'feedback' && !showOverlay && !showOsd) {
return;
}
if (showOverlay) {
const overlayReady = deps.isOverlayReady?.() ?? true;
if (deps.showOverlayNotification && overlayReady) {
deps.showOverlayNotification({
id: options.id,
title: options.title ?? 'SubMiner',
body: message,
variant: options.variant ?? 'info',
persistent: options.persistent ?? false,
});
} else if (desktopEnabled && !shouldShowDesktop(type)) {
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
}
}
if (showOsd) {
deps.showOsd(message);
}
if (desktopEnabled && shouldShowDesktop(type)) {
deps.showDesktopNotification(options.title ?? 'SubMiner', { body: message });
}
}
@@ -62,6 +62,40 @@ test('renderer current subtitle snapshot tokenizes uncached subtitles when token
assert.deepEqual(payload.tokens, [{ text: '新' }]);
});
test('renderer current subtitle snapshot can skip cold tokenizer for first paint', async () => {
let tokenizerCalled = false;
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: 'まだキャッシュされていない字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
tokenizeUncached: false,
tokenizeSubtitle: async (text) => {
tokenizerCalled = true;
return { text, tokens: [{ text: 'ま' } as never] };
},
});
assert.equal(tokenizerCalled, false);
assert.equal(payload.text, 'まだキャッシュされていない字幕');
assert.equal(payload.startTime, 1);
assert.equal(payload.tokens, null);
});
test('renderer current subtitle snapshot reports resolved payload for startup readiness', async () => {
const resolvedPayloads: SubtitleData[] = [];
const payload = await resolveCurrentSubtitleForRenderer({
currentSubText: '起動字幕',
currentSubtitleData: null,
withCurrentSubtitleTiming: withTiming,
tokenizeSubtitle: async (text) => ({ text, tokens: [{ text: '起' } as never] }),
onResolvedSubtitle: (resolved) => {
resolvedPayloads.push(resolved);
},
});
assert.deepEqual(resolvedPayloads, [payload]);
});
test('visible overlay subtitle prime refreshes current text from mpv before showing overlay', async () => {
const calls: string[] = [];
@@ -84,6 +118,29 @@ test('visible overlay subtitle prime refreshes current text from mpv before show
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から', 'refresh:国内外から']);
});
test('visible overlay subtitle prime can defer uncached tokenization until after first paint', async () => {
const calls: string[] = [];
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
calls.push(`request:${name}`);
return '国内外から';
},
}),
setCurrentSubText: (text) => calls.push(`set:${text}`),
getCurrentSubtitleData: () => null,
consumeCachedSubtitle: () => null,
onSubtitleChange: (text) => calls.push(`change:${text}`),
refreshCurrentSubtitle: (text) => calls.push(`refresh:${text}`),
emitSubtitle: (payload) => calls.push(`emit:${payload.text}`),
deferUncachedRefresh: true,
});
assert.deepEqual(calls, ['request:sub-text', 'set:国内外から']);
});
test('visible overlay subtitle prime repaints cached current subtitle immediately', async () => {
const calls: string[] = [];
const cachedPayload: SubtitleData = { text: '字幕', tokens: [{ text: '字' } as never] };
+22 -6
View File
@@ -10,24 +10,34 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
currentSubtitleData: SubtitleData | null;
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
tokenizeSubtitle?: (text: string) => Promise<SubtitleData | null>;
tokenizeUncached?: boolean;
onResolvedSubtitle?: (payload: SubtitleData) => void;
}): Promise<SubtitleData> {
const resolve = (payload: SubtitleData): SubtitleData => {
const timedPayload = deps.withCurrentSubtitleTiming(payload);
deps.onResolvedSubtitle?.(timedPayload);
return timedPayload;
};
if (deps.currentSubtitleData?.text === deps.currentSubText) {
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
return resolve(deps.currentSubtitleData);
}
if (!deps.currentSubText.trim()) {
return deps.withCurrentSubtitleTiming({
return resolve({
text: deps.currentSubText,
tokens: null,
});
}
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
if (tokenized) {
return deps.withCurrentSubtitleTiming(tokenized);
if (deps.tokenizeUncached !== false) {
const tokenized = await deps.tokenizeSubtitle?.(deps.currentSubText);
if (tokenized) {
return resolve(tokenized);
}
}
return deps.withCurrentSubtitleTiming({
return resolve({
text: deps.currentSubText,
tokens: null,
});
@@ -41,6 +51,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (text: string) => void;
emitSubtitle: (payload: SubtitleData) => void;
deferUncachedRefresh?: boolean;
setCurrentSecondarySubText?: (text: string) => void;
emitSecondarySubtitle?: (text: string) => void;
logDebug?: (message: string) => void;
@@ -107,6 +118,11 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
return;
}
if (deps.deferUncachedRefresh === true) {
await primeSecondarySubtitle();
return;
}
deps.refreshCurrentSubtitle(text);
await primeSecondarySubtitle();
}
@@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
};
}
@@ -27,6 +27,7 @@ function createShortcuts(): ConfiguredShortcuts {
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
};
}
@@ -16,6 +16,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
showPlaybackFeedback: (text) => calls.push(`feedback:${text}`),
replayCurrentSubtitle: () => calls.push('replay'),
playNextSubtitle: () => calls.push('next'),
shiftSubDelayToAdjacentSubtitle: async (direction) => {
@@ -34,6 +35,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.showPlaybackFeedback?.('primary');
deps.replayCurrentSubtitle();
deps.playNextSubtitle();
void deps.shiftSubDelayToAdjacentSubtitle('next');
@@ -48,6 +50,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'youtube-picker',
'playlist-browser',
'osd:hello',
'feedback:primary',
'replay',
'next',
'shift:next',
+23 -16
View File
@@ -3,20 +3,27 @@ import type { MpvCommandFromIpcRuntimeDeps } from '../ipc-mpv-command';
export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
deps: MpvCommandFromIpcRuntimeDeps,
) {
return (): MpvCommandFromIpcRuntimeDeps => ({
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
playNextSubtitle: () => deps.playNextSubtitle(),
shiftSubDelayToAdjacentSubtitle: (direction) => deps.shiftSubDelayToAdjacentSubtitle(direction),
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
getMpvClient: () => deps.getMpvClient(),
isMpvConnected: () => deps.isMpvConnected(),
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
});
return (): MpvCommandFromIpcRuntimeDeps => {
const showPlaybackFeedback = deps.showPlaybackFeedback;
return {
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
...(showPlaybackFeedback
? { showPlaybackFeedback: (text: string) => showPlaybackFeedback(text) }
: {}),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
playNextSubtitle: () => deps.playNextSubtitle(),
shiftSubDelayToAdjacentSubtitle: (direction) =>
deps.shiftSubDelayToAdjacentSubtitle(direction),
sendMpvCommand: (command: (string | number)[]) => deps.sendMpvCommand(command),
getMpvClient: () => deps.getMpvClient(),
isMpvConnected: () => deps.isMpvConnected(),
hasRuntimeOptionsManager: () => deps.hasRuntimeOptionsManager(),
};
};
}
@@ -78,6 +78,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false,
}),
getDefaultMpvLogPath: () => '/tmp/mp.log',
@@ -10,6 +10,7 @@ import {
resolveDesiredOverlayInteractive,
resolveForegroundSuppressionWithGrace,
shouldSuppressPointerInteractionForForegroundWindow,
shouldPrimeLinuxOverlayInteractionFromMeasurement,
tickLinuxOverlayPointerInteraction,
} from './linux-overlay-pointer-interaction';
@@ -136,6 +137,59 @@ test('resolveDesiredOverlayInteractive: hit-tests separate subtitle bars without
);
});
test('shouldPrimeLinuxOverlayInteractionFromMeasurement primes input from first measured rect', () => {
assert.equal(
shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
}),
getSubtitleMeasurement: () => ({
...MEASUREMENT,
interactiveRects: [{ x: 900, y: 900, width: 320, height: 80 }],
}),
shouldSuspend: () => false,
shouldSuppressInteraction: () => false,
}),
true,
);
});
test('shouldPrimeLinuxOverlayInteractionFromMeasurement skips hidden or empty startup surfaces', () => {
assert.equal(
shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => false,
getBounds: () => BOUNDS,
}),
getSubtitleMeasurement: () => MEASUREMENT,
shouldSuspend: () => false,
}),
false,
);
assert.equal(
shouldPrimeLinuxOverlayInteractionFromMeasurement({
getVisibleOverlayVisible: () => true,
getMainWindow: () => ({
isDestroyed: () => false,
isVisible: () => true,
getBounds: () => BOUNDS,
}),
getSubtitleMeasurement: () => ({
viewport: MEASUREMENT.viewport,
contentRect: null,
interactiveRects: [],
}),
shouldSuspend: () => false,
}),
false,
);
});
test('mapOverlayMeasurementForPointerInteraction preserves renderer interactive rects', () => {
const mapped = mapOverlayMeasurementForPointerInteraction({
layer: 'visible',
@@ -146,6 +146,29 @@ function measuredRectsForInput(measurement: OverlayContentMeasurementLike): Poin
: [];
}
function hasMeasuredInputRects(measurement: OverlayContentMeasurementLike): boolean {
return measuredRectsForInput(measurement).some((rect) => rect.width > 0 && rect.height > 0);
}
export function shouldPrimeLinuxOverlayInteractionFromMeasurement(deps: {
getVisibleOverlayVisible: () => boolean;
getMainWindow: () => PointerInteractionWindow | null;
getSubtitleMeasurement: () => OverlayContentMeasurementLike;
shouldSuspend: () => boolean;
shouldSuppressInteraction?: () => boolean;
}): boolean {
if (!deps.getVisibleOverlayVisible()) return false;
if (deps.shouldSuspend()) return false;
const mainWindow = deps.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return false;
}
if (deps.shouldSuppressInteraction?.()) return false;
return hasMeasuredInputRects(deps.getSubtitleMeasurement());
}
function clampRectToWindow(rect: PointerRect, bounds: PointerRect): PointerRect | null {
const left = Math.max(0, Math.floor(rect.x));
const top = Math.max(0, Math.floor(rect.y));
@@ -183,6 +183,34 @@ test('media path change handler signals autoplay readiness from warm media path'
]);
});
test('media path change handler schedules character dictionary once per media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '/tmp/video.mkv' });
handler({ path: '/tmp/video.mkv' });
handler({ path: '/tmp/next-video.mkv' });
handler({ path: '' });
handler({ path: '/tmp/video.mkv' });
assert.deepEqual(
calls.filter((call) => call === 'dict-sync'),
['dict-sync', 'dict-sync', 'dict-sync'],
);
});
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
+10 -3
View File
@@ -74,9 +74,13 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
refreshDiscordPresence: () => void;
}) {
let lastCharacterDictionarySyncMediaPath: string | null = null;
return ({ path }: { path: string | null }): void => {
const normalizedPath = typeof path === 'string' ? path : '';
if (!normalizedPath) {
const trimmedPath = normalizedPath.trim();
if (!trimmedPath) {
lastCharacterDictionarySyncMediaPath = null;
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
}
deps.updateCurrentMediaPath(normalizedPath);
@@ -92,9 +96,12 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
deps.ensureAnilistMediaGuess(mediaKey);
}
deps.syncImmersionMediaState();
if (normalizedPath.trim().length > 0) {
if (trimmedPath.length > 0) {
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
deps.scheduleCharacterDictionarySync?.();
if (trimmedPath !== lastCharacterDictionarySyncMediaPath) {
lastCharacterDictionarySyncMediaPath = trimmedPath;
deps.scheduleCharacterDictionarySync?.();
}
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
}
deps.refreshDiscordPresence();
@@ -0,0 +1,29 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveOverlayReadinessNotificationType,
shouldShowDesktop,
shouldShowOverlay,
shouldShowOsd,
} from './notification-routing';
test('notification routing preserves system notification while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('system', false), 'system');
});
test('notification routing preserves both while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('both', false), 'both');
});
test('notification routing preserves overlay-only notification while overlay is not ready', () => {
assert.equal(resolveOverlayReadinessNotificationType('overlay', false), 'overlay');
});
test('notification routing predicates classify delivery channels', () => {
assert.equal(shouldShowOverlay('both'), true);
assert.equal(shouldShowOverlay('system'), false);
assert.equal(shouldShowOsd('osd-system'), true);
assert.equal(shouldShowOsd('both'), false);
assert.equal(shouldShowDesktop('osd-system'), true);
assert.equal(shouldShowDesktop('overlay'), false);
});
+20
View File
@@ -0,0 +1,20 @@
import type { NotificationType } from '../../types/notification';
export function shouldShowOsd(type: NotificationType): boolean {
return type === 'osd' || type === 'osd-system';
}
export function shouldShowOverlay(type: NotificationType): boolean {
return type === 'overlay' || type === 'both';
}
export function shouldShowDesktop(type: NotificationType): boolean {
return type === 'system' || type === 'both' || type === 'osd-system';
}
export function resolveOverlayReadinessNotificationType(
type: NotificationType,
_overlayReady: boolean,
): NotificationType {
return type;
}
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createMaybeStartOverlayLoadingOsdHandler,
shouldStartOverlayLoadingOsd,
} from './overlay-loading-osd-start';
test('overlay loading OSD starts for visible overlay before content is ready', () => {
assert.equal(
shouldStartOverlayLoadingOsd({
visibleOverlayRequested: true,
overlayContentReady: false,
}),
true,
);
});
test('overlay loading OSD does not start when hidden or already ready', () => {
assert.equal(
shouldStartOverlayLoadingOsd({
visibleOverlayRequested: false,
overlayContentReady: false,
}),
false,
);
assert.equal(
shouldStartOverlayLoadingOsd({
visibleOverlayRequested: true,
overlayContentReady: true,
}),
false,
);
});
test('overlay loading OSD media-path trigger ignores empty paths', () => {
assert.equal(
shouldStartOverlayLoadingOsd({
visibleOverlayRequested: true,
overlayContentReady: false,
mediaPath: ' ',
}),
false,
);
});
test('overlay loading OSD handler starts idempotent status through injected deps', () => {
const calls: string[] = [];
const maybeStart = createMaybeStartOverlayLoadingOsdHandler({
getVisibleOverlayRequested: () => true,
isOverlayContentReady: () => false,
startOverlayLoadingOsd: () => {
calls.push('start');
},
});
maybeStart();
maybeStart('/tmp/video.mkv');
maybeStart(' ');
assert.deepEqual(calls, ['start', 'start']);
});
@@ -0,0 +1,32 @@
export function shouldStartOverlayLoadingOsd(args: {
visibleOverlayRequested: boolean;
overlayContentReady: boolean;
mediaPath?: string | null;
}): boolean {
if (!args.visibleOverlayRequested || args.overlayContentReady) {
return false;
}
if (args.mediaPath !== undefined && (args.mediaPath ?? '').trim().length === 0) {
return false;
}
return true;
}
export function createMaybeStartOverlayLoadingOsdHandler(deps: {
getVisibleOverlayRequested: () => boolean;
isOverlayContentReady: () => boolean;
startOverlayLoadingOsd: () => void;
}) {
return (mediaPath?: string | null): void => {
if (
!shouldStartOverlayLoadingOsd({
visibleOverlayRequested: deps.getVisibleOverlayRequested(),
overlayContentReady: deps.isOverlayContentReady(),
mediaPath,
})
) {
return;
}
deps.startOverlayLoadingOsd();
};
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOverlayLoadingOsdController } from './overlay-loading-osd';
test('overlay loading OSD shows spinner ticks and clears when stopped', () => {
const messages: string[] = [];
const clearedTimers: unknown[] = [];
let tick: (() => void) | null = null;
const controller = createOverlayLoadingOsdController({
showOsd: (message) => {
messages.push(message);
},
clearOsd: () => {
messages.push('clear');
},
setInterval: (callback) => {
tick = callback;
return 'timer';
},
clearInterval: (timer) => {
clearedTimers.push(timer);
},
});
controller.start();
controller.start();
assert.deepEqual(messages, ['Overlay loading |']);
if (!tick) {
assert.fail('expected spinner tick callback');
}
const tickCallback: () => void = tick;
tickCallback();
tickCallback();
controller.stop();
controller.stop();
assert.deepEqual(messages, ['Overlay loading |', 'Overlay loading /', 'Overlay loading -', 'clear']);
assert.deepEqual(clearedTimers, ['timer']);
});
+49
View File
@@ -0,0 +1,49 @@
const DEFAULT_OVERLAY_LOADING_OSD_TICK_MS = 180;
const OVERLAY_LOADING_OSD_FRAMES = ['|', '/', '-', '\\'] as const;
export function createOverlayLoadingOsdController(deps: {
showOsd: (message: string) => void;
clearOsd: () => void;
setInterval?: (callback: () => void, delayMs: number) => unknown;
clearInterval?: (timer: unknown) => void;
}) {
const setIntervalHandler =
deps.setInterval ??
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
const clearIntervalHandler =
deps.clearInterval ??
((timer: unknown): void => clearInterval(timer as ReturnType<typeof setInterval>));
let active = false;
let frame = 0;
let timer: unknown = null;
const showNextFrame = (): void => {
deps.showOsd(
`Overlay loading ${OVERLAY_LOADING_OSD_FRAMES[frame % OVERLAY_LOADING_OSD_FRAMES.length]}`,
);
frame += 1;
};
return {
start(): void {
if (active) {
return;
}
active = true;
frame = 0;
showNextFrame();
timer = setIntervalHandler(showNextFrame, DEFAULT_OVERLAY_LOADING_OSD_TICK_MS);
},
stop(): void {
if (!active) {
return;
}
active = false;
if (timer !== null) {
clearIntervalHandler(timer);
timer = null;
}
deps.clearOsd();
},
};
}
@@ -0,0 +1,158 @@
import type { OverlayNotificationEventPayload } from '../../types/notification';
export interface OverlayNotificationDeliveryDeps {
hasReadyOverlayWindow: () => boolean;
send: (payload: OverlayNotificationEventPayload) => void;
maxQueuedEvents?: number;
flushRetryDelayMs?: number;
terminalUpdateDelayMs?: number;
scheduleFlushRetry?: (callback: () => void, delayMs: number) => unknown;
clearFlushRetry?: (handle: unknown) => void;
}
function getPayloadId(payload: OverlayNotificationEventPayload): string | null {
return typeof payload.id === 'string' && payload.id.trim().length > 0 ? payload.id : null;
}
function getPayloadHistoryId(payload: OverlayNotificationEventPayload): string | null {
if ('dismiss' in payload) {
return null;
}
return typeof payload.historyId === 'string' && payload.historyId.trim().length > 0
? payload.historyId
: null;
}
function isDismissPayload(
payload: OverlayNotificationEventPayload,
): payload is Extract<OverlayNotificationEventPayload, { dismiss: true }> {
return 'dismiss' in payload && payload.dismiss === true;
}
export function createOverlayNotificationDelivery(deps: OverlayNotificationDeliveryDeps): {
send: (payload: OverlayNotificationEventPayload) => void;
flush: () => void;
getQueuedCount: () => number;
} {
const maxQueuedEvents = Math.max(1, deps.maxQueuedEvents ?? 32);
const flushRetryDelayMs = Math.max(1, deps.flushRetryDelayMs ?? 50);
const terminalUpdateDelayMs = Math.max(1, deps.terminalUpdateDelayMs ?? 750);
const queuedEvents: OverlayNotificationEventPayload[] = [];
let flushRetryHandle: unknown = null;
const removeQueuedPayloadsById = (id: string): void => {
const nextEvents = queuedEvents.filter((queued) => getPayloadId(queued) !== id);
queuedEvents.splice(0, queuedEvents.length, ...nextEvents);
};
const clearFlushRetry = (): void => {
if (flushRetryHandle === null) {
return;
}
deps.clearFlushRetry?.(flushRetryHandle);
flushRetryHandle = null;
};
const scheduleFlushRetry = (delayMs = flushRetryDelayMs): void => {
if (!deps.scheduleFlushRetry || flushRetryHandle !== null || queuedEvents.length === 0) {
return;
}
flushRetryHandle = deps.scheduleFlushRetry(() => {
flushRetryHandle = null;
flush();
}, delayMs);
};
const queuePayload = (payload: OverlayNotificationEventPayload): void => {
const id = getPayloadId(payload);
if (isDismissPayload(payload)) {
if (id) {
removeQueuedPayloadsById(id);
}
return;
}
if (id) {
const payloadPersistent = payload.persistent === true;
const payloadHistoryId = getPayloadHistoryId(payload);
const existingIndex = queuedEvents.findIndex(
(queued) =>
getPayloadId(queued) === id &&
!isDismissPayload(queued) &&
getPayloadHistoryId(queued) === payloadHistoryId &&
(queued.persistent === true) === payloadPersistent,
);
if (existingIndex >= 0) {
queuedEvents[existingIndex] = payload;
return;
}
}
queuedEvents.push(payload);
while (queuedEvents.length > maxQueuedEvents) {
queuedEvents.shift();
}
};
const flush = (): void => {
if (!deps.hasReadyOverlayWindow()) {
scheduleFlushRetry();
return;
}
clearFlushRetry();
const readyEvents = queuedEvents.splice(0, queuedEvents.length);
const sentPersistentIds = new Set<string>();
const deferredTerminalEvents: OverlayNotificationEventPayload[] = [];
for (const payload of readyEvents) {
const id = getPayloadId(payload);
if (
id &&
!isDismissPayload(payload) &&
payload.persistent !== true &&
sentPersistentIds.has(id)
) {
deferredTerminalEvents.push(payload);
continue;
}
deps.send(payload);
if (id && !isDismissPayload(payload) && payload.persistent === true) {
sentPersistentIds.add(id);
}
}
if (deferredTerminalEvents.length > 0) {
if (!deps.scheduleFlushRetry) {
for (const payload of deferredTerminalEvents) {
deps.send(payload);
}
return;
}
queuedEvents.unshift(...deferredTerminalEvents);
scheduleFlushRetry(terminalUpdateDelayMs);
}
};
const send = (payload: OverlayNotificationEventPayload): void => {
if (isDismissPayload(payload)) {
const id = getPayloadId(payload);
if (id) {
removeQueuedPayloadsById(id);
}
if (deps.hasReadyOverlayWindow()) {
deps.send(payload);
}
return;
}
if (!deps.hasReadyOverlayWindow()) {
queuePayload(payload);
return;
}
deps.send(payload);
};
return {
send,
flush,
getQueuedCount: () => queuedEvents.length,
};
}
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { withConfiguredOverlayNotificationPosition } from './overlay-notification-position';
test('overlay notification payloads inherit configured overlay position', () => {
assert.deepEqual(
withConfiguredOverlayNotificationPosition(
{ title: 'SubMiner', body: 'Ready' },
{ notifications: { overlayPosition: 'top' } },
),
{ title: 'SubMiner', body: 'Ready', position: 'top' },
);
});
test('overlay notification payload position can override configured position', () => {
assert.deepEqual(
withConfiguredOverlayNotificationPosition(
{ title: 'SubMiner', body: 'Ready', position: 'top-left' },
{ notifications: { overlayPosition: 'top-right' } },
),
{ title: 'SubMiner', body: 'Ready', position: 'top-left' },
);
});
@@ -0,0 +1,12 @@
import type { ResolvedConfig } from '../../types/config';
import type { OverlayNotificationPayload } from '../../types/notification';
export function withConfiguredOverlayNotificationPosition(
payload: OverlayNotificationPayload,
config: Pick<ResolvedConfig, 'notifications'>,
): OverlayNotificationPayload {
return {
...payload,
position: payload.position ?? config.notifications.overlayPosition,
};
}
@@ -1,4 +1,5 @@
import type { AnkiConnectConfig } from '../../types';
import type { OverlayNotificationPayload } from '../../types/notification';
import type { createBuildInitializeOverlayRuntimeOptionsHandler } from './overlay-runtime-options';
type OverlayRuntimeOptionsMainDeps = Parameters<
@@ -37,6 +38,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
createWindowTracker?: OverlayRuntimeOptionsMainDeps['createWindowTracker'];
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string;
shouldStartAnkiIntegration: () => boolean;
@@ -72,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
deps.appState.ankiIntegration = integration;
},
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
shouldStartAnkiIntegration: () => deps.shouldStartAnkiIntegration(),
@@ -5,6 +5,7 @@ import type {
} from '../../types/anki';
import type { BrowserWindow } from 'electron';
import type { WindowGeometry } from '../../types/runtime';
import type { OverlayNotificationPayload } from '../../types/notification';
import type { BaseWindowTracker } from '../../window-trackers';
type OverlayRuntimeOptions = {
@@ -31,6 +32,7 @@ type OverlayRuntimeOptions = {
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -64,6 +66,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
} | null;
setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>;
@@ -91,6 +94,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
getRuntimeOptionsManager: deps.getRuntimeOptionsManager,
setAnkiIntegration: deps.setAnkiIntegration,
showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
shouldStartAnkiIntegration: deps.shouldStartAnkiIntegration,
@@ -36,6 +36,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
isMacOSPlatform: () => true,
isWindowsPlatform: () => false,
showOverlayLoadingOsd: () => calls.push('overlay-loading-osd'),
dismissOverlayLoadingOsd: () => calls.push('dismiss-overlay-loading-osd'),
resolveFallbackBounds: () => ({ x: 0, y: 0, width: 20, height: 20 }),
})();
@@ -60,6 +61,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
assert.equal(deps.isMacOSPlatform(), true);
assert.equal(deps.isWindowsPlatform(), false);
deps.showOverlayLoadingOsd('Overlay loading...');
deps.dismissOverlayLoadingOsd?.();
assert.deepEqual(deps.resolveFallbackBounds(), { x: 0, y: 0, width: 20, height: 20 });
assert.equal(trackerNotReadyWarningShown, true);
assert.deepEqual(calls, [
@@ -71,5 +73,6 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
'enforce-order',
'sync-shortcuts',
'overlay-loading-osd',
'dismiss-overlay-loading-osd',
]);
});
@@ -32,6 +32,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
isMacOSPlatform: () => deps.isMacOSPlatform(),
isWindowsPlatform: () => deps.isWindowsPlatform(),
showOverlayLoadingOsd: (message: string) => deps.showOverlayLoadingOsd(message),
dismissOverlayLoadingOsd: () => deps.dismissOverlayLoadingOsd?.(),
hideNonNativeOverlayWhenTargetUnfocused: () =>
deps.hideNonNativeOverlayWhenTargetUnfocused?.() ?? false,
resolveFallbackBounds: () => deps.resolveFallbackBounds(),
@@ -14,6 +14,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
yomitanSession?: Session | null;
@@ -29,6 +30,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: 'visible' | 'modal', window: TWindow) => void;
getYomitanSession?: () => Session | null;
@@ -45,6 +47,7 @@ export function createBuildCreateOverlayWindowMainDepsHandler<TWindow>(deps: {
getLinuxX11FullscreenOverlay: deps.getLinuxX11FullscreenOverlay,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
getYomitanSession: () => deps.getYomitanSession?.() ?? null,
@@ -16,6 +16,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
linuxX11FullscreenOverlay?: boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
yomitanSession?: Session | null;
@@ -31,6 +32,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
getLinuxX11FullscreenOverlay?: () => boolean;
onVisibleWindowBlurred?: () => void;
onVisibleWindowFocused?: () => void;
onWindowDidFinishLoad?: () => void;
onWindowContentReady?: () => void;
onWindowClosed: (windowKind: OverlayWindowKind, window: TWindow) => void;
getYomitanSession?: () => Session | null;
@@ -48,6 +50,7 @@ export function createCreateOverlayWindowHandler<TWindow>(deps: {
kind === 'visible' ? deps.getLinuxX11FullscreenOverlay?.() : undefined,
onVisibleWindowBlurred: deps.onVisibleWindowBlurred,
onVisibleWindowFocused: deps.onVisibleWindowFocused,
onWindowDidFinishLoad: deps.onWindowDidFinishLoad,
onWindowContentReady: deps.onWindowContentReady,
onWindowClosed: deps.onWindowClosed,
yomitanSession: deps.getYomitanSession?.() ?? null,
@@ -12,6 +12,7 @@ test('autoplay release keeps the short retry budget for normal playback signals'
});
test('autoplay release uses the full startup timeout window while paused', () => {
assert.equal(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS, 30_000);
assert.equal(
resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }),
Math.ceil(STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS),
@@ -1,5 +1,5 @@
const DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS = 200;
const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 15_000;
const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 30_000;
export function resolveAutoplayReadyMaxReleaseAttempts(options?: {
forceWhilePaused?: boolean;
@@ -3,6 +3,7 @@ import test from 'node:test';
import { parseArgs } from '../../cli/args';
import {
getStartupModeFlags,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
shouldRefreshAnilistOnConfigReload,
shouldStartAutomaticUpdateChecks,
} from './startup-mode-flags';
@@ -25,3 +26,14 @@ test('normal startup still allows background integrations', () => {
assert.equal(shouldRefreshAnilistOnConfigReload(null), true);
assert.equal(shouldStartAutomaticUpdateChecks(null), true);
});
test('managed background playback handles initial args before deferred overlay warmup', () => {
const args = parseArgs(['--start', '--background', '--managed-playback']);
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(args), true);
assert.equal(
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(parseArgs(['--start', '--background'])),
false,
);
assert.equal(shouldHandleInitialArgsBeforeDeferredOverlayWarmup(null), false);
});
+6
View File
@@ -29,6 +29,12 @@ export function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
};
}
export function shouldHandleInitialArgsBeforeDeferredOverlayWarmup(
initialArgs: CliArgs | null | undefined,
): boolean {
return Boolean(initialArgs?.start && initialArgs.background && initialArgs.managedPlayback);
}
export function shouldRefreshAnilistOnConfigReload(
initialArgs: CliArgs | null | undefined,
): boolean {
@@ -161,6 +161,34 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => {
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
});
test('startup OSD reset preserves in-flight tokenization loading for ready update', () => {
const calls: string[] = [];
const sequencer = createStartupOsdSequencer({
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) => {
calls.push(
`overlay:${payload.id}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
);
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body ?? ''}`);
},
});
sequencer.showTokenizationLoading('Loading subtitle tokenization...');
sequencer.reset();
sequencer.markTokenizationReady();
assert.deepEqual(calls, [
'overlay:startup-tokenization:Subtitle tokenization:Loading subtitle tokenization...:progress:pin',
'overlay:startup-tokenization:Subtitle tokenization:Subtitle tokenization ready:success:auto',
'desktop:SubMiner:Subtitle tokenization ready',
]);
});
test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
@@ -222,3 +250,35 @@ test('startup OSD keeps dictionary progress pending when mpv osd is unavailable'
'Character dictionary ready for Frieren',
]);
});
test('startup notifications route tokenization and annotation status to overlay and system without osd for both', () => {
const calls: string[] = [];
const sequencer = createStartupOsdSequencer({
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload) => {
calls.push(
`overlay:${payload.id}:${payload.title}:${payload.body}:${payload.variant}:${payload.persistent ? 'pin' : 'auto'}`,
);
},
showDesktopNotification: (title, options) => {
calls.push(`desktop:${title}:${options.body ?? ''}`);
},
});
sequencer.showTokenizationLoading('Loading subtitle tokenization...');
sequencer.markTokenizationReady();
sequencer.showAnnotationLoading('Loading subtitle annotations |');
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(calls, [
'overlay:startup-tokenization:Subtitle tokenization:Loading subtitle tokenization...:progress:pin',
'overlay:startup-tokenization:Subtitle tokenization:Subtitle tokenization ready:success:auto',
'desktop:SubMiner:Subtitle tokenization ready',
'overlay:startup-subtitle-annotations:Subtitle annotations:Loading subtitle annotations |:progress:pin',
'overlay:startup-subtitle-annotations:Subtitle annotations:Subtitle annotations loaded:success:auto',
'desktop:SubMiner:Subtitle annotations loaded',
]);
});
+99 -5
View File
@@ -1,10 +1,30 @@
import type { NotificationType, OverlayNotificationPayload } from '../../types/notification';
import { shouldShowDesktop, shouldShowOverlay, shouldShowOsd } from './notification-routing';
export interface StartupOsdSequencerCharacterDictionaryEvent {
phase: 'checking' | 'generating' | 'syncing' | 'building' | 'importing' | 'ready' | 'failed';
message: string;
}
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => boolean | void }): {
export interface StartupOsdSequencerDeps {
getNotificationType?: () => NotificationType | undefined;
showOsd: (message: string) => boolean | void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
showDesktopNotification?: (title: string, options: { body?: string }) => void;
}
interface StartupStatusNotificationOptions {
id: string;
title: string;
message: string;
variant: OverlayNotificationPayload['variant'];
persistent: boolean;
desktop?: boolean;
}
export function createStartupOsdSequencer(deps: StartupOsdSequencerDeps): {
reset: () => void;
showTokenizationLoading: (message: string) => void;
markTokenizationReady: () => void;
showAnnotationLoading: (message: string) => void;
markAnnotationLoadingComplete: (message: string) => void;
@@ -12,6 +32,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
} {
let tokenizationReady = false;
let tokenizationWarmupCompleted = false;
let tokenizationLoadingShown = false;
let annotationLoadingMessage: string | null = null;
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
@@ -20,7 +41,66 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
const canShowDictionaryStatus = (): boolean =>
tokenizationReady && annotationLoadingMessage === null;
const showOsd = (message: string): boolean => deps.showOsd(message) !== false;
const getNotificationType = (): NotificationType => deps.getNotificationType?.() ?? 'osd';
const notifyStartupStatus = (options: StartupStatusNotificationOptions): boolean => {
const type = getNotificationType();
if (type === 'none') {
return false;
}
let shown = false;
if (shouldShowOverlay(type)) {
deps.showOverlayNotification?.({
id: options.id,
title: options.title,
body: options.message,
variant: options.variant,
persistent: options.persistent,
});
shown = true;
}
if (shouldShowOsd(type)) {
shown = deps.showOsd(options.message) !== false || shown;
}
if (options.desktop !== false && shouldShowDesktop(type)) {
deps.showDesktopNotification?.('SubMiner', { body: options.message });
shown = true;
}
return shown;
};
const showOsd = (message: string): boolean =>
notifyStartupStatus({
id: 'startup-status',
title: 'SubMiner',
message,
variant: 'info',
persistent: false,
});
const notifyTokenization = (
message: string,
variant: OverlayNotificationPayload['variant'],
persistent: boolean,
): boolean =>
notifyStartupStatus({
id: 'startup-tokenization',
title: 'Subtitle tokenization',
message,
variant,
persistent,
desktop: !persistent,
});
const notifyAnnotation = (
message: string,
variant: OverlayNotificationPayload['variant'],
persistent: boolean,
): boolean =>
notifyStartupStatus({
id: 'startup-subtitle-annotations',
title: 'Subtitle annotations',
message,
variant,
persistent,
desktop: !persistent,
});
const flushBufferedDictionaryStatus = (): boolean => {
if (!canShowDictionaryStatus()) {
@@ -55,17 +135,31 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
return {
reset: () => {
tokenizationReady = tokenizationWarmupCompleted;
if (tokenizationWarmupCompleted) {
tokenizationLoadingShown = false;
}
annotationLoadingMessage = null;
pendingDictionaryProgress = null;
pendingDictionaryFailure = null;
pendingDictionaryReady = null;
dictionaryProgressShown = false;
},
showTokenizationLoading: (message) => {
if (tokenizationReady) {
return;
}
tokenizationLoadingShown = true;
notifyTokenization(message, 'progress', true);
},
markTokenizationReady: () => {
tokenizationWarmupCompleted = true;
tokenizationReady = true;
if (tokenizationLoadingShown) {
notifyTokenization('Subtitle tokenization ready', 'success', false);
tokenizationLoadingShown = false;
}
if (annotationLoadingMessage !== null) {
showOsd(annotationLoadingMessage);
notifyAnnotation(annotationLoadingMessage, 'progress', true);
return;
}
flushBufferedDictionaryStatus();
@@ -73,7 +167,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
showAnnotationLoading: (message) => {
annotationLoadingMessage = message;
if (tokenizationReady) {
showOsd(message);
notifyAnnotation(message, 'progress', true);
}
},
markAnnotationLoadingComplete: (message) => {
@@ -84,7 +178,7 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
if (flushBufferedDictionaryStatus()) {
return;
}
showOsd(message);
notifyAnnotation(message, 'success', false);
},
notifyCharacterDictionaryStatus: (event) => {
if (
@@ -1,8 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { notifyUpdateAvailable } from './update-notifications';
import type { OverlayNotificationPayload } from '../../../types/notification';
test('notifyUpdateAvailable routes system and osd notifications from config', async () => {
test('notifyUpdateAvailable routes notification surfaces from config', async () => {
const calls: string[] = [];
const deps = {
showSystemNotification: (title: string, body: string) => {
@@ -11,22 +12,52 @@ test('notifyUpdateAvailable routes system and osd notifications from config', as
showOsdNotification: async (message: string) => {
calls.push(`osd:${message}`);
},
showOverlayNotification: (payload: OverlayNotificationPayload) => {
calls.push(`overlay:${payload.title}:${payload.body ?? ''}`);
},
log: (message: string) => {
calls.push(`log:${message}`);
},
};
await notifyUpdateAvailable({ notificationType: 'overlay', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'system', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'both', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'osd-system', version: '0.15.0' }, deps);
await notifyUpdateAvailable({ notificationType: 'none', version: '0.15.0' }, deps);
assert.deepEqual(calls, [
'overlay:SubMiner update available:SubMiner v0.15.0 is available',
'system:SubMiner update available:SubMiner v0.15.0 is available',
'overlay:SubMiner update available:SubMiner v0.15.0 is available',
'system:SubMiner update available:SubMiner v0.15.0 is available',
'osd:SubMiner v0.15.0 is available',
'system:SubMiner update available:SubMiner v0.15.0 is available',
]);
});
test('notifyUpdateAvailable adds an install action to overlay update notifications', async () => {
const payloads: OverlayNotificationPayload[] = [];
await notifyUpdateAvailable(
{ notificationType: 'overlay', version: '0.15.0' },
{
showSystemNotification: () => {},
showOsdNotification: async () => {},
showOverlayNotification: (nextPayload) => {
payloads.push(nextPayload);
},
log: () => {},
},
);
const payload = payloads[0];
assert.ok(payload);
assert.deepEqual(payload.actions, [{ id: 'install-update', label: 'Update' }]);
assert.equal(payload.id, 'subminer-update-available');
assert.equal(payload.persistent, true);
});
test('notifyUpdateAvailable logs osd fallback when overlay notification fails', async () => {
const calls: string[] = [];
@@ -39,6 +70,9 @@ test('notifyUpdateAvailable logs osd fallback when overlay notification fails',
showOsdNotification: async () => {
throw new Error('mpv disconnected');
},
showOverlayNotification: () => {
calls.push('overlay');
},
log: (message) => {
calls.push(message);
},
@@ -60,6 +94,9 @@ test('notifyUpdateAvailable logs non-error osd failures with thrown value', asyn
showOsdNotification: async () => {
throw 'mpv disconnected';
},
showOverlayNotification: () => {
calls.push('overlay');
},
log: (message) => {
calls.push(message);
},
@@ -1,7 +1,12 @@
import type { UpdateNotificationType } from '../../../types/config';
import type { OverlayNotificationPayload } from '../../../types/notification';
export const UPDATE_AVAILABLE_NOTIFICATION_ID = 'subminer-update-available';
export const INSTALL_UPDATE_ACTION_ID = 'install-update';
export interface UpdateNotificationDeps {
showSystemNotification: (title: string, body: string) => void;
showOverlayNotification: (payload: OverlayNotificationPayload) => void;
showOsdNotification: (message: string) => void | Promise<void>;
log: (message: string) => void;
}
@@ -13,10 +18,17 @@ export async function notifyUpdateAvailable(
if (options.notificationType === 'none') return;
const message = `SubMiner v${options.version} is available`;
if (options.notificationType === 'system' || options.notificationType === 'both') {
deps.showSystemNotification('SubMiner update available', message);
if (options.notificationType === 'overlay' || options.notificationType === 'both') {
deps.showOverlayNotification({
id: UPDATE_AVAILABLE_NOTIFICATION_ID,
title: 'SubMiner update available',
body: message,
variant: 'info',
persistent: true,
actions: [{ id: INSTALL_UPDATE_ACTION_ID, label: 'Update' }],
});
}
if (options.notificationType === 'osd' || options.notificationType === 'both') {
if (options.notificationType === 'osd' || options.notificationType === 'osd-system') {
try {
await deps.showOsdNotification(message);
} catch (error) {
@@ -24,4 +36,11 @@ export async function notifyUpdateAvailable(
deps.log(`Update OSD notification failed: ${reason}`);
}
}
if (
options.notificationType === 'system' ||
options.notificationType === 'both' ||
options.notificationType === 'osd-system'
) {
deps.showSystemNotification('SubMiner update available', message);
}
}
@@ -96,6 +96,28 @@ test('manual update check falls back to GitHub release when app metadata is unav
assert.deepEqual(calls, ['available-dialog:0.15.0']);
});
test('manual update install request skips available dialog and updates app', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: true, version: '0.15.0' }),
showUpdateAvailableDialog: async () => {
throw new Error('unexpected update confirmation');
},
updateLauncher: async (_launcherPath, channel) => {
calls.push(`launcher:${channel}`);
return { status: 'skipped' };
},
});
const service = createUpdateService(deps);
const result = await service.checkForUpdates({
source: 'manual',
installWhenAvailable: true,
});
assert.equal(result.status, 'updated');
assert.deepEqual(calls, ['download', 'launcher:stable', 'restart-dialog']);
});
test('manual update check reports available when no update asset was applied', async () => {
const { deps, calls } = createDeps({
checkAppUpdate: async () => ({ available: false, version: '0.14.0', canUpdate: false }),
@@ -271,6 +293,28 @@ test('concurrent update checks share one in-flight check', async () => {
assert.equal(checkCount, 1);
});
test('manual install request does not reuse in-flight manual check', async () => {
let checkCount = 0;
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
const { deps } = createDeps({
checkAppUpdate: () =>
new Promise((resolve) => {
checkCount += 1;
resolveChecks.push(resolve);
}),
});
const service = createUpdateService(deps);
const manualCheck = service.checkForUpdates({ source: 'manual' });
const manualInstall = service.checkForUpdates({ source: 'manual', installWhenAvailable: true });
await Promise.resolve();
assert.equal(checkCount, 2);
for (const resolve of resolveChecks) {
resolve({ available: false, version: '0.14.0' });
}
await Promise.all([manualCheck, manualInstall]);
});
test('manual update check does not reuse in-flight automatic check', async () => {
let checkCount = 0;
const resolveChecks: Array<(value: { available: boolean; version: string }) => void> = [];
+18 -7
View File
@@ -15,6 +15,7 @@ export interface UpdateCheckRequest {
source: UpdateCheckSource;
force?: boolean;
launcherPath?: string;
installWhenAvailable?: boolean;
}
export type UpdateCheckStatus =
@@ -107,7 +108,14 @@ function summarizeError(error: unknown): string {
}
export function createUpdateService(deps: UpdateServiceDeps) {
const inFlightBySource = new Map<UpdateCheckSource, Promise<UpdateCheckResult>>();
const inFlightBySource = new Map<string, Promise<UpdateCheckResult>>();
function getInFlightKey(request: UpdateCheckRequest): string {
if (request.source === 'manual') {
return request.installWhenAvailable ? 'manual:install' : 'manual:check';
}
return request.source;
}
async function runCheck(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const now = deps.now();
@@ -164,9 +172,11 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return { status: 'update-available', version: latest.version };
}
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
if (!request.installWhenAvailable) {
const choice = await deps.showUpdateAvailableDialog(latest.version);
if (choice === 'close') {
return { status: 'update-available', version: latest.version };
}
}
const canInstallAppUpdate = appUpdate.available && appUpdate.canUpdate !== false;
@@ -203,12 +213,13 @@ export function createUpdateService(deps: UpdateServiceDeps) {
return {
checkForUpdates(request: UpdateCheckRequest): Promise<UpdateCheckResult> {
const inFlight = inFlightBySource.get(request.source);
const key = getInFlightKey(request);
const inFlight = inFlightBySource.get(key);
if (inFlight) return inFlight;
const nextInFlight = runCheck(request).finally(() => {
inFlightBySource.delete(request.source);
inFlightBySource.delete(key);
});
inFlightBySource.set(request.source, nextInFlight);
inFlightBySource.set(key, nextInFlight);
return nextInFlight;
},
startAutomaticChecks(options: { startupDelayMs?: number; pollIntervalMs?: number } = {}): void {
@@ -62,7 +62,41 @@ test('visible overlay autoplay target falls back when interactive rects have no
assert.equal(ready, true);
});
test('visible overlay autoplay target rejects synthetic warmup readiness', () => {
test('visible overlay autoplay target accepts synthetic warmup readiness after content-ready', () => {
const ready = isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => true,
isOverlayWindowReady: () => true,
getLatestVisibleMeasurement: () => null,
},
{
mediaPath: '/media/video.mkv',
payload: { text: '__warm__', tokens: null },
requestedAtMs: 1_000,
},
);
assert.equal(ready, true);
});
test('visible overlay autoplay target waits for content-ready before synthetic warmup readiness', () => {
const ready = isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => true,
isOverlayWindowReady: () => false,
getLatestVisibleMeasurement: () => visibleMeasurement(2_000),
},
{
mediaPath: '/media/video.mkv',
payload: { text: '__warm__', tokens: null },
requestedAtMs: 1_000,
},
);
assert.equal(ready, false);
});
test('visible overlay autoplay target rejects empty readiness payloads', () => {
const ready = isVisibleOverlayAutoplayTargetReady(
{
getVisibleOverlayVisible: () => true,
@@ -71,7 +105,7 @@ test('visible overlay autoplay target rejects synthetic warmup readiness', () =>
},
{
mediaPath: '/media/video.mkv',
payload: { text: '__warm__', tokens: null },
payload: { text: '', tokens: null },
requestedAtMs: 1_000,
},
);
@@ -31,7 +31,7 @@ export function isVisibleOverlayAutoplayTargetReady(
}
const subtitleText = signal.payload.text.trim();
if (!subtitleText || subtitleText === '__warm__') {
if (!subtitleText) {
return false;
}
@@ -39,6 +39,10 @@ export function isVisibleOverlayAutoplayTargetReady(
return false;
}
if (subtitleText === '__warm__') {
return true;
}
const measurement = deps.getLatestVisibleMeasurement();
if (!measurement || measurement.measuredAtMs < signal.requestedAtMs) {
return false;
@@ -210,6 +210,7 @@ test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
osdMessages: false,
texthookerEnabled: false,
},
);
@@ -238,6 +239,7 @@ test('buildWindowsMpvLaunchArgs keeps Windows ipc default unless explicitly over
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
);
@@ -286,6 +288,7 @@ test('launchWindowsMpv attaches a launched video to a running app and disables p
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: true,
},
);
@@ -348,6 +351,7 @@ test('launchWindowsMpv leaves plugin auto-start enabled when no running app cont
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
);
@@ -436,6 +440,7 @@ test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
osdMessages: false,
texthookerEnabled: false,
},
);