mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-13 16:46:08 -08:00
fix(controller): show dialog hints only while a controller is connected
- Add createControllerHintVisibility: use the dialog window's pad, else ask main which controller the overlay is reading, at most once per second while a dialog is open - Hide shortcut tags when controller support is on but no controller is present - Update the Anki integration docs and changelog fragments to match
This commit is contained in:
7 files changed
+135
-8
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
|
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut while a controller is connected, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
type: added
|
type: added
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while controller support is on, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
|
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while a controller is connected, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
|
||||||
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
|
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
|
||||||
@@ -162,7 +162,7 @@ When you cancel, you can go back to editing, keep the original timing, create th
|
|||||||
|
|
||||||
#### With a controller
|
#### With a controller
|
||||||
|
|
||||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut once SubMiner has seen a controller, which takes one button press after connecting it. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
||||||
|
|
||||||
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
|
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ The manual dialog cancels itself after 90 seconds. Identical entries are not ded
|
|||||||
|
|
||||||
### With a controller {#field-grouping-with-a-controller}
|
### With a controller {#field-grouping-with-a-controller}
|
||||||
|
|
||||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut once SubMiner has seen a controller. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
||||||
|
|
||||||
| Controller | Choosing the card | Merge preview |
|
| Controller | Choosing the card | Merge preview |
|
||||||
| ------------------ | --------------------------------- | ----------------------- |
|
| ------------------ | --------------------------------- | ----------------------- |
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { createControllerHintVisibility } from './controller-hint-visibility.js';
|
||||||
|
|
||||||
|
function createHarness(answers: Array<string | null | Error>) {
|
||||||
|
const pending = [...answers];
|
||||||
|
let requests = 0;
|
||||||
|
const visibility = createControllerHintVisibility({
|
||||||
|
getOverlayControllerId: async () => {
|
||||||
|
requests += 1;
|
||||||
|
const answer = pending.shift() ?? null;
|
||||||
|
if (answer instanceof Error) throw answer;
|
||||||
|
return answer;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const frame = async (now: number, activeGamepadId: string | null = null) => {
|
||||||
|
const visible = visibility.update({ enabled: true, dialogOpen: true, activeGamepadId, now });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
return visible;
|
||||||
|
};
|
||||||
|
return { visibility, frame, getRequests: () => requests };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hints stay hidden while no window can see a controller', async () => {
|
||||||
|
const { frame } = createHarness([null, null]);
|
||||||
|
|
||||||
|
assert.equal(await frame(0), false);
|
||||||
|
assert.equal(await frame(16), false);
|
||||||
|
assert.equal(await frame(2_000), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a controller the dialog window can see shows hints without asking the overlay', async () => {
|
||||||
|
const { frame, getRequests } = createHarness([]);
|
||||||
|
|
||||||
|
assert.equal(await frame(0, 'pad-1'), true);
|
||||||
|
assert.equal(getRequests(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fresh dialog window shows hints for the controller the overlay reports', async () => {
|
||||||
|
const { frame } = createHarness(['pad-1']);
|
||||||
|
|
||||||
|
assert.equal(await frame(0), false);
|
||||||
|
assert.equal(await frame(16), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hints hide on the next refresh after the overlay loses the controller', async () => {
|
||||||
|
const { frame, getRequests } = createHarness(['pad-1', null]);
|
||||||
|
|
||||||
|
await frame(0);
|
||||||
|
assert.equal(await frame(500), true);
|
||||||
|
assert.equal(getRequests(), 1);
|
||||||
|
|
||||||
|
await frame(1_000);
|
||||||
|
assert.equal(getRequests(), 2);
|
||||||
|
assert.equal(await frame(1_016), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed request keeps the last answer and retries on the next refresh', async () => {
|
||||||
|
const { frame, getRequests } = createHarness(['pad-1', new Error('ipc closed'), null]);
|
||||||
|
|
||||||
|
await frame(0);
|
||||||
|
await frame(1_000);
|
||||||
|
assert.equal(await frame(1_016), true);
|
||||||
|
|
||||||
|
await frame(2_000);
|
||||||
|
assert.equal(getRequests(), 3);
|
||||||
|
assert.equal(await frame(2_016), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing is requested while controller support is off or no dialog is open', () => {
|
||||||
|
const { visibility, getRequests } = createHarness([]);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
visibility.update({ enabled: false, dialogOpen: true, activeGamepadId: 'pad-1', now: 0 }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
visibility.update({ enabled: true, dialogOpen: false, activeGamepadId: null, now: 0 });
|
||||||
|
assert.equal(getRequests(), 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const OVERLAY_CONTROLLER_REFRESH_MS = 1_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether dialogs show their controller shortcut tags. A fresh dialog window cannot
|
||||||
|
* see a pad until a button is pressed in it, so an open dialog also asks main which controller
|
||||||
|
* the overlay is reading. Called every frame; requests are limited to one per refresh interval.
|
||||||
|
*/
|
||||||
|
export function createControllerHintVisibility(options: {
|
||||||
|
getOverlayControllerId: () => Promise<string | null>;
|
||||||
|
}) {
|
||||||
|
let overlayHasController = false;
|
||||||
|
let nextRefreshAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
function refreshOverlayController(now: number): void {
|
||||||
|
if (now < nextRefreshAt) return;
|
||||||
|
nextRefreshAt = now + OVERLAY_CONTROLLER_REFRESH_MS;
|
||||||
|
options.getOverlayControllerId().then(
|
||||||
|
(gamepadId) => {
|
||||||
|
overlayHasController = gamepadId !== null;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Keep the last answer; the next refresh tries again.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(state: {
|
||||||
|
enabled: boolean;
|
||||||
|
dialogOpen: boolean;
|
||||||
|
activeGamepadId: string | null;
|
||||||
|
now: number;
|
||||||
|
}): boolean {
|
||||||
|
if (!state.enabled) return false;
|
||||||
|
if (state.activeGamepadId !== null) return true;
|
||||||
|
if (state.dialogOpen) refreshOverlayController(state.now);
|
||||||
|
return overlayHasController;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||||
|
import { createControllerHintVisibility } from './controller-hint-visibility.js';
|
||||||
import { createControllerPressReplay } from './handlers/controller-press-replay.js';
|
import { createControllerPressReplay } from './handlers/controller-press-replay.js';
|
||||||
import { createMouseHandlers } from './handlers/mouse.js';
|
import { createMouseHandlers } from './handlers/mouse.js';
|
||||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||||
@@ -398,6 +399,9 @@ let lastSecondarySubtitlePreview = '';
|
|||||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let controllerAnimationFrameId: number | null = null;
|
let controllerAnimationFrameId: number | null = null;
|
||||||
const controllerPressReplay = createControllerPressReplay();
|
const controllerPressReplay = createControllerPressReplay();
|
||||||
|
const controllerHintVisibility = createControllerHintVisibility({
|
||||||
|
getOverlayControllerId: () => window.electronAPI.getActiveControllerId(),
|
||||||
|
});
|
||||||
|
|
||||||
function truncateForErrorLog(text: string): string {
|
function truncateForErrorLog(text: string): string {
|
||||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||||
@@ -453,10 +457,13 @@ function applyControllerSnapshot(snapshot: {
|
|||||||
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
ctx.state.controllerRawButtons = snapshot.rawButtons;
|
||||||
controllerSelectModal.updateDevices();
|
controllerSelectModal.updateDevices();
|
||||||
controllerDebugModal.updateSnapshot();
|
controllerDebugModal.updateSnapshot();
|
||||||
// Shown whenever controller support is on: a fresh dialog window cannot see the pad
|
|
||||||
// until its first press, so "connected" would hide the hints exactly when needed.
|
|
||||||
const controllerConfig = ctx.state.controllerConfig;
|
const controllerConfig = ctx.state.controllerConfig;
|
||||||
const hintsVisible = controllerConfig?.enabled === true;
|
const hintsVisible = controllerHintVisibility.update({
|
||||||
|
enabled: controllerConfig?.enabled === true,
|
||||||
|
dialogOpen: ctx.state.mediaTimingReviewModalOpen || ctx.state.kikuModalOpen,
|
||||||
|
activeGamepadId: snapshot.activeGamepadId,
|
||||||
|
now: performance.now(),
|
||||||
|
});
|
||||||
mediaTimingReviewModal.setControllerHintsVisible(hintsVisible);
|
mediaTimingReviewModal.setControllerHintsVisible(hintsVisible);
|
||||||
kikuModal.setControllerHintsVisible(hintsVisible);
|
kikuModal.setControllerHintsVisible(hintsVisible);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -2045,7 +2045,7 @@ body:focus-visible,
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controller hints: shown beside each control while controller support is on. */
|
/* Controller hints: shown beside each control while a controller is connected. */
|
||||||
.media-timing-review-modal.controller-hints-visible button[data-controller-hint]::after,
|
.media-timing-review-modal.controller-hints-visible button[data-controller-hint]::after,
|
||||||
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
|
#kikuFieldGroupingModal.controller-hints-visible [data-controller-hint]::after {
|
||||||
content: attr(data-controller-hint);
|
content: attr(data-controller-hint);
|
||||||
|
|||||||
Reference in new issue
Block a user