feat(controller): route status messages through notifications

- Send controller connected and disabled messages over a new IPC channel so they follow ankiConnect.behavior.notificationType
- Remove the separate controller status toast from the overlay
- Only announce connected controllers when controller.enabled is on
- Announce a controller again after it reconnects, and update usage docs
This commit is contained in:
sudacode committed 2026-11-03 21:55:55 -08:00
1 parent 1c50d249e0
commit 8976d78390
15 files changed
+84 -167

No files matched your search

+23
View File
@@ -146,6 +146,7 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
saveControllerConfig: async () => {},
reportActiveController: () => {},
getActiveControllerId: () => null,
notifyControllerStatus: () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
@@ -257,6 +258,26 @@ test('registerIpcHandlers stores the controller the overlay reports for Alt+C to
assert.equal(await handlers.handle.get(IPC_CHANNELS.request.getActiveControllerId)?.({}), null);
});
test('registerIpcHandlers forwards controller status messages and drops invalid ones', () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const messages: string[] = [];
registerIpcHandlers(
createRegisterIpcDeps({
notifyControllerStatus: (message) => {
messages.push(message);
},
}),
registrar,
);
const notify = handlers.on.get(IPC_CHANNELS.command.notifyControllerStatus);
notify?.({}, 'Controller connected: pad-1');
notify?.({}, 42);
notify?.({}, ' ');
assert.deepEqual(messages, ['Controller connected: pad-1']);
});
test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: string[] = [];
@@ -361,6 +382,7 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
saveControllerConfig: () => {},
reportActiveController: () => {},
getActiveControllerId: () => null,
notifyControllerStatus: () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
@@ -644,6 +666,7 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
saveControllerConfig: () => {},
reportActiveController: () => {},
getActiveControllerId: () => null,
notifyControllerStatus: () => {},
getSecondarySubMode: () => 'hover',
getCurrentSecondarySub: () => '',
focusMainWindow: () => {},
+8
View File
@@ -92,6 +92,7 @@ export interface IpcServiceDeps {
/** The overlay reports the controller it is reading (null when none). */
reportActiveController: (gamepadId: string | null) => void;
getActiveControllerId: () => string | null;
notifyControllerStatus: (message: string) => void;
getSecondarySubMode: () => unknown;
getCurrentSecondarySub: () => string;
focusMainWindow: () => void;
@@ -362,6 +363,7 @@ export interface IpcDepsRuntimeOptions {
/** The overlay reports the controller it is reading (null when none). */
reportActiveController: (gamepadId: string | null) => void;
getActiveControllerId: () => string | null;
notifyControllerStatus: (message: string) => void;
getSecondarySubMode: () => unknown;
getMpvClient: () => MpvClientLike | null;
focusMainWindow: () => void;
@@ -440,6 +442,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
saveControllerConfig: options.saveControllerConfig,
reportActiveController: options.reportActiveController,
getActiveControllerId: options.getActiveControllerId,
notifyControllerStatus: options.notifyControllerStatus,
getSecondarySubMode: options.getSecondarySubMode,
getCurrentSecondarySub: () => options.getMpvClient()?.currentSecondarySubText || '',
focusMainWindow: () => {
@@ -777,6 +780,11 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.getActiveControllerId();
});
ipc.on(IPC_CHANNELS.command.notifyControllerStatus, (_event: unknown, message: unknown) => {
if (typeof message !== 'string' || message.trim().length === 0) return;
deps.notifyControllerStatus(message);
});
ipc.handle(IPC_CHANNELS.request.getSecondarySubMode, () => {
return deps.getSecondarySubMode();
});
+5
View File
@@ -2102,6 +2102,11 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
activeControllerIdFromOverlay = gamepadId;
},
getActiveControllerId: () => activeControllerIdFromOverlay,
notifyControllerStatus: (message) => {
overlayNotificationsRuntime.showConfiguredStatusNotification(message, {
id: 'controller-status',
});
},
saveControllerConfig: (update) => {
const currentRawConfig = configService.getRawConfig();
configService.patchRawConfig({
+2
View File
@@ -80,6 +80,7 @@ export interface MainIpcRuntimeServiceDepsParams {
saveControllerConfig: IpcDepsRuntimeOptions['saveControllerConfig'];
reportActiveController: IpcDepsRuntimeOptions['reportActiveController'];
getActiveControllerId: IpcDepsRuntimeOptions['getActiveControllerId'];
notifyControllerStatus: IpcDepsRuntimeOptions['notifyControllerStatus'];
getSecondarySubMode: IpcDepsRuntimeOptions['getSecondarySubMode'];
getMpvClient: IpcDepsRuntimeOptions['getMpvClient'];
runSubsyncManual: IpcDepsRuntimeOptions['runSubsyncManual'];
@@ -260,6 +261,7 @@ export function createMainIpcRuntimeServiceDeps(
saveControllerConfig: params.saveControllerConfig,
reportActiveController: params.reportActiveController,
getActiveControllerId: params.getActiveControllerId,
notifyControllerStatus: params.notifyControllerStatus,
focusMainWindow: params.focusMainWindow ?? (() => {}),
activatePlaybackWindowForOverlayInteraction: params.activatePlaybackWindowForOverlayInteraction,
getSecondarySubMode: params.getSecondarySubMode,
+3
View File
@@ -360,6 +360,9 @@ const electronAPI: ElectronAPI = {
reportActiveController: (gamepadId: string | null): void => {
ipcRenderer.send(IPC_CHANNELS.command.reportActiveController, gamepadId);
},
notifyControllerStatus: (message: string): void => {
ipcRenderer.send(IPC_CHANNELS.command.notifyControllerStatus, message);
},
getActiveControllerId: (): Promise<string | null> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getActiveControllerId),
@@ -3,99 +3,40 @@ import test from 'node:test';
import { createControllerStatusIndicator } from './controller-status-indicator.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
};
}
const pad = (id: string, index = 0) => ({ id, index, mapping: 'standard', connected: true });
test('controller status indicator shows once when a controller is first detected and auto-hides', () => {
let nextTimerId = 1;
const scheduled = new Map<number, () => void>();
const classList = createClassList(['hidden']);
const toast = {
textContent: '',
classList,
};
test('controller status indicator notifies once when a controller is first detected', () => {
const messages: string[] = [];
const indicator = createControllerStatusIndicator((message) => messages.push(message));
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
durationMs: 1500,
setTimeout: (callback: () => void) => {
const id = nextTimerId++;
scheduled.set(id, callback);
return id as never;
},
clearTimeout: (id) => {
scheduled.delete(id as never as number);
},
});
indicator.update({ connectedGamepads: [], activeGamepadId: null });
assert.deepEqual(messages, []);
indicator.update({
connectedGamepads: [],
activeGamepadId: null,
});
assert.equal(classList.contains('hidden'), true);
assert.equal(toast.textContent, '');
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
assert.equal(classList.contains('hidden'), false);
assert.match(toast.textContent, /controller detected/i);
assert.match(toast.textContent, /pad-1/i);
assert.equal(scheduled.size, 1);
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
activeGamepadId: 'pad-1',
});
assert.equal(scheduled.size, 1);
const [hide] = scheduled.values();
hide?.();
assert.equal(classList.contains('hidden'), true);
assert.equal(toast.textContent, '');
indicator.update({ connectedGamepads: [pad('pad-1')], activeGamepadId: 'pad-1' });
indicator.update({ connectedGamepads: [pad('pad-1')], activeGamepadId: 'pad-1' });
assert.deepEqual(messages, ['Controller connected: pad-1']);
});
test('controller status indicator announces newly detected controllers after startup', () => {
const toast = {
textContent: '',
classList: createClassList(['hidden']),
};
const indicator = createControllerStatusIndicator({ controllerStatusToast: toast } as never, {
setTimeout: () => 1 as never,
clearTimeout: () => {},
});
const messages: string[] = [];
const indicator = createControllerStatusIndicator((message) => messages.push(message));
indicator.update({ connectedGamepads: [pad('pad-1')], activeGamepadId: 'pad-1' });
indicator.update({
connectedGamepads: [{ id: 'pad-1', index: 0, mapping: 'standard', connected: true }],
connectedGamepads: [pad('pad-1'), pad('pad-2', 1)],
activeGamepadId: 'pad-1',
});
toast.classList.add('hidden');
toast.textContent = '';
assert.deepEqual(messages, ['Controller connected: pad-1', 'Controller connected: pad-2']);
});
indicator.update({
connectedGamepads: [
{ id: 'pad-1', index: 0, mapping: 'standard', connected: true },
{ id: 'pad-2', index: 1, mapping: 'standard', connected: true },
],
activeGamepadId: 'pad-1',
});
test('controller status indicator announces a controller again after it reconnects', () => {
const messages: string[] = [];
const indicator = createControllerStatusIndicator((message) => messages.push(message));
assert.equal(toast.classList.contains('hidden'), false);
assert.match(toast.textContent, /pad-2/i);
indicator.update({ connectedGamepads: [pad('pad-1')], activeGamepadId: 'pad-1' });
indicator.update({ connectedGamepads: [], activeGamepadId: null });
indicator.update({ connectedGamepads: [pad('pad-1')], activeGamepadId: 'pad-1' });
assert.equal(messages.length, 2);
});
+5 -45
View File
@@ -5,67 +5,27 @@ type ControllerSnapshot = {
activeGamepadId: string | null;
};
type ControllerStatusIndicatorOptions = {
durationMs?: number;
setTimeout?: (callback: () => void, delay: number) => ReturnType<typeof setTimeout>;
clearTimeout?: (timer: ReturnType<typeof setTimeout> | number) => void;
};
function getDeviceLabel(device: ControllerDeviceInfo | undefined): string {
if (!device) return 'Controller';
return device.id || `Gamepad ${device.index}`;
}
export function createControllerStatusIndicator(
dom: {
controllerStatusToast: {
textContent: string;
classList: { add: (...entries: string[]) => void; remove: (...entries: string[]) => void };
};
},
options: ControllerStatusIndicatorOptions = {},
) {
const durationMs = options.durationMs ?? 2200;
const scheduleTimeout = options.setTimeout ?? globalThis.setTimeout;
const cancelTimeout =
options.clearTimeout ??
((timer: ReturnType<typeof setTimeout> | number) =>
globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>));
let hideTimeout: ReturnType<typeof setTimeout> | number | null = null;
/** Watches controller snapshots and calls `notify` when a new device appears. */
export function createControllerStatusIndicator(notify: (message: string) => void) {
let previousConnectedIds = new Set<string>();
function show(message: string): void {
if (hideTimeout !== null) {
cancelTimeout(hideTimeout);
hideTimeout = null;
}
dom.controllerStatusToast.textContent = message;
dom.controllerStatusToast.classList.remove('hidden');
hideTimeout = scheduleTimeout(() => {
dom.controllerStatusToast.classList.add('hidden');
dom.controllerStatusToast.textContent = '';
hideTimeout = null;
}, durationMs);
}
function update(snapshot: ControllerSnapshot): void {
const newDevices = snapshot.connectedGamepads.filter(
(device) => !previousConnectedIds.has(device.id),
);
if (newDevices.length > 0) {
const activeDevice = snapshot.connectedGamepads.find(
(device) => device.id === snapshot.activeGamepadId,
);
const announcedDevice =
newDevices.find((device) => device.id === snapshot.activeGamepadId) ??
newDevices[0] ??
activeDevice;
show(`Controller detected: ${getDeviceLabel(announcedDevice)}`);
newDevices.find((device) => device.id === snapshot.activeGamepadId) ?? newDevices[0];
notify(`Controller connected: ${getDeviceLabel(announcedDevice)}`);
}
previousConnectedIds = new Set(snapshot.connectedGamepads.map((device) => device.id));
}
return { show, update };
return { update };
}
-6
View File
@@ -30,12 +30,6 @@
<body>
<!-- Programmatic focus fallback target for Electron/window focus management. -->
<div id="overlay" tabindex="-1">
<div
id="controllerStatusToast"
class="controller-status-toast hidden"
role="status"
aria-live="polite"
></div>
<div
id="overlayErrorToast"
class="overlay-error-toast hidden"
+7 -4
View File
@@ -269,7 +269,9 @@ const controllerDebugModal = createControllerDebugModal(ctx, {
syncSettingsModalSubtitleSuppression,
notifyControllerDisabled: showControllerDisabledNotice,
});
const controllerStatusIndicator = createControllerStatusIndicator(ctx.dom);
const controllerStatusIndicator = createControllerStatusIndicator((message) => {
window.electronAPI.notifyControllerStatus(message);
});
const sessionHelpModal = createSessionHelpModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
@@ -431,8 +433,9 @@ function applyControllerSnapshot(snapshot: {
rawButtons: Array<{ value: number; pressed: boolean; touched?: boolean }>;
}): void {
// Modal windows are fresh documents that only see the pad after a press; announcing
// that as "Controller detected" on every dialog reads like a reconnection.
if (!ctx.platform.isModalLayer) {
// that as "Controller connected" on every dialog reads like a reconnection. Polling also
// runs with controller support off, where a system notification would just be noise.
if (!ctx.platform.isModalLayer && ctx.state.controllerConfig?.enabled === true) {
controllerStatusIndicator.update({
connectedGamepads: snapshot.connectedGamepads,
activeGamepadId: snapshot.activeGamepadId,
@@ -500,7 +503,7 @@ function withAltCSelectedController(config: ResolvedControllerConfig): ResolvedC
let reportedActiveControllerId: string | null | undefined;
function showControllerDisabledNotice(): void {
controllerStatusIndicator.show(
window.electronAPI.notifyControllerStatus(
'Controller support disabled. Set controller.enabled to true in config to use controller tools.',
);
}
-27
View File
@@ -93,33 +93,6 @@ body:focus-visible,
pointer-events: auto;
}
.controller-status-toast {
position: absolute;
top: 16px;
left: 16px;
max-width: min(360px, calc(100vw - 32px));
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(138, 213, 202, 0.45);
background: linear-gradient(135deg, rgba(10, 44, 40, 0.94), rgba(8, 28, 33, 0.94));
color: rgba(228, 255, 251, 0.98);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 160ms ease,
transform 160ms ease;
z-index: 1300;
}
.controller-status-toast:not(.hidden) {
opacity: 1;
transform: translateY(0);
}
.overlay-error-toast {
position: absolute;
top: 16px;
-2
View File
@@ -4,7 +4,6 @@ export type RendererDom = {
overlay: HTMLElement;
overlayNotificationStack: HTMLDivElement;
overlayNotificationHistory: HTMLElement;
controllerStatusToast: HTMLDivElement;
overlayErrorToast: HTMLDivElement;
secondarySubContainer: HTMLElement;
secondarySubRoot: HTMLElement;
@@ -222,7 +221,6 @@ export function resolveRendererDom(): RendererDom {
overlay: getRequiredElement<HTMLElement>('overlay'),
overlayNotificationStack: getRequiredElement<HTMLDivElement>('overlayNotificationStack'),
overlayNotificationHistory: getRequiredElement<HTMLElement>('overlayNotificationHistory'),
controllerStatusToast: getRequiredElement<HTMLDivElement>('controllerStatusToast'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),
+1
View File
@@ -35,6 +35,7 @@ export const IPC_CHANNELS = {
statsNativeDialogClosed: 'stats:native-dialog-closed',
saveControllerConfig: 'save-controller-config',
reportActiveController: 'controller:report-active',
notifyControllerStatus: 'controller:notify-status',
mpvCommand: 'mpv-command',
refreshKnownWords: 'anki:refresh-known-words',
kikuFieldGroupingRespond: 'kiku:field-grouping-respond',
+2
View File
@@ -611,6 +611,8 @@ export interface ElectronAPI {
saveControllerConfig: (update: ControllerConfigUpdate) => Promise<void>;
/** The overlay reports which controller it is reading (null when none). */
reportActiveController: (gamepadId: string | null) => void;
/** Announces a controller status message through the configured notification surfaces. */
notifyControllerStatus: (message: string) => void;
/** The controller the overlay last reported, for Alt+C before it sees a pad itself. */
getActiveControllerId: () => Promise<string | null>;
getJimakuMediaInfo: () => Promise<JimakuMediaInfo>;