From 8976d78390de729b06a3109203f57414a1ba501f Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 3 Nov 2026 21:55:55 -0800 Subject: [PATCH] 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 --- changes/controller-connected-notification.md | 4 + docs-site/usage.md | 2 +- src/core/services/ipc.test.ts | 23 ++++ src/core/services/ipc.ts | 8 ++ src/main.ts | 5 + src/main/dependencies.ts | 2 + src/preload.ts | 3 + .../controller-status-indicator.test.ts | 105 ++++-------------- src/renderer/controller-status-indicator.ts | 50 +-------- src/renderer/index.html | 6 - src/renderer/renderer.ts | 11 +- src/renderer/style.css | 27 ----- src/renderer/utils/dom.ts | 2 - src/shared/ipc/contracts.ts | 1 + src/types/runtime.ts | 2 + 15 files changed, 84 insertions(+), 167 deletions(-) create mode 100644 changes/controller-connected-notification.md diff --git a/changes/controller-connected-notification.md b/changes/controller-connected-notification.md new file mode 100644 index 00000000..1e5d7f16 --- /dev/null +++ b/changes/controller-connected-notification.md @@ -0,0 +1,4 @@ +type: changed +area: overlay + +- Controller connected and controller-disabled messages are now regular notifications that follow `ankiConnect.behavior.notificationType` (overlay, system, both or none) instead of a separate pill in the corner of the overlay. Connected notices only appear when `controller.enabled` is on. diff --git a/docs-site/usage.md b/docs-site/usage.md index f88b37f2..2ae61b01 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -160,7 +160,7 @@ On Wayland, the tray icon only appears if your panel provides a StatusNotifier ( You can drive the overlay with a gamepad. 1. Set `controller.enabled` to `true` in your config. -2. Connect a controller. SubMiner uses the first one it sees. +2. Connect a controller and press a button. SubMiner uses the first one it sees and announces it through [`ankiConnect.behavior.notificationType`](/configuration#ankiconnect). 3. Press `Y` on the controller to turn on keyboard-only mode. Most controller actions only work in this mode. 4. Move between words with the left stick, press `A` to look one up, and `X` to mine it. diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 2d431a7a..ab7573de 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -146,6 +146,7 @@ function createRegisterIpcDeps(overrides: Partial = {}): 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: () => {}, diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 8d723bed..b2baa519 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -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(); }); diff --git a/src/main.ts b/src/main.ts index 28dd425c..13d54621 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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({ diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index fd34a16c..29117915 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -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, diff --git a/src/preload.ts b/src/preload.ts index 49eb53e8..b1e2d227 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -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 => ipcRenderer.invoke(IPC_CHANNELS.request.getActiveControllerId), diff --git a/src/renderer/controller-status-indicator.test.ts b/src/renderer/controller-status-indicator.test.ts index 8393f909..9fb17602 100644 --- a/src/renderer/controller-status-indicator.test.ts +++ b/src/renderer/controller-status-indicator.test.ts @@ -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 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); }); diff --git a/src/renderer/controller-status-indicator.ts b/src/renderer/controller-status-indicator.ts index fc43fddf..a62a89bc 100644 --- a/src/renderer/controller-status-indicator.ts +++ b/src/renderer/controller-status-indicator.ts @@ -5,67 +5,27 @@ type ControllerSnapshot = { activeGamepadId: string | null; }; -type ControllerStatusIndicatorOptions = { - durationMs?: number; - setTimeout?: (callback: () => void, delay: number) => ReturnType; - clearTimeout?: (timer: ReturnType | 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 | number) => - globalThis.clearTimeout(timer as ReturnType)); - let hideTimeout: ReturnType | 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(); - 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 }; } diff --git a/src/renderer/index.html b/src/renderer/index.html index 4b4fd9d7..bff9553e 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,12 +30,6 @@
-