mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
509 lines
18 KiB
TypeScript
509 lines
18 KiB
TypeScript
import type { ModalStateReader, RendererContext } from '../context';
|
|
import { resolveControllerConfigForGamepad } from '../controller-profile-config.js';
|
|
import { createControllerBindingCapture } from '../handlers/controller-binding-capture.js';
|
|
import {
|
|
createControllerConfigForm,
|
|
getControllerBindingDefinition,
|
|
getDefaultControllerBinding,
|
|
getDefaultDpadFallback,
|
|
} from './controller-config-form.js';
|
|
|
|
function clampSelectedIndex(ctx: RendererContext): void {
|
|
if (ctx.state.connectedGamepads.length === 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = 0;
|
|
return;
|
|
}
|
|
|
|
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
|
Math.max(ctx.state.controllerDeviceSelectedIndex, 0),
|
|
ctx.state.connectedGamepads.length - 1,
|
|
);
|
|
}
|
|
|
|
export function createControllerSelectModal(
|
|
ctx: RendererContext,
|
|
options: {
|
|
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
|
syncSettingsModalSubtitleSuppression: () => void;
|
|
notifyControllerDisabled: () => void;
|
|
},
|
|
) {
|
|
let selectedControllerKey: string | null = null;
|
|
let lastRenderedDevicesKey = '';
|
|
let lastRenderedActiveGamepadId: string | null = null;
|
|
let lastRenderedPreferredId = '';
|
|
type ControllerBindingKey = keyof NonNullable<typeof ctx.state.controllerConfig>['bindings'];
|
|
type ControllerBindingValue = NonNullable<
|
|
NonNullable<typeof ctx.state.controllerConfig>['bindings']
|
|
>[ControllerBindingKey];
|
|
let learningActionId: ControllerBindingKey | null = null;
|
|
let dpadLearningActionId: ControllerBindingKey | null = null;
|
|
let bindingCapture: ReturnType<typeof createControllerBindingCapture> | null = null;
|
|
|
|
function getSelectedController() {
|
|
return ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex] ?? null;
|
|
}
|
|
|
|
function getSelectedControllerId(): string | null {
|
|
return getSelectedController()?.id ?? null;
|
|
}
|
|
|
|
function getSelectedControllerConfig() {
|
|
const config = ctx.state.controllerConfig;
|
|
if (!config) return null;
|
|
return resolveControllerConfigForGamepad(config, getSelectedControllerId());
|
|
}
|
|
|
|
const controllerConfigForm = createControllerConfigForm({
|
|
container: ctx.dom.controllerConfigList,
|
|
getBindings: () =>
|
|
getSelectedControllerConfig()?.bindings ?? {
|
|
toggleLookup: { kind: 'button', buttonIndex: 0 },
|
|
closeLookup: { kind: 'button', buttonIndex: 1 },
|
|
toggleKeyboardOnlyMode: { kind: 'button', buttonIndex: 3 },
|
|
mineCard: { kind: 'button', buttonIndex: 2 },
|
|
quitMpv: { kind: 'button', buttonIndex: 6 },
|
|
previousAudio: { kind: 'none' },
|
|
nextAudio: { kind: 'button', buttonIndex: 5 },
|
|
playCurrentAudio: { kind: 'button', buttonIndex: 4 },
|
|
toggleMpvPause: { kind: 'button', buttonIndex: 9 },
|
|
leftStickHorizontal: { kind: 'axis', axisIndex: 0, dpadFallback: 'horizontal' },
|
|
leftStickVertical: { kind: 'axis', axisIndex: 1, dpadFallback: 'vertical' },
|
|
rightStickHorizontal: { kind: 'axis', axisIndex: 3, dpadFallback: 'none' },
|
|
rightStickVertical: { kind: 'axis', axisIndex: 4, dpadFallback: 'none' },
|
|
},
|
|
getLearningActionId: () => learningActionId,
|
|
getDpadLearningActionId: () => dpadLearningActionId,
|
|
onLearn: (actionId, bindingType) => {
|
|
const definition = getControllerBindingDefinition(actionId);
|
|
if (!definition) return;
|
|
dpadLearningActionId = null;
|
|
const config = ctx.state.controllerConfig;
|
|
bindingCapture = createControllerBindingCapture({
|
|
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
|
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
|
});
|
|
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
|
const currentDpadFallback =
|
|
currentBinding && currentBinding.kind === 'axis' && 'dpadFallback' in currentBinding
|
|
? currentBinding.dpadFallback
|
|
: 'none';
|
|
bindingCapture.arm(
|
|
bindingType === 'axis'
|
|
? {
|
|
actionId,
|
|
bindingType: 'axis',
|
|
dpadFallback: currentDpadFallback,
|
|
}
|
|
: {
|
|
actionId,
|
|
bindingType: 'discrete',
|
|
},
|
|
{
|
|
axes: ctx.state.controllerRawAxes,
|
|
buttons: ctx.state.controllerRawButtons,
|
|
},
|
|
);
|
|
learningActionId = actionId;
|
|
controllerConfigForm.render();
|
|
setStatus(`Waiting for input for ${definition.label}.`);
|
|
},
|
|
onClear: (actionId) => {
|
|
void saveBinding(actionId, { kind: 'none' });
|
|
},
|
|
onReset: (actionId) => {
|
|
void saveBinding(actionId, getDefaultControllerBinding(actionId));
|
|
},
|
|
onDpadLearn: (actionId) => {
|
|
const definition = getControllerBindingDefinition(actionId);
|
|
if (!definition) return;
|
|
learningActionId = null;
|
|
const config = ctx.state.controllerConfig;
|
|
bindingCapture = createControllerBindingCapture({
|
|
triggerDeadzone: config?.triggerDeadzone ?? 0.5,
|
|
stickDeadzone: config?.stickDeadzone ?? 0.2,
|
|
});
|
|
bindingCapture.arm(
|
|
{ actionId, bindingType: 'dpad' },
|
|
{
|
|
axes: ctx.state.controllerRawAxes,
|
|
buttons: ctx.state.controllerRawButtons,
|
|
},
|
|
);
|
|
dpadLearningActionId = actionId;
|
|
controllerConfigForm.render();
|
|
setStatus(`Press a D-pad direction for ${definition.label}.`);
|
|
},
|
|
onDpadClear: (actionId) => {
|
|
void saveDpadFallback(actionId, 'none');
|
|
},
|
|
onDpadReset: (actionId) => {
|
|
void saveDpadFallback(actionId, getDefaultDpadFallback(actionId));
|
|
},
|
|
});
|
|
|
|
function getDevicesKey(): string {
|
|
return ctx.state.connectedGamepads
|
|
.map((device) => `${device.id}|${device.index}|${device.mapping}|${device.connected}`)
|
|
.join('||');
|
|
}
|
|
|
|
function getDeviceSelectionKey(device: { id: string; index: number }): string {
|
|
return `${device.id}:${device.index}`;
|
|
}
|
|
|
|
function syncSelectedControllerId(): void {
|
|
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
|
selectedControllerKey = selected ? getDeviceSelectionKey(selected) : null;
|
|
}
|
|
|
|
function syncSelectedIndexToCurrentController(): void {
|
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
|
const activeIndex = ctx.state.connectedGamepads.findIndex(
|
|
(device) => device.id === ctx.state.activeGamepadId,
|
|
);
|
|
if (activeIndex >= 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = activeIndex;
|
|
syncSelectedControllerId();
|
|
return;
|
|
}
|
|
const preferredIndex = ctx.state.connectedGamepads.findIndex(
|
|
(device) => device.id === preferredId,
|
|
);
|
|
if (preferredIndex >= 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = preferredIndex;
|
|
syncSelectedControllerId();
|
|
return;
|
|
}
|
|
clampSelectedIndex(ctx);
|
|
syncSelectedControllerId();
|
|
}
|
|
|
|
function setStatus(message: string, isError = false): void {
|
|
ctx.dom.controllerSelectStatus.textContent = message;
|
|
ctx.dom.controllerSelectStatus.classList.toggle('error', isError);
|
|
}
|
|
|
|
function renderPicker(): void {
|
|
ctx.dom.controllerSelectPicker.innerHTML = '';
|
|
clampSelectedIndex(ctx);
|
|
|
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
|
ctx.state.connectedGamepads.forEach((device, index) => {
|
|
const option = document.createElement('option');
|
|
option.value = getDeviceSelectionKey(device);
|
|
option.selected = index === ctx.state.controllerDeviceSelectedIndex;
|
|
option.textContent = `${device.id || `Gamepad ${device.index}`} (${[
|
|
`#${device.index}`,
|
|
device.mapping || 'unknown',
|
|
device.id === ctx.state.activeGamepadId ? 'active' : null,
|
|
device.id === preferredId ? 'saved' : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join(', ')})`;
|
|
ctx.dom.controllerSelectPicker.appendChild(option);
|
|
});
|
|
|
|
ctx.dom.controllerSelectPicker.disabled = ctx.state.connectedGamepads.length === 0;
|
|
ctx.dom.controllerSelectSummary.textContent =
|
|
ctx.state.connectedGamepads.length === 0
|
|
? 'No controller detected.'
|
|
: `Active: ${ctx.state.activeGamepadId ?? 'none'} · Preferred: ${preferredId || 'none'}`;
|
|
|
|
lastRenderedDevicesKey = getDevicesKey();
|
|
lastRenderedActiveGamepadId = ctx.state.activeGamepadId;
|
|
lastRenderedPreferredId = preferredId;
|
|
}
|
|
|
|
async function saveControllerConfig(
|
|
update: Parameters<typeof window.electronAPI.saveControllerConfig>[0],
|
|
) {
|
|
await window.electronAPI.saveControllerConfig(update);
|
|
if (!ctx.state.controllerConfig) return;
|
|
if (update.preferredGamepadId !== undefined) {
|
|
ctx.state.controllerConfig.preferredGamepadId = update.preferredGamepadId;
|
|
}
|
|
if (update.preferredGamepadLabel !== undefined) {
|
|
ctx.state.controllerConfig.preferredGamepadLabel = update.preferredGamepadLabel;
|
|
}
|
|
if (update.bindings) {
|
|
ctx.state.controllerConfig.bindings = {
|
|
...ctx.state.controllerConfig.bindings,
|
|
...update.bindings,
|
|
} as typeof ctx.state.controllerConfig.bindings;
|
|
}
|
|
if (update.profiles) {
|
|
ctx.state.controllerConfig.profiles = ctx.state.controllerConfig.profiles ?? {};
|
|
for (const [profileId, profileUpdate] of Object.entries(update.profiles)) {
|
|
const currentProfile = ctx.state.controllerConfig.profiles[profileId];
|
|
const baseProfile = currentProfile ?? {
|
|
label: profileUpdate.label ?? profileId,
|
|
buttonIndices: ctx.state.controllerConfig.buttonIndices,
|
|
bindings: ctx.state.controllerConfig.bindings,
|
|
};
|
|
ctx.state.controllerConfig.profiles[profileId] = {
|
|
label: profileUpdate.label ?? baseProfile.label,
|
|
buttonIndices: {
|
|
...baseProfile.buttonIndices,
|
|
...(profileUpdate.buttonIndices ?? {}),
|
|
},
|
|
bindings: {
|
|
...baseProfile.bindings,
|
|
...(profileUpdate.bindings ?? {}),
|
|
},
|
|
} as (typeof ctx.state.controllerConfig.profiles)[string];
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildBindingConfigUpdate(
|
|
actionId: ControllerBindingKey,
|
|
binding: ControllerBindingValue,
|
|
): Parameters<typeof window.electronAPI.saveControllerConfig>[0] {
|
|
const selected = getSelectedController();
|
|
if (!selected) {
|
|
return {
|
|
bindings: {
|
|
[actionId]: binding,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
profiles: {
|
|
[selected.id]: {
|
|
bindings: {
|
|
[actionId]: binding,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
async function saveBinding(
|
|
actionId: ControllerBindingKey,
|
|
binding: ControllerBindingValue,
|
|
): Promise<void> {
|
|
const definition = getControllerBindingDefinition(actionId);
|
|
try {
|
|
await saveControllerConfig(buildBindingConfigUpdate(actionId, binding));
|
|
learningActionId = null;
|
|
dpadLearningActionId = null;
|
|
bindingCapture = null;
|
|
controllerConfigForm.render();
|
|
setStatus(`${definition?.label ?? actionId} updated.`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setStatus(`Failed to save binding: ${message}`, true);
|
|
}
|
|
}
|
|
|
|
async function saveDpadFallback(
|
|
actionId: ControllerBindingKey,
|
|
dpadFallback: import('../../types').ControllerDpadFallback,
|
|
): Promise<void> {
|
|
const definition = getControllerBindingDefinition(actionId);
|
|
const currentBinding = getSelectedControllerConfig()?.bindings[actionId];
|
|
if (!currentBinding || currentBinding.kind !== 'axis') return;
|
|
const updated = { ...currentBinding, dpadFallback };
|
|
try {
|
|
await saveControllerConfig(buildBindingConfigUpdate(actionId, updated));
|
|
dpadLearningActionId = null;
|
|
bindingCapture = null;
|
|
controllerConfigForm.render();
|
|
setStatus(`${definition?.label ?? actionId} D-pad updated.`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setStatus(`Failed to save D-pad binding: ${message}`, true);
|
|
}
|
|
}
|
|
|
|
async function saveSelectedController(): Promise<void> {
|
|
const selected = ctx.state.connectedGamepads[ctx.state.controllerDeviceSelectedIndex];
|
|
if (!selected) {
|
|
setStatus('No controller selected.', true);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await saveControllerConfig({
|
|
preferredGamepadId: selected.id,
|
|
preferredGamepadLabel: selected.id,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
setStatus(`Failed to save preferred controller: ${message}`, true);
|
|
return;
|
|
}
|
|
|
|
syncSelectedControllerId();
|
|
renderPicker();
|
|
setStatus(`Saved preferred controller: ${selected.id || `Gamepad ${selected.index}`}`);
|
|
}
|
|
|
|
function updateDevices(): void {
|
|
if (!ctx.state.controllerSelectModalOpen) return;
|
|
if (selectedControllerKey) {
|
|
const preservedIndex = ctx.state.connectedGamepads.findIndex(
|
|
(device) => getDeviceSelectionKey(device) === selectedControllerKey,
|
|
);
|
|
if (preservedIndex >= 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = preservedIndex;
|
|
} else {
|
|
syncSelectedIndexToCurrentController();
|
|
}
|
|
} else {
|
|
syncSelectedIndexToCurrentController();
|
|
}
|
|
|
|
if (bindingCapture && (learningActionId || dpadLearningActionId)) {
|
|
const result = bindingCapture.poll({
|
|
axes: ctx.state.controllerRawAxes,
|
|
buttons: ctx.state.controllerRawButtons,
|
|
});
|
|
if (result) {
|
|
if (result.bindingType === 'dpad') {
|
|
void saveDpadFallback(result.actionId as ControllerBindingKey, result.dpadDirection);
|
|
} else {
|
|
void saveBinding(
|
|
result.actionId as ControllerBindingKey,
|
|
result.binding as ControllerBindingValue,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const preferredId = ctx.state.controllerConfig?.preferredGamepadId ?? '';
|
|
const shouldRender =
|
|
getDevicesKey() !== lastRenderedDevicesKey ||
|
|
ctx.state.activeGamepadId !== lastRenderedActiveGamepadId ||
|
|
preferredId !== lastRenderedPreferredId;
|
|
if (shouldRender) {
|
|
renderPicker();
|
|
controllerConfigForm.render();
|
|
}
|
|
|
|
if (ctx.state.connectedGamepads.length === 0 && !learningActionId && !dpadLearningActionId) {
|
|
setStatus('No controllers detected.');
|
|
}
|
|
}
|
|
|
|
function openControllerSelectModal(): boolean {
|
|
if (ctx.state.controllerConfig?.enabled !== true) {
|
|
options.notifyControllerDisabled();
|
|
return false;
|
|
}
|
|
ctx.state.controllerSelectModalOpen = true;
|
|
syncSelectedIndexToCurrentController();
|
|
options.syncSettingsModalSubtitleSuppression();
|
|
ctx.dom.overlay.classList.add('interactive');
|
|
ctx.dom.controllerSelectModal.classList.remove('hidden');
|
|
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'false');
|
|
window.focus();
|
|
ctx.dom.overlay.focus({ preventScroll: true });
|
|
renderPicker();
|
|
controllerConfigForm.render();
|
|
if (ctx.state.connectedGamepads.length === 0) {
|
|
setStatus('No controllers detected.');
|
|
} else {
|
|
setStatus('Choose a controller or click Learn to remap an action.');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function closeControllerSelectModal(): void {
|
|
if (!ctx.state.controllerSelectModalOpen) return;
|
|
learningActionId = null;
|
|
dpadLearningActionId = null;
|
|
bindingCapture = null;
|
|
ctx.state.controllerSelectModalOpen = false;
|
|
options.syncSettingsModalSubtitleSuppression();
|
|
ctx.dom.controllerSelectModal.classList.add('hidden');
|
|
ctx.dom.controllerSelectModal.setAttribute('aria-hidden', 'true');
|
|
window.electronAPI.notifyOverlayModalClosed('controller-select');
|
|
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
|
ctx.dom.overlay.classList.remove('interactive');
|
|
}
|
|
}
|
|
|
|
function handleControllerSelectKeydown(event: KeyboardEvent): boolean {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
if (learningActionId || dpadLearningActionId) {
|
|
learningActionId = null;
|
|
dpadLearningActionId = null;
|
|
bindingCapture = null;
|
|
controllerConfigForm.render();
|
|
setStatus('Controller learn mode cancelled.');
|
|
return true;
|
|
}
|
|
closeControllerSelectModal();
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'ArrowDown' || event.key === 'j' || event.key === 'J') {
|
|
event.preventDefault();
|
|
if (ctx.state.connectedGamepads.length > 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = Math.min(
|
|
ctx.state.connectedGamepads.length - 1,
|
|
ctx.state.controllerDeviceSelectedIndex + 1,
|
|
);
|
|
syncSelectedControllerId();
|
|
renderPicker();
|
|
controllerConfigForm.render();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'ArrowUp' || event.key === 'k' || event.key === 'K') {
|
|
event.preventDefault();
|
|
if (ctx.state.connectedGamepads.length > 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = Math.max(
|
|
0,
|
|
ctx.state.controllerDeviceSelectedIndex - 1,
|
|
);
|
|
syncSelectedControllerId();
|
|
renderPicker();
|
|
controllerConfigForm.render();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (event.key === 'Enter' && !learningActionId && !dpadLearningActionId) {
|
|
event.preventDefault();
|
|
void saveSelectedController();
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function wireDomEvents(): void {
|
|
ctx.dom.controllerSelectClose.addEventListener('click', () => {
|
|
closeControllerSelectModal();
|
|
});
|
|
ctx.dom.controllerSelectSave.addEventListener('click', () => {
|
|
void saveSelectedController();
|
|
});
|
|
ctx.dom.controllerSelectPicker.addEventListener('change', () => {
|
|
const selectedKey = ctx.dom.controllerSelectPicker.value;
|
|
const selectedIndex = ctx.state.connectedGamepads.findIndex(
|
|
(device) => getDeviceSelectionKey(device) === selectedKey,
|
|
);
|
|
if (selectedIndex >= 0) {
|
|
ctx.state.controllerDeviceSelectedIndex = selectedIndex;
|
|
syncSelectedControllerId();
|
|
renderPicker();
|
|
controllerConfigForm.render();
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
openControllerSelectModal,
|
|
closeControllerSelectModal,
|
|
handleControllerSelectKeydown,
|
|
updateDevices,
|
|
wireDomEvents,
|
|
};
|
|
}
|