mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
15 files changed
+84
-167
No files matched your search
@@ -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.
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 };
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user