mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Controller: subtitle line actions, Button Style, keyboard-only mode pause, Alt+C fixes (#316)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
6d0f37ac21
commit
b99409bbcd
60 files changed
+1900
-219
No files matched your search
@@ -15,6 +15,7 @@ import {
|
||||
getControllerReviewInputContext,
|
||||
} from './controller-screen-layout.js';
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
||||
|
||||
const CONFIG: ResolvedControllerConfig = { ...CORE_DEFAULT_CONFIG.controller, enabled: true };
|
||||
const { repeatDelayMs, repeatIntervalMs } = CONFIG;
|
||||
@@ -155,3 +156,33 @@ test('remapped duplicate card bindings replace the defaults', () => {
|
||||
|
||||
assert.deepEqual(actions, ['cancelMerge']);
|
||||
});
|
||||
|
||||
test('dialog hints follow the button style of the controller in use', () => {
|
||||
const config: ResolvedControllerConfig = {
|
||||
...CONFIG,
|
||||
profiles: {
|
||||
'pad-ps': {
|
||||
label: 'DualSense',
|
||||
faceButtonLabels: 'playstation',
|
||||
buttonIndices: CONFIG.buttonIndices,
|
||||
bindings: CONFIG.bindings,
|
||||
reviewBindings: CONFIG.reviewBindings,
|
||||
fieldGroupingBindings: CONFIG.fieldGroupingBindings,
|
||||
},
|
||||
},
|
||||
};
|
||||
const hintsFor = (gamepadId: string) => {
|
||||
const padConfig = resolveControllerConfigForGamepad(config, gamepadId);
|
||||
return formatControllerScreenHints(
|
||||
CONTROLLER_FIELD_GROUPING_SCREEN,
|
||||
padConfig.fieldGroupingBindings,
|
||||
padConfig,
|
||||
'standard',
|
||||
);
|
||||
};
|
||||
|
||||
assert.equal(hintsFor('pad-ps').continue, '╳');
|
||||
assert.equal(hintsFor('pad-ps').toggleView, '△');
|
||||
// Controllers without their own style use controller.faceButtonLabels.
|
||||
assert.equal(hintsFor('other-pad').continue, 'A');
|
||||
});
|
||||
@@ -70,6 +70,10 @@ const VOID_ACTIONS = [
|
||||
'previousEntry',
|
||||
'nextEntry',
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
const DELTA_ACTIONS = ['moveSelection', 'scrollPopup', 'jumpPopup'] as const;
|
||||
|
||||
@@ -114,6 +118,10 @@ function createHarness(
|
||||
previousEntry: () => {},
|
||||
nextEntry: () => {},
|
||||
toggleMpvPause: () => {},
|
||||
previousSubtitle: () => {},
|
||||
nextSubtitle: () => {},
|
||||
replaySubtitle: () => {},
|
||||
playNextSubtitle: () => {},
|
||||
setSecondarySubHeld: () => {},
|
||||
scrollPopup: () => {},
|
||||
jumpPopup: () => {},
|
||||
@@ -288,6 +296,7 @@ test('gamepad controller uses active controller profile bindings before global b
|
||||
profiles: {
|
||||
'pad-profile': {
|
||||
label: 'Profile Pad',
|
||||
faceButtonLabels: globalConfig.faceButtonLabels,
|
||||
buttonIndices: globalConfig.buttonIndices,
|
||||
bindings: { ...globalConfig.bindings, toggleLookup: { kind: 'button', buttonIndex: 11 } },
|
||||
reviewBindings: globalConfig.reviewBindings,
|
||||
@@ -427,7 +436,8 @@ for (const c of [
|
||||
|
||||
harness.poll(0);
|
||||
|
||||
assert.deepEqual(harness.calls, c.fired ? ['playCurrentAudio', 'toggleMpvPause'] : []);
|
||||
// Pause works outside keyboard-only mode, so it is handled before the popup actions.
|
||||
assert.deepEqual(harness.calls, c.fired ? ['toggleMpvPause', 'playCurrentAudio'] : []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -784,3 +794,99 @@ test('gamepad controller still ignores a button held when the screen opened', ()
|
||||
|
||||
assert.deepEqual(events, ['activate:pad-1:latch']);
|
||||
});
|
||||
|
||||
const SUBTITLE_NAVIGATION_BINDINGS = {
|
||||
previousSubtitle: { kind: 'button', buttonIndex: 10 },
|
||||
nextSubtitle: { kind: 'button', buttonIndex: 11 },
|
||||
replaySubtitle: { kind: 'button', buttonIndex: 8 },
|
||||
playNextSubtitle: { kind: 'button', buttonIndex: 16 },
|
||||
} as const;
|
||||
|
||||
test('gamepad controller leaves subtitle navigation unbound by default', () => {
|
||||
for (const action of [
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const) {
|
||||
assert.deepEqual(CORE_DEFAULT_CONFIG.controller.bindings[action], { kind: 'none' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
test('gamepad controller navigates subtitles once per press, even outside keyboard-only mode', () => {
|
||||
let pressed: number[] = [];
|
||||
const harness = createHarness({
|
||||
gamepads: () => [
|
||||
createGamepad('pad-1', {
|
||||
buttons: Object.fromEntries(pressed.map((index) => [index, PRESSED])),
|
||||
}),
|
||||
],
|
||||
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
|
||||
deps: { getKeyboardModeEnabled: () => false },
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
for (const index of [11, 10, 8, 16]) {
|
||||
pressed = [index];
|
||||
// Holding the button does not repeat.
|
||||
harness.poll(16, 500, 1_000);
|
||||
pressed = [];
|
||||
harness.poll(1_016);
|
||||
}
|
||||
|
||||
assert.deepEqual(harness.calls, [
|
||||
'nextSubtitle',
|
||||
'previousSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
]);
|
||||
});
|
||||
|
||||
test('gamepad controller pauses subtitle navigation while a blocking modal is open', () => {
|
||||
let blocked = true;
|
||||
let pressed = true;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 11: PRESSED } : {} })],
|
||||
config: createControllerConfig({ bindings: SUBTITLE_NAVIGATION_BINDINGS }),
|
||||
deps: { getInteractionBlocked: () => blocked },
|
||||
});
|
||||
|
||||
harness.poll(0, 16);
|
||||
// Still held when the modal closes: not a fresh press.
|
||||
blocked = false;
|
||||
harness.poll(32);
|
||||
assert.deepEqual(harness.calls, []);
|
||||
|
||||
pressed = false;
|
||||
harness.poll(48);
|
||||
pressed = true;
|
||||
harness.poll(64);
|
||||
assert.deepEqual(harness.calls, ['nextSubtitle']);
|
||||
});
|
||||
|
||||
test('gamepad controller pauses mpv outside keyboard-only mode, but not under a blocking modal', () => {
|
||||
let blocked = false;
|
||||
let pressed = false;
|
||||
const harness = createHarness({
|
||||
gamepads: () => [createGamepad('pad-1', { buttons: pressed ? { 9: PRESSED } : {} })],
|
||||
config: createControllerConfig({
|
||||
bindings: { toggleMpvPause: { kind: 'button', buttonIndex: 9 } },
|
||||
}),
|
||||
deps: {
|
||||
getKeyboardModeEnabled: () => false,
|
||||
getInteractionBlocked: () => blocked,
|
||||
},
|
||||
});
|
||||
|
||||
harness.poll(0);
|
||||
pressed = true;
|
||||
// Held: fires once.
|
||||
harness.poll(16, 500);
|
||||
pressed = false;
|
||||
harness.poll(516);
|
||||
blocked = true;
|
||||
pressed = true;
|
||||
harness.poll(532);
|
||||
|
||||
assert.deepEqual(harness.calls, ['toggleMpvPause']);
|
||||
});
|
||||
@@ -18,6 +18,8 @@ export type GamepadLike = {
|
||||
index: number;
|
||||
connected: boolean;
|
||||
mapping: string;
|
||||
/** When the pad last reported new input (Gamepad API). */
|
||||
timestamp?: number;
|
||||
axes: readonly number[];
|
||||
buttons: readonly ControllerButtonState[];
|
||||
};
|
||||
@@ -56,6 +58,10 @@ type GamepadControllerOptions = {
|
||||
previousEntry: () => void;
|
||||
nextEntry: () => void;
|
||||
toggleMpvPause: () => void;
|
||||
previousSubtitle: () => void;
|
||||
nextSubtitle: () => void;
|
||||
replaySubtitle: () => void;
|
||||
playNextSubtitle: () => void;
|
||||
setSecondarySubHeld: (held: boolean) => void;
|
||||
scrollPopup: (deltaPixels: number) => void;
|
||||
jumpPopup: (deltaPixels: number) => void;
|
||||
@@ -73,14 +79,16 @@ export type HoldState = {
|
||||
initialFired: boolean;
|
||||
};
|
||||
|
||||
const DPAD_BUTTON_INDEX = {
|
||||
/** Buttons read as the D-pad on every pad, whatever its reported mapping. */
|
||||
export const DPAD_BUTTON_INDEX = {
|
||||
up: 12,
|
||||
down: 13,
|
||||
left: 14,
|
||||
right: 15,
|
||||
} as const;
|
||||
|
||||
const DPAD_AXIS_INDEX = {
|
||||
/** Axes some raw (non-standard) pads use for the D-pad. */
|
||||
export const DPAD_AXIS_INDEX = {
|
||||
horizontal: 6,
|
||||
vertical: 7,
|
||||
} as const;
|
||||
@@ -276,6 +284,15 @@ const POPUP_ONLY_ACTIONS = [
|
||||
'nextEntry',
|
||||
] as const;
|
||||
|
||||
/** Playback and subtitle actions; like the secondary hold, they do not need keyboard-only mode. */
|
||||
const WATCHING_ACTIONS = [
|
||||
'toggleMpvPause',
|
||||
'previousSubtitle',
|
||||
'nextSubtitle',
|
||||
'replaySubtitle',
|
||||
'playNextSubtitle',
|
||||
] as const;
|
||||
|
||||
export function createGamepadController(options: GamepadControllerOptions) {
|
||||
let previousActions = new Map<string, boolean>();
|
||||
let selectionHold = createHoldState();
|
||||
@@ -312,6 +329,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
index: gamepad.index,
|
||||
mapping: gamepad.mapping,
|
||||
connected: gamepad.connected,
|
||||
timestamp: gamepad.timestamp,
|
||||
})) satisfies ControllerDeviceInfo[],
|
||||
activeGamepadId,
|
||||
rawAxes: activeGamepad?.axes ? [...activeGamepad.axes] : [],
|
||||
@@ -401,7 +419,7 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
['playCurrentAudio', config.bindings.playCurrentAudio],
|
||||
['previousEntry', config.bindings.previousEntry],
|
||||
['nextEntry', config.bindings.nextEntry],
|
||||
['toggleMpvPause', config.bindings.toggleMpvPause],
|
||||
...WATCHING_ACTIONS.map((actionKey) => [actionKey, config.bindings[actionKey]] as const),
|
||||
] as const;
|
||||
|
||||
for (const [actionKey, binding] of discreteActions) {
|
||||
@@ -503,6 +521,19 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
),
|
||||
);
|
||||
|
||||
// Pause and subtitle navigation also work while simply watching; a blocking modal pauses them.
|
||||
if (activeConfig.enabled && !options.getInteractionBlocked()) {
|
||||
for (const actionKey of WATCHING_ACTIONS) {
|
||||
handleActionEdge(
|
||||
actionKey,
|
||||
activeConfig.bindings[actionKey],
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options[actionKey],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeConfig.enabled) {
|
||||
handleActionEdge(
|
||||
'toggleKeyboardOnlyMode',
|
||||
@@ -628,14 +659,6 @@ export function createGamepadController(options: GamepadControllerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
handleActionEdge(
|
||||
'toggleMpvPause',
|
||||
activeConfig.bindings.toggleMpvPause,
|
||||
activeGamepad,
|
||||
activeConfig,
|
||||
options.toggleMpvPause,
|
||||
);
|
||||
|
||||
handleSelectionAxis(
|
||||
resolveAxisBindingValue(
|
||||
activeGamepad,
|
||||
|
||||
@@ -538,10 +538,15 @@ function createKeyboardHandlerHarness() {
|
||||
let changelogKeydownCount = 0;
|
||||
let jimakuKeydownCount = 0;
|
||||
|
||||
// Synthetic pointer/mouse events dispatched on words: the lookup's fallback scan.
|
||||
let wordEventCount = 0;
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect: () => ({ left, top: 10, width: 30, height: 20 }),
|
||||
dispatchEvent: () => true,
|
||||
dispatchEvent: () => {
|
||||
wordEventCount += 1;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
let wordNodes = [createWordNode(10), createWordNode(80), createWordNode(150)];
|
||||
|
||||
@@ -618,6 +623,7 @@ function createKeyboardHandlerHarness() {
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
changelogKeydownCount: () => changelogKeydownCount,
|
||||
jimakuKeydownCount: () => jimakuKeydownCount,
|
||||
wordEventCount: () => wordEventCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -2554,3 +2560,57 @@ test('controller popup entry navigation forwards Alt+Up / Alt+Down only while th
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: leaving the mode cancels a lookup whose popup has not appeared yet', async () => {
|
||||
const { ctx, handlers, testGlobals, wordEventCount } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
|
||||
// Without a popup, the lookup falls back to a synthetic scan shortly after.
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
await wait(100);
|
||||
const afterFallback = wordEventCount();
|
||||
assert.ok(afterFallback > 0, 'the fallback scan runs');
|
||||
|
||||
// Leaving the mode before that fallback runs must cancel it.
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY', ctrlKey: true });
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(100);
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(wordEventCount(), afterFallback, 'no scan after the mode is off');
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: leaving the mode closes an open popup', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
const closeEvents = () =>
|
||||
testGlobals.commandEvents.filter(
|
||||
(event) => event.type === 'setVisible' && event.visible === false,
|
||||
);
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(0);
|
||||
|
||||
assert.equal(ctx.state.keyboardDrivenModeEnabled, false);
|
||||
assert.equal(closeEvents().length, 1);
|
||||
|
||||
// Turning the mode on again leaves a popup alone.
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
await wait(0);
|
||||
assert.equal(closeEvents().length, 1);
|
||||
} finally {
|
||||
ctx.state.keyboardDrivenModeEnabled = false;
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
@@ -41,6 +41,8 @@ export function createKeyboardHandlers(
|
||||
openControllerSelectModal?: () => void;
|
||||
openControllerDebugModal?: () => void;
|
||||
getPlaybackPaused: () => Promise<boolean | null>;
|
||||
/** Keyboard-only mode turned on or off (keyboard shortcut or controller). */
|
||||
onKeyboardDrivenModeChanged?: (enabled: boolean) => void;
|
||||
toggleSubtitleSidebarModal?: () => void;
|
||||
},
|
||||
) {
|
||||
@@ -550,6 +552,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
function setKeyboardDrivenModeEnabled(enabled: boolean): void {
|
||||
const changed = ctx.state.keyboardDrivenModeEnabled !== enabled;
|
||||
ctx.state.keyboardDrivenModeEnabled = enabled;
|
||||
ctx.state.keyboardSelectionVisible = enabled;
|
||||
if (!enabled) {
|
||||
@@ -560,6 +563,10 @@ export function createKeyboardHandlers(
|
||||
clearNativeSubtitleSelection();
|
||||
}
|
||||
syncKeyboardTokenSelection();
|
||||
// Leaving the mode means going back to watching: close the popup it was used for.
|
||||
// Done after the mode is off, so focus is not pulled back to the overlay.
|
||||
if (changed && !enabled) closeLookupWindow();
|
||||
if (changed) options.onKeyboardDrivenModeChanged?.(enabled);
|
||||
}
|
||||
|
||||
function toggleKeyboardDrivenMode(): void {
|
||||
@@ -756,6 +763,12 @@ export function createKeyboardHandlers(
|
||||
// released) and attention without a pane, so callers like the Kiku modal
|
||||
// opener leave nothing behind.
|
||||
function closeLookupWindow(): boolean {
|
||||
// Also when no popup shows yet: a lookup that has not opened one must not open it
|
||||
// later through its fallback scan (for example after leaving keyboard-only mode).
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
if (
|
||||
!ctx.state.yomitanPopupVisible &&
|
||||
!isYomitanPopupVisible(document) &&
|
||||
@@ -764,10 +777,6 @@ export function createKeyboardHandlers(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lookupScanFallbackTimer !== null) {
|
||||
clearTimeout(lookupScanFallbackTimer);
|
||||
lookupScanFallbackTimer = null;
|
||||
}
|
||||
dispatchYomitanPopupVisibility(false);
|
||||
dispatchYomitanFrontendClearActiveTextSource();
|
||||
clearNativeSubtitleSelection();
|
||||
|
||||
@@ -509,6 +509,21 @@ test('popup hidden on macOS releases click-away capture back to mpv', (t) => {
|
||||
assert.deepEqual(h.ignoreCalls.at(-1), { ignore: true, forward: true });
|
||||
});
|
||||
|
||||
test('a pointer leaving a popup that just closed does not mark it open again', (t) => {
|
||||
const h = createMouseHarness(t, { platform: 'macos', popupHostVisible: true });
|
||||
h.handlers.setupYomitanObserver();
|
||||
h.fire('window', YOMITAN_POPUP_MOUSE_ENTER_EVENT);
|
||||
|
||||
// The popup hides under the pointer: hidden, then the leave it causes.
|
||||
h.popupHostVisible = false;
|
||||
h.fire('window', YOMITAN_POPUP_HIDDEN_EVENT);
|
||||
h.fire('window', YOMITAN_POPUP_MOUSE_LEAVE_EVENT);
|
||||
|
||||
assert.equal(h.ctx.state.yomitanPopupVisible, false);
|
||||
assert.equal(h.ctx.state.isOverYomitanPopup, false);
|
||||
assert.equal(h.isInteractive(), false);
|
||||
});
|
||||
|
||||
test('yomitan popup visibility marks primary subtitle hover hold while enabled', (t) => {
|
||||
const h = createMouseHarness(t);
|
||||
h.ctx.state.primaryVisibleOnYomitanPopup = true;
|
||||
|
||||
@@ -505,7 +505,10 @@ export function createMouseHandlers(
|
||||
|
||||
window.addEventListener(YOMITAN_POPUP_MOUSE_LEAVE_EVENT, () => {
|
||||
ctx.state.isOverYomitanPopup = false;
|
||||
reconcilePopupInteraction({ assumeVisible: true });
|
||||
// A popup that hides under a resting pointer also reports a leave, right after its
|
||||
// hidden event. Only keep a popup marked open if it still was; otherwise the next
|
||||
// keyboard or controller word move would look it up again and reopen it.
|
||||
reconcilePopupInteraction({ assumeVisible: yomitanPopupVisible });
|
||||
});
|
||||
|
||||
window.addEventListener('focus', () => {
|
||||
|
||||
Reference in new issue
Block a user