emoemu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +77 -0
- package/.node-version +1 -0
- package/CLAUDE.md +435 -0
- package/README.md +404 -0
- package/TODO.md +655 -0
- package/dist/index.cjs +25108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +25085 -0
- package/docs/building-libretro-cores-arm-mac.md +237 -0
- package/docs/config-file-format.md +488 -0
- package/docs/cores-trd.md +425 -0
- package/docs/headless-hardware-rendering-trd.md +676 -0
- package/docs/libretro-cores-trd.md +997 -0
- package/docs/mupen64-software-rendering-trd.md +751 -0
- package/docs/n64-support-trd.md +306 -0
- package/docs/native-rendering-trd.md +540 -0
- package/docs/native-ui-rendering-trd.md +1195 -0
- package/docs/netplay-trd.md +665 -0
- package/docs/retroarch-netplay-docs.md +277 -0
- package/docs/save-state-format.md +666 -0
- package/eslint.config.js +111 -0
- package/icon/icon.png +0 -0
- package/icon/icon.pxd +0 -0
- package/package.json +63 -0
- package/pnpm-workspace.yaml +10 -0
- package/src/Emulator/consts.ts +14 -0
- package/src/Emulator/index.ts +2496 -0
- package/src/Emulator/saveState/index.ts +155 -0
- package/src/Emulator/screenshot/index.ts +160 -0
- package/src/Emulator/terminalDimensions/index.ts +79 -0
- package/src/Emulator/types.ts +83 -0
- package/src/cli/commands/consts.ts +10 -0
- package/src/cli/commands/index.ts +462 -0
- package/src/cli/parseArgs/consts.ts +17 -0
- package/src/cli/parseArgs/index.ts +457 -0
- package/src/cli/parseArgs/types.ts +61 -0
- package/src/cli/runEmulator/index.ts +406 -0
- package/src/cli/runEmulator/types.ts +7 -0
- package/src/consts.ts +19 -0
- package/src/core/button/consts.ts +35 -0
- package/src/core/button/index.ts +123 -0
- package/src/core/core.ts +300 -0
- package/src/core/index.ts +19 -0
- package/src/cores/libretro/api/index.ts +334 -0
- package/src/cores/libretro/api/types.ts +148 -0
- package/src/cores/libretro/callbacks/consts.ts +41 -0
- package/src/cores/libretro/callbacks/index.ts +456 -0
- package/src/cores/libretro/consts.ts +45 -0
- package/src/cores/libretro/coreOptions/consts.ts +36 -0
- package/src/cores/libretro/coreOptions/index.ts +222 -0
- package/src/cores/libretro/environment/consts.ts +118 -0
- package/src/cores/libretro/environment/index.ts +1095 -0
- package/src/cores/libretro/index.ts +937 -0
- package/src/cores/libretro/loader/index.ts +496 -0
- package/src/cores/libretro/pixelFormat/consts.ts +43 -0
- package/src/cores/libretro/pixelFormat/index.ts +397 -0
- package/src/cores/libretro/types.ts +339 -0
- package/src/frontend/AudioManager/index.ts +420 -0
- package/src/frontend/SettingsManager/index.ts +250 -0
- package/src/frontend/config/index.ts +608 -0
- package/src/frontend/config/tests.ts +354 -0
- package/src/frontend/config/types.ts +36 -0
- package/src/frontend/consts.ts +114 -0
- package/src/frontend/coreBuilder/index.ts +644 -0
- package/src/frontend/coreBuilder/types.ts +15 -0
- package/src/frontend/coreDownloader/index.ts +620 -0
- package/src/frontend/coreDownloader/types.ts +17 -0
- package/src/frontend/corePreferences/index.ts +69 -0
- package/src/frontend/coreRegistry/index.ts +276 -0
- package/src/frontend/directoryCache/index.ts +75 -0
- package/src/frontend/index.ts +79 -0
- package/src/frontend/notifications/consts.ts +14 -0
- package/src/frontend/notifications/index.ts +250 -0
- package/src/frontend/playlist/consts.ts +168 -0
- package/src/frontend/playlist/index.ts +899 -0
- package/src/frontend/playlist/labelFormatter/consts.ts +15 -0
- package/src/frontend/playlist/labelFormatter/index.ts +155 -0
- package/src/frontend/playlist/labelFormatter/tests.ts +153 -0
- package/src/frontend/playlist/reader/index.ts +559 -0
- package/src/frontend/playlist/sync/index.ts +511 -0
- package/src/frontend/playlist/systemLookup/index.ts +233 -0
- package/src/frontend/playlist/utils/index.ts +50 -0
- package/src/frontend/romScanner/consts.ts +348 -0
- package/src/frontend/romScanner/index.ts +1957 -0
- package/src/frontend/saveServices/consts.ts +2 -0
- package/src/frontend/saveServices/index.ts +313 -0
- package/src/frontend/serviceProvider/index.ts +108 -0
- package/src/frontend/serviceProvider/types.ts +13 -0
- package/src/index.ts +428 -0
- package/src/input/Controller/consts.ts +50 -0
- package/src/input/Controller/index.ts +81 -0
- package/src/input/GamepadManager/consts.ts +22 -0
- package/src/input/GamepadManager/index.ts +418 -0
- package/src/input/InputManager/consts.ts +86 -0
- package/src/input/InputManager/index.ts +593 -0
- package/src/input/InputMapper/consts.ts +33 -0
- package/src/input/InputMapper/index.ts +436 -0
- package/src/input/consts.ts +410 -0
- package/src/input/gamepadProfiles/index.ts +1100 -0
- package/src/input/index.ts +38 -0
- package/src/input/inputUtils/index.ts +31 -0
- package/src/netplay/FrameBuffer/consts.ts +2 -0
- package/src/netplay/FrameBuffer/index.ts +364 -0
- package/src/netplay/FrameBuffer/tests.ts +286 -0
- package/src/netplay/InputBuffer/consts.ts +7 -0
- package/src/netplay/InputBuffer/index.ts +347 -0
- package/src/netplay/InputBuffer/tests.ts +166 -0
- package/src/netplay/NetplayClient/index.ts +976 -0
- package/src/netplay/NetplayConnection/index.ts +536 -0
- package/src/netplay/NetplayDiscovery/consts.ts +41 -0
- package/src/netplay/NetplayDiscovery/index.ts +525 -0
- package/src/netplay/NetplayServer/index.ts +1407 -0
- package/src/netplay/SyncManager/index.ts +984 -0
- package/src/netplay/SyncManager/tests.ts +419 -0
- package/src/netplay/consts.ts +371 -0
- package/src/netplay/crc32/consts.ts +14 -0
- package/src/netplay/crc32/index.ts +97 -0
- package/src/netplay/crc32/tests.ts +40 -0
- package/src/netplay/index.ts +41 -0
- package/src/netplay/netplayLogger/consts.ts +30 -0
- package/src/netplay/netplayLogger/index.ts +345 -0
- package/src/netplay/protocol/consts.ts +86 -0
- package/src/netplay/protocol/index.ts +1280 -0
- package/src/netplay/protocol/tests.ts +606 -0
- package/src/netplay/protocol/types.ts +20 -0
- package/src/netplay/types.ts +395 -0
- package/src/rendering/KittyRenderer/index.ts +616 -0
- package/src/rendering/NativeRenderer/index.ts +279 -0
- package/src/rendering/NativeRenderer/tests.ts +133 -0
- package/src/rendering/TerminalRenderer/index.ts +770 -0
- package/src/rendering/consts.ts +401 -0
- package/src/rendering/fonts/CozetteVector.ttf +0 -0
- package/src/rendering/index.ts +26 -0
- package/src/rendering/nativeUi/NativeWindowManager/index.ts +158 -0
- package/src/rendering/nativeUi/NativeWindowManager/tests.ts +81 -0
- package/src/rendering/nativeUi/consts.ts +6 -0
- package/src/rendering/nativeUi/index.ts +20 -0
- package/src/rendering/postProcessing/consts.ts +38 -0
- package/src/rendering/postProcessing/index.ts +923 -0
- package/src/rendering/shared/ansi/consts.ts +12 -0
- package/src/rendering/shared/ansi/index.ts +104 -0
- package/src/rendering/shared/consts.ts +2 -0
- package/src/rendering/shared/fitToTerminal/index.ts +67 -0
- package/src/ui/AddRomsPrompt/consts.ts +13 -0
- package/src/ui/AddRomsPrompt/index.tsx +781 -0
- package/src/ui/App/consts.ts +2 -0
- package/src/ui/App/index.tsx +456 -0
- package/src/ui/AppCapabilities/index.tsx +67 -0
- package/src/ui/ConfigContext/index.tsx +56 -0
- package/src/ui/CoreManager/consts.ts +11 -0
- package/src/ui/CoreManager/index.tsx +779 -0
- package/src/ui/CoreSelector/consts.ts +2 -0
- package/src/ui/CoreSelector/index.tsx +251 -0
- package/src/ui/DialogContainer/index.tsx +42 -0
- package/src/ui/DialogOptionsList/index.tsx +61 -0
- package/src/ui/DuplicateCrcPrompt/consts.ts +5 -0
- package/src/ui/DuplicateCrcPrompt/index.tsx +146 -0
- package/src/ui/GamepadContext/consts.ts +15 -0
- package/src/ui/GamepadContext/index.tsx +295 -0
- package/src/ui/NativeDialog/index.tsx +120 -0
- package/src/ui/NetplayDisconnectedDialog/index.tsx +93 -0
- package/src/ui/NetplayPauseMenu/consts.ts +2 -0
- package/src/ui/NetplayPauseMenu/index.tsx +97 -0
- package/src/ui/RomBrowser/NetplayPanel/consts.ts +24 -0
- package/src/ui/RomBrowser/NetplayPanel/index.tsx +520 -0
- package/src/ui/RomBrowser/SettingsPanel/index.tsx +478 -0
- package/src/ui/RomBrowser/consts.ts +61 -0
- package/src/ui/RomBrowser/index.tsx +1164 -0
- package/src/ui/RomBrowser/settingsConfig/index.ts +320 -0
- package/src/ui/RomBrowser/types.ts +67 -0
- package/src/ui/SaveStateDialog/consts.ts +2 -0
- package/src/ui/SaveStateDialog/index.tsx +225 -0
- package/src/ui/WarningDialog/index.tsx +113 -0
- package/src/ui/consts.ts +27 -0
- package/src/ui/hooks/useClearTerminal/consts.ts +2 -0
- package/src/ui/hooks/useClearTerminal/index.ts +37 -0
- package/src/ui/hooks/useDialogNavigation/index.ts +99 -0
- package/src/ui/hooks/useGamepad/consts.ts +21 -0
- package/src/ui/hooks/useGamepad/index.ts +194 -0
- package/src/ui/index.ts +27 -0
- package/src/utils/buffer/consts.ts +17 -0
- package/src/utils/buffer/index.ts +129 -0
- package/src/utils/color/consts.ts +58 -0
- package/src/utils/color/index.ts +183 -0
- package/src/utils/compression/consts.ts +50 -0
- package/src/utils/compression/index.ts +101 -0
- package/src/utils/consts.ts +2 -0
- package/src/utils/crc32/consts.ts +22 -0
- package/src/utils/crc32/index.ts +83 -0
- package/src/utils/ensureDirectory/index.ts +10 -0
- package/src/utils/format/consts.ts +8 -0
- package/src/utils/format/index.ts +53 -0
- package/src/utils/getErrorMessage/index.ts +10 -0
- package/src/utils/index.ts +113 -0
- package/src/utils/ini/index.ts +200 -0
- package/src/utils/kitty/consts.ts +13 -0
- package/src/utils/kitty/index.ts +181 -0
- package/src/utils/logger/consts.ts +35 -0
- package/src/utils/logger/index.ts +217 -0
- package/src/utils/paths/consts.ts +18 -0
- package/src/utils/paths/index.ts +151 -0
- package/src/utils/png/consts.ts +34 -0
- package/src/utils/png/index.ts +131 -0
- package/src/utils/readJsonFile/index.ts +16 -0
- package/src/utils/rotateLogFile/index.ts +44 -0
- package/src/utils/safeClose/index.ts +10 -0
- package/src/utils/terminal/consts.ts +8 -0
- package/src/utils/terminal/index.ts +102 -0
- package/src/utils/thumbnailRenderer/consts.ts +2 -0
- package/src/utils/thumbnailRenderer/index.ts +147 -0
- package/src/utils/typedError/index.ts +26 -0
- package/tsconfig.json +31 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gamepad Context for Shared Input Management
|
|
3
|
+
*
|
|
4
|
+
* Provides a single GamepadManager instance shared across all UI components.
|
|
5
|
+
* Uses a focus stack to determine which component receives input - the most
|
|
6
|
+
* recently mounted component with gamepad handlers takes priority.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createContext, useContext, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react';
|
|
10
|
+
import { GamepadManager } from '../../input/GamepadManager';
|
|
11
|
+
import { StandardButton } from '../../core/button';
|
|
12
|
+
import {
|
|
13
|
+
INITIAL_DELAY_MS,
|
|
14
|
+
INITIAL_REPEAT_MS,
|
|
15
|
+
MIN_REPEAT_MS,
|
|
16
|
+
ACCELERATION_TIME_MS,
|
|
17
|
+
EASE_CUBIC_FACTOR,
|
|
18
|
+
EASE_CUBIC_DIVISOR,
|
|
19
|
+
} from './consts';
|
|
20
|
+
|
|
21
|
+
export * from './consts';
|
|
22
|
+
|
|
23
|
+
export interface GamepadCallbacks {
|
|
24
|
+
onUp?: () => void;
|
|
25
|
+
onDown?: () => void;
|
|
26
|
+
onLeft?: () => void;
|
|
27
|
+
onRight?: () => void;
|
|
28
|
+
onConfirm?: () => void; // A button
|
|
29
|
+
onCancel?: () => void; // B button
|
|
30
|
+
onStart?: () => void; // Start button
|
|
31
|
+
onGuide?: () => void; // Guide/Xbox/Home button
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Direction = 'up' | 'down' | 'left' | 'right';
|
|
35
|
+
|
|
36
|
+
const DIRECTION_CALLBACKS: Record<Direction, keyof Pick<GamepadCallbacks, 'onUp' | 'onDown' | 'onLeft' | 'onRight'>> = {
|
|
37
|
+
up: 'onUp',
|
|
38
|
+
down: 'onDown',
|
|
39
|
+
left: 'onLeft',
|
|
40
|
+
right: 'onRight',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const fireDirectionalCallback = (callbacks: GamepadCallbacks, direction: Direction): void => {
|
|
44
|
+
callbacks[DIRECTION_CALLBACKS[direction]]?.();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface RepeatState {
|
|
48
|
+
direction: Direction;
|
|
49
|
+
startTime: number;
|
|
50
|
+
timeoutId: ReturnType<typeof setTimeout> | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface GamepadContextValue {
|
|
54
|
+
register: (id: string, callbacks: GamepadCallbacks) => void;
|
|
55
|
+
unregister: (id: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const GamepadContext = createContext<GamepadContextValue | null>(null);
|
|
59
|
+
|
|
60
|
+
// Counter for generating unique IDs
|
|
61
|
+
let idCounter = 0;
|
|
62
|
+
const generateId = (): string => `gamepad-handler-${++idCounter}`;
|
|
63
|
+
|
|
64
|
+
interface GamepadProviderProps {
|
|
65
|
+
children: ReactNode;
|
|
66
|
+
enabled?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* GamepadProvider - Wraps the app to provide shared gamepad input
|
|
71
|
+
*
|
|
72
|
+
* Maintains a stack of registered handlers. The most recently registered
|
|
73
|
+
* handler (top of stack) receives all input events.
|
|
74
|
+
*/
|
|
75
|
+
export const GamepadProvider = ({ children, enabled = true }: GamepadProviderProps) => {
|
|
76
|
+
const managerRef = useRef<GamepadManager | null>(null);
|
|
77
|
+
const handlersRef = useRef<Map<string, GamepadCallbacks>>(new Map());
|
|
78
|
+
const stackRef = useRef<string[]>([]);
|
|
79
|
+
const repeatStateRef = useRef<RepeatState | null>(null);
|
|
80
|
+
|
|
81
|
+
// Get the currently active callbacks (top of stack)
|
|
82
|
+
const getActiveCallbacks = useCallback((): GamepadCallbacks | null => {
|
|
83
|
+
const stack = stackRef.current;
|
|
84
|
+
if (stack.length === 0) {return null;}
|
|
85
|
+
const activeId = stack[stack.length - 1];
|
|
86
|
+
return handlersRef.current.get(activeId) ?? null;
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
// Calculate repeat interval based on hold duration
|
|
90
|
+
const getRepeatInterval = useCallback((heldDuration: number): number => {
|
|
91
|
+
if (heldDuration < INITIAL_DELAY_MS) {
|
|
92
|
+
return INITIAL_DELAY_MS - heldDuration;
|
|
93
|
+
}
|
|
94
|
+
const accelerationProgress = Math.min(
|
|
95
|
+
1,
|
|
96
|
+
(heldDuration - INITIAL_DELAY_MS) / ACCELERATION_TIME_MS
|
|
97
|
+
);
|
|
98
|
+
const easedProgress = accelerationProgress * accelerationProgress *
|
|
99
|
+
(EASE_CUBIC_FACTOR - EASE_CUBIC_DIVISOR * accelerationProgress);
|
|
100
|
+
return INITIAL_REPEAT_MS - (INITIAL_REPEAT_MS - MIN_REPEAT_MS) * easedProgress;
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
// Fire callback for direction and schedule next repeat
|
|
104
|
+
const fireAndSchedule = useCallback((direction: Direction) => {
|
|
105
|
+
const state = repeatStateRef.current;
|
|
106
|
+
if (!state || state.direction !== direction) {return;}
|
|
107
|
+
|
|
108
|
+
const cb = getActiveCallbacks();
|
|
109
|
+
if (cb) {
|
|
110
|
+
fireDirectionalCallback(cb, direction);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const heldDuration = Date.now() - state.startTime;
|
|
114
|
+
const interval = getRepeatInterval(heldDuration);
|
|
115
|
+
state.timeoutId = setTimeout(() => fireAndSchedule(direction), interval);
|
|
116
|
+
}, [getActiveCallbacks, getRepeatInterval]);
|
|
117
|
+
|
|
118
|
+
// Start repeat for a direction
|
|
119
|
+
const startRepeat = useCallback((direction: Direction) => {
|
|
120
|
+
// Cancel any existing repeat
|
|
121
|
+
const state = repeatStateRef.current;
|
|
122
|
+
if (state?.timeoutId) {
|
|
123
|
+
clearTimeout(state.timeoutId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fire callback immediately
|
|
127
|
+
const cb = getActiveCallbacks();
|
|
128
|
+
if (cb) {
|
|
129
|
+
fireDirectionalCallback(cb, direction);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Start repeat state
|
|
133
|
+
repeatStateRef.current = {
|
|
134
|
+
direction,
|
|
135
|
+
startTime: Date.now(),
|
|
136
|
+
timeoutId: setTimeout(() => fireAndSchedule(direction), INITIAL_DELAY_MS),
|
|
137
|
+
};
|
|
138
|
+
}, [getActiveCallbacks, fireAndSchedule]);
|
|
139
|
+
|
|
140
|
+
// Stop any active repeat
|
|
141
|
+
const stopRepeat = useCallback(() => {
|
|
142
|
+
const state = repeatStateRef.current;
|
|
143
|
+
if (state?.timeoutId) {
|
|
144
|
+
clearTimeout(state.timeoutId);
|
|
145
|
+
}
|
|
146
|
+
repeatStateRef.current = null;
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
// Map button to direction
|
|
150
|
+
const buttonToDirection = useCallback((button: StandardButton): Direction | null => {
|
|
151
|
+
switch (button) {
|
|
152
|
+
case StandardButton.Up:
|
|
153
|
+
case StandardButton.LeftStickUp:
|
|
154
|
+
return 'up';
|
|
155
|
+
case StandardButton.Down:
|
|
156
|
+
case StandardButton.LeftStickDown:
|
|
157
|
+
return 'down';
|
|
158
|
+
case StandardButton.Left:
|
|
159
|
+
case StandardButton.LeftStickLeft:
|
|
160
|
+
return 'left';
|
|
161
|
+
case StandardButton.Right:
|
|
162
|
+
case StandardButton.LeftStickRight:
|
|
163
|
+
return 'right';
|
|
164
|
+
default:
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
// Initialize GamepadManager
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!enabled) {return;}
|
|
172
|
+
|
|
173
|
+
const manager = new GamepadManager();
|
|
174
|
+
managerRef.current = manager;
|
|
175
|
+
|
|
176
|
+
manager.onButtonChange = (_port, button, pressed) => {
|
|
177
|
+
const direction = buttonToDirection(button);
|
|
178
|
+
|
|
179
|
+
if (direction) {
|
|
180
|
+
if (pressed) {
|
|
181
|
+
startRepeat(direction);
|
|
182
|
+
} else if (repeatStateRef.current?.direction === direction) {
|
|
183
|
+
stopRepeat();
|
|
184
|
+
}
|
|
185
|
+
} else if (pressed) {
|
|
186
|
+
const cb = getActiveCallbacks();
|
|
187
|
+
if (cb) {
|
|
188
|
+
switch (button) {
|
|
189
|
+
case StandardButton.A:
|
|
190
|
+
cb.onConfirm?.();
|
|
191
|
+
break;
|
|
192
|
+
case StandardButton.B:
|
|
193
|
+
cb.onCancel?.();
|
|
194
|
+
break;
|
|
195
|
+
case StandardButton.Start:
|
|
196
|
+
cb.onStart?.();
|
|
197
|
+
break;
|
|
198
|
+
case StandardButton.Guide:
|
|
199
|
+
cb.onGuide?.();
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
manager.start();
|
|
207
|
+
|
|
208
|
+
return () => {
|
|
209
|
+
stopRepeat();
|
|
210
|
+
manager.stop();
|
|
211
|
+
managerRef.current = null;
|
|
212
|
+
};
|
|
213
|
+
}, [enabled, buttonToDirection, startRepeat, stopRepeat, getActiveCallbacks]);
|
|
214
|
+
|
|
215
|
+
// Register a new handler (pushes to top of stack)
|
|
216
|
+
const register = useCallback((id: string, callbacks: GamepadCallbacks) => {
|
|
217
|
+
handlersRef.current.set(id, callbacks);
|
|
218
|
+
// Remove from stack if already present, then add to top
|
|
219
|
+
stackRef.current = stackRef.current.filter(i => i !== id);
|
|
220
|
+
stackRef.current.push(id);
|
|
221
|
+
}, []);
|
|
222
|
+
|
|
223
|
+
// Unregister a handler (removes from stack)
|
|
224
|
+
const unregister = useCallback((id: string) => {
|
|
225
|
+
handlersRef.current.delete(id);
|
|
226
|
+
stackRef.current = stackRef.current.filter(i => i !== id);
|
|
227
|
+
// Stop repeat if the active handler was removed
|
|
228
|
+
stopRepeat();
|
|
229
|
+
}, [stopRepeat]);
|
|
230
|
+
|
|
231
|
+
// Memoize context value to prevent unnecessary effect re-runs in consumers
|
|
232
|
+
const contextValue = useMemo<GamepadContextValue>(() => ({
|
|
233
|
+
register,
|
|
234
|
+
unregister,
|
|
235
|
+
}), [register, unregister]);
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<GamepadContext.Provider value={contextValue}>
|
|
239
|
+
{children}
|
|
240
|
+
</GamepadContext.Provider>
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* useGamepadContext - Hook for components to receive gamepad input
|
|
246
|
+
*
|
|
247
|
+
* Automatically registers on mount and unregisters on unmount.
|
|
248
|
+
* The most recently mounted component receives input (focus stack).
|
|
249
|
+
*
|
|
250
|
+
* @param callbacks Object with callback functions for different inputs
|
|
251
|
+
* @param enabled Whether this handler should be active (default: true)
|
|
252
|
+
*/
|
|
253
|
+
export const useGamepadContext = (callbacks: GamepadCallbacks, enabled: boolean = true): void => {
|
|
254
|
+
const context = useContext(GamepadContext);
|
|
255
|
+
const idRef = useRef<string>(generateId());
|
|
256
|
+
const callbacksRef = useRef(callbacks);
|
|
257
|
+
|
|
258
|
+
// Keep callbacks ref up to date
|
|
259
|
+
callbacksRef.current = callbacks;
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!context || !enabled) {return;}
|
|
263
|
+
|
|
264
|
+
const id = idRef.current;
|
|
265
|
+
|
|
266
|
+
// Create a wrapper that always calls the latest callbacks
|
|
267
|
+
const wrappedCallbacks: GamepadCallbacks = {
|
|
268
|
+
onUp: () => callbacksRef.current.onUp?.(),
|
|
269
|
+
onDown: () => callbacksRef.current.onDown?.(),
|
|
270
|
+
onLeft: () => callbacksRef.current.onLeft?.(),
|
|
271
|
+
onRight: () => callbacksRef.current.onRight?.(),
|
|
272
|
+
onConfirm: () => callbacksRef.current.onConfirm?.(),
|
|
273
|
+
onCancel: () => callbacksRef.current.onCancel?.(),
|
|
274
|
+
onStart: () => callbacksRef.current.onStart?.(),
|
|
275
|
+
onGuide: () => callbacksRef.current.onGuide?.(),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
context.register(id, wrappedCallbacks);
|
|
279
|
+
|
|
280
|
+
return () => {
|
|
281
|
+
context.unregister(id);
|
|
282
|
+
};
|
|
283
|
+
}, [context, enabled]);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Note on usage:
|
|
288
|
+
*
|
|
289
|
+
* - Components rendered within the main App tree (RomBrowser, SettingsPanel,
|
|
290
|
+
* ConfirmResetDialog, DirectoryInput) should use useGamepadContext.
|
|
291
|
+
*
|
|
292
|
+
* - Standalone dialogs that create their own Ink render() call (CoreSelector,
|
|
293
|
+
* SaveStateDialog, CorruptedStateDialog) should continue using the original
|
|
294
|
+
* useGamepad hook from useGamepad.ts, as they're outside the context tree.
|
|
295
|
+
*/
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Dialog Utilities
|
|
3
|
+
*
|
|
4
|
+
* Renders Ink dialogs in native window mode using the shared NativeWindowManager
|
|
5
|
+
* streams. In terminal mode, renders to the standard terminal.
|
|
6
|
+
*/
|
|
7
|
+
import { render, type Instance } from 'ink';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { getWindowManager, isFensterAvailable } from '../../rendering/nativeUi';
|
|
10
|
+
import { logger } from '../../utils/logger';
|
|
11
|
+
import { cleanupInkInstance } from '../../utils/terminal';
|
|
12
|
+
|
|
13
|
+
export interface DialogRenderOptions {
|
|
14
|
+
/** Whether to use native window mode (default: auto-detect from video driver) */
|
|
15
|
+
nativeMode?: boolean;
|
|
16
|
+
/** Dialog title (for the native window) */
|
|
17
|
+
title?: string;
|
|
18
|
+
/** Scale factor for native mode (null = auto-detect from display) */
|
|
19
|
+
scaleFactor?: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface NativeDialogContext {
|
|
23
|
+
instance: Instance;
|
|
24
|
+
cleanup: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const isNativeModeAvailable = (): boolean => {
|
|
28
|
+
return isFensterAvailable();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const renderDialog = (
|
|
32
|
+
component: ReactNode,
|
|
33
|
+
options: DialogRenderOptions = {},
|
|
34
|
+
): Promise<NativeDialogContext> => {
|
|
35
|
+
const useNative = options.nativeMode && isNativeModeAvailable();
|
|
36
|
+
if (useNative) {
|
|
37
|
+
return renderDialogNative(component, options);
|
|
38
|
+
}
|
|
39
|
+
const instance = render(component);
|
|
40
|
+
return Promise.resolve({
|
|
41
|
+
instance,
|
|
42
|
+
cleanup: () => {
|
|
43
|
+
// Terminal mode cleanup is handled by Ink
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const renderDialogNative = (
|
|
49
|
+
component: ReactNode,
|
|
50
|
+
options: DialogRenderOptions,
|
|
51
|
+
): Promise<NativeDialogContext> => {
|
|
52
|
+
return new Promise((resolve, reject) => {
|
|
53
|
+
try {
|
|
54
|
+
const windowManager = getWindowManager();
|
|
55
|
+
if (!windowManager.isInitialized()) {
|
|
56
|
+
windowManager.init({ title: options.title ?? 'emoemu', scaleFactor: options.scaleFactor });
|
|
57
|
+
}
|
|
58
|
+
windowManager.setMode('ui');
|
|
59
|
+
|
|
60
|
+
const stdin = windowManager.getStdin();
|
|
61
|
+
const stdout = windowManager.getStdout();
|
|
62
|
+
const window = windowManager.getWindow();
|
|
63
|
+
|
|
64
|
+
// Clear before rendering to avoid artifacts from the previous view.
|
|
65
|
+
windowManager.clearScreen();
|
|
66
|
+
|
|
67
|
+
const onClose = () => {
|
|
68
|
+
stdin.push('\x1b'); // Send escape to trigger exit
|
|
69
|
+
};
|
|
70
|
+
window.on('close', onClose);
|
|
71
|
+
|
|
72
|
+
logger.info('Native dialog mode enabled (shared window)', 'Native-UI');
|
|
73
|
+
|
|
74
|
+
const instance = render(component, {
|
|
75
|
+
exitOnCtrlC: false,
|
|
76
|
+
stdout: stdout as unknown as NodeJS.WriteStream,
|
|
77
|
+
stdin: stdin as unknown as NodeJS.ReadStream,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const cleanup = () => {
|
|
81
|
+
// Detach this dialog's close listener; DO NOT close the shared window.
|
|
82
|
+
window.off('close', onClose);
|
|
83
|
+
windowManager.getRenderer().reset();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
resolve({ instance, cleanup });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
logger.warn(`Native dialog failed: ${error}`, 'Native-UI');
|
|
89
|
+
reject(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const showDialog = async (
|
|
95
|
+
component: ReactNode,
|
|
96
|
+
options: DialogRenderOptions = {},
|
|
97
|
+
): Promise<Instance> => {
|
|
98
|
+
const { instance, cleanup } = await renderDialog(component, options);
|
|
99
|
+
void instance.waitUntilExit().then(() => {
|
|
100
|
+
cleanup();
|
|
101
|
+
});
|
|
102
|
+
return instance;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const launchDialog = <T,>(
|
|
106
|
+
createComponent: (onChoice: (value: T) => void) => ReactNode,
|
|
107
|
+
defaultValue: T,
|
|
108
|
+
options: DialogRenderOptions = {},
|
|
109
|
+
): Promise<T> => new Promise((resolve) => {
|
|
110
|
+
let choice = defaultValue;
|
|
111
|
+
const component = createComponent((value) => {
|
|
112
|
+
choice = value;
|
|
113
|
+
});
|
|
114
|
+
void renderDialog(component, options).then(({ instance, cleanup }) => {
|
|
115
|
+
void instance.waitUntilExit().then(() => {
|
|
116
|
+
cleanup();
|
|
117
|
+
cleanupInkInstance(instance, resolve, choice);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Netplay Disconnected Dialog Component
|
|
3
|
+
*
|
|
4
|
+
* Shows when netplay connection is lost and offers reconnect option.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Box, Text } from 'ink';
|
|
8
|
+
import { DialogOptionsList } from '../DialogOptionsList';
|
|
9
|
+
import { DialogContainer } from '../DialogContainer';
|
|
10
|
+
import { useDialogNavigation } from '../hooks/useDialogNavigation';
|
|
11
|
+
import { launchDialog, type DialogRenderOptions } from '../NativeDialog';
|
|
12
|
+
|
|
13
|
+
export interface DisconnectInfo {
|
|
14
|
+
reason: string;
|
|
15
|
+
host?: string;
|
|
16
|
+
port?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type DisconnectChoice = 'reconnect' | 'menu' | 'exit';
|
|
20
|
+
|
|
21
|
+
interface NetplayDisconnectedDialogProps {
|
|
22
|
+
info: DisconnectInfo;
|
|
23
|
+
onChoice: (choice: DisconnectChoice) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const NetplayDisconnectedDialog = ({ info, onChoice }: NetplayDisconnectedDialogProps) => {
|
|
27
|
+
const options: { label: string; choice: DisconnectChoice; color: string }[] = [
|
|
28
|
+
{ label: 'Try to Reconnect', choice: 'reconnect', color: 'green' },
|
|
29
|
+
{ label: 'Back to Menu', choice: 'menu', color: 'gray' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const { selectedIndex } = useDialogNavigation({
|
|
33
|
+
itemCount: options.length,
|
|
34
|
+
onSelect: (index) => onChoice(options[index].choice),
|
|
35
|
+
onCancel: () => onChoice('menu'),
|
|
36
|
+
onCtrlC: () => onChoice('exit'),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Format host info if available
|
|
40
|
+
const hostInfo = info.host ? `${info.host}${info.port ? `:${info.port}` : ''}` : null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<DialogContainer>
|
|
44
|
+
{(boxWidth) => (
|
|
45
|
+
<>
|
|
46
|
+
{/* Header */}
|
|
47
|
+
<Box
|
|
48
|
+
flexDirection="column"
|
|
49
|
+
borderStyle="round"
|
|
50
|
+
borderColor="red"
|
|
51
|
+
paddingX={2}
|
|
52
|
+
paddingY={1}
|
|
53
|
+
width={boxWidth}
|
|
54
|
+
>
|
|
55
|
+
<Box justifyContent="center" marginBottom={1}>
|
|
56
|
+
<Text bold color="red">{'\u26A0'} Disconnected</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
|
|
59
|
+
{/* Host info if available */}
|
|
60
|
+
{hostInfo && (
|
|
61
|
+
<Box marginBottom={1}>
|
|
62
|
+
<Text color="gray">Host: </Text>
|
|
63
|
+
<Text color="white">{hostInfo}</Text>
|
|
64
|
+
</Box>
|
|
65
|
+
)}
|
|
66
|
+
|
|
67
|
+
{/* Disconnect reason */}
|
|
68
|
+
<Box>
|
|
69
|
+
<Text color="gray">Reason: </Text>
|
|
70
|
+
<Text color="yellow">{info.reason}</Text>
|
|
71
|
+
</Box>
|
|
72
|
+
</Box>
|
|
73
|
+
|
|
74
|
+
<DialogOptionsList options={options} selectedIndex={selectedIndex} boxWidth={boxWidth} escLabel="Back" />
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
</DialogContainer>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Show the netplay disconnected dialog and get user's choice
|
|
83
|
+
*/
|
|
84
|
+
export const showNetplayDisconnectedDialog = (
|
|
85
|
+
info: DisconnectInfo,
|
|
86
|
+
options: DialogRenderOptions = {}
|
|
87
|
+
): Promise<DisconnectChoice> => launchDialog<DisconnectChoice>(
|
|
88
|
+
(onChoice) => <NetplayDisconnectedDialog info={info} onChoice={onChoice} />,
|
|
89
|
+
'menu',
|
|
90
|
+
{ ...options, title: options.title ?? 'emoemu - Disconnected' },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
export default NetplayDisconnectedDialog;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Netplay Pause Menu Component
|
|
3
|
+
*
|
|
4
|
+
* Shows when ESC is pressed during netplay connection/gameplay.
|
|
5
|
+
* Allows user to resume or disconnect from the session.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Box, Text } from 'ink';
|
|
9
|
+
import { DialogOptionsList } from '../DialogOptionsList';
|
|
10
|
+
import { DialogContainer } from '../DialogContainer';
|
|
11
|
+
import { useDialogNavigation } from '../hooks/useDialogNavigation';
|
|
12
|
+
import { launchDialog, type DialogRenderOptions } from '../NativeDialog';
|
|
13
|
+
import { PAUSE_MENU_MIN_WIDTH } from './consts';
|
|
14
|
+
|
|
15
|
+
export * from './consts';
|
|
16
|
+
|
|
17
|
+
export type PauseMenuChoice = 'resume' | 'disconnect';
|
|
18
|
+
|
|
19
|
+
interface NetplayPauseMenuProps {
|
|
20
|
+
gameName?: string;
|
|
21
|
+
isConnecting?: boolean;
|
|
22
|
+
onChoice: (choice: PauseMenuChoice) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NetplayPauseMenu = ({ gameName, isConnecting, onChoice }: NetplayPauseMenuProps) => {
|
|
26
|
+
const options: { label: string; choice: PauseMenuChoice; color: string }[] = [
|
|
27
|
+
{ label: isConnecting ? 'Continue Connecting' : 'Resume Game', choice: 'resume', color: 'green' },
|
|
28
|
+
{ label: 'Back to Browser', choice: 'disconnect', color: 'yellow' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const { selectedIndex } = useDialogNavigation({
|
|
32
|
+
itemCount: options.length,
|
|
33
|
+
onSelect: (index) => onChoice(options[index].choice),
|
|
34
|
+
onCancel: () => onChoice('resume'),
|
|
35
|
+
onCtrlC: () => onChoice('disconnect'),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<DialogContainer minWidth={PAUSE_MENU_MIN_WIDTH}>
|
|
40
|
+
{(boxWidth) => (
|
|
41
|
+
<>
|
|
42
|
+
{/* Header */}
|
|
43
|
+
<Box
|
|
44
|
+
flexDirection="column"
|
|
45
|
+
borderStyle="round"
|
|
46
|
+
borderColor="cyan"
|
|
47
|
+
paddingX={2}
|
|
48
|
+
paddingY={1}
|
|
49
|
+
width={boxWidth}
|
|
50
|
+
>
|
|
51
|
+
<Box justifyContent="center" marginBottom={1}>
|
|
52
|
+
<Text bold color="cyan">{'\u23F8'} Paused</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
|
|
55
|
+
{/* Game name if available */}
|
|
56
|
+
{gameName && (
|
|
57
|
+
<Box justifyContent="center">
|
|
58
|
+
<Text color="white">{gameName}</Text>
|
|
59
|
+
</Box>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{/* Status */}
|
|
63
|
+
<Box justifyContent="center" marginTop={1}>
|
|
64
|
+
<Text color="gray">
|
|
65
|
+
{isConnecting ? 'Connecting to netplay session...' : 'Netplay session active'}
|
|
66
|
+
</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
|
|
70
|
+
<DialogOptionsList options={options} selectedIndex={selectedIndex} boxWidth={boxWidth} prompt={false} escLabel="Resume" />
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
</DialogContainer>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface PauseMenuOptions extends DialogRenderOptions {
|
|
78
|
+
gameName?: string;
|
|
79
|
+
isConnecting?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Show the netplay pause menu and get user's choice
|
|
84
|
+
*/
|
|
85
|
+
export const showNetplayPauseMenu = (options: PauseMenuOptions = {}): Promise<PauseMenuChoice> => launchDialog<PauseMenuChoice>(
|
|
86
|
+
(onChoice) => (
|
|
87
|
+
<NetplayPauseMenu
|
|
88
|
+
gameName={options.gameName}
|
|
89
|
+
isConnecting={options.isConnecting}
|
|
90
|
+
onChoice={onChoice}
|
|
91
|
+
/>
|
|
92
|
+
),
|
|
93
|
+
'resume',
|
|
94
|
+
{ nativeMode: options.nativeMode, title: options.title ?? 'emoemu - Paused' },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
export default NetplayPauseMenu;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Port number constants
|
|
2
|
+
export const PORT_MAX = 65535;
|
|
3
|
+
export const DECIMAL_BASE = 10;
|
|
4
|
+
|
|
5
|
+
// Input delay options for netplay
|
|
6
|
+
export const inputDelayOptions = [
|
|
7
|
+
{ value: 0, label: '0 (Lowest latency)' },
|
|
8
|
+
{ value: 1, label: '1' },
|
|
9
|
+
{ value: 2, label: '2 (Recommended)' },
|
|
10
|
+
{ value: 3, label: '3' },
|
|
11
|
+
{ value: 4, label: '4' },
|
|
12
|
+
{ value: 5, label: '5' },
|
|
13
|
+
{ value: 6, label: '6' },
|
|
14
|
+
{ value: 8, label: '8 (High latency)' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
/** Delay before sending first discovery query (ms) */
|
|
18
|
+
export const DISCOVERY_INITIAL_DELAY_MS = 100;
|
|
19
|
+
|
|
20
|
+
/** Interval for sending discovery queries (ms) */
|
|
21
|
+
export const DISCOVERY_QUERY_INTERVAL_MS = 2000;
|
|
22
|
+
|
|
23
|
+
/** How long hosts are considered "alive" after last seen (ms) */
|
|
24
|
+
export const DISCOVERY_HOST_MAX_AGE_MS = 10000;
|