mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
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:
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -102,6 +102,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleNotificationHistory: 'Ctrl+N',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'toggleNotificationHistory',
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
|
||||
@@ -582,6 +582,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
if (
|
||||
leaf === 'toggleVisibleOverlayGlobal' ||
|
||||
leaf === 'toggleSubtitleSidebar' ||
|
||||
leaf === 'toggleNotificationHistory' ||
|
||||
leaf === 'toggleSecondarySub' ||
|
||||
leaf === 'toggleStatsOverlay' ||
|
||||
leaf === 'markWatched'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {},
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -190,6 +190,7 @@ import {
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup,
|
||||
shouldRefreshAnilistOnConfigReload,
|
||||
shouldStartAutomaticUpdateChecks,
|
||||
} from './main/runtime/startup-mode-flags';
|
||||
@@ -602,7 +603,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,
|
||||
@@ -3364,6 +3369,10 @@ function dismissOverlayNotification(id: string): void {
|
||||
sendOverlayNotificationEvent({ id, dismiss: true });
|
||||
}
|
||||
|
||||
function toggleNotificationHistoryPanel(): void {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.notificationHistoryToggle);
|
||||
}
|
||||
|
||||
function showConfiguredStatusNotification(
|
||||
message: string,
|
||||
options: ConfiguredStatusNotificationOptions = {},
|
||||
@@ -5079,6 +5088,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
|
||||
shouldUseMinimalStartup: () =>
|
||||
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
|
||||
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup: () =>
|
||||
shouldHandleInitialArgsBeforeDeferredOverlayWarmup(appState.initialArgs),
|
||||
createImmersionTracker: () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
},
|
||||
@@ -6675,6 +6686,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
mineSentenceCount: (count) => handleMineSentenceDigit(count),
|
||||
toggleSecondarySub: () => handleCycleSecondarySubMode(),
|
||||
toggleSubtitleSidebar: () => toggleSubtitleSidebar(),
|
||||
toggleNotificationHistory: () => toggleNotificationHistoryPanel(),
|
||||
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
|
||||
markActiveVideoWatched: async () => {
|
||||
ensureImmersionTrackerStarted();
|
||||
@@ -6823,6 +6835,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),
|
||||
@@ -6964,6 +6991,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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SubminerPluginRuntimeScriptOptConfig {
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
const AUTO_START_PAUSE_UNTIL_READY_TIMEOUT_SECONDS = 30;
|
||||
|
||||
function boolScriptOpt(value: boolean): 'yes' | 'no' {
|
||||
return value ? 'yes' : 'no';
|
||||
}
|
||||
@@ -45,6 +47,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)}`,
|
||||
`subminer-aniskip_enabled=${boolScriptOpt(runtimeConfig.aniskipEnabled)}`,
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface ShortcutsConfig {
|
||||
openControllerSelect?: string | null;
|
||||
openControllerDebug?: string | null;
|
||||
toggleSubtitleSidebar?: string | null;
|
||||
toggleNotificationHistory?: string | null;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type SessionActionId =
|
||||
| 'mineSentenceMultiple'
|
||||
| 'toggleSecondarySub'
|
||||
| 'toggleSubtitleSidebar'
|
||||
| 'toggleNotificationHistory'
|
||||
| 'markAudioCard'
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
|
||||
Reference in New Issue
Block a user