feat(notifications): add notification history panel and overlay UX fixes

- New toggleNotificationHistory (Ctrl+N) session-scoped history panel; slides in from same edge as notification stack
- Overlay error/recovery toast follows notifications.overlayPosition; stack and history side seeded at startup
- Cold managed background startup initializes tray and visible overlay shell before tokenization warmups finish
- Add Update button to overlay update-available notifications
- Fix Ctrl+S sentence-card flow: only Anki progress notification, no duplicate status toast
- Fix overlay notification close/actions clickability above subtitle bars on Linux
- Increase pause-until-ready default timeout from 15s to 30s
This commit is contained in:
2026-06-06 15:29:14 -07:00
parent a34ec049a2
commit 8111deac44
68 changed files with 1408 additions and 69 deletions
@@ -271,3 +271,28 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
assert.match(updatedFields[0]?.Picture ?? '', /^<img src="image_\d+\.jpg">$/);
});
test('createSentenceCard relies on Anki progress notification without standalone status toast', async () => {
const statusMessages: string[] = [];
const progressMessages: string[] = [];
const { service } = createManualUpdateService({
showOsdNotification: (message) => {
statusMessages.push(message);
},
withUpdateProgress: async (message, action) => {
progressMessages.push(message);
return await action();
},
mediaGenerator: {
generateAudio: async () => null,
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
const created = await service.createSentenceCard('テスト', 0, 1);
assert.equal(created, true);
assert.deepEqual(progressMessages, ['Creating sentence card']);
assert.deepEqual(statusMessages, []);
});
-1
View File
@@ -511,7 +511,6 @@ export class CardCreationService {
endTime = startTime + maxMediaDuration;
}
this.deps.showOsdNotification('Creating sentence card...');
try {
return await this.deps.withUpdateProgress('Creating sentence card', async () => {
const videoPath = await resolveMediaGenerationInputPath(mpvClient, 'video');
+1
View File
@@ -102,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: 'Backslash',
toggleNotificationHistory: 'Ctrl+N',
},
secondarySub: {
secondarySubLanguages: [],
+6
View File
@@ -622,5 +622,11 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.toggleSubtitleSidebar,
description: 'Accelerator that toggles the subtitle sidebar visibility.',
},
{
path: 'shortcuts.toggleNotificationHistory',
kind: 'string',
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
description: 'Accelerator that toggles the overlay notification history panel.',
},
];
}
+1
View File
@@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
'toggleNotificationHistory',
] as const;
for (const key of shortcutKeys) {
+1
View File
@@ -582,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
if (
leaf === 'toggleVisibleOverlayGlobal' ||
leaf === 'toggleSubtitleSidebar' ||
leaf === 'toggleNotificationHistory' ||
leaf === 'toggleSecondarySub' ||
leaf === 'toggleStatsOverlay' ||
leaf === 'markWatched'
+69
View File
@@ -2,6 +2,10 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { AppReadyRuntimeDeps, runAppReadyRuntime } from './startup';
function waitTurn(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function makeDeps(overrides: Partial<AppReadyRuntimeDeps> = {}) {
const calls: string[] = [];
const deps = {
@@ -277,6 +281,71 @@ test('runAppReadyRuntime does not await background warmups', async () => {
releaseWarmup();
});
test('runAppReadyRuntime handles managed background initial args before deferred Yomitan wait', async () => {
const calls: string[] = [];
let releaseYomitan!: () => void;
const yomitanGate = new Promise<void>((resolve) => {
releaseYomitan = resolve;
});
const { deps } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => true,
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension:start');
await yomitanGate;
calls.push('loadYomitanExtension:done');
},
handleFirstRunSetup: async () => {
calls.push('handleFirstRunSetup');
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
} as Partial<AppReadyRuntimeDeps>);
const readyPromise = runAppReadyRuntime(deps);
await waitTurn();
try {
assert.ok(calls.includes('handleFirstRunSetup'));
assert.ok(calls.includes('handleInitialArgs'));
assert.equal(calls.includes('loadYomitanExtension:done'), false);
} finally {
releaseYomitan();
await readyPromise;
}
});
test('runAppReadyRuntime keeps non-managed deferred overlay startup behind Yomitan readiness', async () => {
const calls: string[] = [];
let releaseYomitan!: () => void;
const yomitanGate = new Promise<void>((resolve) => {
releaseYomitan = resolve;
});
const { deps } = makeDeps({
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () => false,
loadYomitanExtension: async () => {
calls.push('loadYomitanExtension:start');
await yomitanGate;
calls.push('loadYomitanExtension:done');
},
handleInitialArgs: () => {
calls.push('handleInitialArgs');
},
} as Partial<AppReadyRuntimeDeps>);
const readyPromise = runAppReadyRuntime(deps);
await waitTurn();
assert.equal(calls.includes('handleInitialArgs'), false);
releaseYomitan();
await readyPromise;
assert.ok(calls.indexOf('loadYomitanExtension:done') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime starts background warmups before core runtime services', async () => {
const calls: string[] = [];
const { deps } = makeDeps({
+31
View File
@@ -137,6 +137,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
@@ -242,6 +243,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
@@ -552,6 +554,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: () => {},
@@ -977,6 +980,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: () => {},
saveControllerPreference: (update) => {
@@ -1058,6 +1062,7 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async (update) => {
@@ -1262,6 +1267,31 @@ test('registerIpcHandlers validates dispatchSessionAction payloads', async () =>
]);
});
test('registerIpcHandlers forwards valid overlay notification actions', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const actions: Array<{ notificationId: string; actionId: string }> = [];
registerIpcHandlers(
createRegisterIpcDeps({
handleOverlayNotificationAction: (notificationId: string, actionId: string) => {
actions.push({ notificationId, actionId });
},
} as Partial<IpcServiceDeps>),
registrar,
);
const actionHandler = handlers.on.get(IPC_CHANNELS.command.overlayNotificationAction);
assert.ok(actionHandler);
actionHandler({}, null);
actionHandler({}, { notificationId: '', actionId: 'install-update' });
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 42 });
actionHandler({}, { notificationId: 'subminer-update-available', actionId: 'install-update' });
assert.deepEqual(actions, [
{ notificationId: 'subminer-update-available', actionId: 'install-update' },
]);
});
test('registerIpcHandlers rejects malformed controller preference payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
registerIpcHandlers(
@@ -1289,6 +1319,7 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
dispatchSessionAction: async () => {},
getStatsToggleKey: () => 'Backquote',
getMarkWatchedKey: () => 'KeyW',
getOverlayNotificationPosition: () => 'top-right',
getControllerConfig: () => createControllerConfigFixture(),
saveControllerConfig: async () => {},
saveControllerPreference: async () => {},
+40
View File
@@ -53,6 +53,10 @@ export interface IpcServiceDeps {
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
) => void | Promise<void>;
openYomitanSettings: () => void;
quitApp: () => void;
toggleDevTools: () => void;
@@ -80,6 +84,7 @@ export interface IpcServiceDeps {
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -223,6 +228,18 @@ function parseSubtitleMiningContext(payload: unknown): SubtitleMiningContext | n
return parsed;
}
function parseOverlayNotificationActionPayload(
payload: unknown,
): { notificationId: string; actionId: string } | null {
if (!payload || typeof payload !== 'object') return null;
const record = payload as Record<string, unknown>;
const notificationId = record.notificationId;
const actionId = record.actionId;
if (typeof notificationId !== 'string' || notificationId.trim().length === 0) return null;
if (typeof actionId !== 'string' || actionId.trim().length === 0) return null;
return { notificationId, actionId };
}
export interface IpcDepsRuntimeOptions {
getMainWindow: () => WindowLike | null;
getVisibleOverlayVisibility: () => boolean;
@@ -242,6 +259,10 @@ export interface IpcDepsRuntimeOptions {
interactive: boolean,
senderWindow: ElectronBrowserWindow | null,
) => void;
handleOverlayNotificationAction?: (
notificationId: string,
actionId: string,
) => void | Promise<void>;
openYomitanSettings: () => void;
quitApp: () => void;
toggleVisibleOverlay: () => void;
@@ -262,6 +283,7 @@ export interface IpcDepsRuntimeOptions {
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
getStatsToggleKey: () => string;
getMarkWatchedKey: () => string;
getOverlayNotificationPosition: () => string;
getControllerConfig: () => ResolvedControllerConfig;
saveControllerConfig: (update: ControllerConfigUpdate) => void | Promise<void>;
saveControllerPreference: (update: ControllerPreferenceUpdate) => void | Promise<void>;
@@ -312,6 +334,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
onOverlayModalOpened: options.onOverlayModalOpened,
onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged,
onOverlayInteractiveHint: options.onOverlayInteractiveHint,
handleOverlayNotificationAction: options.handleOverlayNotificationAction,
openYomitanSettings: options.openYomitanSettings,
recordSubtitleMiningContext: options.recordSubtitleMiningContext,
quitApp: options.quitApp,
@@ -349,6 +372,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
dispatchSessionAction: options.dispatchSessionAction ?? (async () => {}),
getStatsToggleKey: options.getStatsToggleKey,
getMarkWatchedKey: options.getMarkWatchedKey,
getOverlayNotificationPosition: options.getOverlayNotificationPosition,
getControllerConfig: options.getControllerConfig,
saveControllerConfig: options.saveControllerConfig,
saveControllerPreference: options.saveControllerPreference,
@@ -473,6 +497,18 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
electron.BrowserWindow?.fromWebContents((event as IpcMainEvent).sender) ?? null;
deps.onOverlayModalOpened(parsedModal, senderWindow);
});
ipc.on(IPC_CHANNELS.command.overlayNotificationAction, (_event: unknown, payload: unknown) => {
const parsedPayload = parseOverlayNotificationActionPayload(payload);
if (!parsedPayload) return;
void Promise.resolve(
deps.handleOverlayNotificationAction?.(parsedPayload.notificationId, parsedPayload.actionId),
).catch((error) => {
console.warn(
'Failed to handle overlay notification action:',
error instanceof Error ? error.message : String(error),
);
});
});
ipc.handle(
IPC_CHANNELS.request.youtubePickerResolve,
@@ -641,6 +677,10 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getMarkWatchedKey();
});
ipc.handle(IPC_CHANNELS.request.getOverlayNotificationPosition, () => {
return deps.getOverlayNotificationPosition();
});
ipc.handle(IPC_CHANNELS.request.getControllerConfig, () => {
return deps.getControllerConfig();
});
@@ -32,6 +32,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides,
};
}
@@ -27,6 +27,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides,
};
}
@@ -25,6 +25,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
mineSentenceCount: (count) => calls.push(`mine:${count}`),
toggleSecondarySub: () => calls.push('secondary'),
toggleSubtitleSidebar: () => calls.push('sidebar'),
toggleNotificationHistory: () => calls.push('notification-history'),
markLastCardAsAudioCard: async () => {
calls.push('audio');
},
+4
View File
@@ -14,6 +14,7 @@ export interface SessionActionExecutorDeps {
mineSentenceCount: (count: number) => void;
toggleSecondarySub: () => void;
toggleSubtitleSidebar: () => void;
toggleNotificationHistory: () => void;
markLastCardAsAudioCard: () => Promise<void>;
markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void;
@@ -79,6 +80,9 @@ export async function dispatchSessionAction(
case 'toggleSubtitleSidebar':
deps.toggleSubtitleSidebar();
return;
case 'toggleNotificationHistory':
deps.toggleNotificationHistory();
return;
case 'markAudioCard':
await deps.markLastCardAsAudioCard();
return;
+5 -1
View File
@@ -26,6 +26,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
...overrides,
};
}
@@ -195,7 +196,10 @@ test('compileSessionBindings keeps mouse buttons scoped to keybindings', () => {
platform: 'win32',
});
assert.deepEqual(result.bindings.map((binding) => binding.sourcePath), ['keybindings[0].key']);
assert.deepEqual(
result.bindings.map((binding) => binding.sourcePath),
['keybindings[0].key'],
);
assert.deepEqual(
result.warnings.map((warning) => `${warning.kind}:${warning.path}`),
['unsupported:shortcuts.openJimaku'],
+1
View File
@@ -59,6 +59,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
];
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
+29 -6
View File
@@ -158,6 +158,7 @@ export interface AppReadyRuntimeDeps {
shouldRunHeadlessInitialCommand?: () => boolean;
shouldUseMinimalStartup?: () => boolean;
shouldSkipHeavyStartup?: () => boolean;
shouldHandleInitialArgsBeforeDeferredOverlayWarmup?: () => boolean;
}
const REQUIRED_ANKI_FIELD_MAPPING_KEYS = [
@@ -229,6 +230,23 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const startupStartedAtMs = now();
const ensureYomitanExtensionReady =
deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension;
let firstRunSetupHandled = false;
let initialArgsHandled = false;
const handleFirstRunSetupOnce = async (): Promise<void> => {
if (firstRunSetupHandled) {
return;
}
firstRunSetupHandled = true;
await deps.handleFirstRunSetup();
};
const handleInitialArgsOnce = (): void => {
if (initialArgsHandled) {
return;
}
initialArgsHandled = true;
deps.handleInitialArgs();
};
deps.ensureDefaultConfigBootstrap();
if (deps.shouldRunHeadlessInitialCommand?.()) {
deps.reloadConfig();
@@ -247,7 +265,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig();
deps.handleInitialArgs();
handleInitialArgsOnce();
return;
}
@@ -256,8 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (deps.shouldSkipHeavyStartup?.()) {
await ensureYomitanExtensionReady();
deps.reloadConfig();
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
return;
}
@@ -332,10 +350,15 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
deps.initializeOverlayRuntime();
} else {
deps.log('Overlay runtime deferred: waiting for explicit overlay command.');
await ensureYomitanExtensionReady();
if (deps.shouldHandleInitialArgsBeforeDeferredOverlayWarmup?.()) {
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
} else {
await ensureYomitanExtensionReady();
}
}
await deps.handleFirstRunSetup();
deps.handleInitialArgs();
await handleFirstRunSetupOnce();
handleInitialArgsOnce();
deps.logDebug?.(`App-ready critical path finished in ${now() - startupStartedAtMs}ms.`);
}
+2
View File
@@ -19,6 +19,7 @@ export interface ConfiguredShortcuts {
openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined;
toggleSubtitleSidebar: string | null | undefined;
toggleNotificationHistory: string | null | undefined;
}
export function resolveConfiguredShortcuts(
@@ -67,5 +68,6 @@ export function resolveConfiguredShortcuts(
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
toggleSubtitleSidebar: normalizeShortcut(shortcutValue('toggleSubtitleSidebar')),
toggleNotificationHistory: normalizeShortcut(shortcutValue('toggleNotificationHistory')),
};
}
+29 -1
View File
@@ -192,6 +192,7 @@ import {
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
shouldRefreshAnilistOnConfigReload,
shouldStartAutomaticUpdateChecks,
} from './main/runtime/startup-mode-flags';
@@ -604,7 +605,11 @@ import {
} from './main/runtime/update/release-assets';
import { shouldFetchReleaseMetadataForPlatform } from './main/runtime/update/release-metadata-policy';
import { updateLauncherFromRelease } from './main/runtime/update/launcher-updater';
import { notifyUpdateAvailable } from './main/runtime/update/update-notifications';
import {
INSTALL_UPDATE_ACTION_ID,
notifyUpdateAvailable,
UPDATE_AVAILABLE_NOTIFICATION_ID,
} from './main/runtime/update/update-notifications';
import { withConfiguredOverlayNotificationPosition } from './main/runtime/overlay-notification-position';
import {
getPlaybackFeedbackNotificationOptions,
@@ -3367,6 +3372,10 @@ function dismissOverlayNotification(id: string): void {
sendOverlayNotificationEvent({ id, dismiss: true });
}
function toggleNotificationHistoryPanel(): void {
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
}
function showConfiguredStatusNotification(
message: string,
options: ConfiguredStatusNotificationOptions = {},
@@ -5082,6 +5091,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},
@@ -6707,6 +6718,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
mineSentenceCount: (count) => handleMineSentenceDigit(count),
toggleSecondarySub: () => handleCycleSecondarySubMode(),
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
toggleNotificationHistory: () => toggleNotificationHistoryPanel(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
markActiveVideoWatched: async () => {
ensureImmersionTrackerStarted();
@@ -6855,6 +6867,21 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
linuxOverlayInteractiveHint = interactive;
applyLinuxOverlayInputShapeFromLatestMeasurement();
},
handleOverlayNotificationAction: (notificationId, actionId) => {
if (
notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID &&
actionId === INSTALL_UPDATE_ACTION_ID
) {
void getUpdateService()
.checkForUpdates({
source: 'manual',
installWhenAvailable: true,
})
.catch((error) => {
logger.warn('Failed to install update from overlay notification action:', error);
});
}
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
recordSubtitleMiningContext: (context) => recordSubtitleMiningContext(context),
@@ -6996,6 +7023,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => getResolvedConfig().stats.toggleKey,
getMarkWatchedKey: () => getResolvedConfig().stats.markWatchedKey,
getOverlayNotificationPosition: () => getResolvedConfig().notifications.overlayPosition,
getControllerConfig: () => getResolvedConfig().controller,
saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
+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,
};
}
+4
View File
@@ -60,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'];
@@ -83,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'];
@@ -243,6 +245,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,
@@ -264,6 +267,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,
+9
View File
@@ -118,6 +118,15 @@ 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*=>/);
assert.match(source, /notificationId === UPDATE_AVAILABLE_NOTIFICATION_ID/);
assert.match(source, /actionId === INSTALL_UPDATE_ACTION_ID/);
assert.match(source, /installWhenAvailable:\s*true/);
});
test('subtitle change re-prioritizes prefetch around live playback before tokenizing current line', () => {
const source = readMainSource();
const actionBlock = source.match(
@@ -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,
});
}
@@ -365,6 +365,49 @@ test('autoplay ready gate retries deferred readiness without an external flush e
);
});
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;
+1 -1
View File
@@ -2,7 +2,7 @@ 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 = 75;
const MAX_PENDING_AUTOPLAY_READY_RETRY_ATTEMPTS = 150;
type MpvClientLike = {
connected?: boolean;
@@ -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: () => {},
@@ -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,
};
}
@@ -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 {
@@ -36,6 +36,28 @@ test('notifyUpdateAvailable routes notification surfaces from config', async ()
]);
});
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[] = [];
@@ -1,6 +1,9 @@
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;
@@ -17,9 +20,12 @@ export async function notifyUpdateAvailable(
const message = `SubMiner v${options.version} is available`;
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 === 'osd-system') {
@@ -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 }),
+6 -3
View File
@@ -15,6 +15,7 @@ export interface UpdateCheckRequest {
source: UpdateCheckSource;
force?: boolean;
launcherPath?: string;
installWhenAvailable?: boolean;
}
export type UpdateCheckStatus =
@@ -164,9 +165,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;
+7
View File
@@ -60,6 +60,7 @@ import type {
YoutubePickerResolveRequest,
YoutubePickerResolveResult,
OverlayNotificationEventPayload,
OverlayNotificationPosition,
} from './types';
import { IPC_CHANNELS } from './shared/ipc/contracts';
@@ -212,6 +213,9 @@ const onOverlayNotificationEvent =
IPC_CHANNELS.event.overlayNotification,
(payload) => payload as OverlayNotificationEventPayload,
);
const onNotificationHistoryToggleEvent = createQueuedIpcListener(
IPC_CHANNELS.event.notificationHistoryToggle,
);
const onSubtitleVisibilityEvent = createLatestValueIpcListenerWithPayload<boolean>(
IPC_CHANNELS.event.subtitleVisibility,
(payload) => payload === true,
@@ -239,6 +243,7 @@ const electronAPI: ElectronAPI = {
sendOverlayNotificationAction: (notificationId: string, actionId: string) => {
ipcRenderer.send(IPC_CHANNELS.command.overlayNotificationAction, { notificationId, actionId });
},
onNotificationHistoryToggle: onNotificationHistoryToggleEvent,
onVisibility: (callback: (visible: boolean) => void) => {
onSubtitleVisibilityEvent(callback);
@@ -312,6 +317,8 @@ const electronAPI: ElectronAPI = {
ipcRenderer.invoke(IPC_CHANNELS.command.dispatchSessionAction, { actionId, payload }),
getStatsToggleKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getStatsToggleKey),
getOverlayNotificationPosition: (): Promise<OverlayNotificationPosition> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getOverlayNotificationPosition),
getMarkWatchedKey: (): Promise<string> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getMarkWatchedKey),
markActiveVideoWatched: (): Promise<boolean> =>
+3
View File
@@ -94,6 +94,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openControllerSelect: null,
openControllerDebug: null,
toggleSubtitleSidebar: null,
toggleNotificationHistory: null,
};
}
@@ -133,6 +134,7 @@ function installKeyboardTestGlobals() {
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleNotificationHistory: '',
toggleVisibleOverlayGlobal: '',
};
let markActiveVideoWatchedResult = true;
@@ -1178,6 +1180,7 @@ test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', a
openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C',
toggleSubtitleSidebar: '',
toggleNotificationHistory: '',
toggleVisibleOverlayGlobal: '',
});
testGlobals.setStatsToggleKey('');
+25
View File
@@ -48,6 +48,31 @@
aria-live="polite"
aria-atomic="false"
></div>
<aside
id="overlayNotificationHistory"
class="notification-history side-right"
role="dialog"
aria-label="Notification history"
aria-hidden="true"
>
<header class="notification-history-header">
<span class="notification-history-title">Notifications</span>
<div class="notification-history-header-actions">
<button class="notification-history-clear" type="button">Clear</button>
<button
class="notification-history-close"
type="button"
aria-label="Close notification history"
>
×
</button>
</div>
</header>
<div class="notification-history-body">
<ul class="notification-history-list"></ul>
<div class="notification-history-empty">No notifications yet</div>
</div>
</aside>
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>
@@ -201,6 +201,8 @@ function describeSessionAction(
return 'Toggle secondary subtitle mode';
case 'toggleSubtitleSidebar':
return 'Toggle subtitle sidebar';
case 'toggleNotificationHistory':
return 'Toggle notification history';
case 'markAudioCard':
return 'Mark audio card';
case 'markWatched':
@@ -254,6 +256,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
case 'toggleVisibleOverlay':
case 'toggleSecondarySub':
case 'toggleSubtitleSidebar':
case 'toggleNotificationHistory':
return 'Overlay controls';
case 'triggerSubsync':
return 'Subtitle sync';
@@ -85,6 +85,13 @@ function collectInteractiveRects(ctx: RendererContext): OverlayContentRect[] {
}
}
if (ctx.state?.notificationHistoryOpen) {
const historyRect = toMeasuredRect(ctx.dom.overlayNotificationHistory.getBoundingClientRect());
if (historyRect && hasArea(historyRect)) {
rects.push(historyRect);
}
}
return rects;
}
+1
View File
@@ -32,6 +32,7 @@ export function syncOverlayMouseIgnoreState(ctx: RendererContext): void {
ctx.state.isOverSubtitle ||
ctx.state.isOverSubtitleSidebar ||
ctx.state.isOverOverlayNotification ||
ctx.state.isOverNotificationHistory ||
shouldKeepWindowInteractive;
const shouldMarkOverlayInteractive = ctx.platform?.isLinuxPlatform
? shouldKeepWindowInteractive
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { OverlayNotificationEntry } from './overlay-notifications';
import {
createOverlayNotificationHistoryStore,
resolveHistorySideFromStack,
} from './overlay-notification-history';
function entry(
overrides: Partial<OverlayNotificationEntry> & { id: string },
): OverlayNotificationEntry {
return {
title: overrides.title ?? overrides.id,
persistent: false,
createdAt: 0,
...overrides,
};
}
test('history store lists newest entries first', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a', title: 'A' }));
store.record(entry({ id: 'b', title: 'B' }));
store.record(entry({ id: 'c', title: 'C' }));
assert.deepEqual(
store.list().map((item) => item.id),
['c', 'b', 'a'],
);
assert.equal(store.size(), 3);
});
test('history store updates an entry in place without reordering or duplicating', () => {
let clock = 100;
const store = createOverlayNotificationHistoryStore({ now: () => clock });
store.record(entry({ id: 'job', title: 'Working', body: 'Step 1', variant: 'progress' }));
store.record(entry({ id: 'other', title: 'Other' }));
clock = 200;
store.record(entry({ id: 'job', title: 'Done', body: 'Step 2', variant: 'success' }));
const list = store.list();
assert.equal(store.size(), 2);
// Newest-first ordering is by first-seen; the in-place update keeps 'other' on top.
assert.deepEqual(
list.map((item) => item.id),
['other', 'job'],
);
const job = list.find((item) => item.id === 'job');
assert.equal(job?.title, 'Done');
assert.equal(job?.body, 'Step 2');
assert.equal(job?.variant, 'success');
assert.equal(job?.createdAt, 100);
assert.equal(job?.updatedAt, 200);
});
test('history store removes and clears entries', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a' }));
store.record(entry({ id: 'b' }));
store.remove('a');
assert.deepEqual(
store.list().map((item) => item.id),
['b'],
);
store.clear();
assert.equal(store.size(), 0);
assert.deepEqual(store.list(), []);
});
test('history store caps to max and drops the oldest entries', () => {
const store = createOverlayNotificationHistoryStore({ max: 2 });
store.record(entry({ id: 'a' }));
store.record(entry({ id: 'b' }));
store.record(entry({ id: 'c' }));
assert.equal(store.size(), 2);
assert.deepEqual(
store.list().map((item) => item.id),
['c', 'b'],
);
});
test('history store defaults missing variant to info', () => {
const store = createOverlayNotificationHistoryStore();
store.record(entry({ id: 'a' }));
assert.equal(store.list()[0]?.variant, 'info');
});
test('panel side mirrors the notification stack position', () => {
const stackWith = (positionClass: string) =>
({ classList: { contains: (token: string) => token === positionClass } }) as unknown as Element;
assert.equal(resolveHistorySideFromStack(stackWith('position-top-left')), 'left');
assert.equal(resolveHistorySideFromStack(stackWith('position-top-right')), 'right');
// Center notifications open the panel from the right.
assert.equal(resolveHistorySideFromStack(stackWith('position-top')), 'right');
});
@@ -0,0 +1,241 @@
import type { OverlayNotificationVariant } from '../types';
import type { RendererContext } from './context';
import type { OverlayNotificationEntry } from './overlay-notifications.js';
import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
export const DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX = 200;
const OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES = [
'info',
'progress',
'success',
'warning',
'error',
] as const;
export type OverlayNotificationHistoryEntry = {
id: string;
title: string;
body?: string;
image?: string;
variant: OverlayNotificationVariant;
createdAt: number;
updatedAt: number;
};
export type OverlayNotificationHistoryStoreOptions = {
max?: number;
now?: () => number;
};
function normalizeVariant(
variant: OverlayNotificationVariant | undefined,
): OverlayNotificationVariant {
return variant ?? 'info';
}
/**
* Session-scoped log of every overlay notification that was shown. Entries are keyed by id so a
* progress notification that updates in place (same id, new body) overwrites its record rather than
* piling up duplicates. Ordering is by first-seen so the panel can render newest-first.
*/
export function createOverlayNotificationHistoryStore(
options: OverlayNotificationHistoryStoreOptions = {},
) {
const max = Math.max(1, options.max ?? DEFAULT_OVERLAY_NOTIFICATION_HISTORY_MAX);
const now = options.now ?? (() => Date.now());
const entries = new Map<string, OverlayNotificationHistoryEntry>();
function record(entry: OverlayNotificationEntry): OverlayNotificationHistoryEntry {
const timestamp = now();
const existing = entries.get(entry.id);
const next: OverlayNotificationHistoryEntry = {
id: entry.id,
title: entry.title,
body: entry.body,
image: entry.image,
variant: normalizeVariant(entry.variant),
createdAt: existing?.createdAt ?? timestamp,
updatedAt: timestamp,
};
// Setting an existing key keeps its original insertion slot, so an in-place update (same id,
// new body) refreshes content without jumping the entry to the top of the panel.
entries.set(entry.id, next);
while (entries.size > max) {
const oldest = entries.keys().next().value;
if (oldest === undefined) break;
entries.delete(oldest);
}
return next;
}
function remove(id: string): void {
entries.delete(id);
}
function clear(): void {
entries.clear();
}
function list(): OverlayNotificationHistoryEntry[] {
// Newest first.
return [...entries.values()].reverse();
}
function size(): number {
return entries.size;
}
return { record, remove, clear, list, size };
}
export type OverlayNotificationHistorySide = 'left' | 'right';
/**
* The history panel slides in from the same edge the notifications use: left when notifications are
* top-left, right otherwise (including center). We read the live position class off the notification
* stack so the panel always tracks the configured/last-used position.
*/
export function resolveHistorySideFromStack(stack: Element): OverlayNotificationHistorySide {
return stack.classList.contains('position-top-left') ? 'left' : 'right';
}
export function createOverlayNotificationHistoryPanel(
ctx: RendererContext,
options: { onChanged?: () => void } = {},
) {
const store = createOverlayNotificationHistoryStore();
const panel = ctx.dom.overlayNotificationHistory;
const list = panel.querySelector<HTMLUListElement>('.notification-history-list');
const empty = panel.querySelector<HTMLElement>('.notification-history-empty');
const clearButton = panel.querySelector<HTMLButtonElement>('.notification-history-clear');
const closeButton = panel.querySelector<HTMLButtonElement>('.notification-history-close');
let open = false;
function setInteractive(value: boolean): void {
ctx.state.isOverNotificationHistory = value;
syncOverlayMouseIgnoreState(ctx);
}
function applySide(): void {
const side = resolveHistorySideFromStack(ctx.dom.overlayNotificationStack);
panel.classList.toggle('side-left', side === 'left');
panel.classList.toggle('side-right', side === 'right');
}
function formatTime(timestamp: number): string {
try {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return '';
}
}
function buildItem(entry: OverlayNotificationHistoryEntry): HTMLLIElement {
const item = document.createElement('li');
item.className = 'notification-history-item';
for (const variant of OVERLAY_NOTIFICATION_HISTORY_VARIANT_CLASSES) {
item.classList.toggle(variant, variant === entry.variant);
}
item.dataset.notificationId = entry.id;
const trimmedImage = entry.image?.trim();
const leading = trimmedImage ? document.createElement('img') : document.createElement('span');
leading.className = trimmedImage ? 'notification-history-thumb' : 'notification-history-icon';
leading.setAttribute('aria-hidden', 'true');
if (trimmedImage) {
const image = leading as HTMLImageElement;
image.src = trimmedImage;
image.alt = '';
image.decoding = 'async';
}
const content = document.createElement('div');
content.className = 'notification-history-content';
const title = document.createElement('div');
title.className = 'notification-history-item-title';
title.textContent = entry.title;
content.append(title);
if (entry.body && entry.body.trim().length > 0) {
const body = document.createElement('div');
body.className = 'notification-history-item-body';
body.textContent = entry.body;
content.append(body);
}
const time = document.createElement('time');
time.className = 'notification-history-time';
time.dateTime = new Date(entry.createdAt).toISOString();
time.textContent = formatTime(entry.createdAt);
content.append(time);
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'notification-history-remove';
remove.setAttribute('aria-label', 'Remove from history');
remove.textContent = '×';
remove.addEventListener('click', () => {
store.remove(entry.id);
render();
});
item.append(leading, content, remove);
return item;
}
function render(): void {
if (!list || !empty) return;
const entries = store.list();
list.replaceChildren(...entries.map(buildItem));
empty.classList.toggle('hidden', entries.length > 0);
if (clearButton) clearButton.disabled = entries.length === 0;
options.onChanged?.();
}
function setOpen(next: boolean): void {
if (open === next) return;
open = next;
ctx.state.notificationHistoryOpen = next;
if (next) {
applySide();
render();
}
panel.classList.toggle('open', next);
panel.setAttribute('aria-hidden', next ? 'false' : 'true');
setInteractive(next);
options.onChanged?.();
}
clearButton?.addEventListener('click', () => {
store.clear();
render();
});
closeButton?.addEventListener('click', () => setOpen(false));
panel.addEventListener('mouseenter', () => {
if (open) setInteractive(true);
});
panel.addEventListener('mouseleave', () => setInteractive(false));
function record(entry: OverlayNotificationEntry): void {
store.record(entry);
if (open) render();
}
function toggle(): void {
setOpen(!open);
}
return {
record,
toggle,
open: () => setOpen(true),
close: () => setOpen(false),
isOpen: () => open,
};
}
+93 -2
View File
@@ -41,13 +41,16 @@ type FakeElement = {
classList: ReturnType<typeof createClassList>;
append: (...children: FakeElement[]) => void;
replaceChildren: (...children: FakeElement[]) => void;
remove: () => void;
setAttribute: (name: string, value: string) => void;
getAttribute: (name: string) => string | null;
addEventListener: (type: string, listener: (event?: unknown) => void) => void;
dispatchEventType: (type: string, event?: unknown) => void;
};
function createFakeElement(tagName = 'div'): FakeElement {
const attributes = new Map<string, string>();
const listeners = new Map<string, Array<(event?: unknown) => void>>();
const element: FakeElement = {
tagName: tagName.toUpperCase(),
className: '',
@@ -68,7 +71,13 @@ function createFakeElement(tagName = 'div'): FakeElement {
attributes.set(name, value);
},
getAttribute: (name) => attributes.get(name) ?? null,
addEventListener: () => undefined,
remove: () => undefined,
addEventListener: (type, listener) => {
listeners.set(type, [...(listeners.get(type) ?? []), listener]);
},
dispatchEventType: (type, event) => {
for (const listener of listeners.get(type) ?? []) listener(event);
},
};
return element;
}
@@ -197,11 +206,90 @@ test('overlay notification renderer shows thumbnail image from payload', () => {
}
});
test('overlay notification action buttons send action ids', () => {
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const stack = createFakeElement();
const sentActions: Array<{ notificationId: string; actionId: string }> = [];
Object.defineProperty(globalThis, 'document', {
configurable: true,
writable: true,
value: {
createElement: (tagName: string) => createFakeElement(tagName),
},
});
Object.defineProperty(globalThis, 'window', {
configurable: true,
writable: true,
value: {
clearTimeout: () => undefined,
setTimeout: () => {
return 1;
},
electronAPI: {
sendOverlayNotificationAction: (notificationId: string, actionId: string) => {
sentActions.push({ notificationId, actionId });
},
},
},
});
try {
const renderer = createOverlayNotificationRenderer({
dom: {
overlayNotificationStack: stack,
},
state: {
isOverOverlayNotification: false,
},
} as never);
renderer.show({
id: 'subminer-update-available',
title: 'SubMiner update available',
body: 'SubMiner v0.15.0 is available',
persistent: true,
actions: [{ id: 'install-update', label: 'Update' }],
});
const card = stack.children[0];
if (!card) {
assert.fail('Expected overlay notification card.');
}
const button = findChildByClass(card, 'overlay-notification-action');
if (!button) {
assert.fail('Expected overlay notification action button.');
}
button.dispatchEventType('click');
assert.deepEqual(sentActions, [
{ notificationId: 'subminer-update-available', actionId: 'install-update' },
]);
} finally {
if (originalDocument) {
Object.defineProperty(globalThis, 'document', originalDocument);
} else {
delete (globalThis as { document?: unknown }).document;
}
if (originalWindow) {
Object.defineProperty(globalThis, 'window', originalWindow);
} else {
delete (globalThis as { window?: unknown }).window;
}
}
});
test('overlay notification cards use larger display dimensions', () => {
assert.match(
overlayNotificationCss,
/\.overlay-notification-stack\s*\{[^}]*width:\s*min\(420px,\s*calc\(100vw - 32px\)\);/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-stack\s*\{[^}]*z-index:\s*2147483647\s*!important;/s,
);
assert.match(overlayNotificationCss, /\.overlay-notification-card\s*\{[^}]*min-height:\s*72px;/s);
assert.match(
overlayNotificationCss,
@@ -213,7 +301,10 @@ test('overlay notification cards use larger display dimensions', () => {
overlayNotificationCss,
/\.overlay-notification-card\.has-image\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*100px\)\s+minmax\(0,\s*1fr\)\s+22px;/s,
);
assert.match(overlayNotificationCss, /\.overlay-notification-image\s*\{[^}]*max-width:\s*100px;/s);
assert.match(
overlayNotificationCss,
/\.overlay-notification-image\s*\{[^}]*max-width:\s*100px;/s,
);
assert.match(
overlayNotificationCss,
/\.overlay-notification-image\s*\{[^}]*aspect-ratio:\s*100 \/ 56;/s,
+2 -1
View File
@@ -145,7 +145,7 @@ function setInteractiveState(ctx: RendererContext, value: boolean): void {
export function createOverlayNotificationRenderer(
ctx: RendererContext,
options: { onChanged?: () => void } = {},
options: { onChanged?: () => void; onShow?: (entry: OverlayNotificationEntry) => void } = {},
) {
const store = createOverlayNotificationStore();
const timers = new Map<string, number>();
@@ -321,6 +321,7 @@ export function createOverlayNotificationRenderer(
function show(payload: OverlayNotificationPayload): string {
const entry = store.upsert(payload);
position = entry.position ?? DEFAULT_OVERLAY_NOTIFICATION_POSITION;
options.onShow?.(entry);
clearTimer(entry.id);
if (!entry.persistent) {
const timeoutMs = Math.max(0, entry.timeoutMs ?? DEFAULT_OVERLAY_NOTIFICATION_TIMEOUT_MS);
+41
View File
@@ -48,7 +48,9 @@ import { syncOverlayMouseIgnoreState } from './overlay-mouse-ignore.js';
import {
createOverlayNotificationRenderer,
handleOverlayNotificationEvent,
overlayNotificationPositionClass,
} from './overlay-notifications.js';
import { createOverlayNotificationHistoryPanel } from './overlay-notification-history.js';
import { createRendererState } from './state.js';
import { createSubtitleRenderer } from './subtitle-render.js';
import { isYomitanPopupVisible, registerYomitanLookupListener } from './yomitan-popup.js';
@@ -116,8 +118,12 @@ function syncSettingsModalSubtitleSuppression(): void {
const subtitleRenderer = createSubtitleRenderer(ctx);
const measurementReporter = createOverlayContentMeasurementReporter(ctx);
const notificationHistory = createOverlayNotificationHistoryPanel(ctx, {
onChanged: () => measurementReporter.schedule(),
});
const overlayNotifications = createOverlayNotificationRenderer(ctx, {
onChanged: () => measurementReporter.schedule(),
onShow: (entry) => notificationHistory.record(entry),
});
const positioning = createPositioningController(ctx);
const runtimeOptionsModal = createRuntimeOptionsModal(ctx, {
@@ -432,12 +438,30 @@ function restoreOverlayInteractionAfterError(): void {
}
}
const OVERLAY_TOAST_POSITION_CLASSES = [
'position-top-left',
'position-top',
'position-top-right',
] as const;
// Mirror the notification stack's current position onto a toast so error/status toasts honor the
// configured `notifications.overlayPosition` instead of always pinning to the top-right corner.
function applyConfiguredToastPosition(toast: HTMLElement): void {
const stackClasses = ctx.dom.overlayNotificationStack.classList;
const active =
OVERLAY_TOAST_POSITION_CLASSES.find((cls) => stackClasses.contains(cls)) ??
'position-top-right';
toast.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
toast.classList.add(active);
}
function showOverlayErrorToast(message: string): void {
if (overlayErrorToastTimeout) {
clearTimeout(overlayErrorToastTimeout);
overlayErrorToastTimeout = null;
}
ctx.dom.overlayErrorToast.textContent = message;
applyConfiguredToastPosition(ctx.dom.overlayErrorToast);
ctx.dom.overlayErrorToast.classList.remove('hidden');
overlayErrorToastTimeout = setTimeout(() => {
ctx.dom.overlayErrorToast.classList.add('hidden');
@@ -624,9 +648,26 @@ async function init(): Promise<void> {
handleOverlayNotificationEvent(overlayNotifications, payload);
});
});
window.electronAPI.onNotificationHistoryToggle(() => {
runGuarded('notification-history:toggle', () => {
notificationHistory.toggle();
});
});
await keyboardHandlers.setupMpvInputForwarding();
// Seed the notification stack position from config so the stack, error/status toasts, and the
// notification history panel side are correct before the first notification arrives.
try {
const overlayNotificationPosition = await window.electronAPI.getOverlayNotificationPosition();
ctx.dom.overlayNotificationStack.classList.remove(...OVERLAY_TOAST_POSITION_CLASSES);
ctx.dom.overlayNotificationStack.classList.add(
overlayNotificationPositionClass(overlayNotificationPosition),
);
} catch {
// Non-fatal: keep the default position class from index.html.
}
const initialSubtitleStyle = await window.electronAPI.getSubtitleStyle();
subtitleRenderer.applySubtitleStyle(initialSubtitleStyle);
subtitleRenderer.updatePrimarySubMode(initialSubtitleStyle?.primaryDefaultMode ?? 'visible');
+4
View File
@@ -32,6 +32,8 @@ export type RendererState = {
isOverSubtitle: boolean;
isOverSubtitleSidebar: boolean;
isOverOverlayNotification: boolean;
isOverNotificationHistory: boolean;
notificationHistoryOpen: boolean;
isDragging: boolean;
dragStartY: number;
startYPercent: number;
@@ -145,6 +147,8 @@ export function createRendererState(): RendererState {
isOverSubtitle: false,
isOverSubtitleSidebar: false,
isOverOverlayNotification: false,
isOverNotificationHistory: false,
notificationHistoryOpen: false,
isDragging: false,
dragStartY: 0,
startYPercent: 0,
+304 -1
View File
@@ -146,6 +146,27 @@ body:focus-visible,
transform: translateY(0);
}
/* Follow the configured notification position (default stays top-right). */
.overlay-error-toast.position-top-left {
left: 16px;
right: auto;
}
.overlay-error-toast.position-top {
left: 50%;
right: auto;
transform: translate(-50%, -6px);
}
.overlay-error-toast.position-top:not(.hidden) {
transform: translate(-50%, 0);
}
.overlay-error-toast.position-top-right {
left: auto;
right: 16px;
}
.overlay-notification-stack {
position: absolute;
top: 16px;
@@ -154,7 +175,7 @@ body:focus-visible,
flex-direction: column;
gap: 8px;
pointer-events: auto;
z-index: 1350;
z-index: 2147483647 !important;
}
.overlay-notification-stack.position-top-left {
@@ -461,6 +482,288 @@ body:focus-visible,
}
}
/* Notification history panel — slides in from the same edge the notifications use. */
.notification-history {
--notification-history-width: min(380px, calc(100vw - 24px));
position: absolute;
top: 0;
bottom: 0;
width: var(--notification-history-width);
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--ctp-mantle) 94%, transparent);
border: 1px solid var(--ctp-surface0);
box-shadow: 0 18px 48px -18px rgba(24, 25, 38, 0.85);
color: var(--ctp-text);
pointer-events: auto;
z-index: 2147483646;
opacity: 0;
visibility: hidden;
transition:
transform 240ms cubic-bezier(0.21, 1.02, 0.73, 1),
opacity 200ms ease,
visibility 0s linear 240ms;
}
.notification-history.side-left {
left: 0;
right: auto;
border-left: none;
border-top-right-radius: 14px;
border-bottom-right-radius: 14px;
transform: translateX(-104%);
}
.notification-history.side-right {
left: auto;
right: 0;
border-right: none;
border-top-left-radius: 14px;
border-bottom-left-radius: 14px;
transform: translateX(104%);
}
.notification-history.open {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition:
transform 260ms cubic-bezier(0.21, 1.02, 0.73, 1),
opacity 200ms ease,
visibility 0s linear 0s;
}
.notification-history-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--ctp-surface0);
background: color-mix(in srgb, var(--ctp-crust) 60%, transparent);
}
.notification-history-title {
font-size: 14px;
font-weight: 800;
letter-spacing: 0.2px;
color: var(--ctp-lavender);
}
.notification-history-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.notification-history-clear {
padding: 5px 12px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--ctp-mauve) 38%, var(--ctp-surface1));
background: color-mix(in srgb, var(--ctp-mauve) 14%, var(--ctp-surface0));
color: var(--ctp-text);
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
background 120ms ease,
border-color 120ms ease,
opacity 120ms ease;
}
.notification-history-clear:hover:not(:disabled) {
border-color: var(--ctp-mauve);
background: color-mix(in srgb, var(--ctp-mauve) 26%, var(--ctp-surface0));
}
.notification-history-clear:disabled {
opacity: 0.4;
cursor: default;
}
.notification-history-close {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: none;
border-radius: 7px;
background: transparent;
color: var(--ctp-overlay1);
font: inherit;
font-size: 18px;
line-height: 1;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.notification-history-close:hover {
background: color-mix(in srgb, var(--ctp-red) 18%, transparent);
color: var(--ctp-red);
}
.notification-history-body {
position: relative;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
scrollbar-width: thin;
scrollbar-color: var(--ctp-surface2) transparent;
}
.notification-history-body::-webkit-scrollbar {
width: 8px;
}
.notification-history-body::-webkit-scrollbar-thumb {
background: var(--ctp-surface1);
border-radius: 8px;
}
.notification-history-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
margin: 0;
padding: 0;
}
.notification-history-item {
--notification-history-accent: var(--ctp-blue);
position: relative;
display: grid;
grid-template-columns: 4px auto minmax(0, 1fr) 22px;
gap: 10px;
align-items: start;
padding: 11px 12px;
border-radius: 10px;
border: 1px solid var(--ctp-surface0);
background: var(--ctp-base);
}
.notification-history-item::before {
content: '';
align-self: stretch;
border-radius: 4px;
background: var(--notification-history-accent);
}
.notification-history-item.info {
--notification-history-accent: var(--ctp-blue);
}
.notification-history-item.progress {
--notification-history-accent: var(--ctp-sky);
}
.notification-history-item.success {
--notification-history-accent: var(--ctp-green);
}
.notification-history-item.warning {
--notification-history-accent: var(--ctp-yellow);
}
.notification-history-item.error {
--notification-history-accent: var(--ctp-red);
}
.notification-history-thumb {
width: 56px;
aspect-ratio: 100 / 56;
height: auto;
align-self: center;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--notification-history-accent) 28%, var(--ctp-surface2));
background: var(--ctp-crust);
object-fit: cover;
}
.notification-history-icon {
width: 10px;
height: 10px;
align-self: center;
border-radius: 50%;
background: var(--notification-history-accent);
}
.notification-history-content {
min-width: 0;
}
.notification-history-item-title {
font-size: 13px;
font-weight: 700;
line-height: 1.3;
color: var(--ctp-text);
}
.notification-history-item-body {
margin-top: 3px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
color: var(--ctp-subtext0);
overflow-wrap: anywhere;
}
.notification-history-time {
display: block;
margin-top: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
color: var(--ctp-overlay1);
}
.notification-history-remove {
width: 22px;
height: 22px;
align-self: start;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ctp-overlay1);
font: inherit;
font-size: 15px;
line-height: 1;
cursor: pointer;
transition:
background 120ms ease,
color 120ms ease;
}
.notification-history-remove:hover {
background: color-mix(in srgb, var(--ctp-red) 18%, transparent);
color: var(--ctp-red);
}
.notification-history-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 96px;
padding: 24px;
text-align: center;
font-size: 13px;
font-weight: 500;
color: var(--ctp-overlay0);
}
.notification-history-empty.hidden {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.notification-history {
transition-duration: 1ms;
}
}
.modal {
position: absolute;
inset: 0;
+2
View File
@@ -3,6 +3,7 @@ export type RendererDom = {
subtitleContainer: HTMLElement;
overlay: HTMLElement;
overlayNotificationStack: HTMLDivElement;
overlayNotificationHistory: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement;
secondarySubContainer: HTMLElement;
@@ -134,6 +135,7 @@ export function resolveRendererDom(): RendererDom {
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
overlay: getRequiredElement<HTMLElement>('overlay'),
overlayNotificationStack: getRequiredElement<HTMLDivElement>('overlayNotificationStack'),
overlayNotificationHistory: getRequiredElement<HTMLElement>('overlayNotificationHistory'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
+2
View File
@@ -62,6 +62,7 @@ export const IPC_CHANNELS = {
getConfigShortcuts: 'get-config-shortcuts',
getStatsToggleKey: 'get-stats-toggle-key',
getMarkWatchedKey: 'get-mark-watched-key',
getOverlayNotificationPosition: 'get-overlay-notification-position',
getControllerConfig: 'get-controller-config',
getSecondarySubMode: 'get-secondary-sub-mode',
getCurrentSecondarySub: 'get-current-secondary-sub',
@@ -146,6 +147,7 @@ export const IPC_CHANNELS = {
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
configHotReload: 'config:hot-reload',
overlayNotification: 'overlay:notification',
notificationHistoryToggle: 'notification-history:toggle',
},
} as const;
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS } from '../../config/definitions';
import { compileSessionBindings } from '../../core/services/session-bindings';
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
import { parseSessionActionDispatchRequest } from './validators';
// Regression guard: SESSION_ACTION_IDS in validators.ts is a hand-maintained mirror of the
// SessionActionId union. If a new shortcut-backed action is added to the union/defaults but not to
// the validator allow-list, the renderer's dispatchSessionAction IPC is rejected at runtime (which
// surfaces as a "Renderer error recovered" toast). Compile every default binding and assert the
// validator accepts each one so the two lists can't silently drift apart.
test('every default session-action binding is accepted by parseSessionActionDispatchRequest', () => {
const { bindings } = compileSessionBindings({
shortcuts: resolveConfiguredShortcuts(DEFAULT_CONFIG, DEFAULT_CONFIG),
keybindings: DEFAULT_KEYBINDINGS,
statsToggleKey: DEFAULT_CONFIG.stats.toggleKey,
statsMarkWatchedKey: DEFAULT_CONFIG.stats.markWatchedKey,
platform: 'linux',
rawConfig: DEFAULT_CONFIG,
});
const sessionActions = bindings.filter((binding) => binding.actionType === 'session-action');
assert.ok(sessionActions.length > 0, 'expected default session-action bindings to exist');
for (const binding of sessionActions) {
if (binding.actionType !== 'session-action') continue;
const request =
binding.payload === undefined
? { actionId: binding.actionId }
: { actionId: binding.actionId, payload: binding.payload };
assert.ok(
parseSessionActionDispatchRequest(request) !== null,
`validator rejected session action: ${binding.actionId}`,
);
}
});
test('toggleNotificationHistory dispatch request is accepted', () => {
assert.deepEqual(parseSessionActionDispatchRequest({ actionId: 'toggleNotificationHistory' }), {
actionId: 'toggleNotificationHistory',
});
});
+2
View File
@@ -20,6 +20,7 @@ const RESERVED_CONTROLLER_PROFILE_IDS = new Set(['__proto__', 'constructor', 'pr
const SESSION_ACTION_IDS: SessionActionId[] = [
'toggleStatsOverlay',
'markWatched',
'toggleVisibleOverlay',
'copySubtitle',
'copySubtitleMultiple',
@@ -31,6 +32,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'toggleSecondarySub',
'markAudioCard',
'toggleSubtitleSidebar',
'toggleNotificationHistory',
'openRuntimeOptions',
'openSessionHelp',
'openCharacterDictionaryManager',
@@ -14,6 +14,8 @@ export interface SubminerPluginRuntimeScriptOptConfig {
texthookerEnabled: boolean;
}
const AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS = 30;
function boolScriptOpt(value: boolean): 'yes' | 'no' {
return value ? 'yes' : 'no';
}
@@ -42,6 +44,7 @@ export function buildSubminerPluginRuntimeScriptOptParts(
`subminer-auto_start_pause_until_ready=${boolScriptOpt(
runtimeConfig.autoStartPauseUntilReady,
)}`,
`subminer-auto_start_pause_until_ready_timeout_seconds=${AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS}`,
`subminer-osd_messages=${boolScriptOpt(runtimeConfig.osdMessages)}`,
`subminer-texthooker_enabled=${boolScriptOpt(runtimeConfig.texthookerEnabled)}`,
];
+1
View File
@@ -125,6 +125,7 @@ export interface ShortcutsConfig {
openControllerSelect?: string | null;
openControllerDebug?: string | null;
toggleSubtitleSidebar?: string | null;
toggleNotificationHistory?: string | null;
}
export interface Config {
+3 -1
View File
@@ -41,7 +41,7 @@ import type {
RuntimeOptionState,
RuntimeOptionValue,
} from './runtime-options';
import type { OverlayNotificationEventPayload } from './notification';
import type { OverlayNotificationEventPayload, OverlayNotificationPosition } from './notification';
export interface WindowGeometry {
x: number;
@@ -408,6 +408,7 @@ export interface ElectronAPI {
onOverlayPointerRecoveryRequested: (callback: () => void) => void;
onOverlayNotification: (callback: (payload: OverlayNotificationEventPayload) => void) => void;
sendOverlayNotificationAction?: (notificationId: string, actionId: string) => void;
onNotificationHistoryToggle: (callback: () => void) => void;
onVisibility: (callback: (visible: boolean) => void) => void;
onSubtitlePosition: (callback: (position: SubtitlePosition | null) => void) => void;
getOverlayVisibility: () => Promise<boolean>;
@@ -436,6 +437,7 @@ export interface ElectronAPI {
) => Promise<void>;
getStatsToggleKey: () => Promise<string>;
getMarkWatchedKey: () => Promise<string>;
getOverlayNotificationPosition: () => Promise<OverlayNotificationPosition>;
markActiveVideoWatched: () => Promise<boolean>;
getControllerConfig: () => Promise<ResolvedControllerConfig>;
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
+1
View File
@@ -13,6 +13,7 @@ export type SessionActionId =
| 'mineSentenceMultiple'
| 'toggleSecondarySub'
| 'toggleSubtitleSidebar'
| 'toggleNotificationHistory'
| 'markAudioCard'
| 'openRuntimeOptions'
| 'openSessionHelp'